import AppKit import Foundation import LilithLogging import MacSyncShared private let log = AppLogger.logger(for: "IMail.Sync") // MARK: - Stats public struct IMailSyncStats: Equatable, Sendable { public var totalEmails: Int = 0 public var totalFolders: Int = 0 public var syncedThisSession: Int = 0 public init() {} } // MARK: - Sync Error public enum IMailSyncError: Equatable, Sendable { case none case mailAppUnavailable case automationDenied case connectionFailed(String) public var message: String { switch self { case .none: return "" case .mailAppUnavailable: return "Mail.app unavailable — ensure it is running" case .automationDenied: return "Automation access denied — check System Settings > Privacy > Automation" case .connectionFailed(let m): return m } } } // MARK: - SyncManager /// Orchestrates iMail sync: read messages from Mail.app → push to server in batches. /// /// Watermark: handled by `BaseSyncManager` — `lastSync` is updated at the end of every /// cycle (cycle-completion time, not the latest-message date). The reader applies a /// ±1-minute tolerance which makes the cycle-time watermark safe for catching late /// arrivals on the next cycle. /// /// Runs in parallel with the server-side IMAP module during transition. De-duplication /// is handled server-side via `externalId` (Message-ID). @MainActor public final class SyncManager: BaseSyncManager { public static let shared = SyncManager() private let reader = Reader.shared private let sender = Sender.shared private let apiClient = APIClient.shared private let batchSize = 50 // Outbound send queue (server → Mac Mail.app sends). private lazy var sendQueueClient: SendQueueClient = { let transport = IMailSendTransport(apiClient: apiClient) let mailSender = MailSender(sender: sender) return SendQueueClient( label: "imail", transport: transport, interval: 60 ) { item in switch item.action { case "send_mail": return await mailSender.send(item.payload) default: return .failed(reason: "unknown action \(item.action)") } } }() public override func didStartSync() { sendQueueClient.start() } public override func willStopSync() { sendQueueClient.stop() } private init() { super.init( initialStats: IMailSyncStats(), noError: .none, persistenceKey: "imail", timerInterval: 300 ) // Migrate legacy UserDefaults watermark keys (one-time). if lastSync == nil, let legacy = UserDefaults.standard.object(forKey: "imailLastSync") as? Date { setLastSync(legacy) UserDefaults.standard.removeObject(forKey: "imailLastSync") } if lastSyncCompletedAt == nil, let legacy = UserDefaults.standard.object(forKey: "imailLastSyncCompletedAt") as? Date { lastSyncCompletedAt = legacy UserDefaults.standard.set(legacy, forKey: "imail.lastSyncCompletedAt") UserDefaults.standard.removeObject(forKey: "imailLastSyncCompletedAt") } } public func forceFullResync() { guard !isSyncing else { return } log.info("Forcing full resync") setLastSync(nil) syncNow() } // MARK: - Sync cycle public override func performSync() async { log.info("performSync starting") currentOperation = "Reading Mail.app…" // Reader runs synchronously on a background thread — dispatch off main let watermark = lastSync let messages = await Task.detached(priority: .userInitiated) { [reader] in reader.fetchMessages(since: watermark) }.value log.info("Fetched \(messages.count) messages from Mail.app") guard !messages.isEmpty else { currentOperation = "No new mail" await fetchStats() return } currentOperation = "Syncing \(messages.count) messages…" var totalSynced = 0 let batches = stride(from: 0, to: messages.count, by: batchSize).map { Array(messages[$0.. SyncEmailPayload in SyncEmailPayload( messageId: msg.messageId, threadId: msg.threadId, subject: msg.subject, fromAddress: msg.fromAddress, fromName: msg.fromName, to: msg.to.map { EmailAddressPayload(address: $0.address, name: $0.name) }, cc: msg.cc.map { EmailAddressPayload(address: $0.address, name: $0.name) }, folder: msg.folder, direction: msg.direction, textBody: msg.textBody, htmlBody: msg.htmlBody, hasAttachments: msg.hasAttachments, attachmentCount: msg.attachmentCount, sentAt: msg.sentAt, receivedAt: msg.receivedAt, isRead: msg.isRead ) } do { let synced = try await apiClient.syncMail(payloads) totalSynced += synced log.info("Batch \(idx + 1)/\(batches.count) synced=\(synced)") } catch { log.warning("Batch \(idx + 1) failed: \(error.localizedDescription)") if SyncConnectionErrorHeuristic.isConnectionError(error) { syncError = .connectionFailed(error.localizedDescription) } } } stats.syncedThisSession = totalSynced await fetchStats() currentOperation = "Sync complete" log.info("performSync complete: synced=\(totalSynced)") } private func fetchStats() async { do { let response = try await apiClient.getStats() stats.totalEmails = response.totalEmails stats.totalFolders = response.totalFolders if let serverLastSync = response.lastSyncAt, lastSync == nil { setLastSync(serverLastSync) } } catch { log.warning("fetchStats failed: \(error.localizedDescription)") } } // MARK: - Send (outbound via Mail.app) /// Send an email via Mail.app. Called by the local web server on `/api/imail/send` requests. public func sendEmail( to: [String], cc: [String] = [], subject: String, body: String, isHTML: Bool = false ) async -> Bool { let request = Sender.SendRequest(to: to, cc: cc, subject: subject, body: body, isHTML: isHTML) let result = await Task.detached(priority: .userInitiated) { [sender] in sender.send(request) }.value if !result.success { log.warning("Send failed: \(result.error ?? "unknown")") } return result.success } }