213 lines
8.3 KiB
Swift
213 lines
8.3 KiB
Swift
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)
|
|
}
|
|
}
|