import Foundation import ScriptingBridge // MARK: - ScriptingBridge Protocol Declarations // // Mail.app exposes its object model via AppleScript/ScriptingBridge. We declare // lightweight @objc protocols here that mirror the Mail.sdef so we can call them // from Swift without a bridging-header dependency on a generated Mail.h. // // To regenerate the full ObjC header on plum (optional, for IDE completion): // sdef /System/Applications/Mail.app | sdp -fh --basename Mail -o /tmp/ // // The protocols below cover exactly what IMailSync needs; they are NOT an exhaustive // mapping of the Mail dictionary. // // NOTE: Protocol names use the "SB" prefix to avoid collision with the MailMessage // struct defined below. @objc protocol SBMailMessage { @objc optional var messageId: String { get } @objc optional var subject: String { get } @objc optional var sender: String { get } @objc optional var dateSent: Date { get } @objc optional var dateReceived: Date { get } @objc optional var isRead: Bool { get } @objc optional var content: SBObject { get } @objc optional var source: String { get } @objc optional var toRecipients: SBElementArray { get } @objc optional var ccRecipients: SBElementArray { get } @objc optional var numberOfAttachments: Int { get } } @objc protocol SBMailRecipient { @objc optional var address: String { get } @objc optional var name: String { get } } @objc protocol SBMailMailbox { @objc optional var name: String { get } @objc optional func messages() -> SBElementArray } @objc protocol SBMailAccount { @objc optional var name: String { get } @objc optional func mailboxes() -> SBElementArray } @objc protocol SBMailApplication { @objc optional func accounts() -> SBElementArray } // MARK: - Models /// An email message extracted from Mail.app, ready to push to the server. public struct MailMessage: Sendable { public let messageId: String public let threadId: String? public let subject: String? public let fromAddress: String public let fromName: String? public let to: [EmailAddress] public let cc: [EmailAddress] public let folder: String public let direction: String // "incoming" | "outgoing" public let textBody: String? public let htmlBody: String? public let hasAttachments: Bool public let attachmentCount: Int public let sentAt: String // ISO 8601 public let receivedAt: String? public let isRead: Bool } public struct EmailAddress: Sendable { public let address: String public let name: String? } // MARK: - Reader /// Reads mail from Mail.app via ScriptingBridge. /// /// Incremental sync: the caller provides a `since` watermark (last seen send date). /// We enumerate all known mailboxes on all accounts and return messages whose /// `dateSent` is after `since`. /// /// Mail.app must be running and accessible for ScriptingBridge calls to succeed. /// The automation entitlement (`com.apple.security.automation.apple-events`) must be /// present and the user must have granted automation access to Mail.app. public final class Reader: @unchecked Sendable { public static let shared = Reader() /// Set of known own email addresses (used to classify direction). /// Populated from UserDefaults key `imailOwnAddresses`. private var ownAddresses: Set { let stored = UserDefaults.standard.stringArray(forKey: "imailOwnAddresses") ?? [] return Set(stored.map { $0.lowercased() }) } private init() {} // MARK: - Public API /// Enumerate all messages in all account mailboxes with `dateSent` after `since`. /// /// - Parameter since: Only return messages with `dateSent` strictly after this date. /// Pass `nil` on first run to back-fill everything. /// - Returns: All matching messages across all folders. public func fetchMessages(since: Date?) -> [MailMessage] { guard let app = SBApplication(bundleIdentifier: "com.apple.mail"), let mailApp = app as? SBMailApplication else { NSLog("IMailReader: Mail.app not available via ScriptingBridge") return [] } guard let accountsArray = mailApp.accounts?() as? [AnyObject] else { NSLog("IMailReader: no accounts from Mail.app") return [] } var results: [MailMessage] = [] let own = ownAddresses for accountObj in accountsArray { guard let acc = accountObj as? SBMailAccount, let mailboxesArray = acc.mailboxes?() as? [AnyObject] else { continue } for mailboxObj in mailboxesArray { guard let mb = mailboxObj as? SBMailMailbox, let mbName = mb.name, let messagesArray = mb.messages?() as? [AnyObject] else { continue } for messageObj in messagesArray { guard let msg = messageObj as? SBMailMessage else { continue } guard let sent = msg.dateSent else { continue } if let since, sent <= since { continue } guard let parsed = parseMessage(msg, folder: mbName, ownAddresses: own) else { continue } results.append(parsed) } } } NSLog("IMailReader: fetched \(results.count) messages since \(String(describing: since))") return results } // MARK: - Private private func parseMessage(_ msg: SBMailMessage, folder: String, ownAddresses: Set) -> MailMessage? { let msgId = msg.messageId ?? "mail-app-\(UUID().uuidString)" let senderStr = msg.sender ?? "" let (fromAddr, fromName) = parseSenderString(senderStr) guard !fromAddr.isEmpty else { return nil } let direction = ownAddresses.contains(fromAddr.lowercased()) ? "outgoing" : "incoming" let df = ISO8601DateFormatter() let toRecipients: [EmailAddress] = (msg.toRecipients as? [AnyObject])?.compactMap { obj in guard let r = obj as? SBMailRecipient, let addr = r.address, !addr.isEmpty else { return nil } return EmailAddress(address: addr, name: r.name) } ?? [] let ccRecipients: [EmailAddress] = (msg.ccRecipients as? [AnyObject])?.compactMap { obj in guard let r = obj as? SBMailRecipient, let addr = r.address, !addr.isEmpty else { return nil } return EmailAddress(address: addr, name: r.name) } ?? [] // Use raw MIME source as textBody if available; fall back to content. let textBody: String? if let src = msg.source, !src.isEmpty { textBody = src } else if let contentObj = msg.content { textBody = contentObj.get() as? String } else { textBody = nil } let attachmentCount = msg.numberOfAttachments ?? 0 return MailMessage( messageId: msgId, threadId: nil, // Thread grouping is server-side (same as IMAP path) subject: msg.subject.flatMap { $0.isEmpty ? nil : $0 }, fromAddress: fromAddr, fromName: fromName, to: toRecipients, cc: ccRecipients, folder: folder, direction: direction, textBody: textBody, htmlBody: nil, // HTML extraction from raw source is done in MIMEParser hasAttachments: attachmentCount > 0, attachmentCount: attachmentCount, sentAt: df.string(from: msg.dateSent ?? Date()), receivedAt: msg.dateReceived.map { df.string(from: $0) }, isRead: msg.isRead ?? false ) } /// Parse Mail.app's `sender` string ("Name " or "addr") into (address, name?). private func parseSenderString(_ sender: String) -> (address: String, name: String?) { let trimmed = sender.trimmingCharacters(in: .whitespaces) if let ltRange = trimmed.range(of: "<"), let gtRange = trimmed.range(of: ">"), ltRange.lowerBound < gtRange.lowerBound { let addr = String(trimmed[ltRange.upperBound..