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

211 lines
7.2 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: 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<IMailSyncStats, IMailSyncError> {
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<IMailSendTransport> = {
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..<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
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
}
}