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