appleScriptError carried the raw NSDictionary from NSAppleScript's error out-param, which isn't Sendable and got flagged once the type crossed an async boundary. Extract just the two fields actually used (errorNumber, errorMessage) into plain Int/String instead.
664 lines
29 KiB
Swift
664 lines
29 KiB
Swift
//
|
|
// AppleMailService.swift
|
|
// Confab
|
|
//
|
|
// Mail search/read/attachment access via AppleScript/Apple Events to Mail.app
|
|
//
|
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
|
// Copyright (C) 2026 Rune Olsen
|
|
//
|
|
// This file is part of Confab.
|
|
//
|
|
// Confab is licensed under the PolyForm Noncommercial License 1.0.0.
|
|
// You may use, study, modify, and share it for any noncommercial
|
|
// purpose. Commercial use — including selling Confab or any part of
|
|
// it, standalone or bundled into another product or service —
|
|
// requires a separate commercial license from the copyright holder.
|
|
//
|
|
// See the LICENSE file or
|
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
|
// the full license text. For commercial licensing, contact Rune
|
|
// Olsen via <https://confab.no>.
|
|
|
|
|
|
import Carbon
|
|
import Foundation
|
|
|
|
/// One matched message from `mail_search` — a flat, positionally-decoded projection of an
|
|
/// AppleScript record. Field order MUST match `AppleMailService.scriptSource`'s
|
|
/// `searchMessages` handler's returned list order.
|
|
struct MailSearchResult: Sendable, Equatable {
|
|
let messageId: String
|
|
let subject: String
|
|
let from: String
|
|
let dateReceived: String
|
|
let mailbox: String
|
|
let account: String
|
|
let attachmentCount: String
|
|
}
|
|
|
|
enum AppleMailError: Error, Sendable {
|
|
case scriptNotCompiled
|
|
case appleScriptError(number: Int, message: String)
|
|
}
|
|
|
|
@Observable
|
|
final class AppleMailService {
|
|
static let shared = AppleMailService()
|
|
|
|
private init() {}
|
|
|
|
// Apple Events to Mail.app block on IPC round-trip latency, and NSAppleScript isn't
|
|
// documented safe for concurrent use of a single compiled instance — serialize all calls
|
|
// through one queue, mirroring MCPService.runBashCommand's off-main-actor pattern.
|
|
private let queue = DispatchQueue(label: "com.oai.applemail", qos: .userInitiated)
|
|
|
|
@ObservationIgnored
|
|
private lazy var compiledScript: NSAppleScript? = {
|
|
guard let script = NSAppleScript(source: Self.scriptSource) else { return nil }
|
|
var errorDict: NSDictionary?
|
|
guard script.compileAndReturnError(&errorDict) else {
|
|
Log.mail.error("AppleMailService: script failed to compile: \(String(describing: errorDict))")
|
|
return nil
|
|
}
|
|
return script
|
|
}()
|
|
|
|
// MARK: - Embedded AppleScript
|
|
//
|
|
// Return-value field-order contracts (Swift decoding must match these exactly):
|
|
// searchMessages -> list of [messageId, subject, from, dateReceived, mailbox, account, attachmentCount]
|
|
// getMessage -> [subject, from, dateReceived, content, attachmentNamesJoinedByPipe]
|
|
// or a single-element list {"__NOT_FOUND__"} if no match
|
|
// saveAttachment -> [status ("ok"/"error"), pathOnSuccessOrMessageOnError]
|
|
// listAccounts -> list of [name, emailAddressesJoinedByComma]
|
|
//
|
|
// Arguments are always passed as real Apple Event list arguments (see `runHandler`), never
|
|
// interpolated into this source text — user search terms (accented characters, quotes, etc.)
|
|
// are safe by construction.
|
|
nonisolated static let scriptSource = """
|
|
on searchMessages(accountName, mailboxName, subjectQ, fromQ, bodyQ, sinceStr, beforeStr, maxResultsStr)
|
|
\tset maxResults to (maxResultsStr as integer)
|
|
\ttell application "Mail"
|
|
\t\tset resultList to {}
|
|
\t\tset acctList to {}
|
|
\t\tif accountName is "" then
|
|
\t\t\tset acctList to accounts
|
|
\t\telse
|
|
\t\t\trepeat with a in accounts
|
|
\t\t\t\tset matched to false
|
|
\t\t\t\tif (name of a) is accountName then set matched to true
|
|
\t\t\t\tif not matched then
|
|
\t\t\t\t\ttry
|
|
\t\t\t\t\t\tif (email addresses of a) contains accountName then set matched to true
|
|
\t\t\t\t\tend try
|
|
\t\t\t\tend if
|
|
\t\t\t\tif matched then set end of acctList to a
|
|
\t\t\tend repeat
|
|
\t\tend if
|
|
|
|
\t\trepeat with acct in acctList
|
|
\t\t\tset targetMailbox to missing value
|
|
\t\t\ttry
|
|
\t\t\t\tif mailboxName is "" then
|
|
\t\t\t\t\tset targetMailbox to mailbox "INBOX" of acct
|
|
\t\t\t\telse
|
|
\t\t\t\t\tset targetMailbox to mailbox mailboxName of acct
|
|
\t\t\t\tend if
|
|
\t\t\tend try
|
|
|
|
\t\t\tif targetMailbox is not missing value then
|
|
\t\t\t\tset candidateMessages to {}
|
|
\t\t\t\ttry
|
|
\t\t\t\t\tif subjectQ is not "" then
|
|
\t\t\t\t\t\tset candidateMessages to (messages of targetMailbox whose subject contains subjectQ)
|
|
\t\t\t\t\telse
|
|
\t\t\t\t\t\tset candidateMessages to messages of targetMailbox
|
|
\t\t\t\t\tend if
|
|
\t\t\t\tend try
|
|
|
|
\t\t\t\trepeat with m in candidateMessages
|
|
\t\t\t\t\tset isMatch to true
|
|
\t\t\t\t\tif fromQ is not "" then
|
|
\t\t\t\t\t\ttry
|
|
\t\t\t\t\t\t\tif not ((sender of m) contains fromQ) then set isMatch to false
|
|
\t\t\t\t\t\ton error
|
|
\t\t\t\t\t\t\tset isMatch to false
|
|
\t\t\t\t\t\tend try
|
|
\t\t\t\t\tend if
|
|
\t\t\t\t\tif isMatch and bodyQ is not "" then
|
|
\t\t\t\t\t\ttry
|
|
\t\t\t\t\t\t\tif not ((content of m) contains bodyQ) then set isMatch to false
|
|
\t\t\t\t\t\ton error
|
|
\t\t\t\t\t\t\tset isMatch to false
|
|
\t\t\t\t\t\tend try
|
|
\t\t\t\t\tend if
|
|
\t\t\t\t\tif isMatch and sinceStr is not "" then
|
|
\t\t\t\t\t\ttry
|
|
\t\t\t\t\t\t\tif (date received of m) < (date sinceStr) then set isMatch to false
|
|
\t\t\t\t\t\tend try
|
|
\t\t\t\t\tend if
|
|
\t\t\t\t\tif isMatch and beforeStr is not "" then
|
|
\t\t\t\t\t\ttry
|
|
\t\t\t\t\t\t\tif (date received of m) > (date beforeStr) then set isMatch to false
|
|
\t\t\t\t\t\tend try
|
|
\t\t\t\t\tend if
|
|
|
|
\t\t\t\t\tif isMatch then
|
|
\t\t\t\t\t\tset msgId to (message id of m) as string
|
|
\t\t\t\t\t\tset msgSubject to (subject of m) as string
|
|
\t\t\t\t\t\tset msgFrom to (sender of m) as string
|
|
\t\t\t\t\t\tset msgDate to (date received of m) as string
|
|
\t\t\t\t\t\tset attachCount to (count of mail attachments of m)
|
|
\t\t\t\t\t\tset end of resultList to {msgId, msgSubject, msgFrom, msgDate, (name of targetMailbox), (name of acct), (attachCount as string)}
|
|
\t\t\t\t\tend if
|
|
|
|
\t\t\t\t\tif (count of resultList) ≥ maxResults then exit repeat
|
|
\t\t\t\tend repeat
|
|
\t\t\tend if
|
|
\t\t\tif (count of resultList) ≥ maxResults then exit repeat
|
|
\t\tend repeat
|
|
|
|
\t\treturn resultList
|
|
\tend tell
|
|
end searchMessages
|
|
|
|
on findMessage(msgId, accountName, mailboxName)
|
|
\ttell application "Mail"
|
|
\t\tset foundMsg to missing value
|
|
\t\tset acctList to {}
|
|
\t\tif accountName is "" then
|
|
\t\t\tset acctList to accounts
|
|
\t\telse
|
|
\t\t\trepeat with a in accounts
|
|
\t\t\t\tset matched to false
|
|
\t\t\t\tif (name of a) is accountName then set matched to true
|
|
\t\t\t\tif not matched then
|
|
\t\t\t\t\ttry
|
|
\t\t\t\t\t\tif (email addresses of a) contains accountName then set matched to true
|
|
\t\t\t\t\tend try
|
|
\t\t\t\tend if
|
|
\t\t\t\tif matched then set end of acctList to a
|
|
\t\t\tend repeat
|
|
\t\tend if
|
|
|
|
\t\trepeat with acct in acctList
|
|
\t\t\ttry
|
|
\t\t\t\tif mailboxName is "" then
|
|
\t\t\t\t\tset mb to mailbox "INBOX" of acct
|
|
\t\t\t\telse
|
|
\t\t\t\t\tset mb to mailbox mailboxName of acct
|
|
\t\t\t\tend if
|
|
\t\t\t\tset foundMatches to (messages of mb whose message id is msgId)
|
|
\t\t\t\tif (count of foundMatches) > 0 then
|
|
\t\t\t\t\tset foundMsg to item 1 of foundMatches
|
|
\t\t\t\t\texit repeat
|
|
\t\t\t\tend if
|
|
\t\t\tend try
|
|
\t\tend repeat
|
|
|
|
\t\tif foundMsg is missing value then
|
|
\t\t\trepeat with acct in accounts
|
|
\t\t\t\trepeat with mb in mailboxes of acct
|
|
\t\t\t\t\ttry
|
|
\t\t\t\t\t\tset foundMatches to (messages of mb whose message id is msgId)
|
|
\t\t\t\t\t\tif (count of foundMatches) > 0 then
|
|
\t\t\t\t\t\t\tset foundMsg to item 1 of foundMatches
|
|
\t\t\t\t\t\t\texit repeat
|
|
\t\t\t\t\t\tend if
|
|
\t\t\t\t\tend try
|
|
\t\t\t\tend repeat
|
|
\t\t\t\tif foundMsg is not missing value then exit repeat
|
|
\t\t\tend repeat
|
|
\t\tend if
|
|
|
|
\t\treturn foundMsg
|
|
\tend tell
|
|
end findMessage
|
|
|
|
on getMessage(msgId, accountName, mailboxName)
|
|
\ttell application "Mail"
|
|
\t\tset foundMsg to my findMessage(msgId, accountName, mailboxName)
|
|
\t\tif foundMsg is missing value then
|
|
\t\t\treturn {"__NOT_FOUND__"}
|
|
\t\tend if
|
|
|
|
\t\tset msgSubject to (subject of foundMsg) as string
|
|
\t\tset msgFrom to (sender of foundMsg) as string
|
|
\t\tset msgDate to (date received of foundMsg) as string
|
|
\t\tset msgContent to (content of foundMsg) as string
|
|
\t\tset attachNames to {}
|
|
\t\trepeat with att in mail attachments of foundMsg
|
|
\t\t\tset end of attachNames to (name of att)
|
|
\t\tend repeat
|
|
\t\tset attachNamesStr to my joinList(attachNames, "||")
|
|
|
|
\t\treturn {msgSubject, msgFrom, msgDate, msgContent, attachNamesStr}
|
|
\tend tell
|
|
end getMessage
|
|
|
|
on saveAttachment(msgId, attachmentName, accountName, mailboxName, destPath)
|
|
\ttell application "Mail"
|
|
\t\tset foundMsg to my findMessage(msgId, accountName, mailboxName)
|
|
\t\tif foundMsg is missing value then
|
|
\t\t\treturn {"error", "message not found"}
|
|
\t\tend if
|
|
|
|
\t\tset targetAttachment to missing value
|
|
\t\trepeat with att in mail attachments of foundMsg
|
|
\t\t\tif (name of att) is attachmentName then
|
|
\t\t\t\tset targetAttachment to att
|
|
\t\t\t\texit repeat
|
|
\t\t\tend if
|
|
\t\tend repeat
|
|
|
|
\t\tif targetAttachment is missing value then
|
|
\t\t\treturn {"error", "attachment not found"}
|
|
\t\tend if
|
|
|
|
\t\ttry
|
|
\t\t\tsave targetAttachment in (POSIX file destPath)
|
|
\t\t\treturn {"ok", destPath}
|
|
\t\ton error errMsg
|
|
\t\t\treturn {"error", errMsg}
|
|
\t\tend try
|
|
\tend tell
|
|
end saveAttachment
|
|
|
|
on listAccounts()
|
|
\ttell application "Mail"
|
|
\t\tset resultList to {}
|
|
\t\trepeat with acct in accounts
|
|
\t\t\tset acctEmails to ""
|
|
\t\t\ttry
|
|
\t\t\t\tset acctEmails to my joinList((email addresses of acct), ", ")
|
|
\t\t\tend try
|
|
\t\t\tset end of resultList to {(name of acct), acctEmails}
|
|
\t\tend repeat
|
|
\t\treturn resultList
|
|
\tend tell
|
|
end listAccounts
|
|
|
|
on joinList(theList, delim)
|
|
\tset oldDelims to AppleScript's text item delimiters
|
|
\tset AppleScript's text item delimiters to delim
|
|
\tset joined to theList as string
|
|
\tset AppleScript's text item delimiters to oldDelims
|
|
\treturn joined
|
|
end joinList
|
|
"""
|
|
|
|
// MARK: - Apple Event Handler Invocation
|
|
|
|
/// Calls a named handler in the compiled script, passing `arguments` as real Apple Event
|
|
/// list arguments (not string-interpolated source) — safe for arbitrary user text, and the
|
|
/// only way to invoke a specific handler since `NSAppleScript` has no `executeHandler`
|
|
/// convenience API of its own.
|
|
private func runHandler(_ handlerName: String, arguments: [String]) async -> Result<NSAppleEventDescriptor, AppleMailError> {
|
|
await withCheckedContinuation { continuation in
|
|
queue.async {
|
|
guard let script = self.compiledScript else {
|
|
continuation.resume(returning: .failure(.scriptNotCompiled))
|
|
return
|
|
}
|
|
|
|
let argsListDescriptor = NSAppleEventDescriptor.list()
|
|
for (index, arg) in arguments.enumerated() {
|
|
argsListDescriptor.insert(NSAppleEventDescriptor(string: arg), at: index + 1)
|
|
}
|
|
|
|
let event = NSAppleEventDescriptor(
|
|
eventClass: AEEventClass(kASAppleScriptSuite),
|
|
eventID: AEEventID(kASSubroutineEvent),
|
|
targetDescriptor: nil,
|
|
returnID: AEReturnID(kAutoGenerateReturnID),
|
|
transactionID: AETransactionID(kAnyTransactionID)
|
|
)
|
|
event.setParam(NSAppleEventDescriptor(string: handlerName), forKeyword: AEKeyword(keyASSubroutineName))
|
|
event.setParam(argsListDescriptor, forKeyword: AEKeyword(keyDirectObject))
|
|
|
|
var errorDict: NSDictionary?
|
|
let result = script.executeAppleEvent(event, error: &errorDict)
|
|
if let errorDict {
|
|
let number = (errorDict[NSAppleScript.errorNumber] as? Int) ?? 0
|
|
let message = (errorDict[NSAppleScript.errorMessage] as? String) ?? "Unknown error"
|
|
continuation.resume(returning: .failure(.appleScriptError(number: number, message: message)))
|
|
} else {
|
|
continuation.resume(returning: .success(result))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Descriptor Decoding (pure, testable — no live Apple Events involved)
|
|
|
|
nonisolated static func decodeStringList(_ descriptor: NSAppleEventDescriptor) -> [String] {
|
|
guard descriptor.numberOfItems > 0 else {
|
|
if let value = descriptor.stringValue { return [value] }
|
|
return []
|
|
}
|
|
var result: [String] = []
|
|
for i in 1...descriptor.numberOfItems {
|
|
result.append(descriptor.atIndex(i)?.stringValue ?? "")
|
|
}
|
|
return result
|
|
}
|
|
|
|
nonisolated static func parseSearchResults(from descriptor: NSAppleEventDescriptor) -> [MailSearchResult] {
|
|
guard descriptor.numberOfItems > 0 else { return [] }
|
|
var results: [MailSearchResult] = []
|
|
for i in 1...descriptor.numberOfItems {
|
|
guard let itemDescriptor = descriptor.atIndex(i) else { continue }
|
|
let fields = decodeStringList(itemDescriptor)
|
|
guard fields.count >= 7 else { continue }
|
|
results.append(MailSearchResult(
|
|
messageId: fields[0], subject: fields[1], from: fields[2],
|
|
dateReceived: fields[3], mailbox: fields[4], account: fields[5],
|
|
attachmentCount: fields[6]
|
|
))
|
|
}
|
|
return results
|
|
}
|
|
|
|
nonisolated static func mapAppleScriptError(number: Int, message: String) -> String {
|
|
switch number {
|
|
case -1743:
|
|
return "Confab is not authorized to control Mail.app. Grant access in System Settings \u{2192} Privacy & Security \u{2192} Automation \u{2192} Confab \u{2192} Mail, then try again."
|
|
case -600, -609:
|
|
return "Mail.app is not available on this Mac."
|
|
default:
|
|
return "Mail.app error \(number): \(message)"
|
|
}
|
|
}
|
|
|
|
nonisolated static func errorResult(_ error: AppleMailError) -> [String: Any] {
|
|
switch error {
|
|
case .scriptNotCompiled:
|
|
return ["error": "Mail integration failed to initialize (AppleScript compile error)."]
|
|
case .appleScriptError(let number, let message):
|
|
return ["error": mapAppleScriptError(number: number, message: message)]
|
|
}
|
|
}
|
|
|
|
// MARK: - Tool-Backing Methods
|
|
|
|
private func listAccountsResult() async -> [String: Any] {
|
|
switch await runHandler("listAccounts", arguments: []) {
|
|
case .success(let descriptor):
|
|
var accounts: [[String: String]] = []
|
|
if descriptor.numberOfItems > 0 {
|
|
for i in 1...descriptor.numberOfItems {
|
|
guard let item = descriptor.atIndex(i) else { continue }
|
|
let fields = Self.decodeStringList(item)
|
|
guard fields.count >= 2 else { continue }
|
|
accounts.append(["name": fields[0], "emails": fields[1]])
|
|
}
|
|
}
|
|
return ["count": accounts.count, "accounts": accounts]
|
|
case .failure(let error):
|
|
return Self.errorResult(error)
|
|
}
|
|
}
|
|
|
|
/// True if at least one search criterion is non-empty — an unscoped "return everything"
|
|
/// search across all mail is too broad to allow. Pure logic, kept separate for testability.
|
|
nonisolated static func hasSearchCriteria(subjectContains: String?, fromContains: String?, bodyContains: String?, since: String?, before: String?) -> Bool {
|
|
[subjectContains, fromContains, bodyContains, since, before]
|
|
.contains { ($0?.isEmpty ?? true) == false }
|
|
}
|
|
|
|
private func searchMessages(
|
|
account: String?, mailbox: String?, subjectContains: String?,
|
|
fromContains: String?, bodyContains: String?, since: String?, before: String?
|
|
) async -> [String: Any] {
|
|
guard Self.hasSearchCriteria(subjectContains: subjectContains, fromContains: fromContains, bodyContains: bodyContains, since: since, before: before) else {
|
|
return ["error": "At least one of subject_contains, from_contains, body_contains, since, or before is required — an unscoped search across all mail is too broad."]
|
|
}
|
|
|
|
let args = [
|
|
account ?? "", mailbox ?? "INBOX",
|
|
subjectContains ?? "", fromContains ?? "", bodyContains ?? "",
|
|
since ?? "", before ?? "", "100"
|
|
]
|
|
switch await runHandler("searchMessages", arguments: args) {
|
|
case .success(let descriptor):
|
|
let results = Self.parseSearchResults(from: descriptor)
|
|
let messages = results.map { r -> [String: Any] in
|
|
[
|
|
"message_id": r.messageId,
|
|
"subject": r.subject,
|
|
"from": r.from,
|
|
"date_received": r.dateReceived,
|
|
"mailbox": r.mailbox,
|
|
"account": r.account,
|
|
"has_attachments": (Int(r.attachmentCount) ?? 0) > 0
|
|
]
|
|
}
|
|
return ["count": messages.count, "messages": messages]
|
|
case .failure(let error):
|
|
return Self.errorResult(error)
|
|
}
|
|
}
|
|
|
|
private func getMessage(messageId: String, account: String?, mailbox: String?) async -> [String: Any] {
|
|
let args = [messageId, account ?? "", mailbox ?? "INBOX"]
|
|
switch await runHandler("getMessage", arguments: args) {
|
|
case .success(let descriptor):
|
|
let fields = Self.decodeStringList(descriptor)
|
|
if fields.count == 1 && fields[0] == "__NOT_FOUND__" {
|
|
return ["error": "No message found with id '\(messageId)'"]
|
|
}
|
|
guard fields.count >= 5 else {
|
|
return ["error": "Unexpected response from Mail.app"]
|
|
}
|
|
let content = fields[3]
|
|
let truncatedContent = content.utf8.count > 20_000
|
|
? String(content.prefix(20_000)) + "\n... (truncated)"
|
|
: content
|
|
let attachmentNames = fields[4].isEmpty ? [] : fields[4].components(separatedBy: "||")
|
|
return [
|
|
"subject": fields[0],
|
|
"from": fields[1],
|
|
"date_received": fields[2],
|
|
"content": truncatedContent,
|
|
"attachments": attachmentNames.map { ["name": $0] }
|
|
]
|
|
case .failure(let error):
|
|
return Self.errorResult(error)
|
|
}
|
|
}
|
|
|
|
private func saveAttachment(messageId: String, attachmentName: String, account: String?, mailbox: String?, destPath: String) async -> [String: Any] {
|
|
let resolvedDest = ((destPath as NSString).expandingTildeInPath as NSString).standardizingPath
|
|
guard MCPService.shared.isPathAllowed(resolvedDest) else {
|
|
return ["error": "dest_path is outside allowed folders. Configured folders: \(MCPService.shared.allowedFolders.joined(separator: ", "))"]
|
|
}
|
|
|
|
let args = [messageId, attachmentName, account ?? "", mailbox ?? "INBOX", resolvedDest]
|
|
switch await runHandler("saveAttachment", arguments: args) {
|
|
case .success(let descriptor):
|
|
let fields = Self.decodeStringList(descriptor)
|
|
guard fields.count >= 2 else { return ["error": "Unexpected response from Mail.app"] }
|
|
guard fields[0] == "ok" else { return ["error": fields[1]] }
|
|
|
|
var size: Int? = nil
|
|
if let attrs = try? FileManager.default.attributesOfItem(atPath: resolvedDest) {
|
|
size = attrs[.size] as? Int
|
|
}
|
|
var result: [String: Any] = ["success": true, "path": resolvedDest]
|
|
if let size { result["size"] = size }
|
|
return result
|
|
case .failure(let error):
|
|
return Self.errorResult(error)
|
|
}
|
|
}
|
|
|
|
// MARK: - Tool Schemas & Dispatch
|
|
|
|
func getToolSchemas() -> [Tool] {
|
|
[
|
|
makeTool(
|
|
name: "mail_list_accounts",
|
|
description: "List the Mail.app accounts configured on this Mac (name and email addresses). Requires user approval. Use this to find the right value for the 'account' parameter on other mail tools.",
|
|
properties: [:],
|
|
required: []
|
|
),
|
|
makeTool(
|
|
name: "mail_search",
|
|
description: "Search Apple Mail for messages. Requires user approval. Pass 'account' (account name or email address) when the user names a specific account — searching all accounts is significantly slower. Defaults to the INBOX; pass 'mailbox' to search a different folder (e.g. 'Sent', 'Archive'). At least one of subject_contains/from_contains/body_contains/since/before is required.",
|
|
properties: [
|
|
"account": prop("string", "Optional: account name or email address to scope the search to (omit to search all accounts)"),
|
|
"mailbox": prop("string", "Optional: mailbox/folder name (default: INBOX)"),
|
|
"subject_contains": prop("string", "Optional: text the subject must contain"),
|
|
"from_contains": prop("string", "Optional: text the sender must contain"),
|
|
"body_contains": prop("string", "Optional: text the message body must contain (slower — opens each candidate message)"),
|
|
"since": prop("string", "Optional: only messages received on/after this date"),
|
|
"before": prop("string", "Optional: only messages received before this date")
|
|
],
|
|
required: []
|
|
),
|
|
makeTool(
|
|
name: "mail_get_message",
|
|
description: "Read a specific Mail message's full content and attachment list. Requires user approval. Pass the 'account'/'mailbox' values returned alongside this message by mail_search for a faster lookup — omitting them triggers a slower unscoped scan across all accounts.",
|
|
properties: [
|
|
"message_id": prop("string", "The message's id, from mail_search"),
|
|
"account": prop("string", "Optional: account hint from mail_search (speeds up the lookup)"),
|
|
"mailbox": prop("string", "Optional: mailbox hint from mail_search (speeds up the lookup)")
|
|
],
|
|
required: ["message_id"]
|
|
),
|
|
makeTool(
|
|
name: "mail_save_attachment",
|
|
description: "Save an attachment from a Mail message to a local file path (must be inside an allowed MCP folder). Requires user approval. Use the saved path with read_file (e.g. for PDF text) or another tool such as paperless_upload_document.",
|
|
properties: [
|
|
"message_id": prop("string", "The message's id, from mail_search or mail_get_message"),
|
|
"attachment_name": prop("string", "The attachment's filename, from mail_get_message"),
|
|
"dest_path": prop("string", "Absolute local path to save the attachment to (must be inside an allowed folder)"),
|
|
"account": prop("string", "Optional: account hint (speeds up the lookup)"),
|
|
"mailbox": prop("string", "Optional: mailbox hint (speeds up the lookup)")
|
|
],
|
|
required: ["message_id", "attachment_name", "dest_path"]
|
|
)
|
|
]
|
|
}
|
|
|
|
func executeTool(name: String, arguments: String) async -> [String: Any] {
|
|
Log.mail.info("Executing mail tool: \(name)")
|
|
let args = Self.parseArgs(arguments)
|
|
|
|
switch name {
|
|
case "mail_list_accounts":
|
|
return await listAccountsResult()
|
|
|
|
case "mail_search":
|
|
return await searchMessages(
|
|
account: args["account"] as? String,
|
|
mailbox: args["mailbox"] as? String,
|
|
subjectContains: args["subject_contains"] as? String,
|
|
fromContains: args["from_contains"] as? String,
|
|
bodyContains: args["body_contains"] as? String,
|
|
since: args["since"] as? String,
|
|
before: args["before"] as? String
|
|
)
|
|
|
|
case "mail_get_message":
|
|
guard let messageId = args["message_id"] as? String, !messageId.isEmpty else {
|
|
return ["error": "Missing required parameter: message_id"]
|
|
}
|
|
return await getMessage(messageId: messageId, account: args["account"] as? String, mailbox: args["mailbox"] as? String)
|
|
|
|
case "mail_save_attachment":
|
|
guard let messageId = args["message_id"] as? String, !messageId.isEmpty,
|
|
let attachmentName = args["attachment_name"] as? String, !attachmentName.isEmpty,
|
|
let destPath = args["dest_path"] as? String, !destPath.isEmpty else {
|
|
return ["error": "Missing required parameter(s): message_id, attachment_name, dest_path"]
|
|
}
|
|
return await saveAttachment(messageId: messageId, attachmentName: attachmentName, account: args["account"] as? String, mailbox: args["mailbox"] as? String, destPath: destPath)
|
|
|
|
default:
|
|
return ["error": "Unknown Mail tool: \(name)"]
|
|
}
|
|
}
|
|
|
|
// MARK: - Approval Summary
|
|
|
|
func approvalSummary(forTool name: String, arguments: String) -> String {
|
|
let args = Self.parseArgs(arguments)
|
|
switch name {
|
|
case "mail_list_accounts":
|
|
return "List your Mail.app accounts"
|
|
case "mail_search":
|
|
var parts: [String] = []
|
|
if let account = args["account"] as? String, !account.isEmpty { parts.append("in \(account)") }
|
|
if let subject = args["subject_contains"] as? String, !subject.isEmpty { parts.append("subject contains \"\(subject)\"") }
|
|
if let from = args["from_contains"] as? String, !from.isEmpty { parts.append("from contains \"\(from)\"") }
|
|
if let body = args["body_contains"] as? String, !body.isEmpty { parts.append("body contains \"\(body)\"") }
|
|
let scope = parts.isEmpty ? "" : " (\(parts.joined(separator: ", ")))"
|
|
return "Search Mail\(scope)"
|
|
case "mail_get_message":
|
|
let id = (args["message_id"] as? String) ?? ""
|
|
return "Read a Mail message (id: \(id.prefix(40))\(id.count > 40 ? "…" : ""))"
|
|
case "mail_save_attachment":
|
|
let name = (args["attachment_name"] as? String) ?? "attachment"
|
|
let dest = (args["dest_path"] as? String) ?? ""
|
|
return "Save Mail attachment \"\(name)\" to \(dest)"
|
|
default:
|
|
return "Perform action: \(name)"
|
|
}
|
|
}
|
|
|
|
// MARK: - Connection Test (Settings UI)
|
|
|
|
func testConnection() async -> Result<String, Error> {
|
|
switch await runHandler("listAccounts", arguments: []) {
|
|
case .success(let descriptor):
|
|
let count = descriptor.numberOfItems
|
|
var names: [String] = []
|
|
if count > 0 {
|
|
for i in 1...count {
|
|
if let item = descriptor.atIndex(i) {
|
|
let fields = Self.decodeStringList(item)
|
|
if let first = fields.first { names.append(first) }
|
|
}
|
|
}
|
|
}
|
|
let namesSuffix = names.isEmpty ? "" : ": \(names.joined(separator: ", "))"
|
|
return .success("Connected \u{2014} found \(count) account\(count == 1 ? "" : "s")\(namesSuffix)")
|
|
case .failure(let error):
|
|
let message: String
|
|
switch error {
|
|
case .scriptNotCompiled:
|
|
message = "Mail integration failed to initialize (AppleScript compile error)."
|
|
case .appleScriptError(let number, let msg):
|
|
message = Self.mapAppleScriptError(number: number, message: msg)
|
|
}
|
|
return .failure(NSError(domain: "AppleMailService", code: 1, userInfo: [NSLocalizedDescriptionKey: message]))
|
|
}
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private func makeTool(name: String, description: String, properties: [String: Tool.Function.Parameters.Property], required: [String]) -> Tool {
|
|
Tool(
|
|
type: "function",
|
|
function: Tool.Function(
|
|
name: name,
|
|
description: description,
|
|
parameters: Tool.Function.Parameters(type: "object", properties: properties, required: required)
|
|
)
|
|
)
|
|
}
|
|
|
|
private func prop(_ type: String, _ description: String) -> Tool.Function.Parameters.Property {
|
|
Tool.Function.Parameters.Property(type: type, description: description, enum: nil)
|
|
}
|
|
|
|
nonisolated static func parseArgs(_ arguments: String) -> [String: Any] {
|
|
guard let data = arguments.data(using: .utf8),
|
|
let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
return [:]
|
|
}
|
|
return dict
|
|
}
|
|
}
|