Add Mail integration: search, read, and save attachments via AppleScript

Lets the AI search Apple Mail, read a message, and save an attachment to
disk (e.g. to hand off to paperless_upload_document) — e.g. "find the
receipt from Elkjøp and add it to Paperless." Talks to Mail.app via
AppleScript/Apple Events rather than parsing its private on-disk store,
so no Full Disk Access or MIME parsing is needed; Mail's own attachment
save handles all decoding. Every mail_* tool call requires approval
(Deny/Allow Once/Allow for Session), mirroring the bash_execute and
Personal Data gate pattern.

Also fixes a pre-existing bug found while touching the adjacent
tool-activation condition: paperlessEnabled was missing from it, so
Paperless tools could fail to activate unless another integration was
also active.
This commit is contained in:
2026-08-19 13:47:38 +02:00
parent d46fb03a07
commit 4064851a3d
10 changed files with 1117 additions and 1 deletions
+2
View File
@@ -370,6 +370,7 @@
INFOPLIST_FILE = oAI/Info.plist; INFOPLIST_FILE = oAI/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Confab; INFOPLIST_KEY_CFBundleDisplayName = Confab;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_NSAppleEventsUsageDescription = "Confab can search, read, and save attachments from your Apple Mail messages when you ask it to, if you enable Mail access in Settings.";
INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "Confab can read and create calendar events when you ask it to, if you enable Calendar access in Settings."; INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "Confab can read and create calendar events when you ask it to, if you enable Calendar access in Settings.";
INFOPLIST_KEY_NSContactsUsageDescription = "Confab can search your contacts when you ask it to, if you enable Contacts access in Settings."; INFOPLIST_KEY_NSContactsUsageDescription = "Confab can search your contacts when you ask it to, if you enable Contacts access in Settings.";
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Confab can use your current location to answer questions, if you enable Location & Maps access in Settings."; INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Confab can use your current location to answer questions, if you enable Location & Maps access in Settings.";
@@ -422,6 +423,7 @@
INFOPLIST_FILE = oAI/Info.plist; INFOPLIST_FILE = oAI/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Confab; INFOPLIST_KEY_CFBundleDisplayName = Confab;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_NSAppleEventsUsageDescription = "Confab can search, read, and save attachments from your Apple Mail messages when you ask it to, if you enable Mail access in Settings.";
INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "Confab can read and create calendar events when you ask it to, if you enable Calendar access in Settings."; INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "Confab can read and create calendar events when you ask it to, if you enable Calendar access in Settings.";
INFOPLIST_KEY_NSContactsUsageDescription = "Confab can search your contacts when you ask it to, if you enable Contacts access in Settings."; INFOPLIST_KEY_NSContactsUsageDescription = "Confab can search your contacts when you ask it to, if you enable Contacts access in Settings.";
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Confab can use your current location to answer questions, if you enable Location & Maps access in Settings."; INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Confab can use your current location to answer questions, if you enable Location & Maps access in Settings.";
+663
View File
@@ -0,0 +1,663 @@
//
// 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 {
case scriptNotCompiled
case appleScriptError(NSDictionary)
}
@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 {
continuation.resume(returning: .failure(.appleScriptError(errorDict)))
} 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(_ errorInfo: NSDictionary) -> String {
let number = (errorInfo[NSAppleScript.errorNumber] as? Int) ?? 0
let message = (errorInfo[NSAppleScript.errorMessage] as? String) ?? "Unknown error"
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 dict):
return ["error": mapAppleScriptError(dict)]
}
}
// 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 dict):
message = Self.mapAppleScriptError(dict)
}
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
}
}
+64
View File
@@ -124,6 +124,7 @@ class MCPService {
private let eventKitService = EventKitService.shared private let eventKitService = EventKitService.shared
private let contactsService = ContactsService.shared private let contactsService = ContactsService.shared
private let locationMapsService = LocationMapsService.shared private let locationMapsService = LocationMapsService.shared
private let mailService = AppleMailService.shared
// MARK: - Bash Approval State // MARK: - Bash Approval State
@@ -150,6 +151,19 @@ class MCPService {
private var pendingPersonalDataContinuation: CheckedContinuation<[String: Any], Never>? = nil private var pendingPersonalDataContinuation: CheckedContinuation<[String: Any], Never>? = nil
private(set) var personalDataSessionApproved: Bool = false private(set) var personalDataSessionApproved: Bool = false
// MARK: - Mail Approval State
struct PendingMailAction: Identifiable {
let id = UUID()
let toolName: String
let argumentsJSON: String
let summary: String
}
private(set) var pendingMailAction: PendingMailAction? = nil
private var pendingMailContinuation: CheckedContinuation<[String: Any], Never>? = nil
private(set) var mailSessionApproved: Bool = false
// MARK: - Tool Schema Generation // MARK: - Tool Schema Generation
func getToolSchemas(onlineMode: Bool = false) -> [Tool] { func getToolSchemas(onlineMode: Bool = false) -> [Tool] {
@@ -276,6 +290,11 @@ class MCPService {
tools.append(contentsOf: locationMapsService.getToolSchemas()) tools.append(contentsOf: locationMapsService.getToolSchemas())
} }
// Add Mail tools if enabled
if settings.mailEnabled {
tools.append(contentsOf: mailService.getToolSchemas())
}
// Add bash_execute tool when bash is enabled // Add bash_execute tool when bash is enabled
if settings.bashEnabled { if settings.bashEnabled {
let workDir = settings.bashWorkingDirectory let workDir = settings.bashWorkingDirectory
@@ -484,6 +503,13 @@ class MCPService {
let summary = eventKitService.approvalSummary(forTool: name, arguments: arguments) let summary = eventKitService.approvalSummary(forTool: name, arguments: arguments)
return await executePersonalDataAction(toolName: name, argumentsJSON: arguments, summary: summary) return await executePersonalDataAction(toolName: name, argumentsJSON: arguments, summary: summary)
case "mail_list_accounts", "mail_search", "mail_get_message", "mail_save_attachment":
guard settings.mailEnabled else {
return ["error": "Mail access is disabled. Enable it in Settings > MCP."]
}
let summary = mailService.approvalSummary(forTool: name, arguments: arguments)
return await executeMailAction(toolName: name, argumentsJSON: arguments, summary: summary)
default: default:
// Route to external MCP servers (stdio JSON-RPC) // Route to external MCP servers (stdio JSON-RPC)
if ExternalMCPManager.shared.isExternalTool(name) { if ExternalMCPManager.shared.isExternalTool(name) {
@@ -1101,6 +1127,44 @@ class MCPService {
personalDataSessionApproved = false personalDataSessionApproved = false
} }
// MARK: - Mail Approval
private func executeMailAction(toolName: String, argumentsJSON: String, summary: String) async -> [String: Any] {
guard settings.mailRequireApproval, !mailSessionApproved else {
return await mailService.executeTool(name: toolName, arguments: argumentsJSON)
}
return await withCheckedContinuation { continuation in
DispatchQueue.main.async {
self.pendingMailAction = PendingMailAction(toolName: toolName, argumentsJSON: argumentsJSON, summary: summary)
self.pendingMailContinuation = continuation
}
}
}
func approvePendingMailAction(forSession: Bool = false) {
guard let pending = pendingMailAction, let cont = pendingMailContinuation else { return }
pendingMailAction = nil
pendingMailContinuation = nil
if forSession {
mailSessionApproved = true
}
Task.detached(priority: .userInitiated) {
let result = await self.mailService.executeTool(name: pending.toolName, arguments: pending.argumentsJSON)
cont.resume(returning: result)
}
}
func denyPendingMailAction() {
guard pendingMailAction != nil else { return }
pendingMailAction = nil
pendingMailContinuation?.resume(returning: ["error": "User denied this action"])
pendingMailContinuation = nil
}
func resetMailSessionApproval() {
mailSessionApproved = false
}
private func runBashCommand(_ command: String, workingDirectory: String) async -> [String: Any] { private func runBashCommand(_ command: String, workingDirectory: String) async -> [String: Any] {
let timeoutSeconds = settings.bashTimeout let timeoutSeconds = settings.bashTimeout
let workDir = ((workingDirectory as NSString).expandingTildeInPath as NSString).standardizingPath let workDir = ((workingDirectory as NSString).expandingTildeInPath as NSString).standardizingPath
+18
View File
@@ -757,6 +757,24 @@ class SettingsService {
} }
} }
// MARK: - Mail Settings (Apple Mail.app via AppleScript)
var mailEnabled: Bool {
get { cache["mailEnabled"] == "true" }
set {
cache["mailEnabled"] = String(newValue)
DatabaseService.shared.setSetting(key: "mailEnabled", value: String(newValue))
}
}
var mailRequireApproval: Bool {
get { cache["mailRequireApproval"].map { $0 == "true" } ?? true }
set {
cache["mailRequireApproval"] = String(newValue)
DatabaseService.shared.setSetting(key: "mailRequireApproval", value: String(newValue))
}
}
// MARK: - Paperless-NGX Settings // MARK: - Paperless-NGX Settings
var paperlessEnabled: Bool { var paperlessEnabled: Bool {
+1
View File
@@ -153,4 +153,5 @@ enum Log {
nonisolated static let general = AppLogger(subsystem: subsystem, category: "general") nonisolated static let general = AppLogger(subsystem: subsystem, category: "general")
nonisolated static let extMcp = AppLogger(subsystem: subsystem, category: "ext-mcp") nonisolated static let extMcp = AppLogger(subsystem: subsystem, category: "ext-mcp")
nonisolated static let cli = AppLogger(subsystem: subsystem, category: "cli") nonisolated static let cli = AppLogger(subsystem: subsystem, category: "cli")
nonisolated static let mail = AppLogger(subsystem: subsystem, category: "mail")
} }
+3 -1
View File
@@ -1010,6 +1010,8 @@ Don't narrate future actions ("Let me...") - just use the tools.
let personalDataActive = settings.calendarEnabled || settings.remindersEnabled || settings.contactsEnabled || settings.locationMapsEnabled let personalDataActive = settings.calendarEnabled || settings.remindersEnabled || settings.contactsEnabled || settings.locationMapsEnabled
let researchAgentsActive = settings.agentsEnabled let researchAgentsActive = settings.agentsEnabled
let externalMCPActive = !settings.externalMCPServers.filter { $0.isEnabled }.isEmpty let externalMCPActive = !settings.externalMCPServers.filter { $0.isEnabled }.isEmpty
let mailActive = settings.mailEnabled
let paperlessActive = settings.paperlessEnabled && settings.paperlessConfigured
// Dedicated images API path (OpenRouter /images endpoint separate from chat completions) // Dedicated images API path (OpenRouter /images endpoint separate from chat completions)
if selectedModel?.capabilities.usesImagesAPI == true, if selectedModel?.capabilities.usesImagesAPI == true,
let orProvider = provider as? OpenRouterProvider { let orProvider = provider as? OpenRouterProvider {
@@ -1018,7 +1020,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
} }
let modelSupportTools = selectedModel?.capabilities.tools ?? false let modelSupportTools = selectedModel?.capabilities.tools ?? false
if modelSupportTools && (anytypeActive || bashActive || personalDataActive || researchAgentsActive || externalMCPActive || (mcpActive && !mcp.allowedFolders.isEmpty)) { if modelSupportTools && (anytypeActive || bashActive || personalDataActive || researchAgentsActive || externalMCPActive || mailActive || paperlessActive || (mcpActive && !mcp.allowedFolders.isEmpty)) {
generateAIResponseWithTools(provider: provider, modelId: modelId) generateAIResponseWithTools(provider: provider, modelId: modelId)
return return
} }
+10
View File
@@ -131,6 +131,16 @@ struct ChatView: View {
onDeny: { MCPService.shared.denyPendingPersonalDataAction() } onDeny: { MCPService.shared.denyPendingPersonalDataAction() }
) )
} }
.sheet(item: Binding(
get: { MCPService.shared.pendingMailAction },
set: { _ in }
)) { pending in
MailApprovalSheet(
pending: pending,
onApprove: { forSession in MCPService.shared.approvePendingMailAction(forSession: forSession) },
onDeny: { MCPService.shared.denyPendingMailAction() }
)
}
.sheet(item: Binding( .sheet(item: Binding(
get: { GitSyncService.shared.pendingGitConflict }, get: { GitSyncService.shared.pendingGitConflict },
set: { _ in } set: { _ in }
+95
View File
@@ -0,0 +1,95 @@
//
// MailApprovalSheet.swift
// Confab
//
// Approval UI for AI-requested Mail actions (search/read/save attachment)
//
// 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 SwiftUI
struct MailApprovalSheet: View {
let pending: MCPService.PendingMailAction
let onApprove: (_ forSession: Bool) -> Void
let onDeny: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 20) {
// Header
HStack(spacing: 12) {
Image(systemName: "envelope.badge.shield.half.filled")
.font(.title2)
.foregroundStyle(.orange)
VStack(alignment: .leading, spacing: 2) {
Text("Allow This Action?")
.font(.system(size: 17, weight: .semibold))
Text("The AI wants to access your Mail")
.font(.system(size: 13))
.foregroundStyle(.secondary)
}
Spacer()
}
// Action description
VStack(alignment: .leading, spacing: 6) {
Text("ACTION")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(.secondary)
Text(pending.summary)
.font(.system(size: 13))
.foregroundStyle(.primary)
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
.padding(12)
.background(Color.secondary.opacity(0.08))
.clipShape(RoundedRectangle(cornerRadius: 8))
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.secondary.opacity(0.2), lineWidth: 1)
)
}
// Buttons
HStack(spacing: 8) {
Button("Deny") {
onDeny()
}
.buttonStyle(.bordered)
.tint(.red)
.keyboardShortcut(.escape, modifiers: [])
Spacer()
Button("Allow Once") {
onApprove(false)
}
.buttonStyle(.bordered)
.tint(.orange)
Button("Allow for Session") {
onApprove(true)
}
.buttonStyle(.borderedProminent)
.tint(.orange)
.keyboardShortcut(.return, modifiers: [])
}
}
.padding(24)
.frame(width: 480)
}
}
+76
View File
@@ -130,6 +130,10 @@ struct SettingsView: View {
@State private var cliAvailableModels: [ModelInfo] = [] @State private var cliAvailableModels: [ModelInfo] = []
@State private var isLoadingCLIModels = false @State private var isLoadingCLIModels = false
// Mail state
@State private var isTestingMail = false
@State private var mailTestResult: String?
private let labelWidth: CGFloat = 160 private let labelWidth: CGFloat = 160
// Default system prompt - generic for all models // Default system prompt - generic for all models
@@ -1025,6 +1029,63 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
.padding(.horizontal, 4) .padding(.horizontal, 4)
} }
} }
// MARK: Mail
Divider()
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Image(systemName: "envelope.badge")
.font(.title2)
.foregroundStyle(.teal)
Text("Mail")
.font(.system(size: 18, weight: .semibold))
}
Text("Let the AI search your Apple Mail inbox, read messages, and save attachments to disk (e.g. to hand off to Paperless). Uses AppleScript to talk to Mail.app — no separate credentials needed. The first use triggers a one-time macOS Automation permission prompt.")
.font(.system(size: 14))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.bottom, 4)
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Apple Mail")
formSection {
row("Enable Mail Access") {
Toggle("", isOn: $settingsService.mailEnabled)
.toggleStyle(.switch)
}
if settingsService.mailEnabled {
rowDivider()
row("Require Approval for Every Action") {
Toggle("", isOn: $settingsService.mailRequireApproval)
.toggleStyle(.switch)
}
rowDivider()
HStack(spacing: 12) {
Button(action: { Task { await testMailConnection() } }) {
HStack {
if isTestingMail {
ProgressView().scaleEffect(0.7).frame(width: 14, height: 14)
} else {
Image(systemName: "checkmark.circle")
}
Text("Test Connection")
}
}
.disabled(isTestingMail)
if let result = mailTestResult {
Text(result)
.font(.system(size: 13))
.foregroundStyle(result.hasPrefix("") ? .green : .red)
}
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
}
}
}
} }
// MARK: - External MCP Servers Section // MARK: - External MCP Servers Section
@@ -2873,6 +2934,21 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
} }
} }
private func testMailConnection() async {
isTestingMail = true
mailTestResult = nil
let result = await AppleMailService.shared.testConnection()
await MainActor.run {
switch result {
case .success(let msg):
mailTestResult = "\(msg)"
case .failure(let err):
mailTestResult = "\(err.localizedDescription)"
}
isTestingMail = false
}
}
// MARK: - Backup Tab // MARK: - Backup Tab
@ViewBuilder @ViewBuilder
+185
View File
@@ -0,0 +1,185 @@
//
// AppleMailServiceTests.swift
// oAITests
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
import Testing
import Foundation
@testable import Confab
@Suite("AppleMailService descriptor decoding and formatting")
struct AppleMailServiceTests {
// MARK: - decodeStringList
private func makeListDescriptor(_ items: [String]) -> NSAppleEventDescriptor {
let list = NSAppleEventDescriptor.list()
for (index, item) in items.enumerated() {
list.insert(NSAppleEventDescriptor(string: item), at: index + 1)
}
return list
}
@Test("decodeStringList decodes a flat list of strings")
func decodesFlatList() {
let descriptor = makeListDescriptor(["a", "b", "c"])
#expect(AppleMailService.decodeStringList(descriptor) == ["a", "b", "c"])
}
@Test("decodeStringList returns empty array for an empty list")
func decodesEmptyList() {
let descriptor = makeListDescriptor([])
#expect(AppleMailService.decodeStringList(descriptor) == [])
}
@Test("decodeStringList falls back to a single-element array for a bare string descriptor")
func decodesBareString() {
let descriptor = NSAppleEventDescriptor(string: "hello")
#expect(AppleMailService.decodeStringList(descriptor) == ["hello"])
}
// MARK: - parseSearchResults
@Test("parseSearchResults decodes a list of 7-field message records")
func parsesSearchResults() {
let record1 = makeListDescriptor(["msg-1", "Receipt from Elkjøp", "orders@elkjop.no", "Monday, 1 January 2026", "INBOX", "Personal", "1"])
let record2 = makeListDescriptor(["msg-2", "Another subject", "someone@example.com", "Tuesday, 2 January 2026", "INBOX", "Work", "0"])
let outer = NSAppleEventDescriptor.list()
outer.insert(record1, at: 1)
outer.insert(record2, at: 2)
let results = AppleMailService.parseSearchResults(from: outer)
#expect(results.count == 2)
#expect(results[0].messageId == "msg-1")
#expect(results[0].subject == "Receipt from Elkjøp")
#expect(results[0].from == "orders@elkjop.no")
#expect(results[0].mailbox == "INBOX")
#expect(results[0].account == "Personal")
#expect(results[0].attachmentCount == "1")
#expect(results[1].messageId == "msg-2")
#expect(results[1].attachmentCount == "0")
}
@Test("parseSearchResults returns empty array for an empty outer list")
func parsesEmptySearchResults() {
let outer = NSAppleEventDescriptor.list()
#expect(AppleMailService.parseSearchResults(from: outer) == [])
}
@Test("parseSearchResults skips malformed records with too few fields")
func skipsMalformedRecords() {
let goodRecord = makeListDescriptor(["msg-1", "Subject", "from@example.com", "date", "INBOX", "Account", "0"])
let badRecord = makeListDescriptor(["only", "three", "fields"])
let outer = NSAppleEventDescriptor.list()
outer.insert(badRecord, at: 1)
outer.insert(goodRecord, at: 2)
let results = AppleMailService.parseSearchResults(from: outer)
#expect(results.count == 1)
#expect(results[0].messageId == "msg-1")
}
// MARK: - mapAppleScriptError
@Test("mapAppleScriptError maps -1743 to an Automation-permission message")
func mapsNotAuthorizedError() {
let dict: NSDictionary = [
NSAppleScript.errorNumber: -1743,
NSAppleScript.errorMessage: "Not authorized"
]
let message = AppleMailService.mapAppleScriptError(dict)
#expect(message.contains("not authorized"))
#expect(message.contains("Automation"))
}
@Test("mapAppleScriptError maps -600 to a Mail-not-available message")
func mapsApplicationNotRunningError() {
let dict: NSDictionary = [
NSAppleScript.errorNumber: -600,
NSAppleScript.errorMessage: "Application isn't running"
]
let message = AppleMailService.mapAppleScriptError(dict)
#expect(message.contains("not available"))
}
@Test("mapAppleScriptError falls back to a generic formatted message for unknown error numbers")
func mapsGenericError() {
let dict: NSDictionary = [
NSAppleScript.errorNumber: -1234,
NSAppleScript.errorMessage: "Something odd happened"
]
let message = AppleMailService.mapAppleScriptError(dict)
#expect(message.contains("-1234"))
#expect(message.contains("Something odd happened"))
}
// MARK: - parseArgs
@Test("parseArgs decodes a JSON arguments string")
func parsesArgsJSON() {
let args = AppleMailService.parseArgs("{\"account\":\"work@example.com\",\"subject_contains\":\"Elkjøp\"}")
#expect(args["account"] as? String == "work@example.com")
#expect(args["subject_contains"] as? String == "Elkjøp")
}
@Test("parseArgs returns empty dictionary for invalid JSON")
func parsesInvalidArgsJSON() {
let args = AppleMailService.parseArgs("not json")
#expect(args.isEmpty)
}
// MARK: - hasSearchCriteria
@Test("hasSearchCriteria is false when every criterion is nil or empty")
func rejectsUnscopedSearch() {
#expect(AppleMailService.hasSearchCriteria(subjectContains: nil, fromContains: "", bodyContains: nil, since: nil, before: "") == false)
}
@Test("hasSearchCriteria is true when any single criterion is non-empty")
func acceptsScopedSearch() {
#expect(AppleMailService.hasSearchCriteria(subjectContains: "Elkjøp", fromContains: nil, bodyContains: nil, since: nil, before: nil) == true)
#expect(AppleMailService.hasSearchCriteria(subjectContains: nil, fromContains: nil, bodyContains: nil, since: "2026-01-01", before: nil) == true)
}
// MARK: - approvalSummary
@Test("approvalSummary describes mail_list_accounts")
func summarizesListAccounts() {
let summary = AppleMailService.shared.approvalSummary(forTool: "mail_list_accounts", arguments: "{}")
#expect(summary == "List your Mail.app accounts")
}
@Test("approvalSummary includes account and subject scope for mail_search")
func summarizesSearch() {
let summary = AppleMailService.shared.approvalSummary(
forTool: "mail_search",
arguments: "{\"account\":\"work@example.com\",\"subject_contains\":\"Elkjøp\"}"
)
#expect(summary.contains("work@example.com"))
#expect(summary.contains("Elkjøp"))
}
@Test("approvalSummary for mail_search with no scope still returns a sensible label")
func summarizesUnscopedSearch() {
let summary = AppleMailService.shared.approvalSummary(forTool: "mail_search", arguments: "{}")
#expect(summary == "Search Mail")
}
@Test("approvalSummary describes mail_save_attachment with name and destination")
func summarizesSaveAttachment() {
let summary = AppleMailService.shared.approvalSummary(
forTool: "mail_save_attachment",
arguments: "{\"attachment_name\":\"receipt.pdf\",\"dest_path\":\"/tmp/receipt.pdf\"}"
)
#expect(summary.contains("receipt.pdf"))
#expect(summary.contains("/tmp/receipt.pdf"))
}
@Test("approvalSummary falls back to a generic label for an unknown tool name")
func summarizesUnknownTool() {
let summary = AppleMailService.shared.approvalSummary(forTool: "mail_bogus", arguments: "{}")
#expect(summary == "Perform action: mail_bogus")
}
}