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) <noreply@anthropic.com>
This commit is contained in:
2026-03-15 10:33:08 +01:00
parent 31ab18cb2a
commit 8c33d4d4a6
5 changed files with 55 additions and 12 deletions

View File

@@ -13,6 +13,7 @@ public actor IMAPIdleClient {
private var group: EventLoopGroup?
private var isMonitoring = false
private var monitorTask: Task<Void, Never>?
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")))

View File

@@ -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" {

View File

@@ -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)

View File

@@ -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(

View File

@@ -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