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

214 lines
8.3 KiB
Swift
Raw Permalink Normal View History

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<String> {
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<String>) -> 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 <addr>" 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..<gtRange.lowerBound]).trimmingCharacters(in: .whitespaces)
let name = String(trimmed[trimmed.startIndex..<ltRange.lowerBound]).trimmingCharacters(in: .whitespaces)
return (addr, name.isEmpty ? nil : name)
}
return (trimmed, nil)
}
}