Skip to content

Commit

Permalink
Mailgun Backend (#6)
Browse files Browse the repository at this point in the history
Add a backend for Mailgun
  • Loading branch information
anthonycastelli authored and bygri committed Mar 28, 2017
1 parent 26aabf3 commit c890908
Show file tree
Hide file tree
Showing 7 changed files with 400 additions and 0 deletions.
6 changes: 6 additions & 0 deletions Package.swift
Expand Up @@ -18,6 +18,12 @@ let package = Package(
"Mail"
]
),
Target(
name: "Mailgun",
dependencies: [
"Mail"
]
),
],
dependencies: [
.Package(url: "https://github.com/vapor/vapor.git", majorVersion: 1, minor: 5),
Expand Down
33 changes: 33 additions & 0 deletions README.md
Expand Up @@ -7,6 +7,7 @@ Vapor Provider for sending email through swappable backends.

Backends included in this repository:

* `Mailgun`, a basic implementation for sending emails through Mailgun's V3 API.
* `SendGrid`, a fully-featured implementation of the SendGrid V3 Mail Send API.
* `SMTPClient`, which conforms Vapor's built-in SMTP Client to this backend.
* `InMemoryMailClient`, a development-only backend which stores emails in memory.
Expand Down Expand Up @@ -50,6 +51,38 @@ if let complicatedMailer = try drop.mailer.make() as? ComplicatedMailClient {
}
```

### Mailgun backend

First, set up the Provider.

```Swift
import Mail
import Mailgun

let drop = Droplet()
try drop.addProvider(Mail.Provider<MailgunClient>.self)
```

SendGrid expects a configuration file named `mailgun.json` with the following
format, and will throw `.noMailgunConfig` or `.missingConfig(fieldname)` if
configuration was not found.

```json
{
"domain": "MG.YOUR_DOMAIN",
"apiKey": "MG.YOUR_KEY"
}
```

Once installed, you can send simple emails using the following format:

```Swift
let email = Email(from: , to: , subject: , body: )
try drop.mailer?.send(email)
```

Mailgun supports both HTML and Plain Text emails.

### SendGrid backend

First, set up the Provider.
Expand Down
119 changes: 119 additions & 0 deletions Sources/Mailgun/MailgunClient.swift
@@ -0,0 +1,119 @@
//
// MailgunClient.swift
// Mailgun
//
// Created by Anthony Castelli on 11/14/16.
//
//

import Console
import Vapor
import HTTP
import Foundation
import SMTP
import Mail


/**
Mailgun client
*/
public final class MailgunClient {

static var defaultApiKey: String?
static var defaultDomain: String?
static var defaultClient: ClientProtocol.Type?

var domain: String
var apiKey: String
var client: ClientProtocol

public init(clientProtocol: ClientProtocol.Type, domain: String, apiKey: String) throws {
self.domain = domain
self.apiKey = apiKey
self.client = try clientProtocol.make(scheme: "https", host: "api.mailgun.net")
}

public func send(_ emails: [MailgunEmail]) throws {
try emails.forEach { email in
let boundary = "vapor.mailgun.package.\(SecureToken().token)"
let bytes = try createMultipartData(email.makeNode(), boundary: boundary)
let headers: [HeaderKey : String] = [
"Authorization": authorizationHeaderValue(apiKey),
"Content-Type": "multipart/form-data; boundary=\(boundary)"
]
let response = try client.post(path: "/v3/\(domain)/messages", headers: headers, body: Body.data(bytes))
switch response.status.statusCode {
case 200, 202: return
case 400: throw MailgunError.badRequest(try response.json?.extract())
case 401: throw MailgunError.unauthorized
case 500, 503: throw MailgunError.serverError
default: throw MailgunError.unexpectedServerResponse
}
}
}

fileprivate func authorizationHeaderValue(_ apiKey: String) -> String {
let userPasswordString = "api:\(apiKey)"
guard let userPasswordData = userPasswordString.data(using: String.Encoding.utf8) else { return "" }
let base64EncodedCredential = userPasswordData.base64EncodedString()
let authString = "Basic \(base64EncodedCredential)"
return authString
}

fileprivate func createMultipartData(_ node: Node, boundary: String) throws -> Bytes {
var serialized = ""

guard let object = node.object else { throw MailgunError.missingEmailContent }
object.forEach { key, value in
guard let value = value.string else { return }
serialized += "--\(boundary)\r\n"
serialized += "Content-Disposition: form-data; name=\"\(key)\"\r\n\r\n"
serialized += "\(value)\r\n"
}

serialized += "--\(boundary)--\r\n"
return serialized.bytes
}

}

extension MailgunClient: MailClientProtocol {

public static func configure(_ config: Settings.Config) throws {
guard let mg = config["mailgun"]?.object else {
throw MailgunError.noMailgunConfig
}
guard let domain = mg["domain"]?.string else {
throw MailgunError.missingConfig("domain")
}
guard let apiKey = mg["apiKey"]?.string else {
throw MailgunError.missingConfig("apiKey")
}
defaultDomain = domain
defaultApiKey = apiKey
}

public static func boot(_ drop: Vapor.Droplet) {
self.defaultClient = drop.client
}

public convenience init() throws {
guard let client = MailgunClient.defaultClient else {
throw MailgunError.noClient
}
guard let domain = MailgunClient.defaultDomain else {
throw MailgunError.missingConfig("domain")
}
guard let apiKey = MailgunClient.defaultApiKey else {
throw MailgunError.missingConfig("apiKey")
}
try self.init(clientProtocol: client, domain: domain, apiKey: apiKey)
}

public func send(_ emails: [SMTP.Email]) throws {
// Convert to Mailgun Emails and then send
let mgEmails = emails.map { MailgunEmail(from: $0 ) }
try send(mgEmails)
}

}
67 changes: 67 additions & 0 deletions Sources/Mailgun/MailgunError.swift
@@ -0,0 +1,67 @@
//
// MailgunError.swift
// Mail
//
// Created by Anthony Castelli on 3/27/17.
//
//

import Foundation
import Node

/*
An error, either in configuration or in execution.
*/
public enum MailgunError: Swift.Error {

public struct ErrorInfo: NodeInitializable {
public let message: String
public let id: String?

public init(node: Node, in context: Context) throws {
message = try node.extract("message")
id = try node.extract("id")
}
}

/*
No configuration for Mailgun could be found at all.
*/
case noMailgunConfig

/*
A required configuration key was missing. The associated value is the
name of the missing key.
*/
case missingConfig(String)

/*
MailgunClient was instantiated without a Vapor Client. This would
normally be set via Provider, but if you are instantiating directly,
you must pass or set the client protocol first.
*/
case noClient

/*
There was a problem with your request.
*/
case badRequest(ErrorInfo?)

/*
You do not have authorization to make the request.
*/
case unauthorized

/*
An error occurred on a Mailgun server, or the Mailgun v3 API is
not available.
*/
case serverError

/*
Missing email content
*/
case missingEmailContent

case unexpectedServerResponse
}
75 changes: 75 additions & 0 deletions Sources/Mailgun/Models/MailgunEmail.swift
@@ -0,0 +1,75 @@
//
// MailgunEmail.swift
// Mailgun
//
// Created by Anthony Castelli on 11/14/16.
//
//

import Foundation
import SMTP
import Node

public final class MailgunEmail {

public var from: EmailAddressRepresentable
public var to: [EmailAddress]
public var subject: String?
public var body: EmailBody

public init(to: [EmailAddress], from: EmailAddressRepresentable, subject: String?, body: EmailBody) {
self.to = to
self.from = from
self.subject = subject
self.body = body
}

internal func dictionaryRepresentation() -> [String : String] {
var data = [String : String]()
data["from"] = self.from.emailAddress.address
data["to"] = (self.to.map { $0.emailAddress.address }).joined(separator: ",")
data["subject"] = self.subject
switch self.body.type {
case .plain: data["text"] = self.body.content
case .html: data["html"] = self.body.content
}
return data
}

}

extension MailgunEmail {

/*
Convert a basic Vapor SMTP.Email into a MailgunEmail
*/
public convenience init(from: Email) {
self.init(to: from.to, from: from.from, subject: from.subject, body: from.body)
}

}

extension MailgunEmail: NodeRepresentable {

public func makeNode(context: Context) throws -> Node {
var obj = Node([:])
// From
obj["from"] = self.from.emailAddress.address.makeNode()

// To
obj["to"] = (self.to.map { $0.emailAddress.address }).joined(separator: ",").makeNode()

// Subject
if let subject = subject {
obj["subject"] = Node(subject)
}

// Body
switch self.body.type {
case .plain: obj["text"] = self.body.content.makeNode()
case .html: obj["html"] = self.body.content.makeNode()
}
return obj
}

}
51 changes: 51 additions & 0 deletions Sources/Mailgun/Other/SecureToken.swift
@@ -0,0 +1,51 @@
//
// SecureToken.swift
// Mail
//
// Created by Anthony Castelli on 3/28/17.
//
//

import Foundation

/** Coppied from TurstyleCrypto.
* Reason for this is to elimite the requirement on Tunrstyle per Vapor 2.0
**/
public class SecureToken {
private let file = fopen("/dev/urandom", "r")

/// Initialize URandom
public init() {}

deinit {
fclose(file)
}

private func read(numBytes: Int) -> [Int8] {
/// Initialize an empty array with numBytes+1 for null terminated string
var bytes = [Int8](repeating: 0, count: numBytes)
fread(&bytes, 1, numBytes, file)

return bytes
}

/// Get a byte array of random UInt8s
public func random(numBytes: Int) -> [UInt8] {
return unsafeBitCast(read(numBytes: numBytes), to: [UInt8].self)
}

/// Get a random string usable for authentication purposes
public var token: String {
return Data(bytes: random(numBytes: 16)).base64UrlEncodedString
}

}

extension Data {
var base64UrlEncodedString: String {
return base64EncodedString()
.replacingOccurrences(of: "=", with: "")
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
}
}

0 comments on commit c890908

Please sign in to comment.