230 lines
7.9 KiB
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
|
|
}
|
|
}
|