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

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

Also fixes a pre-existing bug found while touching the adjacent
tool-activation condition: paperlessEnabled was missing from it, so
Paperless tools could fail to activate unless another integration was
also active.
This commit is contained in:
2026-08-19 13:47:38 +02:00
parent d46fb03a07
commit 4064851a3d
10 changed files with 1117 additions and 1 deletions
+64
View File
@@ -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