2026-05-15 17:05:13 -07:00
|
|
|
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.
|
|
|
|
|
///
|
2026-05-15 18:02:04 -07:00
|
|
|
/// 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.
|
2026-05-15 17:05:13 -07:00
|
|
|
///
|
|
|
|
|
/// Runs in parallel with the server-side IMAP module during transition. De-duplication
|
|
|
|
|
/// is handled server-side via `externalId` (Message-ID).
|
|
|
|
|
@MainActor
|
2026-05-15 18:02:04 -07:00
|
|
|
public final class SyncManager: BaseSyncManager<IMailSyncStats, IMailSyncError> {
|
2026-05-15 17:05:13 -07:00
|
|
|
public static let shared = SyncManager()
|
|
|
|
|
|
|
|
|
|
private let reader = Reader.shared
|
|
|
|
|
private let sender = Sender.shared
|
|
|
|
|
private let apiClient = APIClient.shared
|
|
|
|
|
|
|
|
|
|
private let batchSize = 50
|
|
|
|
|
|
2026-05-15 18:02:04 -07:00
|
|
|
// 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)")
|
|
|
|
|
}
|
2026-05-15 17:05:39 -07:00
|
|
|
}
|
2026-05-15 18:02:04 -07:00
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
public override func didStartSync() {
|
|
|
|
|
sendQueueClient.start()
|
2026-05-15 17:05:13 -07:00
|
|
|
}
|
|
|
|
|
|
2026-05-15 18:02:04 -07:00
|
|
|
public override func willStopSync() {
|
|
|
|
|
sendQueueClient.stop()
|
2026-05-15 17:05:13 -07:00
|
|
|
}
|
|
|
|
|
|
2026-05-15 18:02:04 -07:00
|
|
|
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")
|
|
|
|
|
}
|
2026-05-15 17:05:13 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public func forceFullResync() {
|
|
|
|
|
guard !isSyncing else { return }
|
|
|
|
|
log.info("Forcing full resync")
|
2026-05-15 18:02:04 -07:00
|
|
|
setLastSync(nil)
|
2026-05-15 17:05:13 -07:00
|
|
|
syncNow()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - Sync cycle
|
|
|
|
|
|
2026-05-15 18:02:04 -07:00
|
|
|
public override func performSync() async {
|
2026-05-15 17:05:13 -07:00
|
|
|
log.info("performSync starting")
|
|
|
|
|
currentOperation = "Reading Mail.app…"
|
|
|
|
|
|
|
|
|
|
// Reader runs synchronously on a background thread — dispatch off main
|
2026-05-15 18:02:04 -07:00
|
|
|
let watermark = lastSync
|
|
|
|
|
let messages = await Task.detached(priority: .userInitiated) { [reader] in
|
|
|
|
|
reader.fetchMessages(since: watermark)
|
2026-05-15 17:05:13 -07:00
|
|
|
}.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)")
|
2026-05-15 18:02:04 -07:00
|
|
|
if SyncConnectionErrorHeuristic.isConnectionError(error) {
|
2026-05-15 17:05:13 -07:00
|
|
|
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 {
|
2026-05-15 18:02:04 -07:00
|
|
|
setLastSync(serverLastSync)
|
2026-05-15 17:05:13 -07:00
|
|
|
}
|
|
|
|
|
} 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
|
|
|
|
|
}
|
|
|
|
|
}
|