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:
@@ -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")))
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user