diff --git a/Package.swift b/Package.swift index 4c7b3e6..c48ffd5 100644 --- a/Package.swift +++ b/Package.swift @@ -1,16 +1,16 @@ -// swift-tools-version:5.2 +// swift-tools-version:5.6 import PackageDescription let package = Package( name: "email", platforms: [ - .macOS(.v10_15) + .macOS(.v12), ], products: [ .library(name: "Email", targets: ["Email"]), ], dependencies: [ - .package(url: "https://github.com/vapor/vapor.git", from: "4.0.0") + .package(url: "https://github.com/vapor/vapor.git", from: "4.66.1"), ], targets: [ .target(name: "Email", dependencies: [ diff --git a/Sources/Email/Application+Emails.swift b/Sources/Email/Application+Emails.swift new file mode 100644 index 0000000..0387d05 --- /dev/null +++ b/Sources/Email/Application+Emails.swift @@ -0,0 +1,60 @@ +import Vapor + +extension Application { + public struct Emails { + public struct Provider { + let run: (Application) -> () + + public init(_ run: @escaping (Application) -> ()) { + self.run = run + } + } + + let application: Application + + final class Storage { + var makeEmailClient: ((Application) -> EmailClient)? + public init() { } + } + + struct Key: StorageKey { + typealias Value = Storage + } + + var storage: Storage { + if self.application.storage[Key.self] == nil { + self.initialize() + } + + return self.application.storage[Key.self]! + } + + func initialize() { + self.application.storage[Key.self] = .init() + } + + public func use(_ makeEmailClient: @escaping ((Application) -> EmailClient)) { + self.storage.makeEmailClient = makeEmailClient + } + + public func use(_ provider: Provider) { + provider.run(application) + } + + public var client: EmailClient { + guard let factory = self.storage.makeEmailClient else { + fatalError("EmailClient is not configured, use: app.emails.use()") + } + + return factory(application) + } + } + + public var emails: Emails { + .init(application: self) + } + + public var email: EmailClient { + emails.client + } +} diff --git a/Sources/Email/Content.swift b/Sources/Email/Content.swift new file mode 100644 index 0000000..feb5adf --- /dev/null +++ b/Sources/Email/Content.swift @@ -0,0 +1,9 @@ +import Foundation + +extension EmailMessage { + public enum Content: Codable, Equatable { + case text(String) + case html(String) + case universal(text: String, html: String) + } +} diff --git a/Sources/Email/EmailAddress.swift b/Sources/Email/EmailAddress.swift new file mode 100644 index 0000000..7f48a4f --- /dev/null +++ b/Sources/Email/EmailAddress.swift @@ -0,0 +1,43 @@ +import Vapor + +public struct EmailAddress: Content, Equatable { + public let email: String + public let name: String? + + public init(email: String, name: String? = nil) { + self.email = email + self.name = name + } + + /// Returns the email address formated as "Name " + public var fullAddress: String { + if let name = self.name { + return "\(name) <\(email)>" + } + return email + } +} + +extension EmailAddress: CustomStringConvertible { + public var description: String { + self.fullAddress + } +} + +extension EmailAddress: ExpressibleByStringLiteral { + public init(stringLiteral value: String) { + // "From: $Name <$Email>" + let split = value.components(separatedBy: " <") + guard + split.count == 2, + let name = split.first, + var emailPart = split.last, + emailPart.removeLast() == ">" + else { + self = .init(email: value) + return + } + + self = .init(email: emailPart, name: name) + } +} diff --git a/Sources/Email/EmailAddressRepresentable.swift b/Sources/Email/EmailAddressRepresentable.swift new file mode 100644 index 0000000..a17cce8 --- /dev/null +++ b/Sources/Email/EmailAddressRepresentable.swift @@ -0,0 +1,15 @@ +public protocol EmailAddressRepresentable { + var email: String { get } + var name: String? { get } +} + +extension EmailAddressRepresentable { + public var emailAddress: EmailAddress { + if let name = self.name { + return .init(email: self.email, name: name) + } else { + return .init(email: self.email) + } + } +} + diff --git a/Sources/Email/EmailAttachment.swift b/Sources/Email/EmailAttachment.swift new file mode 100644 index 0000000..4adbb94 --- /dev/null +++ b/Sources/Email/EmailAttachment.swift @@ -0,0 +1,6 @@ +import Vapor + +public enum EmailAttachment: Codable, Equatable { + case attachment(Vapor.File) + case inline(Vapor.File) +} diff --git a/Sources/Email/EmailClient.swift b/Sources/Email/EmailClient.swift new file mode 100644 index 0000000..481440c --- /dev/null +++ b/Sources/Email/EmailClient.swift @@ -0,0 +1,12 @@ +import Vapor + +public protocol EmailClient { + func send(_ messages: [EmailMessage]) -> EventLoopFuture + func delegating(to eventLoop: EventLoop) -> Self +} + +extension EmailClient { + public func send(_ message: EmailMessage) -> EventLoopFuture { + self.send([message]) + } +} diff --git a/Sources/Email/EmailMessage.swift b/Sources/Email/EmailMessage.swift new file mode 100644 index 0000000..1f80d61 --- /dev/null +++ b/Sources/Email/EmailMessage.swift @@ -0,0 +1,199 @@ +import Vapor + +public struct EmailMessage: Codable, Equatable { + public let from: EmailAddress + public let to: [EmailAddress] + public let replyTo: EmailAddress? + public let cc: [EmailAddress]? + public let bcc: [EmailAddress]? + public let subject: String + public let content: Content + public let attachments: [EmailAttachment]? + + /// Intitialize new message with a single recipient. + public init( + from: EmailAddress, + to: EmailAddress, + replyTo: EmailAddress? = nil, + cc: [EmailAddress]? = nil, + bcc: [EmailAddress]? = nil, + subject: String, + content: Content, + attachments: [EmailAttachment]? = nil + ) { + self.init( + from: from, + to: [to], + replyTo: replyTo, + cc: cc, + bcc: bcc, + subject: subject, + content: content, + attachments: attachments + ) + } + + /// Intitialize new message with a single recipient conforming to `EmailAddressRepresentable` + public init( + from: EmailAddress, + to: E, + replyTo: EmailAddress? = nil, + cc: [EmailAddress]? = nil, + bcc: [EmailAddress]? = nil, + subject: String, + content: Content, + attachments: [EmailAttachment]? = nil + ) + where E: EmailAddressRepresentable + { + self.init( + from: from, + to: [to], + replyTo: replyTo, + cc: cc, + bcc: bcc, + subject: subject, + content: content, + attachments: attachments + ) + } + + /// Intitialize new message with multiple recipients conforming to `EmailAddressRepresentable` + public init( + from: EmailAddress, + to: [E], + replyTo: EmailAddress? = nil, + cc: [EmailAddress]? = nil, + bcc: [EmailAddress]? = nil, + subject: String, + content: Content, + attachments: [EmailAttachment]? = nil + ) + where E: EmailAddressRepresentable + { + self.init( + from: from, + to: to.map { $0.emailAddress }, + replyTo: replyTo, + cc: cc, + bcc: bcc, + subject: subject, + content: content, + attachments: attachments + ) + } + + /// Intitialize new message a single recipient conforming to `EmailAddressRepresentable` + public init( + from: String, + to: E, + replyTo: String? = nil, + cc: [String]? = nil, + bcc: [String]? = nil, + subject: String, + content: Content, + attachments: [EmailAttachment]? = nil + ) + where E: EmailAddressRepresentable + { + self.init( + from: from, + to: [to], + replyTo: replyTo, + cc: cc, + bcc: bcc, + subject: subject, + content: content, + attachments: attachments + ) + } + + /// Intitialize new message with multiple recipients conforming to `EmailAddressRepresentable` + public init( + from: String, + to: [E], + replyTo: String? = nil, + cc: [String]? = nil, + bcc: [String]? = nil, + subject: String, + content: Content, + attachments: [EmailAttachment]? = nil + ) + where E: EmailAddressRepresentable + { + self.init( + from: from, + to: to.map { $0.emailAddress.fullAddress }, + replyTo: replyTo, + cc: cc, + bcc: bcc, + subject: subject, + content: content, + attachments: attachments + ) + } + + public init( + from: String, + to: String, + replyTo: String? = nil, + cc: [String]? = nil, + bcc: [String]? = nil, + subject: String, + content: Content, + attachments: [EmailAttachment]? = nil + ) { + self.init( + from: from, + to: [to], + replyTo: replyTo, + cc: cc, + bcc: bcc, + subject: subject, + content: content, + attachments: attachments + ) + } + + public init( + from: String, + to: [String], + replyTo: String? = nil, + cc: [String]? = nil, + bcc: [String]? = nil, + subject: String, + content: Content, + attachments: [EmailAttachment]? = nil + ) { + self.init( + from: EmailAddress(stringLiteral: from), + to: to.map(EmailAddress.init), + replyTo: replyTo.map(EmailAddress.init), + cc: cc?.map(EmailAddress.init), + bcc: bcc?.map(EmailAddress.init), + subject: subject, + content: content, + attachments: attachments + ) + } + + public init( + from: EmailAddress, + to: [EmailAddress], + replyTo: EmailAddress? = nil, + cc: [EmailAddress]? = nil, + bcc: [EmailAddress]? = nil, + subject: String, + content: Content, + attachments: [EmailAttachment]? = nil + ) { + self.from = from + self.to = to + self.replyTo = replyTo + self.cc = cc + self.bcc = bcc + self.subject = subject + self.content = content + self.attachments = attachments + } +} diff --git a/Sources/Email/Request+Email.swift b/Sources/Email/Request+Email.swift new file mode 100644 index 0000000..4193408 --- /dev/null +++ b/Sources/Email/Request+Email.swift @@ -0,0 +1,7 @@ +import Vapor + +extension Request { + public var email: EmailClient { + application.email.delegating(to: eventLoop) + } +} diff --git a/Tests/EmailTests/EmailAddressTests.swift b/Tests/EmailTests/EmailAddressTests.swift new file mode 100644 index 0000000..1f0e37f --- /dev/null +++ b/Tests/EmailTests/EmailAddressTests.swift @@ -0,0 +1,18 @@ +import XCTest +@testable import Email +import XCTVapor + +final class EmailTests: XCTestCase { + func testInitEmailAdressByString() throws { + let emailAddressWithEmail: EmailAddress = "test@vapor.codes" + XCTAssertEqual(emailAddressWithEmail.email, "test@vapor.codes") + XCTAssertNil(emailAddressWithEmail.name) + + let emailAddressWithEmailAndName: EmailAddress = "Vapor " + XCTAssertEqual(emailAddressWithEmailAndName.email, "test@vapor.codes") + XCTAssertEqual(emailAddressWithEmailAndName.name, "Vapor") + + let emailAddressIncorrectFormat: EmailAddress = "nameasd" + XCTAssertEqual(emailAddressIncorrectFormat.email, "nameasd") + } +}