From 8c33d4d4a65267f8da0956c8c01bc4bcb45addba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20F=C3=B6rtsch?= Date: Sun, 15 Mar 2026 10:33:08 +0100 Subject: [PATCH] fix raw MIME body display, re-parse stale cached bodies, fix minor review items - detect and re-fetch bodies containing unparsed MIME content (boundary markers, Content-Transfer-Encoding headers) from pre-MIMEParser code path - fix MIMEParser section numbering: pass cumulative sectionPrefix in nested multiparts instead of resetting to empty string - generate snippet from parsed body text when envelope snippet is missing - add pendingAction(id:) direct lookup to MailStore, avoid re-fetching all actions - add updateSnippet method to MailStore - fix IMAPIdleClient.selectInbox: use incrementing tag counter instead of hardcoded tag - use static nonisolated(unsafe) ISO8601DateFormatter in ActionQueue (avoid repeated alloc) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Sources/IMAPClient/IMAPIdleClient.swift | 4 +++- .../Sources/MIMEParser/MIMEParser.swift | 12 ++++++---- .../Sources/MailStore/MailStore.swift | 15 ++++++++++++ .../Sources/SyncEngine/ActionQueue.swift | 13 +++++++---- .../Sources/SyncEngine/SyncCoordinator.swift | 23 ++++++++++++++++++- 5 files changed, 55 insertions(+), 12 deletions(-) diff --git a/Packages/MagnumOpusCore/Sources/IMAPClient/IMAPIdleClient.swift b/Packages/MagnumOpusCore/Sources/IMAPClient/IMAPIdleClient.swift index 98b0456..c01a627 100644 --- a/Packages/MagnumOpusCore/Sources/IMAPClient/IMAPIdleClient.swift +++ b/Packages/MagnumOpusCore/Sources/IMAPClient/IMAPIdleClient.swift @@ -13,6 +13,7 @@ public actor IMAPIdleClient { private var group: EventLoopGroup? private var isMonitoring = false private var monitorTask: Task? + private var tagCounter = 0 private let reIdleInterval: Duration = .seconds(29 * 60) // 29 minutes per RFC 2177 public init(host: String, port: Int, credentials: Credentials) { @@ -120,7 +121,8 @@ public actor IMAPIdleClient { guard let channel else { throw IMAPError.notConnected } let responseHandler = try await getResponseHandler() - let tag = "IDLESEL1" + tagCounter += 1 + let tag = "IDLESEL\(tagCounter)" let selectCommand = TaggedCommand( tag: tag, command: .select(MailboxName(ByteBuffer(string: "INBOX"))) diff --git a/Packages/MagnumOpusCore/Sources/MIMEParser/MIMEParser.swift b/Packages/MagnumOpusCore/Sources/MIMEParser/MIMEParser.swift index c8adf0c..d9d6906 100644 --- a/Packages/MagnumOpusCore/Sources/MIMEParser/MIMEParser.swift +++ b/Packages/MagnumOpusCore/Sources/MIMEParser/MIMEParser.swift @@ -235,9 +235,10 @@ public enum MIMEParser { let lowerType = contentType.lowercased() if lowerType.contains("multipart/alternative") { - for part in parts { + for (index, part) in parts.enumerated() { + let sectionIndex = sectionPrefix.isEmpty ? "\(index + 1)" : "\(sectionPrefix).\(index + 1)" if !part.subparts.isEmpty { - extractBodiesAndAttachments(from: part.subparts, contentType: part.contentType, into: &message, sectionPrefix: "") + extractBodiesAndAttachments(from: part.subparts, contentType: part.contentType, into: &message, sectionPrefix: sectionIndex) } else if part.contentType == "text/plain" && message.textBody == nil { message.textBody = decodeString(part.body, charset: part.charset)?.trimmingCharacters(in: .whitespacesAndNewlines) } else if part.contentType == "text/html" && message.htmlBody == nil { @@ -247,9 +248,10 @@ public enum MIMEParser { } else if lowerType.contains("multipart/related") { // First part is the HTML body, rest are inline resources for (index, part) in parts.enumerated() { + let sectionIndex = sectionPrefix.isEmpty ? "\(index + 1)" : "\(sectionPrefix).\(index + 1)" if index == 0 { if !part.subparts.isEmpty { - extractBodiesAndAttachments(from: part.subparts, contentType: part.contentType, into: &message, sectionPrefix: "") + extractBodiesAndAttachments(from: part.subparts, contentType: part.contentType, into: &message, sectionPrefix: sectionIndex) } else if part.contentType == "text/html" { message.htmlBody = decodeString(part.body, charset: part.charset)?.trimmingCharacters(in: .whitespacesAndNewlines) } else if part.contentType == "text/plain" { @@ -275,8 +277,8 @@ public enum MIMEParser { let sectionIndex = sectionPrefix.isEmpty ? "\(index + 1)" : "\(sectionPrefix).\(index + 1)" if !part.subparts.isEmpty { - // Nested multipart — recurse - extractBodiesAndAttachments(from: part.subparts, contentType: part.contentType, into: &message, sectionPrefix: "") + // Nested multipart — recurse with cumulative section path + extractBodiesAndAttachments(from: part.subparts, contentType: part.contentType, into: &message, sectionPrefix: sectionIndex) bodyFound = true } else if !bodyFound && part.disposition != .attachment && part.contentType.hasPrefix("text/") { if part.contentType == "text/html" { diff --git a/Packages/MagnumOpusCore/Sources/MailStore/MailStore.swift b/Packages/MagnumOpusCore/Sources/MailStore/MailStore.swift index 4444e36..d78744e 100644 --- a/Packages/MagnumOpusCore/Sources/MailStore/MailStore.swift +++ b/Packages/MagnumOpusCore/Sources/MailStore/MailStore.swift @@ -98,6 +98,15 @@ public final class MailStore: Sendable { } } + public func updateSnippet(messageId: String, snippet: String) throws { + try dbWriter.write { db in + try db.execute( + sql: "UPDATE message SET snippet = ? WHERE id = ?", + arguments: [snippet, messageId] + ) + } + } + public func storeBody(messageId: String, text: String?, html: String?) throws { try dbWriter.write { db in try db.execute( @@ -244,6 +253,12 @@ public final class MailStore: Sendable { } } + public func pendingAction(id: String) throws -> PendingActionRecord? { + try dbWriter.read { db in + try PendingActionRecord.fetchOne(db, key: id) + } + } + public func deletePendingAction(id: String) throws { try dbWriter.write { db in _ = try PendingActionRecord.deleteOne(db, key: id) diff --git a/Packages/MagnumOpusCore/Sources/SyncEngine/ActionQueue.swift b/Packages/MagnumOpusCore/Sources/SyncEngine/ActionQueue.swift index 6e4dc9b..479bd0b 100644 --- a/Packages/MagnumOpusCore/Sources/SyncEngine/ActionQueue.swift +++ b/Packages/MagnumOpusCore/Sources/SyncEngine/ActionQueue.swift @@ -1,9 +1,14 @@ import Foundation +import os import Models import IMAPClient import SMTPClient import MailStore +private nonisolated(unsafe) let iso8601Formatter: ISO8601DateFormatter = { + ISO8601DateFormatter() +}() + public actor ActionQueue { private let store: MailStore private let accountId: String @@ -91,9 +96,7 @@ public actor ActionQueue { try await dispatch(action) try store.deletePendingAction(id: action.id) } catch { - // Update retry count on failure; flush will retry later - guard var record = try? store.pendingActions(accountId: accountId) - .first(where: { $0.id == action.id }) else { return } + guard var record = try? store.pendingAction(id: action.id) else { return } record.retryCount += 1 record.lastError = error.localizedDescription if record.retryCount >= 5 { @@ -153,7 +156,7 @@ public actor ActionQueue { let encoder = JSONEncoder() let payloadJson = (try? encoder.encode(action.payload)) .flatMap { String(data: $0, encoding: .utf8) } ?? "{}" - let dateString = ISO8601DateFormatter().string(from: action.createdAt) + let dateString = iso8601Formatter.string(from: action.createdAt) return PendingActionRecord( id: action.id, @@ -171,7 +174,7 @@ public actor ActionQueue { return nil } - let formatter = ISO8601DateFormatter() + let formatter = iso8601Formatter let date = formatter.date(from: record.createdAt) ?? Date() return PendingAction( diff --git a/Packages/MagnumOpusCore/Sources/SyncEngine/SyncCoordinator.swift b/Packages/MagnumOpusCore/Sources/SyncEngine/SyncCoordinator.swift index d9cd5ce..529426f 100644 --- a/Packages/MagnumOpusCore/Sources/SyncEngine/SyncCoordinator.swift +++ b/Packages/MagnumOpusCore/Sources/SyncEngine/SyncCoordinator.swift @@ -210,11 +210,26 @@ public final class SyncCoordinator { } } + /// Check whether a message needs its body (re-)fetched. + /// True when no body exists, or when the stored body contains unparsed MIME + /// (boundary markers from a pre-MIMEParser fetch path). + private func needsBodyFetch(_ msg: MessageRecord) -> Bool { + if msg.bodyText == nil && msg.bodyHtml == nil { return true } + // Detect stale raw MIME content stored by the old fetchBody code path + if let text = msg.bodyText, text.contains("Content-Transfer-Encoding:") || text.contains("------=_") { + return true + } + if let html = msg.bodyHtml, html.contains("Content-Transfer-Encoding:") || html.contains("------=_") { + return true + } + return false + } + /// Fetch full RFC822 messages and parse MIME for body + attachments private func prefetchBodies(mailboxId: String) async { do { let messages = try store.messages(mailboxId: mailboxId) - let recent = messages.filter { $0.bodyText == nil && $0.bodyHtml == nil } + let recent = messages.filter { needsBodyFetch($0) } for message in recent.prefix(50) { guard !Task.isCancelled else { break } let rawMessage = try await imapClient.fetchFullMessage(uid: message.uid) @@ -242,6 +257,12 @@ public final class SyncCoordinator { html: parsed.htmlBody, attachments: attachmentRecords ) + + // Generate snippet from body text if not already set + if message.snippet == nil, let text = parsed.textBody { + let snippet = String(text.prefix(200)).components(separatedBy: .newlines).joined(separator: " ") + try store.updateSnippet(messageId: message.id, snippet: snippet) + } } } catch { // Background prefetch failure is non-fatal