diff --git a/Packages/MagnumOpusCore/Sources/SyncEngine/Placeholder.swift b/Packages/MagnumOpusCore/Sources/SyncEngine/Placeholder.swift deleted file mode 100644 index b9a61cf..0000000 --- a/Packages/MagnumOpusCore/Sources/SyncEngine/Placeholder.swift +++ /dev/null @@ -1 +0,0 @@ -enum SyncEnginePlaceholder {} diff --git a/Packages/MagnumOpusCore/Sources/SyncEngine/SyncCoordinator.swift b/Packages/MagnumOpusCore/Sources/SyncEngine/SyncCoordinator.swift new file mode 100644 index 0000000..c745fa6 --- /dev/null +++ b/Packages/MagnumOpusCore/Sources/SyncEngine/SyncCoordinator.swift @@ -0,0 +1,187 @@ +import Foundation +import Models +import IMAPClient +import MailStore + +@Observable +@MainActor +public final class SyncCoordinator { + private let accountConfig: AccountConfig + private let imapClient: any IMAPClientProtocol + private let store: MailStore + private var syncTask: Task? + + public private(set) var syncState: SyncState = .idle + private var eventHandlers: [(SyncEvent) -> Void] = [] + + public init(accountConfig: AccountConfig, imapClient: any IMAPClientProtocol, store: MailStore) { + self.accountConfig = accountConfig + self.imapClient = imapClient + self.store = store + } + + public func onEvent(_ handler: @escaping (SyncEvent) -> Void) { + eventHandlers.append(handler) + } + + private func emit(_ event: SyncEvent) { + for handler in eventHandlers { + handler(event) + } + } + + // MARK: - Sync + + public func syncNow() async throws { + syncState = .syncing(mailbox: nil) + emit(.syncStarted) + + do { + try await performSync() + syncState = .idle + emit(.syncCompleted) + } catch { + syncState = .error(error.localizedDescription) + emit(.syncFailed(error.localizedDescription)) + throw error + } + } + + private func performSync() async throws { + // Ensure account exists in DB + let existingAccounts = try store.accounts() + if !existingAccounts.contains(where: { $0.id == accountConfig.id }) { + try store.insertAccount(AccountRecord( + id: accountConfig.id, + name: accountConfig.name, + email: accountConfig.email, + imapHost: accountConfig.imapHost, + imapPort: accountConfig.imapPort + )) + } + + try await imapClient.connect() + do { + try await syncAllMailboxes() + } catch { + try? await imapClient.disconnect() + throw error + } + try? await imapClient.disconnect() + } + + private func syncAllMailboxes() async throws { + let remoteMailboxes = try await imapClient.listMailboxes() + for remoteMailbox in remoteMailboxes { + syncState = .syncing(mailbox: remoteMailbox.name) + try await syncMailbox(remoteMailbox) + } + } + + private func syncMailbox(_ remoteMailbox: IMAPMailboxInfo) async throws { + let status = try await imapClient.selectMailbox(remoteMailbox.name) + + let localMailboxes = try store.mailboxes(accountId: accountConfig.id) + let localMailbox = localMailboxes.first { $0.name == remoteMailbox.name } + + let mailboxId: String + let lastUid: Int + + if let local = localMailbox { + mailboxId = local.id + if local.uidValidity != status.uidValidity { + // UID validity changed — re-fetch everything + lastUid = 0 + } else { + lastUid = local.uidNext - 1 + } + } else { + mailboxId = UUID().uuidString + try store.upsertMailbox(MailboxRecord( + id: mailboxId, + accountId: accountConfig.id, + name: remoteMailbox.name, + uidValidity: status.uidValidity, + uidNext: status.uidNext + )) + lastUid = 0 + } + + let envelopes = try await imapClient.fetchEnvelopes(uidsGreaterThan: lastUid) + + if !envelopes.isEmpty { + let records = envelopes.map { envelope -> MessageRecord in + envelopeToRecord(envelope, accountId: accountConfig.id, mailboxId: mailboxId) + } + try store.insertMessages(records) + + let reconstructor = ThreadReconstructor(store: store) + try reconstructor.processMessages(records) + + emit(.newMessages(count: envelopes.count, mailbox: remoteMailbox.name)) + } + + try store.updateMailboxSync( + id: mailboxId, + uidValidity: status.uidValidity, + uidNext: status.uidNext + ) + } + + private func envelopeToRecord( + _ envelope: FetchedEnvelope, accountId: String, mailboxId: String + ) -> MessageRecord { + let toJson = encodeAddresses(envelope.to) + let ccJson = encodeAddresses(envelope.cc) + return MessageRecord( + id: UUID().uuidString, + accountId: accountId, + mailboxId: mailboxId, + uid: envelope.uid, + messageId: envelope.messageId, + inReplyTo: envelope.inReplyTo, + refs: envelope.references, + subject: envelope.subject, + fromAddress: envelope.from?.address, + fromName: envelope.from?.name, + toAddresses: toJson, + ccAddresses: ccJson, + date: envelope.date, + snippet: envelope.snippet, + bodyText: envelope.bodyText, + bodyHtml: envelope.bodyHtml, + isRead: envelope.isRead, + isFlagged: envelope.isFlagged, + size: envelope.size + ) + } + + private func encodeAddresses(_ addresses: [EmailAddress]) -> String? { + guard !addresses.isEmpty else { return nil } + struct Addr: Codable { var name: String?; var address: String } + let addrs = addresses.map { Addr(name: $0.name, address: $0.address) } + guard let data = try? JSONEncoder().encode(addrs) else { return nil } + return String(data: data, encoding: .utf8) + } + + // MARK: - Periodic Sync + + public func startPeriodicSync(interval: Duration = .seconds(300)) { + stopSync() + syncTask = Task { [weak self] in + while !Task.isCancelled { + try? await self?.syncNow() + do { + try await Task.sleep(for: interval) + } catch { + break + } + } + } + } + + public func stopSync() { + syncTask?.cancel() + syncTask = nil + } +} diff --git a/Packages/MagnumOpusCore/Tests/SyncEngineTests/MockIMAPClient.swift b/Packages/MagnumOpusCore/Tests/SyncEngineTests/MockIMAPClient.swift index c0f8ee1..eaf640e 100644 --- a/Packages/MagnumOpusCore/Tests/SyncEngineTests/MockIMAPClient.swift +++ b/Packages/MagnumOpusCore/Tests/SyncEngineTests/MockIMAPClient.swift @@ -5,6 +5,8 @@ final class MockIMAPClient: IMAPClientProtocol, @unchecked Sendable { var mailboxes: [IMAPMailboxInfo] = [] var mailboxStatuses: [String: IMAPMailboxStatus] = [:] var envelopes: [FetchedEnvelope] = [] + /// Per-mailbox envelopes — takes precedence over flat `envelopes` when set + var mailboxEnvelopes: [String: [FetchedEnvelope]] = [:] var flagUpdates: [UIDFlagsPair] = [] var bodies: [Int: (text: String?, html: String?)] = [:] @@ -33,7 +35,13 @@ final class MockIMAPClient: IMAPClientProtocol, @unchecked Sendable { } func fetchEnvelopes(uidsGreaterThan uid: Int) async throws -> [FetchedEnvelope] { - envelopes.filter { $0.uid > uid } + let source: [FetchedEnvelope] + if let mailbox = selectedMailbox, let perMailbox = mailboxEnvelopes[mailbox] { + source = perMailbox + } else { + source = envelopes + } + return source.filter { $0.uid > uid } } func fetchFlags(uids: ClosedRange) async throws -> [UIDFlagsPair] { diff --git a/Packages/MagnumOpusCore/Tests/SyncEngineTests/SyncCoordinatorTests.swift b/Packages/MagnumOpusCore/Tests/SyncEngineTests/SyncCoordinatorTests.swift new file mode 100644 index 0000000..e66cbd7 --- /dev/null +++ b/Packages/MagnumOpusCore/Tests/SyncEngineTests/SyncCoordinatorTests.swift @@ -0,0 +1,140 @@ +import Testing +import GRDB +@testable import SyncEngine +@testable import IMAPClient +@testable import MailStore +@testable import Models + +@Suite("SyncCoordinator") +@MainActor +struct SyncCoordinatorTests { + func makeStore() throws -> MailStore { + try MailStore(dbWriter: DatabaseSetup.openInMemoryDatabase()) + } + + func makeMock() -> MockIMAPClient { + let mock = MockIMAPClient() + mock.mailboxes = [ + IMAPMailboxInfo(name: "INBOX"), + IMAPMailboxInfo(name: "Sent"), + ] + mock.mailboxStatuses = [ + "INBOX": IMAPMailboxStatus(name: "INBOX", uidValidity: 1, uidNext: 3, messageCount: 2, recentCount: 0), + "Sent": IMAPMailboxStatus(name: "Sent", uidValidity: 1, uidNext: 1, messageCount: 0, recentCount: 0), + ] + mock.mailboxEnvelopes = [ + "INBOX": [ + FetchedEnvelope( + uid: 1, messageId: "msg001@example.com", inReplyTo: nil, references: nil, + subject: "Hello", from: EmailAddress(name: "Alice", address: "alice@example.com"), + to: [EmailAddress(address: "me@example.com")], cc: [], + date: "2024-03-08T10:00:00Z", snippet: "Hi there", + bodyText: nil, bodyHtml: nil, isRead: false, isFlagged: false, size: 1024 + ), + FetchedEnvelope( + uid: 2, messageId: "msg002@example.com", inReplyTo: "msg001@example.com", + references: "msg001@example.com", + subject: "Re: Hello", from: EmailAddress(name: "Bob", address: "bob@example.com"), + to: [EmailAddress(address: "alice@example.com")], cc: [], + date: "2024-03-08T11:00:00Z", snippet: "Hey!", + bodyText: nil, bodyHtml: nil, isRead: true, isFlagged: false, size: 512 + ), + ], + "Sent": [], + ] + return mock + } + + @Test("full sync creates account, mailboxes, messages, and threads") + func fullSync() async throws { + let store = try makeStore() + let mock = makeMock() + 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() + + // Account created + let accounts = try store.accounts() + #expect(accounts.count == 1) + + // Mailboxes created + let mailboxes = try store.mailboxes(accountId: "acc1") + #expect(mailboxes.count == 2) + + // Messages stored + let inboxMb = mailboxes.first { $0.name == "INBOX" }! + let messages = try store.messages(mailboxId: inboxMb.id) + #expect(messages.count == 2) + + // Threads created (msg002 replies to msg001, so 1 thread) + let threads = try store.threads(accountId: "acc1") + #expect(threads.count == 1) + #expect(threads[0].messageCount == 2) + + // uidNext updated + let updatedMb = try store.mailbox(id: inboxMb.id) + #expect(updatedMb?.uidNext == 3) + + // IMAP client was connected and disconnected + #expect(mock.connectCalled) + #expect(mock.disconnectCalled) + } + + @Test("delta sync only fetches new messages") + func deltaSync() async throws { + let store = try makeStore() + let mock = makeMock() + let config = AccountConfig( + id: "acc1", name: "Personal", email: "me@example.com", + imapHost: "imap.example.com", imapPort: 993 + ) + let coordinator = SyncCoordinator(accountConfig: config, imapClient: mock, store: store) + + // First sync + try await coordinator.syncNow() + + // Add a new message for delta sync + mock.mailboxEnvelopes["INBOX"]!.append(FetchedEnvelope( + uid: 3, messageId: "msg003@example.com", inReplyTo: nil, references: nil, + subject: "New message", from: EmailAddress(name: "Charlie", address: "charlie@example.com"), + to: [EmailAddress(address: "me@example.com")], cc: [], + date: "2024-03-09T10:00:00Z", snippet: "Something new", + bodyText: nil, bodyHtml: nil, isRead: false, isFlagged: false, size: 256 + )) + mock.mailboxStatuses["INBOX"] = IMAPMailboxStatus( + name: "INBOX", uidValidity: 1, uidNext: 4, messageCount: 3, recentCount: 1 + ) + + // Second sync — should only fetch uid > 2 + try await coordinator.syncNow() + + let inboxMb = try store.mailboxes(accountId: "acc1").first { $0.name == "INBOX" }! + let messages = try store.messages(mailboxId: inboxMb.id) + #expect(messages.count == 3) + } + + @Test("sync state transitions through syncing to idle") + func syncStateTransitions() async throws { + let store = try makeStore() + let mock = makeMock() + let coordinator = SyncCoordinator( + accountConfig: AccountConfig( + id: "acc1", name: "Personal", email: "me@example.com", + imapHost: "imap.example.com", imapPort: 993 + ), + imapClient: mock, + store: store + ) + + #expect(coordinator.syncState == .idle) + try await coordinator.syncNow() + #expect(coordinator.syncState == .idle) + } +}