From 21d598d88a0dc1649e119d59dd8025cd13cd5661 Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Wed, 19 Aug 2026 14:01:29 +0200 Subject: [PATCH] Add a real permission prompt/status for Mail access, matching Personal Data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings previously only surfaced an error message after a failed Test Connection attempt — there was no way to trigger the actual macOS Automation permission dialog or see live grant/deny status, unlike Calendar/Contacts/Reminders/Location. AEDeterminePermissionToAutomateTarget (askUserIfNeeded: false/true) turns out to provide exactly that: a non-prompting status check and an explicit prompt-and-wait call, mirroring EKEventStore.authorizationStatus(for:)/ requestFullAccessToEvents(). Mail's Settings row now uses the same personalDataRow component as Calendar/Contacts — live status badge plus a real "Request Access" button — instead of a one-off Test-Connection-only UI. Test Connection stays as a secondary functional check. --- oAI/Services/AppleMailService.swift | 45 ++++++++++++++++++++++++++++ oAI/Views/Screens/SettingsView.swift | 17 +++++++---- oAITests/AppleMailServiceTests.swift | 18 +++++++++++ 3 files changed, 75 insertions(+), 5 deletions(-) diff --git a/oAI/Services/AppleMailService.swift b/oAI/Services/AppleMailService.swift index a266f03..0db045b 100644 --- a/oAI/Services/AppleMailService.swift +++ b/oAI/Services/AppleMailService.swift @@ -53,6 +53,51 @@ final class AppleMailService { // through one queue, mirroring MCPService.runBashCommand's off-main-actor pattern. private let queue = DispatchQueue(label: "com.oai.applemail", qos: .userInitiated) + // MARK: - Automation Permission (Settings UI status badge + Request Access button) + + /// Live tri-state permission check via `AEDeterminePermissionToAutomateTarget`, which — unlike + /// sending an actual Apple Event — can query Automation permission for Mail.app without + /// triggering any script execution or the system prompt (`askUserIfNeeded: false`). This is + /// the Apple-Events equivalent of `EKEventStore.authorizationStatus(for:)`, reusing the same + /// `PersonalDataAccessState` enum EventKitService/ContactsService/LocationMapsService use. + var accessState: PersonalDataAccessState { + Self.mapAutomationPermissionStatus(Self.checkMailAutomationPermission(askUserIfNeeded: false)) + } + + /// Pure mapping from an `AEDeterminePermissionToAutomateTarget` status code to the shared + /// tri-state enum, kept separate from the live AE call for testability. + nonisolated static func mapAutomationPermissionStatus(_ status: OSStatus) -> PersonalDataAccessState { + switch status { + case 0: return .granted // noErr + case -1743: return .denied // errAEEventNotPermitted + default: return .notDetermined // errAEEventWouldRequireUserConsent (-1744) and others + } + } + + /// Triggers the real macOS "Confab wants to control Mail" Automation permission prompt if + /// the user hasn't been asked yet (a no-op if already granted or denied). Blocks until the + /// user responds, so this must run off the main thread. + @discardableResult + func requestAccess() async -> Bool { + await withCheckedContinuation { continuation in + queue.async { + let status = Self.checkMailAutomationPermission(askUserIfNeeded: true) + continuation.resume(returning: status == 0) + } + } + } + + private static func checkMailAutomationPermission(askUserIfNeeded: Bool) -> OSStatus { + let bundleID = "com.apple.mail" + var target = AEAddressDesc() + let createStatus = bundleID.withCString { cString in + AECreateDesc(typeApplicationBundleID, cString, bundleID.utf8.count, &target) + } + guard createStatus == 0 else { return OSStatus(createStatus) } + defer { AEDisposeDesc(&target) } + return AEDeterminePermissionToAutomateTarget(&target, typeWildCard, typeWildCard, askUserIfNeeded) + } + @ObservationIgnored private lazy var compiledScript: NSAppleScript? = { guard let script = NSAppleScript(source: Self.scriptSource) else { return nil } diff --git a/oAI/Views/Screens/SettingsView.swift b/oAI/Views/Screens/SettingsView.swift index 06fca41..d6774c6 100644 --- a/oAI/Views/Screens/SettingsView.swift +++ b/oAI/Views/Screens/SettingsView.swift @@ -133,6 +133,7 @@ struct SettingsView: View { // Mail state @State private var isTestingMail = false @State private var mailTestResult: String? + @State private var mailAccessState: PersonalDataAccessState = AppleMailService.shared.accessState private let labelWidth: CGFloat = 160 @@ -1041,20 +1042,26 @@ It's better to admit "I need more information" or "I cannot do that" than to fak 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.") + 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.") .font(.system(size: 14)) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } .padding(.bottom, 4) + .onAppear { + mailAccessState = AppleMailService.shared.accessState + } VStack(alignment: .leading, spacing: 6) { sectionHeader("Apple Mail") formSection { - row("Enable Mail Access") { - Toggle("", isOn: $settingsService.mailEnabled) - .toggleStyle(.switch) - } + personalDataRow( + title: "Mail", + isEnabled: $settingsService.mailEnabled, + state: mailAccessState, + systemSettingsAnchor: "Privacy_Automation", + requestAccess: { mailAccessState = await AppleMailService.shared.requestAccess() ? .granted : AppleMailService.shared.accessState } + ) if settingsService.mailEnabled { rowDivider() row("Require Approval for Every Action") { diff --git a/oAITests/AppleMailServiceTests.swift b/oAITests/AppleMailServiceTests.swift index 90510bc..74fe39c 100644 --- a/oAITests/AppleMailServiceTests.swift +++ b/oAITests/AppleMailServiceTests.swift @@ -170,4 +170,22 @@ struct AppleMailServiceTests { let summary = AppleMailService.shared.approvalSummary(forTool: "mail_bogus", arguments: "{}") #expect(summary == "Perform action: mail_bogus") } + + // MARK: - mapAutomationPermissionStatus + + @Test("mapAutomationPermissionStatus maps noErr (0) to granted") + func mapsGrantedStatus() { + #expect(AppleMailService.mapAutomationPermissionStatus(0) == .granted) + } + + @Test("mapAutomationPermissionStatus maps -1743 to denied") + func mapsDeniedStatus() { + #expect(AppleMailService.mapAutomationPermissionStatus(-1743) == .denied) + } + + @Test("mapAutomationPermissionStatus maps -1744 (would require consent) and other codes to notDetermined") + func mapsNotDeterminedStatus() { + #expect(AppleMailService.mapAutomationPermissionStatus(-1744) == .notDetermined) + #expect(AppleMailService.mapAutomationPermissionStatus(-9999) == .notDetermined) + } }