diff --git a/Packages/MagnumOpusCore/Package.swift b/Packages/MagnumOpusCore/Package.swift index a4c802c..bb9c97e 100644 --- a/Packages/MagnumOpusCore/Package.swift +++ b/Packages/MagnumOpusCore/Package.swift @@ -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"]), diff --git a/Packages/MagnumOpusCore/Sources/SyncEngine/SyncCoordinator.swift b/Packages/MagnumOpusCore/Sources/SyncEngine/SyncCoordinator.swift index bac9286..6329f10 100644 --- a/Packages/MagnumOpusCore/Sources/SyncEngine/SyncCoordinator.swift +++ b/Packages/MagnumOpusCore/Sources/SyncEngine/SyncCoordinator.swift @@ -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)") } } diff --git a/Packages/MagnumOpusCore/Tests/SyncEngineTests/SyncCoordinatorTests.swift b/Packages/MagnumOpusCore/Tests/SyncEngineTests/SyncCoordinatorTests.swift index e66cbd7..6019225 100644 --- a/Packages/MagnumOpusCore/Tests/SyncEngineTests/SyncCoordinatorTests.swift +++ b/Packages/MagnumOpusCore/Tests/SyncEngineTests/SyncCoordinatorTests.swift @@ -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()