diff --git a/Feature/Sources/IMAP/IMAPError.swift b/Feature/Sources/IMAP/IMAPError.swift new file mode 100644 index 00000000..2291501c --- /dev/null +++ b/Feature/Sources/IMAP/IMAPError.swift @@ -0,0 +1,17 @@ +/// ``IMAPClient`` throws `IMAPError`. +public enum IMAPError: Error, CustomStringConvertible, Equatable { + case underlying(String) + case example + + init(_ error: Error) { + self = error as? Self ?? .underlying(error.localizedDescription) + } + + // MARK: CustomStringConvertible + public var description: String { + switch self { + case .underlying(let string): "Underlying: \(string)" + case .example: "Example" + } + } +} diff --git a/Feature/Sources/IMAP/LoggingHandler.swift b/Feature/Sources/IMAP/LoggingHandler.swift index 071fc092..752431b6 100644 --- a/Feature/Sources/IMAP/LoggingHandler.swift +++ b/Feature/Sources/IMAP/LoggingHandler.swift @@ -14,25 +14,20 @@ final class LoggingHandler: ChannelDuplexHandler, @unchecked Sendable { typealias OutboundIn = ByteBuffer func write(context: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise?) { - logger?.debug("\(self.unwrapOutboundIn(data).readableLogView(redactBase64Encoded: true))") - context.write(data, promise: promise) + logger?.debug("\(self.unwrapOutboundIn(data).stringValue)") + context.write(data, promise: promise) // Handler only observes; pass unmodified data to next handler } func channelRead(context: ChannelHandlerContext, data: NIOAny) { - logger?.debug("\(self.unwrapInboundIn(data).readableLogView())") - context.fireChannelRead(data) + logger?.debug("\(self.unwrapInboundIn(data).stringValue)") + context.fireChannelRead(data) // Pass unmodified data to next handler } } -extension ByteBuffer { - // Format bytes as debug description; optionally redact user names and passwords - func readableLogView(redactBase64Encoded: Bool = false) -> String { +private extension ByteBuffer { + var stringValue: String { let string: String = String(decoding: readableBytesView, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines) - guard let data: Data = Data(base64Encoded: string), - let string: String = String(data: data, encoding: .utf8) - else { - return string // String not base64-encoded; return as is - } - return redactBase64Encoded ? "[redacted]" : string + // TODO: Redact IMAP credential + return string } } diff --git a/Feature/Sources/IMAP/Message.swift b/Feature/Sources/IMAP/Message.swift index c8955c10..a9d7932e 100644 --- a/Feature/Sources/IMAP/Message.swift +++ b/Feature/Sources/IMAP/Message.swift @@ -1,48 +1,45 @@ +import EmailAddress import Foundation import MIME +import NIOIMAP /// [IMAP message attributes](https://www.ietf.org/rfc/rfc9051.html#section-2.3) public struct Message { - public enum Flag: String, CaseIterable, CustomStringConvertible, Sendable { - case seen = "\\Seen" - case answered = "\\Answered" - case flagged = "\\Flagged" - case deleted = "\\Deleted" - case draft = "\\Draft" - case forwarded = "$Forwarded" - case mdnSent = "$MDNSent" - case junk = "$Junk" - case notJunk = "$NotJunk" - case phishing = "$Phishing" + public typealias Flag = NIOIMAPCore.Flag - // MARK: CustomStringConvertible - public var description: String { rawValue } - } - - public let isDeleted: Bool - public let folderID: Int - public let uid: String - public let subject: String - public let date: Date + public let body: Body + public let contentType: ContentType public let flags: [Flag] - public let senderList: String - public let toList: String - public let ccList: String - public let bccList: String - public let replyToList: String - public let attachmentCount: Int - public let internalDate: Date + public let folderID: Int + public let inReplyTo: String? public let messageID: String - public let previewType: String - public let preview: String - public let mimeType: String - public let normalizedSubjectHash: Int - public let isEmpty: Bool - public let isRead: Bool - public let isFlagged: Bool - public let isAnswered: Bool - public let isForwarded: Bool - public let messagePartID: Int - public let encryptionType: String - public let isNewMessage: Bool + public let replyTo: String? + public let size: Int + public let subject: String + public let uid: UID + + public var isAnswered: Bool { flags.isAnswered } + public var isDeleted: Bool { flags.isDeleted } + public var isDraft: Bool { flags.isDraft } + public var isEmpty: Bool { body.isEmpty } + public var isFlagged: Bool { flags.isFlagged } + public var isForwarded: Bool { flags.isForwarded } + public var isSeen: Bool { flags.isSeen } +} + +extension Message.Flag: @retroactive CustomStringConvertible { + + // MARK: CustomStringConvertible + public var description: String { debugDescription } +} + +public typealias UID = NIOIMAP.UID + +private extension [Message.Flag] { + var isAnswered: Bool { contains(.answered) } + var isDeleted: Bool { contains(.deleted) } + var isDraft: Bool { contains(.draft) } + var isFlagged: Bool { contains(.flagged) } + var isForwarded: Bool { contains(.keyword(.forwarded)) } + var isSeen: Bool { contains(.seen) } } diff --git a/Feature/Sources/IMAP/Server.swift b/Feature/Sources/IMAP/Server.swift index b81aadef..7c2e7beb 100644 --- a/Feature/Sources/IMAP/Server.swift +++ b/Feature/Sources/IMAP/Server.swift @@ -7,11 +7,11 @@ public struct Server: Equatable, Sendable { public let port: Int public init( - _ connectionSecurity: ConnectionSecurity = .startTLS, + _ connectionSecurity: ConnectionSecurity = .tls, hostname: String, username: String, password: String, - port: Int = 143 // 993 + port: Int = 993 ) { self.connectionSecurity = connectionSecurity self.hostname = hostname diff --git a/Feature/Sources/MIME/Body.swift b/Feature/Sources/MIME/Body.swift index 60934d4c..625ef3f3 100644 --- a/Feature/Sources/MIME/Body.swift +++ b/Feature/Sources/MIME/Body.swift @@ -1,7 +1,7 @@ import Foundation /// Multipart body element described in [RFC 2045](https://www.rfc-editor.org/rfc/rfc2045#section-2.6) -public struct Body: CustomStringConvertible, RawRepresentable { +public struct Body: CustomStringConvertible, RawRepresentable, Sendable { public let contentTransferEncoding: ContentTransferEncoding? public let contentType: ContentType // Body encoding is always ASCII public let parts: [Part] @@ -85,3 +85,16 @@ public struct Body: CustomStringConvertible, RawRepresentable { try? self.init(rawValue) } } + +extension Body { + public static var empty: Self { + try! Self( + parts: [ + try! Part(data: "".data(using: .ascii)!, contentType: .text(.plain, .ascii)) + ], contentType: .text(.plain, .ascii)) + } + + public var isEmpty: Bool { + parts.count == 1 && (String(data: parts[0].data, encoding: .ascii) ?? "").isEmpty + } +} diff --git a/Feature/Sources/MIME/CharacterSet.swift b/Feature/Sources/MIME/CharacterSet.swift index f912fc1b..ed6a51e1 100644 --- a/Feature/Sources/MIME/CharacterSet.swift +++ b/Feature/Sources/MIME/CharacterSet.swift @@ -1,5 +1,3 @@ -import Foundation - /// Character encoding described in [RFC 2045](https://www.rfc-editor.org/rfc/rfc2045#section-2.2) public struct CharacterSet: CustomStringConvertible, Equatable, RawRepresentable, Sendable { public static var ascii: Self { try! Self("US-ASCII") } // Default character encoding diff --git a/Feature/Sources/MIME/ContentTransferEncoding.swift b/Feature/Sources/MIME/ContentTransferEncoding.swift index 9092d395..b16b917c 100644 --- a/Feature/Sources/MIME/ContentTransferEncoding.swift +++ b/Feature/Sources/MIME/ContentTransferEncoding.swift @@ -1,5 +1,5 @@ /// Multipart email body must be ASCII in transfer. Parts encoded by this library use `base64` transfer encoding exclusively, but we expect to decode parts in any of the enumerated encodings, especially `quotedPrintable`. -public enum ContentTransferEncoding: String, CaseIterable, CustomStringConvertible, RawRepresentable { +public enum ContentTransferEncoding: String, CaseIterable, CustomStringConvertible, RawRepresentable, Sendable { case ascii = "7bit" case base64 case binary diff --git a/Feature/Sources/MIME/Part.swift b/Feature/Sources/MIME/Part.swift index e15700d5..3fae66d4 100644 --- a/Feature/Sources/MIME/Part.swift +++ b/Feature/Sources/MIME/Part.swift @@ -1,7 +1,7 @@ import Foundation /// MIME part described in [RFC 2045](https://www.rfc-editor.org/rfc/rfc2045#section-2.5) -public struct Part: CustomStringConvertible, RawRepresentable { +public struct Part: CustomStringConvertible, RawRepresentable, Sendable { /// Instruct mail client to display decoded body part inline, in message, or link as an attachment. Optionally include file name and other metadata for source file. public let contentDisposition: ContentDisposition? diff --git a/Feature/Sources/MIME/String.swift b/Feature/Sources/MIME/String.swift index 488e284a..ec1c3e44 100644 --- a/Feature/Sources/MIME/String.swift +++ b/Feature/Sources/MIME/String.swift @@ -83,8 +83,8 @@ extension String { } } +public var crlf: String { .crlf } + extension [Character] { static var quotes: Self { ["\"", "'"] } } - -var crlf: String { .crlf } diff --git a/Feature/Sources/MIME/UUID.swift b/Feature/Sources/MIME/UUID.swift index fa67b8ef..7cfa1287 100644 --- a/Feature/Sources/MIME/UUID.swift +++ b/Feature/Sources/MIME/UUID.swift @@ -6,7 +6,7 @@ extension UUID { try! Boundary("\(prefix?.ascii ?? "")\(uuidString(segments, separator: separator))\(suffix?.ascii ?? "")") } - func uuidString(_ segments: Int, separator: String = .separator) -> String { + public func uuidString(_ segments: Int, separator: String = .separator) -> String { uuidString .components(separatedBy: String.separator) .prefix(max(segments, 1)) diff --git a/Feature/Sources/SMTP/Email.swift b/Feature/Sources/SMTP/Email.swift index 26b75352..f3bd2d49 100644 --- a/Feature/Sources/SMTP/Email.swift +++ b/Feature/Sources/SMTP/Email.swift @@ -1,34 +1,18 @@ import EmailAddress import Foundation import MIME -import UniformTypeIdentifiers /// ``SMTPClient`` sends `Email`. public struct Email: Identifiable, Sendable { - public struct Part { - public let data: Data - public let contentType: String - } - public let sender: EmailAddress public let recipients: [EmailAddress] public let copied: [EmailAddress] public let blindCopied: [EmailAddress] public let subject: String public let date: Date - public let parts: [Data] - - public var body: Data { - var data: Data = Data() - for part in parts { - data.append(line.data(using: .utf8)!) - data.append(part) - } - data.append(line.data(using: .utf8)!) - return data - } + public let body: Body - public var contentType: String { "text/plain; charset=\"UTF-8\"" } + public var contentType: MIME.ContentType { body.contentType } public var messageID: String { // Format: https://www.jwz.org/doc/mid.html @@ -46,7 +30,7 @@ public struct Email: Identifiable, Sendable { blindCopied: [EmailAddress] = [], subject: String = "", date: Date = Date(), - body parts: [Data] = [], + body: Body = .empty, id: UUID = UUID() ) { self.sender = sender @@ -55,26 +39,12 @@ public struct Email: Identifiable, Sendable { self.blindCopied = blindCopied self.subject = subject self.date = date - self.parts = parts + self.body = body self.id = id } - var iso8601Date: String { ISO8601DateFormatter().string(from: date) } var allRecipients: [EmailAddress] { recipients + copied + blindCopied } - var dataBoundary: String { "\(id.uuidString(4))_part" } // MARK: Identifiable public let id: UUID } - -extension UUID { - func uuidString(_ segments: Int) -> String { - uuidString.components(separatedBy: "-").prefix(max(segments, 0)).joined(separator: "-") - } -} - -extension String { - static var line: Self { "\r\n" } -} - -var line: String { .line } diff --git a/Feature/Sources/SMTP/LoggingHandler.swift b/Feature/Sources/SMTP/LoggingHandler.swift index 071fc092..3682c2de 100644 --- a/Feature/Sources/SMTP/LoggingHandler.swift +++ b/Feature/Sources/SMTP/LoggingHandler.swift @@ -14,25 +14,29 @@ final class LoggingHandler: ChannelDuplexHandler, @unchecked Sendable { typealias OutboundIn = ByteBuffer func write(context: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise?) { - logger?.debug("\(self.unwrapOutboundIn(data).readableLogView(redactBase64Encoded: true))") - context.write(data, promise: promise) + logger?.debug("\(self.unwrapOutboundIn(data).stringValue)") + context.write(data, promise: promise) // Handler only observes; pass unmodified data to next handler } func channelRead(context: ChannelHandlerContext, data: NIOAny) { - logger?.debug("\(self.unwrapInboundIn(data).readableLogView())") - context.fireChannelRead(data) + logger?.debug("\(self.unwrapInboundIn(data).stringValue)") + context.fireChannelRead(data) // Pass unmodified data to next handler } } extension ByteBuffer { - // Format bytes as debug description; optionally redact user names and passwords - func readableLogView(redactBase64Encoded: Bool = false) -> String { + var stringValue: String { let string: String = String(decoding: readableBytesView, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines) + // Deocde base64-encoded credentials guard let data: Data = Data(base64Encoded: string), let string: String = String(data: data, encoding: .utf8) else { - return string // String not base64-encoded; return as is + return string } - return redactBase64Encoded ? "[redacted]" : string + #if DEBUG + return string + #else + return String(repeating: "•", count: string.count) // Redact decoded SMTP credential + #endif } } diff --git a/Feature/Sources/SMTP/RequestEncoder.swift b/Feature/Sources/SMTP/RequestEncoder.swift index 19ebbf7f..c533e71b 100644 --- a/Feature/Sources/SMTP/RequestEncoder.swift +++ b/Feature/Sources/SMTP/RequestEncoder.swift @@ -1,5 +1,6 @@ import EmailAddress import Foundation +import MIME import NIOCore enum Request { @@ -37,18 +38,16 @@ struct RequestEncoder: MessageToByteEncoder { case .data: out.writeString("DATA") case .transferData(let email): - out.writeString("From: \(email.sender)\(line)") - out.writeString("To: \(email.recipients.map { $0.description }.joined(separator: " "))\(line)") - out.writeString("Date: \(email.iso8601Date)\(line)") // "EEE, dd MMM yyyy HH:mm:ss Z" - out.writeString("Message-ID: \(email.messageID)\(line)") - out.writeString("Subject: \(email.subject)\(line)") - // out.writeString("MIME-Version: 1.0\(line)") - out.writeString("Content-Type: \(email.contentType)\(line)") - out.writeBytes(email.body) - out.writeString("\(line).") + out.writeString("From: \(email.sender)\(crlf)") + out.writeString("To: \(email.recipients.map { $0.description }.joined(separator: " "))\(crlf)") + out.writeString("Date: \(email.date.rfc822Format())\(crlf)") + out.writeString("Message-ID: \(email.messageID)\(crlf)") + out.writeString("Subject: \(email.subject)\(crlf)") + out.writeBytes(email.body.rawValue) + out.writeString("\(crlf).") case .quit: out.writeString("QUIT") } - out.writeString(line) + out.writeString(crlf) } } diff --git a/Feature/Sources/SMTP/ResponseHandler.swift b/Feature/Sources/SMTP/ResponseHandler.swift index ff4098b1..45875732 100644 --- a/Feature/Sources/SMTP/ResponseHandler.swift +++ b/Feature/Sources/SMTP/ResponseHandler.swift @@ -1,4 +1,3 @@ -import Foundation import NIOCore enum Response { diff --git a/Feature/Sources/SMTP/SMTPClient.swift b/Feature/Sources/SMTP/SMTPClient.swift index 6021fded..794d5ce5 100644 --- a/Feature/Sources/SMTP/SMTPClient.swift +++ b/Feature/Sources/SMTP/SMTPClient.swift @@ -12,7 +12,7 @@ public struct SMTPClient { public init( _ server: Server, - logger: Logger? = Logger(subsystem: "net.thunderbird.ios", category: "SMTP") + logger: Logger? = Logger(subsystem: "net.thunderbird", category: "SMTP") ) { group = NIOTSEventLoopGroup(loopCount: 1, defaultQoS: .utility) self.server = server diff --git a/Feature/Sources/SMTP/SMTPError.swift b/Feature/Sources/SMTP/SMTPError.swift index 50537d3d..3cb70a1c 100644 --- a/Feature/Sources/SMTP/SMTPError.swift +++ b/Feature/Sources/SMTP/SMTPError.swift @@ -1,5 +1,5 @@ /// ``SMTPClient`` throws `SMTPError`. -public enum SMTPError: Error { +public enum SMTPError: Error, CustomStringConvertible, Equatable { case emailRecipientNotFound case remoteConnectionClosed case requiredTLSNotConfigured @@ -9,4 +9,15 @@ public enum SMTPError: Error { init(_ error: Error) { self = error as? Self ?? .response(error.localizedDescription) } + + // MARK: CustomStringConvertible + public var description: String { + switch self { + case .emailRecipientNotFound: "Email recipient not found" + case .remoteConnectionClosed: "Remote connection closed" + case .requiredTLSNotConfigured: "Required TLS not configured" + case .responseNotDecoded: "Response not decoded" + case .response(let string): "Response: \(string)" + } + } } diff --git a/Feature/Tests/AutoconfigurationTests/DNSResolverTests.swift b/Feature/Tests/AutoconfigurationTests/DNSResolverTests.swift index a8df037f..58ff31f5 100644 --- a/Feature/Tests/AutoconfigurationTests/DNSResolverTests.swift +++ b/Feature/Tests/AutoconfigurationTests/DNSResolverTests.swift @@ -19,7 +19,6 @@ struct DNSResolverTests { @Test func queryMX() async throws { let thunderbird: [MXRecord] = try await DNSResolver.queryMX("uname@thunderbird.net") #expect(thunderbird.count > 0) - print(thunderbird) let fastmail: [MXRecord] = try await DNSResolver.queryMX("user@fastmail.com") #expect(fastmail.count > 0) let google: [MXRecord] = try await DNSResolver.queryMX("user.name@gmail.com") diff --git a/Feature/Tests/AutoconfigurationTests/FoundationTests/URLSessionTests.swift b/Feature/Tests/AutoconfigurationTests/FoundationTests/URLSessionTests.swift index 3aeb06a2..82c87bca 100644 --- a/Feature/Tests/AutoconfigurationTests/FoundationTests/URLSessionTests.swift +++ b/Feature/Tests/AutoconfigurationTests/FoundationTests/URLSessionTests.swift @@ -114,6 +114,6 @@ extension URLSessionTests { @Test func suffixList() async throws { let suffixList: [String] = try await URLSession.shared.suffixList() - print(suffixList) + #expect(suffixList.count > 10000) } } diff --git a/Feature/Tests/EmailAddressTests/EmailAddressTests.swift b/Feature/Tests/EmailAddressTests/EmailAddressTests.swift index cc51dc1f..ddeae014 100644 --- a/Feature/Tests/EmailAddressTests/EmailAddressTests.swift +++ b/Feature/Tests/EmailAddressTests/EmailAddressTests.swift @@ -36,7 +36,6 @@ struct EmailAddressTests { "\"Example Name \"".data(using: .utf8)!, "\"name@example.com\"".data(using: .utf8)! ] - print(try JSONDecoder().decode(EmailAddress.self, from: data[0]).value) #expect(try JSONDecoder().decode(EmailAddress.self, from: data[0]).value == "name@example.com") #expect(try JSONDecoder().decode(EmailAddress.self, from: data[0]).label == "Example Name") #expect(try JSONDecoder().decode(EmailAddress.self, from: data[1]).value == "name@example.com") diff --git a/Feature/Tests/IMAPTests/IMAPClientTests.swift b/Feature/Tests/IMAPTests/IMAPClientTests.swift new file mode 100644 index 00000000..55c4defa --- /dev/null +++ b/Feature/Tests/IMAPTests/IMAPClientTests.swift @@ -0,0 +1,6 @@ +@testable import IMAP +import Testing + +struct IMAPClientTests { + +} diff --git a/Feature/Tests/IMAPTests/IMAPErrorTests.swift b/Feature/Tests/IMAPTests/IMAPErrorTests.swift new file mode 100644 index 00000000..dc3eb9a8 --- /dev/null +++ b/Feature/Tests/IMAPTests/IMAPErrorTests.swift @@ -0,0 +1,10 @@ +@testable import IMAP +import Testing + +struct IMAPErrorTests { + + // MARK: CustomStringConvertible + @Test func description() { + + } +} diff --git a/Feature/Tests/IMAPTests/LoggingHandlerTests.swift b/Feature/Tests/IMAPTests/LoggingHandlerTests.swift index 872f5361..771790dd 100644 --- a/Feature/Tests/IMAPTests/LoggingHandlerTests.swift +++ b/Feature/Tests/IMAPTests/LoggingHandlerTests.swift @@ -1,13 +1,9 @@ -import Foundation import NIOCore @testable import IMAP import Testing struct ByteBufferTests { - @Test func readableSMTPView() { - #expect(ByteBuffer(string: "334 VXNlcm5hbWU6").readableLogView() == "334 VXNlcm5hbWU6") - #expect(ByteBuffer(string: "334 VXNlcm5hbWU6").readableLogView(redactBase64Encoded: true) == "334 VXNlcm5hbWU6") - #expect(ByteBuffer(string: "dXNlcm5hbWVAZXhhbXBsZS5jb20==").readableLogView() == "username@example.com") - #expect(ByteBuffer(string: "dXNlcm5hbWVAZXhhbXBsZS5jb20==").readableLogView(redactBase64Encoded: true) == "[redacted]") + @Test func stringValue() { + } } diff --git a/Feature/Tests/IMAPTests/MessageTests.swift b/Feature/Tests/IMAPTests/MessageTests.swift new file mode 100644 index 00000000..32fb7b4c --- /dev/null +++ b/Feature/Tests/IMAPTests/MessageTests.swift @@ -0,0 +1,23 @@ +import Foundation +@testable import IMAP +import Testing + +struct MessageTests { + +} + +extension MessageTests { + struct FlagTests { + + // MARK: CustomStringConvertible + @Test func description() { + #expect(Message.Flag.answered.description == "\\Answered") + #expect(Message.Flag.deleted.description == "\\Deleted") + #expect(Message.Flag.draft.description == "\\Draft") + #expect(Message.Flag.flagged.description == "\\Flagged") + #expect(Message.Flag.seen.description == "\\Seen") + #expect(Message.Flag.extension("\\Example").description == "\\Example") + #expect(Message.Flag.keyword(.junk).description == "$Junk") + } + } +} diff --git a/Feature/Tests/MIMETests/BodyTests.swift b/Feature/Tests/MIMETests/BodyTests.swift index 6342dfba..ea567feb 100644 --- a/Feature/Tests/MIMETests/BodyTests.swift +++ b/Feature/Tests/MIMETests/BodyTests.swift @@ -59,6 +59,22 @@ struct BodyTests { } } +extension BodyTests { + @Test func empty() { + #expect(Body.empty.parts.first?.data == "".data(using: .ascii)!) + #expect(Body.empty.parts.first?.contentType == .text(.plain, .ascii)) + #expect(Body.empty.contentType == .text(.plain, .ascii)) + } + + @Test func isEmpty() throws { + #expect(try Body(.fastmail).isEmpty == false) + #expect(try Body(.icloud).isEmpty == false) + #expect(try Body(.outlook).isEmpty == false) + #expect(try Body(.posteo).isEmpty == false) + #expect(Body.empty.isEmpty == true) + } +} + private extension Data { static var fastmail: Self { try! Bundle.module.data(forResource: "mime-body-fastmail.eml") } static var icloud: Self { try! Bundle.module.data(forResource: "mime-body-icloud.eml") } diff --git a/Feature/Tests/SMTPTests/EmailTests.swift b/Feature/Tests/SMTPTests/EmailTests.swift index 95f70d1e..00dfcd6c 100644 --- a/Feature/Tests/SMTPTests/EmailTests.swift +++ b/Feature/Tests/SMTPTests/EmailTests.swift @@ -1,14 +1,11 @@ import Foundation +import MIME @testable import SMTP import Testing struct EmailTests { - @Test func body() { - #expect(String(data: Email.email.body, encoding: .utf8) == emailBody.replacingOccurrences(of: "\n", with: "\r\n")) - } - @Test func contentType() { - #expect(Email.email.contentType == "text/plain; charset=\"UTF-8\"") + #expect(Email.example.contentType == .text(.plain, .ascii)) } @Test func messageID() { @@ -18,13 +15,9 @@ struct EmailTests { #expect(Email(sender: "abc@", date: date, id: id).messageID == "<1762463150.A51D5B17>") } - @Test func iso8601Date() { - #expect(Email.email.iso8601Date == "2025-11-06T21:05:50Z") - } - @Test func allRecipients() { #expect( - Email.email.allRecipients == [ + Email.example.allRecipients == [ "recipient@example.com", "no.name@exmaple.com", "cc@example.com", @@ -32,34 +25,10 @@ struct EmailTests { ] ) } - - @Test func dataBoundary() { - #expect(Email.email.dataBoundary == "A51D5B17-CA61-4FF1-A4A8_part") - } -} - -struct UUIDTests { - @Test func uuidString() { - #expect(Email.email.id.uuidString == "A51D5B17-CA61-4FF1-A4A8-C717289B8F9E") - #expect(Email.email.id.uuidString(-2) == "") - #expect(Email.email.id.uuidString(0) == "") - #expect(Email.email.id.uuidString(1) == "A51D5B17") - #expect(Email.email.id.uuidString(2) == "A51D5B17-CA61") - #expect(Email.email.id.uuidString(3) == "A51D5B17-CA61-4FF1") - #expect(Email.email.id.uuidString(4) == "A51D5B17-CA61-4FF1-A4A8") - #expect(Email.email.id.uuidString(5) == "A51D5B17-CA61-4FF1-A4A8-C717289B8F9E") - #expect(Email.email.id.uuidString(99) == "A51D5B17-CA61-4FF1-A4A8-C717289B8F9E") - } -} - -struct StringTests { - @Test func line() { - #expect(String.line == "\r\n") - } } -private extension Email { - static var email: Self { +extension Email { + static var example: Self { Self( sender: "Sender Name ", recipients: [ @@ -74,17 +43,8 @@ private extension Email { ], subject: "Example email subject", date: Date(timeIntervalSince1970: 1762463150.82521), - body: [ - "Body content parts can be plain text for now ;)".data(using: .utf8)! - ], + body: try! Body("Content-Type: text/plain; charset=\"US-ASCII\"\(crlf)\(crlf)Plain text body content (using only US-ASCII characters)\(crlf)"), id: UUID(uuidString: "A51D5B17-CA61-4FF1-A4A8-C717289B8F9E")! ) } } - -// swift-format-ignore -private let emailBody: String = """ - -Body content parts can be plain text for now ;) - -""" diff --git a/Feature/Tests/SMTPTests/LoggingHandlerTests.swift b/Feature/Tests/SMTPTests/LoggingHandlerTests.swift index 0be71ccf..16c0e8cb 100644 --- a/Feature/Tests/SMTPTests/LoggingHandlerTests.swift +++ b/Feature/Tests/SMTPTests/LoggingHandlerTests.swift @@ -1,13 +1,10 @@ -import Foundation import NIOCore @testable import SMTP import Testing struct ByteBufferTests { - @Test func readableSMTPView() { - #expect(ByteBuffer(string: "334 VXNlcm5hbWU6").readableLogView() == "334 VXNlcm5hbWU6") - #expect(ByteBuffer(string: "334 VXNlcm5hbWU6").readableLogView(redactBase64Encoded: true) == "334 VXNlcm5hbWU6") - #expect(ByteBuffer(string: "dXNlcm5hbWVAZXhhbXBsZS5jb20==").readableLogView() == "username@example.com") - #expect(ByteBuffer(string: "dXNlcm5hbWVAZXhhbXBsZS5jb20==").readableLogView(redactBase64Encoded: true) == "[redacted]") + @Test func stringValue() { + #expect(ByteBuffer(string: "334 VXNlcm5hbWU6").stringValue == "334 VXNlcm5hbWU6") + #expect(ByteBuffer(string: "dXNlcm5hbWVAZXhhbXBsZS5jb20==").stringValue == "username@example.com") } } diff --git a/Feature/Tests/SMTPTests/RequestTests.swift b/Feature/Tests/SMTPTests/RequestEncoderTests.swift similarity index 62% rename from Feature/Tests/SMTPTests/RequestTests.swift rename to Feature/Tests/SMTPTests/RequestEncoderTests.swift index d7f4e67e..0039d0ab 100644 --- a/Feature/Tests/SMTPTests/RequestTests.swift +++ b/Feature/Tests/SMTPTests/RequestEncoderTests.swift @@ -1,4 +1,4 @@ -import Foundation +import MIME import NIOCore @testable import SMTP import Testing @@ -22,41 +22,24 @@ struct RequestEncoderTests { #expect(buffer.readString(length: buffer.readableBytes) == "RCPT TO:\r\n") try RequestEncoder().encode(data: .data, out: &buffer) #expect(buffer.readString(length: buffer.readableBytes) == "DATA\r\n") - try RequestEncoder().encode(data: .transferData(.email), out: &buffer) - #expect(buffer.readString(length: buffer.readableBytes) == "\(emailBody.replacingOccurrences(of: "\n", with: "\r\n"))\r\n") + try RequestEncoder().encode(data: .transferData(.example), out: &buffer) + #expect(buffer.readString(length: buffer.readableBytes) == example) try RequestEncoder().encode(data: .quit, out: &buffer) #expect(buffer.readString(length: buffer.readableBytes) == "QUIT\r\n") } } -private extension Email { - static var email: Self { - Self( - sender: "Sender Name ", - recipients: [ - "Recipient Name ", - "no.name@exmaple.com" - ], - subject: "Example email subject", - date: Date(timeIntervalSince1970: 1762463150.82521), - body: [ - "Body content parts can be plain text for now ;)".data(using: .utf8)! - ], - id: UUID(uuidString: "A51D5B17-CA61-4FF1-A4A8-C717289B8F9E")! - ) - } -} - // swift-format-ignore -private let emailBody: String = """ -From: Sender Name -To: Recipient Name no.name@exmaple.com -Date: 2025-11-06T21:05:50Z -Message-ID: <1762463150.A51D5B17@example.com> -Subject: Example email subject -Content-Type: text/plain; charset="UTF-8" - -Body content parts can be plain text for now ;) +private let example: String = """ +From: Sender Name \r +To: Recipient Name no.name@exmaple.com\r +Date: Thu, 06 Nov 2025 21:05:50 +0000\r +Message-ID: <1762463150.A51D5B17@example.com>\r +Subject: Example email subject\r +Content-Type: text/plain; charset="US-ASCII"\r +\r +Plain text body content (using only US-ASCII characters)\r +\r +.\r -. """ diff --git a/Feature/Tests/SMTPTests/SMTPClientTests.swift b/Feature/Tests/SMTPTests/SMTPClientTests.swift index 9c880eaf..b9e2a7a8 100644 --- a/Feature/Tests/SMTPTests/SMTPClientTests.swift +++ b/Feature/Tests/SMTPTests/SMTPClientTests.swift @@ -1,38 +1,29 @@ import Foundation +import MIME @testable import SMTP import Testing struct SMTPClientTests { @Test(.disabled(if: Server.server.password.isEmpty)) func send() async throws { - let client: SMTPClient = SMTPClient(.server) - do { - try await client.send(.email) - } catch { - print(error) - } + try await SMTPClient(.server).send(.email) } @Test(.disabled(if: Server.server.password.isEmpty)) func sendToRecipient() async throws { - let client: SMTPClient = SMTPClient(.server) - do { - try await client.send(.email, to: "") - } catch { - print(error) - } + try await SMTPClient(.server).send(.email, to: "recipient@example.com") } } private extension Email { static var email: Self { Self( - sender: "", + sender: "sender@example.com", recipients: [ - "" + "recipient@example.com" ], subject: "Example email subject", - body: [ - "Body content parts can be plain text for now ;)".data(using: .utf8)! - ], + body: try! Body(parts: [ + Part(data: "For a brief period of time, email body contents were made of plain, ASCII text only :)".data(using: .ascii)!, contentType: .text(.plain, .ascii)) + ]), id: UUID(uuidString: "A51D5B17-CA61-4FF1-A4A8-C717289B8F9E")! ) } diff --git a/Feature/Tests/SMTPTests/SMTPErrorTests.swift b/Feature/Tests/SMTPTests/SMTPErrorTests.swift new file mode 100644 index 00000000..b3818341 --- /dev/null +++ b/Feature/Tests/SMTPTests/SMTPErrorTests.swift @@ -0,0 +1,10 @@ +@testable import SMTP +import Testing + +struct SMTPErrorTests { + + // MARK: CustomStringConvertible + @Test func description() { + #expect(SMTPError.response("failure-description").description == "Response: failure-description") + } +}