Files
oai-swift/oAI/Services/AppleMailService.swift
T
rune cf82720b88 Fix Sendable warning in AppleMailError by dropping the NSDictionary payload
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.
2026-08-19 13:53:06 +02:00

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
}
}