diff --git a/Package.swift b/Package.swift index 69135ca..c38528b 100644 --- a/Package.swift +++ b/Package.swift @@ -2,6 +2,17 @@ import PackageDescription let package = Package( name: "Mail", + targets: [ + Target( + name: "Mail" + ), + Target( + name: "SendGrid", + dependencies: [ + "Mail" + ] + ), + ], dependencies: [ .Package(url: "https://github.com/vapor/vapor.git", majorVersion: 1, minor: 5), ] diff --git a/README.md b/README.md index 5c9c434..984661e 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ Vapor Provider for sending email through swappable backends. Backends included in this repository: +* `SendGrid`, a fully-featured implementation of the SendGrid V3 Mail Send API. * `InMemoryMailClient`, a development-only backend which stores emails in memory. * `ConsoleMailClient`, a development-only backend which outputs emails to the console. @@ -50,6 +51,52 @@ if let complicatedMailer = try drop.mailer.make() as? ComplicatedMailClient { } ``` +### SendGrid backend + +First, set up the Provider. + +```Swift +import Mail +import SendGrid + +let drop = Droplet() +try drop.addProvider(Mail.Provider.self) +``` + +SendGrid expects a configuration file named `sendgrid.json` with the following +format, and will throw `.noSendGridConfig` or `.missingConfig(fieldname)` if +configuration was not found. + +```json +{ + "apiKey": "SG.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) +``` + +However, `SendGrid` supports the full range of options available in SendGrid's +[V3 Mail Send API](https://sendgrid.com/docs/API_Reference/Web_API_v3/Mail/index.html). + +```Swift +let email = SendGridEmail(from: "from@test.com", templateId: "welcome_email") +email.personalizations.append(Personalization([ + to: "to@test.com" +])) +email.sandboxMode = true +email.openTracking = .enabled(nil) +if let sendgrid = try drop.mailer.make() as? SendGridClient { + sendgrid.send(email) +} +``` + +See `SendGridEmail.swift` for all configuration options. + ### Development backends There are two options for testing your emails in development. diff --git a/Sources/Mail/ConsoleMailClient/ConsoleMailClient.swift b/Sources/Mail/ConsoleMailClient/ConsoleMailClient.swift index d200523..7b0ba92 100644 --- a/Sources/Mail/ConsoleMailClient/ConsoleMailClient.swift +++ b/Sources/Mail/ConsoleMailClient/ConsoleMailClient.swift @@ -13,6 +13,8 @@ public final class ConsoleMailClient: MailClientProtocol { public static func configure(_ config: Config) throws {} + public static func boot(_ drop: Droplet) {} + public init() {} public func send(_ emails: [SMTP.Email]) throws { diff --git a/Sources/Mail/InMemoryMailClient/InMemoryMailClient.swift b/Sources/Mail/InMemoryMailClient/InMemoryMailClient.swift index fa5df66..414bc4d 100644 --- a/Sources/Mail/InMemoryMailClient/InMemoryMailClient.swift +++ b/Sources/Mail/InMemoryMailClient/InMemoryMailClient.swift @@ -14,6 +14,8 @@ public final class InMemoryMailClient: MailClientProtocol { public static func configure(_ config: Config) throws {} + public static func boot(_ drop: Droplet) {} + public init() {} public func send(_ emails: [SMTP.Email]) throws { diff --git a/Sources/Mail/MailClientProtocol.swift b/Sources/Mail/MailClientProtocol.swift index c2f4d04..a6d4643 100644 --- a/Sources/Mail/MailClientProtocol.swift +++ b/Sources/Mail/MailClientProtocol.swift @@ -29,6 +29,12 @@ public protocol MailClientProtocol { */ static func configure(_ config: Config) throws + /* + Called during the Provider's `boot(_:)` method. Use this method to + store a reference to the Droplet, if you need it. + */ + static func boot(_ drop: Droplet) + /* MailClient must be able to init without arguments. Store configuration loaded by the Provider in static vars on your MailClient, and throw if diff --git a/Sources/Mail/Provider.swift b/Sources/Mail/Provider.swift index 1949e10..253e19d 100644 --- a/Sources/Mail/Provider.swift +++ b/Sources/Mail/Provider.swift @@ -10,6 +10,7 @@ public final class Provider: Vapor.Provider { public init() throws {} public func boot(_ drop: Droplet) { + T.boot(drop) if let existing = drop.mailer { print("\(String(describing: T.self)) will overwrite existing mailer: \(String(describing: existing))") } diff --git a/Sources/SendGrid/Models/ClickTracking.swift b/Sources/SendGrid/Models/ClickTracking.swift new file mode 100644 index 0000000..9b6b8ec --- /dev/null +++ b/Sources/SendGrid/Models/ClickTracking.swift @@ -0,0 +1,14 @@ +public enum ClickTracking { + /* + Do not track clicks + */ + case disabled + /* + Track clicks in HTML emails only + */ + case htmlOnly + /* + Track clicks in HTML and plain text emails + */ + case enabled +} diff --git a/Sources/SendGrid/Models/EmailAddress+Node.swift b/Sources/SendGrid/Models/EmailAddress+Node.swift new file mode 100644 index 0000000..9f752d0 --- /dev/null +++ b/Sources/SendGrid/Models/EmailAddress+Node.swift @@ -0,0 +1,13 @@ +import SMTP +import Vapor + +extension EmailAddress: NodeRepresentable { + + public func makeNode(context: Context) throws -> Node { + guard let name = name else { + return Node(["email": Node(address)]) + } + return Node(["name": Node(name), "email": Node(address)]) + } + +} diff --git a/Sources/SendGrid/Models/Footer.swift b/Sources/SendGrid/Models/Footer.swift new file mode 100644 index 0000000..9a3c497 --- /dev/null +++ b/Sources/SendGrid/Models/Footer.swift @@ -0,0 +1,22 @@ +/* + Footer to append to the email. Can be either plaintext or HTML. +*/ +public struct Footer { + public enum ContentType { + case html, plain + } + + public let type: ContentType + public let content: String + + public init(type: ContentType, content: String) { + self.type = type + self.content = content + } +} + +extension Footer: Equatable {} +public func ==(lhs: Footer, rhs: Footer) -> Bool { + return lhs.type == rhs.type + && lhs.content == rhs.content +} diff --git a/Sources/SendGrid/Models/GoogleAnalytics.swift b/Sources/SendGrid/Models/GoogleAnalytics.swift new file mode 100644 index 0000000..761454a --- /dev/null +++ b/Sources/SendGrid/Models/GoogleAnalytics.swift @@ -0,0 +1,15 @@ +public struct GoogleAnalytics { + let source: String? + let medium: String? + let term: String? + let content: String? + let campaign: String? + + public init(source: String?, medium: String, term: String, content: String?, campaign: String?) { + self.source = source + self.medium = medium + self.term = term + self.content = content + self.campaign = campaign + } +} diff --git a/Sources/SendGrid/Models/OpenTracking.swift b/Sources/SendGrid/Models/OpenTracking.swift new file mode 100644 index 0000000..a4bf0eb --- /dev/null +++ b/Sources/SendGrid/Models/OpenTracking.swift @@ -0,0 +1,12 @@ +public enum OpenTracking { + /* + Do not track opens + */ + case disabled + /* + Track opens in emails. + + substitutionTag: This tag will be replaced by the open tracking pixel. + */ + case enabled(substitutionTag: String?) +} diff --git a/Sources/SendGrid/Models/Personalization.swift b/Sources/SendGrid/Models/Personalization.swift new file mode 100644 index 0000000..2baea76 --- /dev/null +++ b/Sources/SendGrid/Models/Personalization.swift @@ -0,0 +1,68 @@ +import Foundation +import SMTP +import Vapor + +public struct Personalization { + + /* + The email recipients + */ + public let to: [EmailAddress] + /* + The email copy recipients + */ + public let cc: [EmailAddress] = [] + /* + The email blind copy recipients + */ + public let bcc: [EmailAddress] = [] + /* + The email subject, overriding that of the Email, if set + */ + public let subject: String? = nil + /* + Custom headers + */ + public let headers: [String: String] = [:] + /* + Custom substitutions in the format ["tag": "value"] + */ + public let substitutions: [String: String] = [:] + /* + Date to send the email, or `nil` if email to be sent immediately + */ + public let sendAt: Date? = nil + + public init(to: [EmailAddress]) { + self.to = to + } + +} + +extension Personalization: NodeRepresentable { + + public func makeNode(context: Context) throws -> Node { + var node = Node([:]) + node["to"] = try Node(to.map { try $0.makeNode() }) + if !cc.isEmpty { + node["cc"] = try Node(cc.map { try $0.makeNode() }) + } + if !bcc.isEmpty { + node["bcc"] = try Node(bcc.map { try $0.makeNode() }) + } + if let subject = subject { + node["subject"] = Node(subject) + } + if !headers.isEmpty { + node["headers"] = try headers.makeNode() + } + if !substitutions.isEmpty { + node["substitutions"] = try substitutions.makeNode() + } + if let sendAt = sendAt { + node["send_at"] = Node(sendAt.timeIntervalSince1970) + } + return node + } + +} diff --git a/Sources/SendGrid/Models/SendGridEmail+Email.swift b/Sources/SendGrid/Models/SendGridEmail+Email.swift new file mode 100644 index 0000000..9d72f15 --- /dev/null +++ b/Sources/SendGrid/Models/SendGridEmail+Email.swift @@ -0,0 +1,14 @@ +import SMTP + +extension SendGridEmail { + + /* + Convert a basic Vapor SMTP.Email into a SendGridEmail + */ + public convenience init(from: Email) { + self.init(from: from.from, subject: from.subject, body: from.body) + attachments = from.attachments + personalizations = [Personalization(to: from.to)] + } + +} diff --git a/Sources/SendGrid/Models/SendGridEmail+NodeRepresentable.swift b/Sources/SendGrid/Models/SendGridEmail+NodeRepresentable.swift new file mode 100644 index 0000000..df1699c --- /dev/null +++ b/Sources/SendGrid/Models/SendGridEmail+NodeRepresentable.swift @@ -0,0 +1,158 @@ +import Vapor + +extension SendGridEmail: NodeRepresentable { + + public func makeNode(context: Context) throws -> Node { + var obj = Node([:]) + // Personalizations + obj["personalizations"] = try personalizations.makeNode() + // From + obj["from"] = try from.makeNode() + // Reply To + if let replyTo = replyTo { + obj["reply_to"] = try replyTo.makeNode() + } + // Subject + if let subject = subject { + obj["subject"] = Node(subject) + } + // Content + if !content.isEmpty { + obj["content"] = Node(content.map { + switch $0.type { + case .html: + return Node(["type": "text/html", + "value": $0.content.makeNode()]) + case .plain: + return Node(["type": "text/plain", + "value": $0.content.makeNode()]) + } + }) + } + // Attachments + if !attachments.isEmpty { + obj["attachments"] = Node(attachments.map { + Node([ + "filename": $0.emailAttachment.filename.makeNode(), + "content": Node($0.emailAttachment.body.base64String), + "type": $0.emailAttachment.contentType.makeNode(), + ]) + }) + } + print(String(describing: obj["attachments"])) + // Template Id + if let templateId = templateId { + obj["template_id"] = Node(templateId) + } + // Sections + if !sections.isEmpty { + obj["sections"] = try sections.makeNode() + } + // Categories + if !categories.isEmpty { + obj["categories"] = try categories.makeNode() + } + // Send At + if let sendAt = sendAt { + obj["send_at"] = Node(sendAt.timeIntervalSince1970) + } + // Batch Id + if let batchId = batchId { + obj["batch_id"] = Node(batchId) + } + // asm + switch unsubscribeHandling { + case let .usingGroupId(groupId, groups): + obj["asm", "group_id"] = Node(groupId) + obj["asm", "groups_to_display"] = Node(groups.map { Node($0) }) + case .default: + break + } + // IP Pool Name + if let ipPoolName = ipPoolName { + obj["ip_pool_name"] = Node(ipPoolName) + } + /// MAIL SETTINGS + var ms = Node([:]) + // BCC + if let bccFirst = bccFirst { + ms["bcc", "enable"] = true + ms["bcc", "email"] = try bccFirst.makeNode() + } + // Bypass List Management + if bypassListManagement { + ms["bypass_list_management", "enable"] = true + } + // Footer + if !footer.isEmpty { + ms["footer", "enable"] = true + footer.forEach { + switch $0.type { + case .html: + ms["footer", "html"] = Node($0.content) + case .plain: + ms["footer", "text"] = Node($0.content) + } + } + } + // Sandbox Mode + if sandboxMode { + ms["sandbox_mode", "enable"] = true + } + // Spam Check + switch spamCheckMode { + case let .enabled(threshold, url): + ms["spam_check", "enable"] = true + ms["spam_check", "threshold"] = Node(threshold) + ms["spam_check", "post_to_url"] = Node(url) + case .disabled: + break + } + obj["mail_settings"] = ms + /// TRACKING SETTINGS + var ts = Node([:]) + // Click Tracking + switch clickTracking { + case .enabled: + ts["click_tracking", "enable"] = true + ts["click_tracking", "enable_text"] = true + case .htmlOnly: + ts["click_tracking", "enable"] = true + ts["click_tracking", "enable_text"] = false + case .disabled: + break + } + // Open Tracking + switch openTracking { + case .enabled(let substitutionTag): + ts["open_tracking", "enable"] = true + ts["open_tracking", "substitution_tag"] = substitutionTag?.makeNode() + case .disabled: + break + } + // Subscription Tracking + switch subscriptionManagement { + case let .appending(text, html): + ts["subscription_tracking", "enable"] = true + ts["subscription_tracking", "text"] = text?.makeNode() + ts["subscription_tracking", "html"] = html?.makeNode() + case .enabled(let replacingTag): + ts["subscription_tracking", "enable"] = true + ts["subscription_tracking", "substitution_tag"] = Node(replacingTag) + case .disabled: + break + } + // Google Analytics + if let ga = googleAnalytics { + ts["ganalytics", "enable"] = true + ts["ganalytics", "utm_source"] = ga.source?.makeNode() + ts["ganalytics", "utm_medium"] = ga.medium?.makeNode() + ts["ganalytics", "utm_term"] = ga.term?.makeNode() + ts["ganalytics", "utm_content"] = ga.content?.makeNode() + ts["ganalytics", "utm_campaign"] = ga.campaign?.makeNode() + } + obj["tracking_settings"] = ts + return obj + } + +} diff --git a/Sources/SendGrid/Models/SendGridEmail.swift b/Sources/SendGrid/Models/SendGridEmail.swift new file mode 100644 index 0000000..c518a32 --- /dev/null +++ b/Sources/SendGrid/Models/SendGridEmail.swift @@ -0,0 +1,130 @@ +import Foundation +import SMTP + +public final class SendGridEmail { + + /* + Array of personalization 'envelopes' for this email. + + See https://sendgrid.com/docs/Classroom/Send/v3_Mail_Send/personalizations.html + */ + public var personalizations: [Personalization] = [] + /* + Email sender address + */ + public let from: EmailAddress + /* + If set, the first 'personalization' will be sent BCC to this address + */ + public var bccFirst: EmailAddress? = nil + /* + Email reply-to address + */ + public var replyTo: EmailAddress? = nil + /* + Email subject, which can be overwritten by each personalization, + and is required unless every personalization has a subject set or + the email uses a template with a subject already set + */ + public let subject: String? + /* + Set of attachments to this email + */ + public var attachments: [EmailAttachmentRepresentable] = [] + /* + An object of key/value pairs that define large blocks of content that + can be inserted into your emails using substitution tags. + */ + public var sections: [String: String] = [:] + /* + Custom email headers + */ + public var headers: [String: String] = [:] + /* + Category names for email, max 10 + */ + public var categories: [String] = [] + /* + Date to send the email, or `nil` if email to be sent immediately + */ + public let sendAt: Date? = nil + /* + Include this email in a named batch + */ + public let batchId: String? = nil + /* + Determines how to handle unsubscribe links + */ + public let unsubscribeHandling: UnsubscribeHandling = .default + /* + IP pool to send from + */ + public let ipPoolName: String? = nil + /* + Bypass unsubscriptions and suppressions - use only in emergency + */ + public let bypassListManagement: Bool = false + /* + Footer to add to email in variety of content types + */ + public var footer: [Footer] = [] + /* + Send as test email + */ + public var sandboxMode: Bool = false + /* + Enable spam testing + */ + public var spamCheckMode: SpamCheck = .disabled + /* + Set click tracking behaviour + */ + public var clickTracking: ClickTracking = .disabled + /* + Set open tracking behaviour + */ + public var openTracking: OpenTracking = .disabled + /* + Set behaviour of subscription management links + */ + public var subscriptionManagement: SubscriptionManagement = .disabled + /* + Set Google Analytics tracking + */ + public var googleAnalytics: GoogleAnalytics? = nil + + /* + ID of a predefined template to use + */ + public let templateId: String? + /* + Email body content. Can only be empty if `templateId` is set + */ + public var content: [EmailBody] = [] + + + private init(from: EmailAddressRepresentable, subject: String?, templateId: String?, body: EmailBodyRepresentable?) { + personalizations = [] + self.from = from.emailAddress + self.subject = subject + self.templateId = templateId + if let body = body { + self.content = [body.emailBody] + } + } + + /* + Init from a template + */ + public convenience init(from: EmailAddressRepresentable, templateId: String, subject: String?=nil, body: EmailBodyRepresentable?=nil) { + self.init(from: from, subject: subject, templateId: templateId, body: body) + } + + /* + Init from an email body + */ + public convenience init(from: EmailAddressRepresentable, subject: String, body: EmailBodyRepresentable) { + self.init(from: from, subject: subject, templateId: nil, body: body) + } + +} diff --git a/Sources/SendGrid/Models/SpamCheck.swift b/Sources/SendGrid/Models/SpamCheck.swift new file mode 100644 index 0000000..a4f4c8b --- /dev/null +++ b/Sources/SendGrid/Models/SpamCheck.swift @@ -0,0 +1,13 @@ +public enum SpamCheck { + /* + Do not run a spam check. + */ + case disabled + /* + Run a spam check. + + threshold: Strictness of checking, from 1 (least) to 10 (most). + url: Inbound Parse URL where the email and spam report are sent. + */ + case enabled(threshold: Int, url: String) +} diff --git a/Sources/SendGrid/Models/SubscriptionManagement.swift b/Sources/SendGrid/Models/SubscriptionManagement.swift new file mode 100644 index 0000000..37b8178 --- /dev/null +++ b/Sources/SendGrid/Models/SubscriptionManagement.swift @@ -0,0 +1,21 @@ +public enum SubscriptionManagement { + /* + Do not insert a subscription management link. + */ + case disabled + /* + Append the given content to the email, replacing `<% %>` with the + subscription management link. + + text: The content to use for plain text emails + + html: The content to use for html emails. + */ + case appending(text: String?, html: String?) + /* + Replace the given tag with the subscription management URL. + + replacingTag: The tag to replace + */ + case enabled(replacingTag: String) +} diff --git a/Sources/SendGrid/Models/UnsubscribeHandling.swift b/Sources/SendGrid/Models/UnsubscribeHandling.swift new file mode 100644 index 0000000..3e3dc57 --- /dev/null +++ b/Sources/SendGrid/Models/UnsubscribeHandling.swift @@ -0,0 +1,5 @@ +// 'asm' in the JSON, I don't get this one yet +public enum UnsubscribeHandling { + case `default` + case usingGroupId(Int, displayGroups: [Int]) +} diff --git a/Sources/SendGrid/SendGridClient.swift b/Sources/SendGrid/SendGridClient.swift new file mode 100644 index 0000000..8994e40 --- /dev/null +++ b/Sources/SendGrid/SendGridClient.swift @@ -0,0 +1,85 @@ +import HTTP +import Mail +import SMTP +import Vapor +import Foundation + +/** + SendGrid client +*/ +public final class SendGridClient { + + static var defaultApiKey: String? + static var defaultClient: ClientProtocol.Type? + + var apiKey: String + var client: ClientProtocol + + public init(clientProtocol: ClientProtocol.Type, apiKey: String) throws { + self.apiKey = apiKey + client = try clientProtocol.make(scheme: "https", host: "api.sendgrid.com") + } + + public func send(_ emails: [SendGridEmail]) throws { + try emails.forEach { email in + let jsonBytes = try JSON(node: email.makeNode()).makeBytes() + let response = try client.post(path: "/v3/mail/send", headers: [ + "Authorization": "Bearer \(apiKey)", + "Content-Type": "application/json" + ], body: Body(jsonBytes)) + switch response.status.statusCode { + case 200, 202: + return + case 400: + throw SendGridError.badRequest( + try response.json?.extract("errors") ?? [] + ) + case 401: + throw SendGridError.unauthorized + case 413: + throw SendGridError.payloadTooLarge + case 429: + throw SendGridError.tooManyRequests + case 500, 503: + throw SendGridError.serverError + default: + throw SendGridError.unexpectedServerResponse + } + } + } + +} + +extension SendGridClient: MailClientProtocol { + + public static func configure(_ config: Settings.Config) throws { + guard let sg = config["sendgrid"]?.object else { + throw SendGridError.noSendGridConfig + } + guard let apiKey = sg["apiKey"]?.string else { + throw SendGridError.missingConfig("apiKey") + } + defaultApiKey = apiKey + } + + public static func boot(_ drop: Vapor.Droplet) { + defaultClient = drop.client + } + + public convenience init() throws { + guard let client = SendGridClient.defaultClient else { + throw SendGridError.noClient + } + guard let apiKey = SendGridClient.defaultApiKey else { + throw SendGridError.missingConfig("apiKey") + } + try self.init(clientProtocol: client, apiKey: apiKey) + } + + public func send(_ emails: [SMTP.Email]) throws { + // Convert to SendGrid Emails and then send + let sgEmails = emails.map { SendGridEmail(from: $0 ) } + try send(sgEmails) + } + +} diff --git a/Sources/SendGrid/SendGridError.swift b/Sources/SendGrid/SendGridError.swift new file mode 100644 index 0000000..ad2518f --- /dev/null +++ b/Sources/SendGrid/SendGridError.swift @@ -0,0 +1,62 @@ +import Node + +/* + An error, either in configuration or in execution. +*/ +public enum SendGridError: Swift.Error { + + public struct ErrorInfo: NodeInitializable { + public let message: String + public let field: String? + public let helpMessage: String? + + public init(node: Node, in context: Context) throws { + message = try node.extract("message") + field = try node.extract("field") + helpMessage = try node.extract("help") + } + } + + /* + No configuration for SendGrid could be found at all. + */ + case noSendGridConfig + /* + A required configuration key was missing. The associated value is the + name of the missing key. + */ + case missingConfig(String) + /* + SendGridClient 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 + + // SendGrid's errors: + // https://sendgrid.com/docs/API_Reference/Web_API_v3/Mail/errors.html + /* + There was a problem with your request. + */ + case badRequest([ErrorInfo]) + /* + You do not have authorization to make the request. + */ + case unauthorized + /* + The JSON payload you have included in your request is too large. + */ + case payloadTooLarge + /* + The number of requests you have made exceeds SendGrid’s rate + limitations. + */ + case tooManyRequests + /* + An error occurred on a SendGrid server, or the SendGrid v3 Web API is + not available. + */ + case serverError + + case unexpectedServerResponse +} diff --git a/Tests/LinuxMain.swift b/Tests/LinuxMain.swift index 3b702f7..42cd2e0 100644 --- a/Tests/LinuxMain.swift +++ b/Tests/LinuxMain.swift @@ -3,6 +3,7 @@ import XCTest @testable import MailTests +@testable import SendGridTests XCTMain([ // MailClientProtocol Tests @@ -13,6 +14,9 @@ XCTMain([ // InMemoryMailClientTests testCase(InMemoryMailClientTests.allTests), + + // SendGridClient Tests + testCase(SendGridClientTests.allTests), ]) #endif diff --git a/Tests/MailTests/DropletTests.swift b/Tests/MailTests/DropletTests.swift index c4cf193..654799d 100644 --- a/Tests/MailTests/DropletTests.swift +++ b/Tests/MailTests/DropletTests.swift @@ -24,6 +24,8 @@ private final class DummyMailClient: MailClientProtocol { configurationString = string } + public static func boot(_ drop: Droplet) {} + init() throws {} func send(_ emails: [SMTP.Email]) throws { diff --git a/Tests/SendGridTests/SendGridClientTests.swift b/Tests/SendGridTests/SendGridClientTests.swift new file mode 100644 index 0000000..c799696 --- /dev/null +++ b/Tests/SendGridTests/SendGridClientTests.swift @@ -0,0 +1,48 @@ +import XCTest +import Mail +import SMTP +import SendGrid +@testable import Vapor + +// Test inbox: https://www.mailinator.com/inbox2.jsp?public_to=bygri-mail + +class SendGridClientTests: XCTestCase { + static let allTests = [ + ("testProvider", testProvider), + ] + + let apiKey = "SG.YOUR_KEY" // Set here, but don't commit to git! + + func testProvider() throws { + if apiKey == "SG.YOUR_KEY" { + print("Not testing SendGrid as no API Key is set") + return + } + let config = Config([ + "sendgrid": [ + "apiKey": Node(apiKey) + ], + ]) + let drop = try makeDroplet(config: config) + try drop.addProvider(Mail.Provider.self) + + let email = SMTP.Email(from: "bygri-mail-from@mailinator.com", + to: "bygri-mail@mailinator.com", + subject: "Email Subject", + body: "Hello Email") + let attachment = EmailAttachment(filename: "dummy.data", + contentType: "dummy/data", + body: [1,2,3,4,5]) + email.attachments.append(attachment) + try drop.mailer?.send(email) + } + +} + +extension SendGridClientTests { + func makeDroplet(config: Config? = nil) throws -> Droplet { + let drop = Droplet(arguments: ["/dummy/path/", "prepare"], config: config) + try drop.runCommands() + return drop + } +}