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