macsync/@packages/imail/Sources/IMailSync/SyncManager.swift

230 lines
7.9 KiB
Swift

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: the Message-ID of the most recently synced message is stored in UserDefaults
/// under `imailLastMessageId`, and the send date under `imailLastSync`. On the next cycle
/// we pass `lastSync` to `Reader.fetchMessages(since:)` so we only enumerate messages
/// sent after that date (±1 minute tolerance is applied by the reader).
///
/// 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: ObservableObject {
public static let shared = SyncManager()
@Published public var isSyncing = false
@Published public var lastSync: Date?
@Published public var lastSyncCompletedAt: Date?
@Published public var stats = IMailSyncStats()
@Published public var syncError: IMailSyncError = .none
@Published public var currentOperation: String = ""
private let reader = Reader.shared
private let sender = Sender.shared
private let apiClient = APIClient.shared
private var syncTimer: Timer?
private let batchSize = 50
private init() {
lastSync = UserDefaults.standard.object(forKey: "imailLastSync") as? Date
lastSyncCompletedAt = UserDefaults.standard.object(forKey: "imailLastSyncCompletedAt") as? Date
log.info("init lastSync=\(String(describing: self.lastSync))")
}
// MARK: - Lifecycle
public func startSync() {
log.info("startSync called")
syncError = .none
Task { await performSync() }
syncTimer = Timer.scheduledTimer(withTimeInterval: 300, repeats: true) { [weak self] _ in
Task { @MainActor in self?.syncNow() }
}
log.info("Scheduled automatic sync every 5 minutes")
}
public func stopSync() {
syncTimer?.invalidate()
syncTimer = nil
}
public func syncNow() {
guard !isSyncing else { return }
Task { await performSync() }
}
public func forceFullResync() {
guard !isSyncing else { return }
log.info("Forcing full resync")
lastSync = nil
UserDefaults.standard.removeObject(forKey: "imailLastSync")
syncNow()
}
// MARK: - Sync cycle
private func performSync() async {
log.info("performSync starting")
isSyncing = true
currentOperation = "Reading Mail.app…"
// Reader runs synchronously on a background thread dispatch off main
let messages = await Task.detached(priority: .userInitiated) { [weak self] in
guard let self else { return [MailMessage]() }
return self.reader.fetchMessages(since: await self.lastSync)
}.value
log.info("Fetched \(messages.count) messages from Mail.app")
guard !messages.isEmpty else {
currentOperation = "No new mail"
await fetchStats()
isSyncing = false
lastSyncCompletedAt = Date()
UserDefaults.standard.set(lastSyncCompletedAt, forKey: "imailLastSyncCompletedAt")
return
}
currentOperation = "Syncing \(messages.count) messages…"
var totalSynced = 0
var latestDate: Date?
let batches = stride(from: 0, to: messages.count, by: batchSize).map {
Array(messages[$0..<min($0 + batchSize, messages.count)])
}
for (idx, batch) in batches.enumerated() {
currentOperation = "Syncing… (\(idx + 1)/\(batches.count))"
let payloads = batch.map { msg -> 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
// Track latest date for watermark
let df = ISO8601DateFormatter()
for msg in batch {
if let d = df.date(from: msg.sentAt) {
if latestDate == nil || d > latestDate! { latestDate = d }
}
}
log.info("Batch \(idx + 1)/\(batches.count) synced=\(synced)")
} catch {
log.warning("Batch \(idx + 1) failed: \(error.localizedDescription)")
let msg = error.localizedDescription.lowercased()
if msg.contains("connection") || msg.contains("network") || msg.contains("timeout") {
syncError = .connectionFailed(error.localizedDescription)
}
}
}
stats.syncedThisSession = totalSynced
if let d = latestDate {
lastSync = d
UserDefaults.standard.set(d, forKey: "imailLastSync")
}
let now = Date()
lastSyncCompletedAt = now
UserDefaults.standard.set(now, forKey: "imailLastSyncCompletedAt")
await fetchStats()
currentOperation = "Sync complete"
log.info("performSync complete: synced=\(totalSynced)")
isSyncing = false
}
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 {
lastSync = serverLastSync
UserDefaults.standard.set(serverLastSync, forKey: "imailLastSync")
}
} 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
}
}