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:
@@ -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.";
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user