diff --git a/Feature/Sources/IMAP/EmailAddress.swift b/Feature/Sources/IMAP/EmailAddress.swift index 13807bb3..cf670b45 100644 --- a/Feature/Sources/IMAP/EmailAddress.swift +++ b/Feature/Sources/IMAP/EmailAddress.swift @@ -1,4 +1,5 @@ import EmailAddress +import MIME import NIOIMAPCore extension [EmailAddressListElement] { @@ -17,7 +18,10 @@ extension EmailAddressListElement { extension NIOIMAPCore.EmailAddressGroup: @retroactive CustomStringConvertible { // MARK: CustomStringConvertible - public var description: String { groupName.readableBytesView.description } // TODO: Decode quoted-printable/base64 (RFC 2047) + public var description: String { + let description: String = groupName.readableBytesView.description + return (try? description.headerDecoded()) ?? description + } } extension NIOIMAPCore.EmailAddress: @retroactive CustomStringConvertible { @@ -32,7 +36,7 @@ extension NIOIMAPCore.EmailAddress: @retroactive CustomStringConvertible { return "" } let description: String = "\(mailbox)@\(host)" - if let personName: String = personName?.readableBytesView.description, // TODO: Decode quoted-printable/base64 (RFC 2047) + if let personName: String = try? personName?.readableBytesView.description.headerDecoded(), !personName.isEmpty { return "\(personName) <\(description)>" diff --git a/Feature/Sources/IMAP/Envelope.swift b/Feature/Sources/IMAP/Envelope.swift index adaf8e20..4ef6fe96 100644 --- a/Feature/Sources/IMAP/Envelope.swift +++ b/Feature/Sources/IMAP/Envelope.swift @@ -40,7 +40,7 @@ public struct Envelope: Sendable { } init(_ envelope: NIOIMAPCore.Envelope) { - subject = envelope.subject?.readableBytesView.description // TODO: Decode quoted-printable/base64 (RFC 2047) + subject = try? envelope.subject?.readableBytesView.description.headerDecoded() from = envelope.from.addresses sender = envelope.sender.addresses reply = envelope.reply.addresses diff --git a/Feature/Sources/IMAP/FetchAttribute.swift b/Feature/Sources/IMAP/FetchAttribute.swift index 21e552fa..c1ec40f4 100644 --- a/Feature/Sources/IMAP/FetchAttribute.swift +++ b/Feature/Sources/IMAP/FetchAttribute.swift @@ -4,37 +4,32 @@ public typealias FetchAttribute = NIOIMAPCore.FetchAttribute extension [FetchAttribute] { - // Add supported fetch attributes according to set of server capabilities - static func extended(_ capabilities: Set) -> Self { - Array(Set(capabilities.flatMap { extended($0) })) - } - - // Add supported fetch attributes according to server capability - static func extended(_ capability: Capability) -> Self { - switch capability { - case .gmailExtensions: - [ - .gmailLabels, - .gmailMessageID, - .gmailThreadID - ] - case .objectID: - [ - .emailID, - .threadID - ] - default: [] - } - } + /// Fetch complete, raw message data in one blob. + public static let complete: Self = + [ + .bodySection(peek: true, .complete, nil) + ] + standard - // Standard set of fetch attributes supported by all IMAP4 servers - static var standard: Self { + /// Fetch all message headers and body structure map. + public static let header: Self = [ - .envelope, - .flags, - .uid - ] - } + .bodySection(peek: true, .header, nil), + .bodyStructure(extensions: true) + ] + standard + + /// Fetch message envelope, IDs, flags and Gmail labels. + public static let standard: Self = [ + .emailID, + .envelope, + .flags, + .gmailLabels, + .gmailMessageID, + .gmailThreadID, + .internalDate, + .preview(lazy: true), + .threadID, + .uid + ] // Filter unsupported attributes according to server capabilities func filtered(_ capabilities: Set) -> Self { diff --git a/Feature/Sources/IMAP/FetchCommand.swift b/Feature/Sources/IMAP/FetchCommand.swift index 54443d5a..6c687400 100644 --- a/Feature/Sources/IMAP/FetchCommand.swift +++ b/Feature/Sources/IMAP/FetchCommand.swift @@ -28,8 +28,8 @@ class FetchHandler: IMAPCommandHandler, @unchecked Sendable { var isStreaming: Bool { streaming != nil } private var streaming: (kind: StreamingKind, data: Data, byteCount: Int)? - private var components: [Message.Component] = [] private var sequenceNumber: SequenceNumber? + private var components: [Message.Component] = [] // MARK: IMAPCommandHandler typealias InboundIn = Response @@ -79,7 +79,7 @@ class FetchHandler: IMAPCommandHandler, @unchecked Sendable { } streaming = nil case .finish: - messages[sequenceNumber!] = Message(components: components) + messages[sequenceNumber!] = Message(components) sequenceNumber = nil components = [] default: diff --git a/Feature/Sources/IMAP/IMAPClient.swift b/Feature/Sources/IMAP/IMAPClient.swift index 8490d35a..3682ea82 100644 --- a/Feature/Sources/IMAP/IMAPClient.swift +++ b/Feature/Sources/IMAP/IMAPClient.swift @@ -180,18 +180,38 @@ public class IMAPClient { try await execute(command: VoidCommand(.close)) } - /// Fetch messages by mailbox sequence number. - public func fetch(_ set: SequenceSet = .all, attributes: [FetchAttribute]) async throws -> MessageSet { + /// Fetch a set of messages by mailbox ``SequenceNumber``; fetches all headers and omits message body by default. + public func fetch(_ set: SequenceSet = .all, attributes: [FetchAttribute] = .header) async throws -> MessageSet { logger?.info("Fetching messages by mailbox sequence number…") return try await execute(command: FetchCommand(set, attributes: attributes.filtered(capabilities))) } - /// Fetch messages by UID. - public func fetch(_ set: UIDSet, attributes: [FetchAttribute]) async throws -> MessageSet { + /// Fetch a specific message by ``SequenceNumber``; fetches complete message by default. + public func fetch(_ number: SequenceNumber, attributes: [FetchAttribute] = .complete) async throws -> Message { + logger?.info("Fetching message \(number)…") + let messages: MessageSet = try await fetch(SequenceSet(number), attributes: attributes) + guard let message: Message = messages.first?.value else { + throw IMAPError.commandFailed("Message \(number) not found") + } + return message + } + + /// Fetch a set of messages by ``UID``; fetches all headers and omits message body by default. + public func fetch(uid set: UIDSet, attributes: [FetchAttribute] = .header) async throws -> MessageSet { logger?.info("Fetching messages by UID…") return try await execute(command: UIDFetchCommand(set, attributes: attributes.filtered(capabilities))) } + /// Fetch a specific message by ``UID``; fetches complete message by default. + public func fetch(uid: UID, attributes: [FetchAttribute] = .complete) async throws -> Message { + logger?.info("Fetching message UID \(uid)…") + let messages: MessageSet = try await fetch(uid: UIDSet(uid), attributes: attributes) + guard let message: Message = messages.first?.value else { + throw IMAPError.commandFailed("Message UID \(uid) not found") + } + return message + } + public init( _ server: Server, logger: Logger? = Logger(subsystem: "net.thunderbird", category: "IMAP") diff --git a/Feature/Sources/IMAP/IMAPCommand.swift b/Feature/Sources/IMAP/IMAPCommand.swift index 5d601533..e8f16727 100644 --- a/Feature/Sources/IMAP/IMAPCommand.swift +++ b/Feature/Sources/IMAP/IMAPCommand.swift @@ -74,7 +74,7 @@ extension IMAPCommandHandler where InboundIn == Response, Result == Void { } extension Int64 { - static let timeout: Self = 30 // Practical default + static let timeout: Self = 60 // Practical default } // IMAP [CLIENTBUG] is an error code mail servers include in responses to diff --git a/Feature/Sources/IMAP/Message.swift b/Feature/Sources/IMAP/Message.swift index 7f8aa886..4827d09c 100644 --- a/Feature/Sources/IMAP/Message.swift +++ b/Feature/Sources/IMAP/Message.swift @@ -1,28 +1,114 @@ import Foundation +import MIME import NIOIMAPCore -public typealias BodyStructure = NIOIMAPCore.BodyStructure -public typealias SequenceNumber = NIOIMAPCore.SequenceNumber -public typealias SequenceSet = NIOIMAPCore.MessageIdentifierSetNonEmpty -public typealias UID = NIOIMAPCore.UID -public typealias UIDSet = NIOIMAPCore.UIDSetNonEmpty +public typealias MessageSet = [SequenceNumber: Message] public struct Message: Sendable { + public fileprivate(set) var body: Body? + public fileprivate(set) var emailID: String? + public fileprivate(set) var envelope: Envelope + public fileprivate(set) var flags: Set + public fileprivate(set) var gmailLabels: Set + public fileprivate(set) var gmailMessageID: UInt64? + public fileprivate(set) var gmailThreadID: UInt64? + public fileprivate(set) var internalDate: Date? + public fileprivate(set) var threadID: String? + public fileprivate(set) var uid: UID? + + public init( + body: Body? = nil, + emailID: String? = nil, + envelope: Envelope = Envelope(), + flags: [Flag] = [], + gmailLabels: [GmailLabel] = [], + gmailMessageID: UInt64? = nil, + gmailThreadID: UInt64? = nil, + internalDate: Date? = nil, + threadID: String? = nil, + uid: UID? = nil + ) { + self.body = body + self.emailID = emailID + self.envelope = envelope + self.flags = Set(flags) + self.gmailLabels = Set(gmailLabels) + self.gmailMessageID = gmailMessageID + self.gmailThreadID = gmailThreadID + self.internalDate = internalDate + self.threadID = threadID + self.uid = uid + } +} + +extension Message { public enum Component: CustomStringConvertible, Equatable, Identifiable, Sendable { case bodyPart(SectionSpecifier, Data) case bodyStructure(BodyStructure, _ hasExtensionData: Bool = false) case emailID(String) case envelope(Envelope) case flags(Set) - case gmailLabels([GmailLabel]) + case gmailLabels(Set) case gmailMessageID(UInt64) case gmailThreadID(UInt64) case internalDate(Date) case threadID(String) case uid(UID) - public typealias BodyStructure = NIOIMAPCore.MessageAttribute.BodyStructure - public typealias SectionSpecifier = NIOIMAPCore.SectionSpecifier + // Shared convenience decoder/mapper for message components + // Used by `FetchHandler`, `IdleHandler` and `NoopHandler` + init?(_ attribute: MessageAttribute) { + switch attribute { + case .body(let structure, let hasExtensionData): + switch structure { + case .valid(let structure): + self = .bodyStructure(structure, hasExtensionData) + case .invalid: + return nil + } + case .emailID(let emailID): + self = .emailID(String(emailID)) + case .envelope(let envelope): + self = .envelope(Envelope(envelope)) + case .flags(let flags): + self = .flags(Set(flags)) + case .gmailLabels(let labels): + self = .gmailLabels(Set(labels)) + case .gmailMessageID(let id): + self = .gmailMessageID(id) + case .gmailThreadID(let id): + self = .gmailThreadID(id) + case .internalDate(let serverMessageDate): + guard let date: Date = try? Date(serverMessageDate: serverMessageDate) else { + return nil + } + self = .internalDate(date) + case .threadID(let threadID): + guard let threadID: String = String(threadID: threadID) else { + return nil + } + self = .threadID(threadID) + case .uid(let uid): + self = .uid(uid) + default: + return nil + } + } + + // MARK: CustomStringConvertible + public var description: String { + switch self { + case .bodyPart(_, let data): "\(id) (\(data))" + case .bodyStructure(let structure, let hasExtensionsData): "\(id): \(structure)\(hasExtensionsData ? " (hasExtensionsData)" : "")" + case .emailID(let id), .threadID(let id): "\(self.id): \(id)" + case .envelope(let envelope): "\(id): \(envelope)" + case .flags(let flags): "\(id): \(flags)" + case .gmailLabels(let labels): "\(id): \(labels)" + case .gmailMessageID(let id), .gmailThreadID(let id): "\(self.id): \(id)" + case .internalDate(let date): "\(id): \(date)" + case .uid(let uid): "\(id): \(uid)" + } + } // MARK: Equatable public static func == (lhs: Self, rhs: Self) -> Bool { @@ -45,32 +131,44 @@ public struct Message: Sendable { case .uid: "uid" } } + } - // MARK: CustomStringConvertible - public var description: String { - switch self { - case .bodyPart(_, let data): "\(id) (\(data))" - case .bodyStructure(let structure, let hasExtensionsData): "\(id): \(structure)\(hasExtensionsData ? " (hasExtensionsData)" : "")" - case .emailID(let id), .threadID(let id): "\(self.id): \(id)" - case .envelope(let envelope): "\(id): \(envelope)" - case .flags(let flags): "\(id): \(flags)" - case .gmailLabels(let labels): "\(id): \(labels)" - case .gmailMessageID(let id), .gmailThreadID(let id): "\(self.id): \(id)" - case .internalDate(let date): "\(id): \(date)" - case .uid(let uid): "\(id): \(uid)" + func merging(_ components: [Component]) -> Self { + var message: Self = self + for component in components { + switch component { + case .bodyPart(_, let data): + message.body = try? Body(data) + case .bodyStructure: + break // Only decode complete message body + case .emailID(let emailID): + message.emailID = emailID + case .envelope(let envelope): + message.envelope = envelope + case .flags(let flags): + message.flags = flags + case .gmailLabels(let gmailLabels): + message.gmailLabels = gmailLabels + case .gmailMessageID(let gmailMessageID): + message.gmailMessageID = gmailMessageID + case .gmailThreadID(let gmailThreadID): + message.gmailThreadID = gmailThreadID + case .internalDate(let internalDate): + message.internalDate = internalDate + case .threadID(let threadID): + message.threadID = threadID + case .uid(let uid): + message.uid = uid } } + return message } - public let components: [Component] - - public init(components: [Component]) { - self.components = components + init(_ components: [Component]) { + self = Self().merging(components) } } -public typealias MessageSet = [SequenceNumber: Message] - extension MessageSet { /// Array of ``Message`` ordered by ascending ``SequenceNumber`` @@ -83,54 +181,6 @@ extension MessageSet { } } -extension [Message.Component] { - var uid: UID? { - compactMap { component in - switch component { - case .uid(let uid): uid - default: nil - } - }.first - } -} - -extension Message.Component { - // Shared convenience decoder/mapper - // Used by `FetchHandler`, `IdleHandler` and `NoopHandler` - init?(_ attribute: MessageAttribute) { - switch attribute { - case .body(let structure, let hasExtensionData): - self = .bodyStructure(structure, hasExtensionData) - case .emailID(let emailID): - self = .emailID(String(emailID)) - case .envelope(let envelope): - self = .envelope(Envelope(envelope)) - case .flags(let flags): - self = .flags(Set(flags)) - case .gmailLabels(let labels): - self = .gmailLabels(labels) - case .gmailMessageID(let id): - self = .gmailMessageID(id) - case .gmailThreadID(let id): - self = .gmailThreadID(id) - case .internalDate(let serverMessageDate): - guard let date: Date = try? Date(serverMessageDate: serverMessageDate) else { - return nil - } - self = .internalDate(date) - case .threadID(let threadID): - guard let threadID: String = String(threadID: threadID) else { - return nil - } - self = .threadID(threadID) - case .uid(let uid): - self = .uid(uid) - default: - return nil - } - } -} - private extension String { init?(threadID: ThreadID?) { guard let threadID else { diff --git a/Feature/Sources/IMAP/SequenceNumber.swift b/Feature/Sources/IMAP/SequenceNumber.swift new file mode 100644 index 00000000..dd8bb88b --- /dev/null +++ b/Feature/Sources/IMAP/SequenceNumber.swift @@ -0,0 +1,16 @@ +import NIOIMAPCore + +public typealias SequenceNumber = NIOIMAPCore.SequenceNumber +public typealias SequenceSet = NIOIMAPCore.MessageIdentifierSetNonEmpty + +extension SequenceNumber: @retroactive CustomStringConvertible { + + // MARK: CustomStringConvertible + public var description: String { debugDescription } +} + +extension SequenceSet { + init(_ sequenceNumber: SequenceNumber) { + self.init(range: MessageIdentifierRange(sequenceNumber)) + } +} diff --git a/Feature/Sources/IMAP/UID.swift b/Feature/Sources/IMAP/UID.swift new file mode 100644 index 00000000..4c908b54 --- /dev/null +++ b/Feature/Sources/IMAP/UID.swift @@ -0,0 +1,16 @@ +import NIOIMAPCore + +public typealias UID = NIOIMAPCore.UID +public typealias UIDSet = NIOIMAPCore.UIDSetNonEmpty + +extension UID: @retroactive CustomStringConvertible { + + // MARK: CustomStringConvertible + public var description: String { debugDescription } +} + +extension UIDSet { + init(_ uid: UID) { + self.init(range: MessageIdentifierRange(uid)) + } +} diff --git a/Feature/Sources/MIME/Body.swift b/Feature/Sources/MIME/Body.swift index 625ef3f3..217f2a01 100644 --- a/Feature/Sources/MIME/Body.swift +++ b/Feature/Sources/MIME/Body.swift @@ -3,7 +3,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, Sendable { public let contentTransferEncoding: ContentTransferEncoding? - public let contentType: ContentType // Body encoding is always ASCII + public let contentType: ContentType public let parts: [Part] public var headers: [String: String] { @@ -22,9 +22,14 @@ public struct Body: CustomStringConvertible, RawRepresentable, Sendable { guard !parts.isEmpty else { throw MIMEError.dataNotFound } - if parts.count == 1, parts[0].contentType == .text(.plain, .ascii) { - contentTransferEncoding = parts[0].contentTransferEncoding - self.contentType = .text(.plain, .ascii) + if parts.count == 1 { + switch parts[0].contentType { + case .text: + contentTransferEncoding = parts[0].contentTransferEncoding + self.contentType = contentType + default: + throw MIMEError.contentTypeNotPossible(contentType) + } } else if contentType.isMultipart { contentTransferEncoding = encoding self.contentType = contentType @@ -42,10 +47,7 @@ public struct Body: CustomStringConvertible, RawRepresentable, Sendable { let parts: [Part] = try part.parts try self.init(parts: parts, contentType: part.contentType, encoding: part.contentTransferEncoding) case .text(let subtype, let charset): - guard subtype == .plain, charset == .ascii else { - throw MIMEError.contentTypeNotPossible(part.contentType) - } - try self.init(parts: [part], contentType: .text(.plain, .ascii)) + try self.init(parts: [part], contentType: .text(subtype, charset)) default: throw MIMEError.contentTypeNotPossible(part.contentType) } diff --git a/Feature/Sources/MIME/MIMEError.swift b/Feature/Sources/MIME/MIMEError.swift index afe65c49..00446714 100644 --- a/Feature/Sources/MIME/MIMEError.swift +++ b/Feature/Sources/MIME/MIMEError.swift @@ -12,6 +12,7 @@ public enum MIMEError: Error, CustomStringConvertible, Equatable { case dataNotFound case dataNotQuotedPrintable case dateNotDecoded(String) + case headerNotDecoded(String) // MARK: CustomStringConvertible public var description: String { @@ -27,6 +28,7 @@ public enum MIMEError: Error, CustomStringConvertible, Equatable { case .dataNotFound: "Multipart data not found" case .dataNotQuotedPrintable: "Data not quoted-printable" case .dateNotDecoded(let string): "Date not decoded: \(string)" + case .headerNotDecoded(let string): "Header not decoded: \(string)" } } } diff --git a/Feature/Sources/MIME/Part.swift b/Feature/Sources/MIME/Part.swift index 3fae66d4..555bc336 100644 --- a/Feature/Sources/MIME/Part.swift +++ b/Feature/Sources/MIME/Part.swift @@ -59,6 +59,8 @@ public struct Part: CustomStringConvertible, RawRepresentable, Sendable { let description: String = description .replacingOccurrences(of: crlf, with: "\n") + .replacingOccurrences(of: "\n b", with: " b") // Handle soft wrap caused by long data boundary + .replacingOccurrences(of: "\n <", with: " <") // Handle soft wrap caused by long email address label .replacingOccurrences(of: "\n\t", with: "") let components: [String] = description.components(separatedBy: "\n") guard let index: Int = components.firstIndex(of: "") else { @@ -70,7 +72,7 @@ public struct Part: CustomStringConvertible, RawRepresentable, Sendable { for component in components[0.. Self { + + // Because encoding creates significantly longer strings, headers can be encoded in multiple segments + // Allows selecting the shortest encoding method for arbitrary chunks + let segments: [Self] = trimmed().components(separatedBy: "?= =?") + if segments.count > 1 { + let segments: [Self] = try segments.map { + try "\(!$0.hasPrefix("=?") ? "=?" : "")\($0)\(!$0.hasSuffix("?=") ? "?=" : "")".headerDecoded() + } + return segments.joined(separator: "") + } + var string: Self = trimmed() + guard string.hasPrefix("=?"), string.hasSuffix("?=") else { + return self // String not header-encoded; skip decoding + } + let components: [Self] = Array(string.components(separatedBy: "?").dropLast().dropFirst()) + guard components.count > 2 else { + throw MIMEError.headerNotDecoded(string) + } + let encoding: Encoding = try Encoding(components[0]) + let contentTransferEncoding: ContentTransferEncoding = try ContentTransferEncoding(components[1]) + string = components.dropFirst(2).joined(separator: "?") + switch contentTransferEncoding { + case .base64: + return try Self(base64: string, encoding: encoding) + case .quotedPrintable: + return try Self(quotedPrintable: string, encoding: encoding) + default: + throw MIMEError.headerNotDecoded(string) + } + } + + /// Encode any string as a UTF-8, base64 email header. + /// Described in [RFC 2047](https://www.rfc-editor.org/rfc/rfc2047) + public func headerEncoded() throws -> Self { + if let data: Data = data(using: .ascii), + let string: Self = Self(data: data, encoding: .ascii) + { + return string // Already plain ASCII + } + guard let data: Data = data(using: .utf8) else { + throw MIMEError.dataNotFound + } + return [ + "=", + "UTF-8", + "B", + data.base64EncodedString(), + "=" + ].joined(separator: "?") + } + /// Decode quoted-printable data to given `String.Encoding`. public init(quotedPrintable data: Data, encoding: Encoding = .utf8) throws { - guard - let string: String = String(data: data, encoding: encoding)? - .replacingOccurrences(of: "=\r\n", with: "") // Remove quoted-printable line-limit wrapping - .replacingOccurrences(of: "=\n", with: "") - else { + guard let string: String = String(data: data, encoding: encoding) else { throw MIMEError.dataNotDecoded(data, encoding: encoding) } + self = try Self(quotedPrintable: string, encoding: encoding) + } + + /// Decode quoted-printable string to given `String.Encoding`. + public init(quotedPrintable string: Self, encoding: Encoding = .utf8) throws { self = try string.decodingQuotedPrintable(to: encoding) } - // Decoding method adapted from https://stackoverflow.com/questions/32184783 + /// Decode base64 data to given `String.Encoding`. + public init(base64 data: Data, encoding: Encoding = .utf8) throws { + guard let string: String = String(data: data, encoding: .utf8) else { + throw MIMEError.dataNotDecoded(data, encoding: encoding) + } + self = try string.decodingBase64(to: encoding) + } + + /// Decode base64 string to given `String.Encoding`. + public init(base64 string: Self, encoding: Encoding = .utf8) throws { + self = try string.decodingBase64(to: encoding) + } + + func decodingBase64(to encoding: Encoding = .utf8) throws -> Self { + guard let data: Data = Data(base64Encoded: self), + let string: Self = Self(data: data, encoding: encoding) + else { + throw MIMEError.headerNotDecoded(self) + } + return string + } + func decodingQuotedPrintable(to encoding: Encoding = .utf8) throws -> Self { - var string: Self = "" - var index: Index = startIndex - while let range = range(of: "=", range: index.. 1, let byte: UInt8 = UInt8(code, radix: 16) else { // Invalid or incomplete hex code - throw MIMEError.dataNotQuotedPrintable - } - data.append(byte) - index = self.index(index, offsetBy: 3) - } while index != endIndex && self[index] == "=" - guard let decodedString: String = String(data: data, encoding: encoding) else { - throw MIMEError.dataNotDecoded(data, encoding: encoding) - } - string.append(contentsOf: decodedString) + guard + let string: String = replacingOccurrences(of: "=\r\n", with: "") // Remove quoted-printable line-limit wrapping + .replacingOccurrences(of: "=\n", with: "") // Remove quoted-printable line-limit wrapping + .replacingOccurrences(of: "%", with: "%25") // Percent-encode percent control character + .replacingOccurrences(of: "=", with: "%") // Swap quoted-printable and percent-encoding control characters + .replacingOccurrences(of: "_", with: " ") + .removingPercentEncoding // Use built-in percent-encoded decoding + else { + throw MIMEError.dataNotQuotedPrintable } - string.append(contentsOf: self[index..= 0) #expect(status.unseenCount != nil && status.unseenCount! >= 0) - // Fetch all inbox messages w/ all attributes - let messages: [SequenceNumber: Message] = try await client.fetch(attributes: [ - .bodySection(peek: true, .header, nil), - .bodyStructure(extensions: true), - .emailID, - .envelope, - .flags, - .gmailLabels, - .gmailMessageID, - .gmailThreadID, - .internalDate, - .preview(lazy: false), - .threadID, - .uid - ]) + let messages: MessageSet = try await client.fetch() #expect(messages.count > 0) - for sequenceNumber in messages.keys.sorted().reversed() { - let message: Message = messages[sequenceNumber]! - for component in message.components { - switch component { - case .envelope(let envelope): - #expect(!envelope.from.isEmpty == true) - #expect(!envelope.to.isEmpty == true) - default: break - } - } + guard let number: SequenceNumber = messages.keys.sorted().reversed().first else { + throw IMAPError.unexpectedResponse("Message not found") } + let message: Message = try await client.fetch(number) + #expect(message.body != nil) let mailbox: Mailbox.Name = Mailbox.Name(UUID().uuidString(2, separator: " ")) // Unique mailbox name w/ space let renamed: Mailbox.Name = Mailbox.Name(UUID().uuidString(1)) @@ -62,7 +42,10 @@ struct IMAPClientTests { try await Task.sleep(for: .seconds(5.0)) // Idle try await client.done() } catch { - print(error) + switch error { + case IMAPError.capabilityNotSupported: break + default: throw error + } } try await client.logout() diff --git a/Feature/Tests/IMAPTests/MailboxTests.swift b/Feature/Tests/IMAPTests/MailboxTests.swift index 1e9406f0..be3a620d 100644 --- a/Feature/Tests/IMAPTests/MailboxTests.swift +++ b/Feature/Tests/IMAPTests/MailboxTests.swift @@ -9,8 +9,8 @@ struct MailboxNameTests { // MARK: CustomStringConvertible @Test func description() { - print(Mailbox.Name.inbox.description == "INBOX") - print(Mailbox.Name("Deleted Items").description == "Deleted Items") - print(Mailbox.Name("Sent").description == "Sent") + #expect(Mailbox.Name.inbox.description == "INBOX") + #expect(Mailbox.Name("Deleted Items").description == "Deleted Items") + #expect(Mailbox.Name("Sent").description == "Sent") } } diff --git a/Feature/Tests/IMAPTests/MessageTests.swift b/Feature/Tests/IMAPTests/MessageTests.swift index fbe5b532..11563471 100644 --- a/Feature/Tests/IMAPTests/MessageTests.swift +++ b/Feature/Tests/IMAPTests/MessageTests.swift @@ -8,12 +8,12 @@ struct MessageTests { struct MessageSetTests { @Test func messages() { let messageSet: MessageSet = [ - 4: Message(components: [.uid(9)]), - 1: Message(components: [.uid(1)]), - 5: Message(components: [.uid(2)]), - 3: Message(components: [.uid(5)]), - 2: Message(components: [.uid(3)]) + 4: Message([.uid(9)]), + 1: Message([.uid(1)]), + 5: Message([.uid(2)]), + 3: Message([.uid(5)]), + 2: Message([.uid(3)]) ] - #expect(messageSet.messages.compactMap { $0.components.uid } == [1, 3, 5, 9, 2]) + #expect(messageSet.messages.compactMap { $0.uid } == [1, 3, 5, 9, 2]) } } diff --git a/Feature/Tests/MIMETests/BodyTests.swift b/Feature/Tests/MIMETests/BodyTests.swift index ea567feb..6b397df8 100644 --- a/Feature/Tests/MIMETests/BodyTests.swift +++ b/Feature/Tests/MIMETests/BodyTests.swift @@ -4,6 +4,12 @@ import Testing struct BodyTests { @Test func headers() throws { + #expect( + try Body(.aol).headers == [ + "Content-Transfer-Encoding": "quoted-printable", + "Content-Type": "multipart/alternative; boundary=\"-=Part.d96da9.c8ced2f941b41333.19beaf8523d.267666a9a62272b=-\"", + "MIME-Version": "1.0" + ]) // AOL puts the MIME header at the bottom #expect( try Body(.fastmail).headers == [ "MIME-Version": "1.0", @@ -13,6 +19,7 @@ struct BodyTests { #expect( try Body(.icloud).headers == [ "MIME-Version": "1.0", + "Content-Transfer-Encoding": "quoted-printable", "Content-Type": "multipart/alternative; boundary=\"----=_Part_15950895_843396275.1764942606546\"" ]) #expect( @@ -28,6 +35,11 @@ struct BodyTests { } @Test func descriptionInit() throws { + let aol: Body = try Body(.aol) + #expect(aol.contentType == .multipart(.alternative, try! Boundary("-=Part.d96da9.c8ced2f941b41333.19beaf8523d.267666a9a62272b=-"))) + #expect(aol.contentTransferEncoding == .quotedPrintable) + #expect(aol.parts.first?.contentType == .text(.html, .utf8)) + #expect(aol.parts.count == 1) let fastmail: Body = try Body(.fastmail) #expect(fastmail.contentType == .multipart(.alternative, try! Boundary("_----------=_17617196041979919223967"))) #expect(fastmail.contentTransferEncoding == .data) @@ -35,7 +47,7 @@ struct BodyTests { #expect(fastmail.parts.count == 2) let icloud: Body = try Body(.icloud) #expect(icloud.contentType == .multipart(.alternative, try! Boundary("----=_Part_15950895_843396275.1764942606546"))) - #expect(icloud.contentTransferEncoding == nil) + #expect(icloud.contentTransferEncoding == .quotedPrintable) #expect(icloud.parts.first?.contentType == .text(.html, .iso8859)) #expect(icloud.parts.count == 1) let outlook: Body = try Body(.outlook) @@ -52,10 +64,11 @@ struct BodyTests { // MARK: RawRepresentable @Test func rawValue() throws { + #expect(try Body(.aol).rawValue.count == 4878) #expect(try Body(.fastmail).rawValue.count == 20530) - #expect(try Body(.icloud).rawValue.count == 8807) - #expect(try Body(.outlook).rawValue.count == 69560) - #expect(try Body(.posteo).rawValue.count == 89894) + #expect(try Body(.icloud).rawValue.count == 8852) + #expect(try Body(.outlook).rawValue.count == 69556) + #expect(try Body(.posteo).rawValue.count == 89893) } } @@ -67,6 +80,7 @@ extension BodyTests { } @Test func isEmpty() throws { + #expect(try Body(.aol).isEmpty == false) #expect(try Body(.fastmail).isEmpty == false) #expect(try Body(.icloud).isEmpty == false) #expect(try Body(.outlook).isEmpty == false) @@ -76,6 +90,7 @@ extension BodyTests { } private extension Data { + static var aol: Self { try! Bundle.module.data(forResource: "mime-body-aol.eml") } 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") } static var outlook: Self { try! Bundle.module.data(forResource: "mime-body-outlook.eml") } diff --git a/Feature/Tests/MIMETests/Resources/mime-body-aol.eml b/Feature/Tests/MIMETests/Resources/mime-body-aol.eml new file mode 100644 index 00000000..f64b29eb --- /dev/null +++ b/Feature/Tests/MIMETests/Resources/mime-body-aol.eml @@ -0,0 +1,161 @@ +Received: from 10.223.248.109 + by atlas103.aol.mail.ne1.yahoo.com pod-id NONE with HTTPS; Fri, 23 Jan 2026 13:08:21 +0000 +Return-Path: +X-Originating-Ip: [159.127.162.109] +Received-SPF: fail (domain of comms.aol.net does not designate 159.127.162.109 as permitted sender) +Authentication-Results: mta.yahoo.com; + dkim=pass header.i=@comms.aol.net header.s=ep1 arc_overridden_status=NOT_OVERRIDDEN; + spf=fail smtp.mailfrom=comms.aol.net arc_overridden_status=NOT_OVERRIDDEN; + dmarc=pass(p=REJECT) header.from=comms.aol.net arc_overridden_status=NOT_OVERRIDDEN; +X-Apparently-To: user@example.com; Fri, 23 Jan 2026 13:08:21 +0000 +X-YMailISG: s4GqqTgWLDvSReax9q9JHfBrfrNiDSGVuw4518uDpHCjUnN_ + ZWCikJ7GcZDsUDM_xd10C_gQOj3Cz8sLXTBjdLnIs8vsXhhxKAKAc.JUWqjD + qyqDSKnQntcntZ8eU556CzacNaLjFjfy7QAXF_9NRWRx7nBVfPVpHMMyXiqK + oP93EP2PlvZ3rtQhgvh8NhXuuygwE7IEcwqT15FldLdmBQ80eo_2F_UT8eJ9 + ULJ7IsopoOvInRFWlo20JdcnnSiAoYOxAyw0DKMqkWdwOcTNXVKfMxH6EIDe + Nh0zs3RFU30RmEOtPbLuiP5kGn4Pzx8q4Xue8Kf62CIKzDkrH47vcidoKaf. + Qyi59SKUyigV9w0eO8qKVMS4NNFqYNWny9rJj1VT1RIVpJOz8Y2dS5zMQ6OB + r82shX75B4_IaYlg0c7SaauiRfpC_IugLhnxh54DCQdIC_trNvguMUeO9DBq + 18nr3dyWW6IFxwdI9KFuAHnfhikbVcpMc2C79CPbZZyFlawVhUKEP.kqca6C + j4g.v0x6Cft2VSjPI4fqxm69o1JUAXZ3vxHPtnDoaqSvOfLR1bcIVxMenN.M + b3lvX_tpbrcgb.VhlfrM.h4Ogg00EnTjanLUqCnDNo.VTyRfviZlZ3zQbDIJ + UNNPto_BDeVd62sDfwMI344Yg1fUdJfsgwityaIxQ_7SHfTHiUGajktOTNcd + q2mEI4aEK_S9N64jsP.zwF7BNAhyNvXbBxDz7490OwLSaNa.XO4LPpgDy4wc + WNFr4X7HzrWAZNpKoXSroT7inP6VXluJyCJnENIZiyjNOMzaLvSeoUMgd7j6 + z7fjwKTYSOZCyjG24UXQe1XP778ULHwhhB9.UQOc6GYez.kHrO3vxukB41LB + Y8SjBgDUOTbbSC_06k314YKCDe1TfZgh7tSQtSQMXf2j3mtKYWhpT_QXmvJw + Iau9Sqkprfu58XNGzL8VN7vkNO8Ep_XtsDO4TV6cZalQXGaHECYh3UwOZ.rw + jIblSqA5NTgwfwsjU8pL_jRJie52MDMKiDbwdIa2C7Nl4zxfS.YtOtETva.1 + 6corcpQmthOkdHLbMShvFY.1CM8ItHxisyd9f9JYwIE4m3U.DfcNbUjZRaav + 2XHDnDny4HH6gO4v.4QZTx5Gva8SQ_D9Q4BuAcyAqJhTwtoT6e8yObwAufQh + vvfyOwktzI5ypd0LTrP3KWltRq82WItaSbkeUdv0.aHc9GfiKjwJtenOpmfo + AxyY24dJJhKcD0qN4y6hzWoLGlR3zLGubyw- +Received: from 159.127.162.109 (EHLO mta109aa.pmx1.epsl1.com) + by 10.223.248.109 with SMTPs + (version=TLS1_2 cipher=TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256); + Fri, 23 Jan 2026 13:08:21 +0000 +DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=comms.aol.net; + s=ep1; t=1769173701; + bh=THVuSqnj7z7zr6PsIk3ZarfQREjI3c/JJLWBgNfL+rw=; + h=List-Unsubscribe:List-Unsubscribe-Post:MIME-Version:Subject:From: + To:Date:Content-Type; + b=OBYz39dXQCifC0qqdNolZusQHwNk5ItiwFvTtLbXYmGvFQ7tnnLwkfGKW0P3BVJ5S + W/AwKtioKCKMpEws2OA1ZDyXImlQ9mI3/qA7eoNxyHeFreAdoLhWvCE0kT6/+u1umb + 0NK7/Wjkg0Yurg+DzCvmJiCtk64iRNmmrjml6VOM= +Received: from [10.233.84.240] ([10.233.84.240:54872]) + by pc1udsmtn2n14 (envelope-from ) + (ecelerity 3.6.9.48312 r(Core:3.6.9.0)) with ECSTREAM + id 80/D7-41976-5C273796; Fri, 23 Jan 2026 13:08:21 +0000 +List-Unsubscribe: , +List-Unsubscribe-Post: List-Unsubscribe=One-Click +Message-ID: +MIME-Version: 1.0 +Feedback-ID: 654b770f-c6f0-4e64-a8d0-72e65bb5e80e:a3a78beb-ed1e-4d40-8d59-80898de8e54c:email:epslh1 +X-NSS: 654b770f-c6f0-4e64-a8d0-72e65bb5e80e +Reply-To: "reply@comms.aol.net" + +Subject: You've updated your AOL account information +From: AOL Support +To: user@example.com +Date: Fri, 23 Jan 2026 13:08:21 +0000 +Content-Type: multipart/alternative; + boundary="-=Part.d96da9.c8ced2f941b41333.19beaf8523d.267666a9a62272b=-" +Content-Length: 4698 + +---=Part.d96da9.c8ced2f941b41333.19beaf8523d.267666a9a62272b=- +Content-Transfer-Encoding: quoted-printable +Content-Type: text/html; charset=UTF-8 + + + + + + + + + + + + + + + +
3D"AOL"

+ + + + + + + +

Dear user,
+
+Our records indicate that your AOL account was recently updated and will n= +ow reflect the new information you provided=2E It is important to keep your= + account information current in the event you need to retrieve your forgott= +en password or for interactions with our Customer Support Team=2E
+
+If you did not request this change or would like to view your changes, ple= +ase sign in to myaccount=2Eaol=2Ecom=2E
+
+Thank you for being an AOL member=2E
+
+Sincerely,
+AOL Member Services


+
+
+This email has been sent from an unmonitored email address=2E Please do no= +t reply to this message=2E
+
+Privacy Policy | Customer Support
+©2026 AOL Media LLC=2E All rights reserved=2E
+11955 Democracy Drive, 14th Floor, Reston, VA 20190, USA
+ + + +---=Part.d96da9.c8ced2f941b41333.19beaf8523d.267666a9a62272b=--- + diff --git a/Feature/Tests/MIMETests/StringTests.swift b/Feature/Tests/MIMETests/StringTests.swift index d072f418..785ab0a4 100644 --- a/Feature/Tests/MIMETests/StringTests.swift +++ b/Feature/Tests/MIMETests/StringTests.swift @@ -63,20 +63,39 @@ struct StringTests { #expect("inline".parameters == [:]) } - @Test func quotedPrintableInit() throws { - #expect(try String(quotedPrintable: .quotedPrintable) == decodedQuotedPrintable) + @Test func headerDecoded() throws { + let subject: String = "=?UTF-8?Q?=F0=9F=91=8D=F0=9F=A4=96_S=C3=A4mpl=C3=A9_=C3=A6m?= =?UTF-8?Q?@il_$\\ubject=F0=9F=93=A6?=" + #expect(try subject.headerDecoded() == "👍🤖 Sämplé æm@il $\\ubject📦") + #expect(try "=?utf-8?B?4p2k77iP4p2k77iP4p2k77iPw6nDhvCfpJYiXOKdpO+4j+KdpO+4jw==?=".headerDecoded() == "❤️❤️❤️éÆ🤖\"\\❤️❤️") + #expect(try "=?utf-8?Q?=E2=9D=A4=EF=B8=8F=E2=9D=A4=EF=B8=8F=E2=9D=A4=EF=B8=8F=C3=A9=C3=86=F0=9F=A4=96\"\\=E2=9D=A4=EF=B8=8F=E2=9D=A4=EF=B8=8F?=".headerDecoded() == "❤️❤️❤️éÆ🤖\"\\❤️❤️") + #expect(try "Your app password was used to sign in to a third-party app".headerDecoded() == "Your app password was used to sign in to a third-party app") + #expect(try encodedQuotedPrintable.headerDecoded() == "❤️❤️❤️éÆ🤖\"\\❤️❤️") #expect(throws: MIMEError.self) { - try String(quotedPrintable: .quotedPrintable, encoding: .ascii) + try "=?utf-8?B?4p2k77iP4p2k77iP4p2k77iPw6nDhvCfpJYi+4j+KdpO+4jw==?=".headerDecoded() } } + @Test func headerEncoded() throws { + #expect(try "❤️❤️❤️éÆ🤖\"\\❤️❤️".headerEncoded() == "=?UTF-8?B?4p2k77iP4p2k77iP4p2k77iPw6nDhvCfpJYiXOKdpO+4j+KdpO+4jw==?=") + #expect(try "Plain ASCII string requiring 0/no encoding".headerEncoded() == "Plain ASCII string requiring 0/no encoding") + #expect(try "".headerEncoded() == "") + } + + @Test func quotedPrintableInit() throws { + #expect(try! String(quotedPrintable: .quotedPrintable, encoding: .ascii) == decodedQuotedPrintable) + #expect(try String(quotedPrintable: .quotedPrintable) == decodedQuotedPrintable) + } + @Test func decodingQuotedPrintable() throws { let quotedPrintable: String = String(data: .quotedPrintable, encoding: .ascii)! - .replacingOccurrences(of: "=\r\n", with: "") - .replacingOccurrences(of: "=\n", with: "") + #expect(try quotedPrintable.decodingQuotedPrintable(to: .ascii) == decodedQuotedPrintable) #expect(try quotedPrintable.decodingQuotedPrintable() == decodedQuotedPrintable) + } + + @Test func decodingBase64() throws { + #expect(try "4p2k77iP4p2k77iP4p2k77iPw6nDhvCfpJYiXOKdpO+4j+KdpO+4jw==".decodingBase64() == "❤️❤️❤️éÆ🤖\"\\❤️❤️") #expect(throws: MIMEError.self) { - try quotedPrintable.decodingQuotedPrintable(to: .ascii) + try "4p2k77iP4p2k77iPw6nDhvCfpJYi4j+KdpO+4jw==".decodingBase64() } } } @@ -99,3 +118,9 @@ private let decodedQuotedPrintable: String = """

Thunderbird avatar

""" + +// swift-format-ignore +private let encodedQuotedPrintable: String = """ +=?utf-8?Q?=E2=9D=A4=EF=B8=8F=E2=9D=A4=EF=B8=8F=E2=9D=A4=EF=B8=8F=C3=A9=C3=86= +=F0=9F=A4=96"\\=E2=9D=A4=EF=B8=8F=E2=9D=A4=EF=B8=8F?= +""" diff --git a/Thunderbird/Thunderbird/EmailDisplay/EmailCellView.swift b/Thunderbird/Thunderbird/EmailDisplay/EmailCellView.swift index 9acbc3d9..974b871b 100644 --- a/Thunderbird/Thunderbird/EmailDisplay/EmailCellView.swift +++ b/Thunderbird/Thunderbird/EmailDisplay/EmailCellView.swift @@ -45,9 +45,10 @@ struct EmailCellView: View { .font(.headline) .fontWeight(unread ? .semibold : .regular) Spacer() - Text(SmartDateFormatter() - .dateFormatter(date: dateSent, isSmartDate: !flags.flagForKey(key: Flag.fullDate.rawValue)) -) + Text( + SmartDateFormatter() + .dateFormatter(date: dateSent, isSmartDate: !flags.flagForKey(key: Flag.fullDate.rawValue)) + ) }.padding(.leading, pinned ? 0 : 20) HStack { if newEmail {