decode RFC 2047 in To/CC names, convert dates to ISO 8601 for correct sorting

- decode RFC 2047 encoded words in To/CC address display names
- convert IMAP RFC 2822 dates to ISO 8601 before storing in MessageRecord,
  so SQLite ORDER BY text comparison sorts chronologically
- strip parenthesized timezone names from dates (e.g. "+0100 (CET)")

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-15 12:34:10 +01:00
parent a71f609f3b
commit fc90d71021

View File

@@ -275,11 +275,22 @@ public final class SyncCoordinator {
private func envelopeToRecord( private func envelopeToRecord(
_ envelope: FetchedEnvelope, accountId: String, mailboxId: String _ envelope: FetchedEnvelope, accountId: String, mailboxId: String
) -> MessageRecord { ) -> MessageRecord {
let toJson = encodeAddresses(envelope.to) // Decode RFC 2047 encoded words in all display names
let ccJson = encodeAddresses(envelope.cc) let decodedTo = envelope.to.map { EmailAddress(name: $0.name.map { RFC2047Decoder.decode($0) }, address: $0.address) }
// Decode RFC 2047 encoded words in subject and sender name let decodedCc = envelope.cc.map { EmailAddress(name: $0.name.map { RFC2047Decoder.decode($0) }, address: $0.address) }
let toJson = encodeAddresses(decodedTo)
let ccJson = encodeAddresses(decodedCc)
let decodedSubject = envelope.subject.map { RFC2047Decoder.decode($0) } let decodedSubject = envelope.subject.map { RFC2047Decoder.decode($0) }
let decodedFromName = envelope.from?.name.map { RFC2047Decoder.decode($0) } let decodedFromName = envelope.from?.name.map { RFC2047Decoder.decode($0) }
// Convert RFC 2822 date to ISO 8601 for correct text-based sorting in SQLite
let isoDate: String
if let parsed = Self.parseRFC2822Date(envelope.date) {
isoDate = ISO8601DateFormatter().string(from: parsed)
} else {
isoDate = envelope.date
}
return MessageRecord( return MessageRecord(
id: UUID().uuidString, id: UUID().uuidString,
accountId: accountId, accountId: accountId,
@@ -293,7 +304,7 @@ public final class SyncCoordinator {
fromName: decodedFromName, fromName: decodedFromName,
toAddresses: toJson, toAddresses: toJson,
ccAddresses: ccJson, ccAddresses: ccJson,
date: envelope.date, date: isoDate,
snippet: envelope.snippet, snippet: envelope.snippet,
bodyText: envelope.bodyText, bodyText: envelope.bodyText,
bodyHtml: envelope.bodyHtml, bodyHtml: envelope.bodyHtml,
@@ -303,6 +314,29 @@ public final class SyncCoordinator {
) )
} }
nonisolated(unsafe) private static let rfc2822Fmt: DateFormatter = {
let f = DateFormatter()
f.locale = Locale(identifier: "en_US_POSIX")
f.dateFormat = "EEE, dd MMM yyyy HH:mm:ss Z"
return f
}()
nonisolated(unsafe) private static let rfc2822FmtNoDow: DateFormatter = {
let f = DateFormatter()
f.locale = Locale(identifier: "en_US_POSIX")
f.dateFormat = "dd MMM yyyy HH:mm:ss Z"
return f
}()
private static func parseRFC2822Date(_ dateString: String) -> Date? {
// Strip parenthesized timezone name if present: "... +0100 (CET)" "... +0100"
var cleaned = dateString.trimmingCharacters(in: .whitespaces)
if let parenRange = cleaned.range(of: #"\s*\([^)]*\)\s*$"#, options: .regularExpression) {
cleaned = String(cleaned[..<parenRange.lowerBound])
}
return rfc2822Fmt.date(from: cleaned) ?? rfc2822FmtNoDow.date(from: cleaned)
}
private func encodeAddresses(_ addresses: [EmailAddress]) -> String? { private func encodeAddresses(_ addresses: [EmailAddress]) -> String? {
guard !addresses.isEmpty else { return nil } guard !addresses.isEmpty else { return nil }
struct Addr: Codable { var name: String?; var address: String } struct Addr: Codable { var name: String?; var address: String }