add sync coordinator: imap → mailstore pipeline with delta sync

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 18:32:38 +01:00
parent ce5c985272
commit 300b583695
4 changed files with 336 additions and 2 deletions

View File

@@ -1 +0,0 @@
enum SyncEnginePlaceholder {}

View File

@@ -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<Void, Never>?
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
}
}