add sync coordinator: imap → mailstore pipeline with delta sync
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1 +0,0 @@
|
||||
enum SyncEnginePlaceholder {}
|
||||
187
Packages/MagnumOpusCore/Sources/SyncEngine/SyncCoordinator.swift
Normal file
187
Packages/MagnumOpusCore/Sources/SyncEngine/SyncCoordinator.swift
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user