Add a real permission prompt/status for Mail access, matching Personal Data

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.
This commit is contained in:
2026-08-19 14:01:29 +02:00
parent cf82720b88
commit 21d598d88a
3 changed files with 75 additions and 5 deletions
+45
View File
@@ -53,6 +53,51 @@ final class AppleMailService {
// through one queue, mirroring MCPService.runBashCommand's off-main-actor pattern. // through one queue, mirroring MCPService.runBashCommand's off-main-actor pattern.
private let queue = DispatchQueue(label: "com.oai.applemail", qos: .userInitiated) 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 @ObservationIgnored
private lazy var compiledScript: NSAppleScript? = { private lazy var compiledScript: NSAppleScript? = {
guard let script = NSAppleScript(source: Self.scriptSource) else { return nil } guard let script = NSAppleScript(source: Self.scriptSource) else { return nil }
+12 -5
View File
@@ -133,6 +133,7 @@ struct SettingsView: View {
// Mail state // Mail state
@State private var isTestingMail = false @State private var isTestingMail = false
@State private var mailTestResult: String? @State private var mailTestResult: String?
@State private var mailAccessState: PersonalDataAccessState = AppleMailService.shared.accessState
private let labelWidth: CGFloat = 160 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") Text("Mail")
.font(.system(size: 18, weight: .semibold)) .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)) .font(.system(size: 14))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
} }
.padding(.bottom, 4) .padding(.bottom, 4)
.onAppear {
mailAccessState = AppleMailService.shared.accessState
}
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
sectionHeader("Apple Mail") sectionHeader("Apple Mail")
formSection { formSection {
row("Enable Mail Access") { personalDataRow(
Toggle("", isOn: $settingsService.mailEnabled) title: "Mail",
.toggleStyle(.switch) isEnabled: $settingsService.mailEnabled,
} state: mailAccessState,
systemSettingsAnchor: "Privacy_Automation",
requestAccess: { mailAccessState = await AppleMailService.shared.requestAccess() ? .granted : AppleMailService.shared.accessState }
)
if settingsService.mailEnabled { if settingsService.mailEnabled {
rowDivider() rowDivider()
row("Require Approval for Every Action") { row("Require Approval for Every Action") {
+18
View File
@@ -170,4 +170,22 @@ struct AppleMailServiceTests {
let summary = AppleMailService.shared.approvalSummary(forTool: "mail_bogus", arguments: "{}") let summary = AppleMailService.shared.approvalSummary(forTool: "mail_bogus", arguments: "{}")
#expect(summary == "Perform action: mail_bogus") #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)
}
} }