From 4064851a3dbbfb29f2dc0136210ec5ae49bb8631 Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Wed, 19 Aug 2026 13:47:22 +0200 Subject: [PATCH] Add Mail integration: search, read, and save attachments via AppleScript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- oAI.xcodeproj/project.pbxproj | 2 + oAI/Services/AppleMailService.swift | 663 ++++++++++++++++++++++ oAI/Services/MCPService.swift | 64 +++ oAI/Services/SettingsService.swift | 18 + oAI/Utilities/Logging.swift | 1 + oAI/ViewModels/ChatViewModel.swift | 4 +- oAI/Views/Main/ChatView.swift | 10 + oAI/Views/Screens/MailApprovalSheet.swift | 95 ++++ oAI/Views/Screens/SettingsView.swift | 76 +++ oAITests/AppleMailServiceTests.swift | 185 ++++++ 10 files changed, 1117 insertions(+), 1 deletion(-) create mode 100644 oAI/Services/AppleMailService.swift create mode 100644 oAI/Views/Screens/MailApprovalSheet.swift create mode 100644 oAITests/AppleMailServiceTests.swift diff --git a/oAI.xcodeproj/project.pbxproj b/oAI.xcodeproj/project.pbxproj index f547837..6111ddf 100644 --- a/oAI.xcodeproj/project.pbxproj +++ b/oAI.xcodeproj/project.pbxproj @@ -370,6 +370,7 @@ INFOPLIST_FILE = oAI/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Confab; 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_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."; @@ -422,6 +423,7 @@ INFOPLIST_FILE = oAI/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Confab; 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_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."; diff --git a/oAI/Services/AppleMailService.swift b/oAI/Services/AppleMailService.swift new file mode 100644 index 0000000..ad7a6fd --- /dev/null +++ b/oAI/Services/AppleMailService.swift @@ -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 +// for +// the full license text. For commercial licensing, contact Rune +// Olsen via . + + +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 { + 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 { + 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 + } +} diff --git a/oAI/Services/MCPService.swift b/oAI/Services/MCPService.swift index b6b818e..4a075a8 100644 --- a/oAI/Services/MCPService.swift +++ b/oAI/Services/MCPService.swift @@ -124,6 +124,7 @@ class MCPService { private let eventKitService = EventKitService.shared private let contactsService = ContactsService.shared private let locationMapsService = LocationMapsService.shared + private let mailService = AppleMailService.shared // MARK: - Bash Approval State @@ -150,6 +151,19 @@ class MCPService { private var pendingPersonalDataContinuation: CheckedContinuation<[String: Any], Never>? = nil 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 func getToolSchemas(onlineMode: Bool = false) -> [Tool] { @@ -276,6 +290,11 @@ class MCPService { 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 if settings.bashEnabled { let workDir = settings.bashWorkingDirectory @@ -484,6 +503,13 @@ class MCPService { let summary = eventKitService.approvalSummary(forTool: name, arguments: arguments) 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: // Route to external MCP servers (stdio JSON-RPC) if ExternalMCPManager.shared.isExternalTool(name) { @@ -1101,6 +1127,44 @@ class MCPService { 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] { let timeoutSeconds = settings.bashTimeout let workDir = ((workingDirectory as NSString).expandingTildeInPath as NSString).standardizingPath diff --git a/oAI/Services/SettingsService.swift b/oAI/Services/SettingsService.swift index 1ca30b3..2e6e8f4 100644 --- a/oAI/Services/SettingsService.swift +++ b/oAI/Services/SettingsService.swift @@ -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 var paperlessEnabled: Bool { diff --git a/oAI/Utilities/Logging.swift b/oAI/Utilities/Logging.swift index 0dff966..efbd12a 100644 --- a/oAI/Utilities/Logging.swift +++ b/oAI/Utilities/Logging.swift @@ -153,4 +153,5 @@ enum Log { nonisolated static let general = AppLogger(subsystem: subsystem, category: "general") nonisolated static let extMcp = AppLogger(subsystem: subsystem, category: "ext-mcp") nonisolated static let cli = AppLogger(subsystem: subsystem, category: "cli") + nonisolated static let mail = AppLogger(subsystem: subsystem, category: "mail") } diff --git a/oAI/ViewModels/ChatViewModel.swift b/oAI/ViewModels/ChatViewModel.swift index c09e892..3694788 100644 --- a/oAI/ViewModels/ChatViewModel.swift +++ b/oAI/ViewModels/ChatViewModel.swift @@ -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 researchAgentsActive = settings.agentsEnabled 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) if selectedModel?.capabilities.usesImagesAPI == true, 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 - 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) return } diff --git a/oAI/Views/Main/ChatView.swift b/oAI/Views/Main/ChatView.swift index f48342b..4ef93b5 100644 --- a/oAI/Views/Main/ChatView.swift +++ b/oAI/Views/Main/ChatView.swift @@ -131,6 +131,16 @@ struct ChatView: View { 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( get: { GitSyncService.shared.pendingGitConflict }, set: { _ in } diff --git a/oAI/Views/Screens/MailApprovalSheet.swift b/oAI/Views/Screens/MailApprovalSheet.swift new file mode 100644 index 0000000..e4fcb30 --- /dev/null +++ b/oAI/Views/Screens/MailApprovalSheet.swift @@ -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 +// for +// the full license text. For commercial licensing, contact Rune +// Olsen via . + + +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) + } +} diff --git a/oAI/Views/Screens/SettingsView.swift b/oAI/Views/Screens/SettingsView.swift index 0c2c23d..06fca41 100644 --- a/oAI/Views/Screens/SettingsView.swift +++ b/oAI/Views/Screens/SettingsView.swift @@ -130,6 +130,10 @@ struct SettingsView: View { @State private var cliAvailableModels: [ModelInfo] = [] @State private var isLoadingCLIModels = false + // Mail state + @State private var isTestingMail = false + @State private var mailTestResult: String? + private let labelWidth: CGFloat = 160 // 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) } } + + // 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 @@ -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 @ViewBuilder diff --git a/oAITests/AppleMailServiceTests.swift b/oAITests/AppleMailServiceTests.swift new file mode 100644 index 0000000..a55b9fb --- /dev/null +++ b/oAITests/AppleMailServiceTests.swift @@ -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") + } +}