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(
|
.target(
|
||||||
name: "SyncEngine",
|
name: "SyncEngine",
|
||||||
dependencies: ["Models", "IMAPClient", "SMTPClient", "MailStore", "TaskStore"]
|
dependencies: ["Models", "IMAPClient", "SMTPClient", "MailStore", "TaskStore", "MIMEParser"]
|
||||||
),
|
),
|
||||||
.testTarget(name: "ModelsTests", dependencies: ["Models"]),
|
.testTarget(name: "ModelsTests", dependencies: ["Models"]),
|
||||||
.testTarget(name: "MailStoreTests", dependencies: ["MailStore"]),
|
.testTarget(name: "MailStoreTests", dependencies: ["MailStore"]),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Foundation
|
|||||||
import Models
|
import Models
|
||||||
import IMAPClient
|
import IMAPClient
|
||||||
import MailStore
|
import MailStore
|
||||||
|
import MIMEParser
|
||||||
import TaskStore
|
import TaskStore
|
||||||
|
|
||||||
@Observable
|
@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 {
|
private func prefetchBodies(mailboxId: String) async {
|
||||||
let thirtyDaysAgo = ISO8601DateFormatter().string(
|
let thirtyDaysAgo = ISO8601DateFormatter().string(
|
||||||
from: Calendar.current.date(byAdding: .day, value: -30, to: Date())!
|
from: Calendar.current.date(byAdding: .day, value: -30, to: Date())!
|
||||||
)
|
)
|
||||||
do {
|
do {
|
||||||
let messages = try store.messages(mailboxId: mailboxId)
|
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) {
|
for message in recent.prefix(50) {
|
||||||
guard !Task.isCancelled else { break }
|
guard !Task.isCancelled else { break }
|
||||||
let (text, html) = try await imapClient.fetchBody(uid: message.uid)
|
let rawMessage = try await imapClient.fetchFullMessage(uid: message.uid)
|
||||||
if text != nil || html != nil {
|
guard !rawMessage.isEmpty else { continue }
|
||||||
try store.storeBody(messageId: message.id, text: text, html: html)
|
|
||||||
|
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 {
|
} catch {
|
||||||
// Background prefetch failure is non-fatal
|
// Background prefetch failure is non-fatal
|
||||||
|
print("[SyncCoordinator] prefetchBodies error: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import Foundation
|
||||||
import Testing
|
import Testing
|
||||||
import GRDB
|
import GRDB
|
||||||
@testable import SyncEngine
|
@testable import SyncEngine
|
||||||
@@ -120,6 +121,53 @@ struct SyncCoordinatorTests {
|
|||||||
#expect(messages.count == 3)
|
#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")
|
@Test("sync state transitions through syncing to idle")
|
||||||
func syncStateTransitions() async throws {
|
func syncStateTransitions() async throws {
|
||||||
let store = try makeStore()
|
let store = try makeStore()
|
||||||
|
|||||||
Reference in New Issue
Block a user