replace prefetchBodies with MIME-aware parsing, store attachments during sync

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-14 13:35:14 +01:00
parent 0b9bbe1255
commit 858cdf5284
3 changed files with 77 additions and 6 deletions

View File

@@ -59,7 +59,7 @@ let package = Package(
),
.target(
name: "SyncEngine",
dependencies: ["Models", "IMAPClient", "SMTPClient", "MailStore", "TaskStore"]
dependencies: ["Models", "IMAPClient", "SMTPClient", "MailStore", "TaskStore", "MIMEParser"]
),
.testTarget(name: "ModelsTests", dependencies: ["Models"]),
.testTarget(name: "MailStoreTests", dependencies: ["MailStore"]),

View File

@@ -2,6 +2,7 @@ import Foundation
import Models
import IMAPClient
import MailStore
import MIMEParser
import TaskStore
@Observable
@@ -174,23 +175,45 @@ public final class SyncCoordinator {
)
}
/// Fetch full bodies for recent messages that don't have bodyText yet
/// Fetch full RFC822 messages and parse MIME for body + attachments
private func prefetchBodies(mailboxId: String) async {
let thirtyDaysAgo = ISO8601DateFormatter().string(
from: Calendar.current.date(byAdding: .day, value: -30, to: Date())!
)
do {
let messages = try store.messages(mailboxId: mailboxId)
let recent = messages.filter { $0.bodyText == nil && $0.date >= thirtyDaysAgo }
let recent = messages.filter { $0.bodyText == nil && $0.bodyHtml == nil && $0.date >= thirtyDaysAgo }
for message in recent.prefix(50) {
guard !Task.isCancelled else { break }
let (text, html) = try await imapClient.fetchBody(uid: message.uid)
if text != nil || html != nil {
try store.storeBody(messageId: message.id, text: text, html: html)
let rawMessage = try await imapClient.fetchFullMessage(uid: message.uid)
guard !rawMessage.isEmpty else { continue }
let parsed = MIMEParser.parse(rawMessage)
// Build attachment records
let attachmentRecords = (parsed.attachments + parsed.inlineImages).map { att in
AttachmentRecord(
id: UUID().uuidString,
messageId: message.id,
filename: att.filename,
mimeType: att.mimeType,
size: att.size,
contentId: att.contentId,
cachePath: nil,
sectionPath: att.sectionPath
)
}
try store.storeBodyWithAttachments(
messageId: message.id,
text: parsed.textBody,
html: parsed.htmlBody,
attachments: attachmentRecords
)
}
} catch {
// Background prefetch failure is non-fatal
print("[SyncCoordinator] prefetchBodies error: \(error)")
}
}

View File

@@ -1,3 +1,4 @@
import Foundation
import Testing
import GRDB
@testable import SyncEngine
@@ -120,6 +121,53 @@ struct SyncCoordinatorTests {
#expect(messages.count == 3)
}
@Test("sync with full message parses MIME and stores attachments")
func mimeAwareSync() async throws {
let store = try makeStore()
let mock = makeMock()
// Use recent dates so messages pass the 30-day prefetch filter
let recentDate = ISO8601DateFormatter().string(from: Date())
mock.mailboxEnvelopes["INBOX"] = mock.mailboxEnvelopes["INBOX"]!.map { env in
FetchedEnvelope(
uid: env.uid, messageId: env.messageId, inReplyTo: env.inReplyTo,
references: env.references, subject: env.subject, from: env.from,
to: env.to, cc: env.cc, date: recentDate, snippet: env.snippet,
bodyText: nil, bodyHtml: nil, isRead: env.isRead,
isFlagged: env.isFlagged, size: env.size
)
}
// Provide a multipart MIME message for body prefetch
let mimeMessage = "Content-Type: multipart/mixed; boundary=\"bound\"\r\n\r\n--bound\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nHello from MIME.\r\n--bound\r\nContent-Type: application/pdf; name=\"report.pdf\"\r\nContent-Disposition: attachment; filename=\"report.pdf\"\r\nContent-Transfer-Encoding: base64\r\n\r\nSGVsbG8=\r\n--bound--"
mock.fullMessages[1] = mimeMessage
let coordinator = SyncCoordinator(
accountConfig: AccountConfig(
id: "acc1", name: "Personal", email: "me@example.com",
imapHost: "imap.example.com", imapPort: 993
),
imapClient: mock,
store: store
)
try await coordinator.syncNow()
// Verify MIME body was stored
let inboxMb = try store.mailboxes(accountId: "acc1").first { $0.name == "INBOX" }!
let messages = try store.messages(mailboxId: inboxMb.id)
let msg = messages.first { $0.uid == 1 }!
#expect(msg.bodyText == "Hello from MIME.")
#expect(msg.hasAttachments == true)
// Verify attachment was stored
let attachments = try store.attachments(messageId: msg.id)
#expect(attachments.count == 1)
#expect(attachments.first?.filename == "report.pdf")
#expect(attachments.first?.mimeType == "application/pdf")
#expect(attachments.first?.sectionPath == "2")
}
@Test("sync state transitions through syncing to idle")
func syncStateTransitions() async throws {
let store = try makeStore()