apple-tools-mcp 2.0.10 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -16
- package/lib/appleScript.js +15 -2
- package/lib/mailWrite.js +290 -208
- package/lib/permissions.js +15 -10
- package/lib/shell.js +3 -0
- package/lib/writeRouting.js +3 -3
- package/lib/writeTools.js +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -77,7 +77,7 @@ Write tools drive Mail, Messages, Calendar, and Contacts through AppleScript. ma
|
|
|
77
77
|
|
|
78
78
|
#### First install and upgrade: `apple-tools-mcp permissions`
|
|
79
79
|
|
|
80
|
-
Run this **after first global install** and **after upgrade** when write surfaces are present or change. It probes **`process.execPath`** (the `node` running the command) so macOS can pop **Allow** dialogs for Contacts, Calendar, Mail, and
|
|
80
|
+
Run this **after first global install** and **after upgrade** when write surfaces are present or change. It probes **`process.execPath`** (the `node` running the command) so macOS can pop **Allow** dialogs for Contacts, Calendar, Mail, Messages, and System Events in one sitting. You click **Allow**; the command cannot grant silently. Already-granted surfaces report OK without another click. Missing grants print a report and the process **exits non-zero** (fail closed).
|
|
81
81
|
|
|
82
82
|
Use the **same `node` the product uses** — not the MCP host app. Always invoke so **`process.execPath`** is that node. A bare `apple-tools-mcp` (or a path like `~/.nvm/versions/node/v22.21.1/bin/apple-tools-mcp`) can have a shebang that starts a **different** node; Allow dialogs attach to `execPath`, not the CLI path you typed.
|
|
83
83
|
|
|
@@ -98,15 +98,15 @@ If the invoked CLI path and `process.execPath` differ, the command prints a **WA
|
|
|
98
98
|
- **Mini:** `/Users/petercoates/.local/node/bin/node` (global npm / indexer LaunchAgent)
|
|
99
99
|
- **MacBook:** `/Users/petercoates/.nvm/versions/node/v22.21.1/bin/node` (Claude’s nvm `node`, **not** Homebrew)
|
|
100
100
|
|
|
101
|
-
**MacBook (default for `permissions`):** run from **Terminal.app**. Click **Allow** for the **printed `process.execPath`**. Confirm **System Settings → Privacy & Security → Automation** for that node → Contacts, Calendar, Mail, and
|
|
101
|
+
**MacBook (default for `permissions`):** run from **Terminal.app**. Click **Allow** for the **printed `process.execPath`**. Confirm **System Settings → Privacy & Security → Automation** for that node → Contacts, Calendar, Mail, Messages, and System Events. Do **not** start `apple-tools-indexer` — the always-on indexer / write bridge is a **Mini** install only.
|
|
102
102
|
|
|
103
103
|
**Mini:** use the [Mini ship-gate](#mini-ship-gate-host-setup) path (LaunchAgent-owned node / `writer.sock`). Missing-grant copy mentions the write bridge only when that socket is present.
|
|
104
104
|
|
|
105
|
-
On the host UI (Mini Screen Sharing or MacBook local), open **System Settings → Privacy & Security → Automation**, then run the command. Click **Allow** for **`node`** → Contacts, Calendar, Mail, and
|
|
105
|
+
On the host UI (Mini Screen Sharing or MacBook local), open **System Settings → Privacy & Security → Automation**, then run the command. Click **Allow** for **`node`** → Contacts, Calendar, Mail, Messages, and System Events. npm `postinstall` only **prints a reminder** — it does not run the probes unattended.
|
|
106
106
|
|
|
107
|
-
This is a real Apple Events pass: Mail uses `make new outgoing message` (compose then discard; nothing is sent). Messages enumerates accounts (nothing is sent). `dry_run` of `mail_send` / `messages_send` never talks to those apps and **does not count**. Contacts creates and deletes a throwaway person in-script. Calendar lists calendars only (no leftover events).
|
|
107
|
+
This is a real Apple Events pass: Mail uses `make new outgoing message` (compose then discard; nothing is sent). Messages enumerates accounts (nothing is sent). `dry_run` of `mail_send` / `messages_send` never talks to those apps and **does not count**. Contacts creates and deletes a throwaway person in-script. Calendar lists calendars only (no leftover events). System Events is asked for its process name (nothing is typed) so the keystroke compose path can pop **Allow**.
|
|
108
108
|
|
|
109
|
-
**One-pass first-run:** Allow **`node`** to control **Mail**, **Messages**, **Contacts**, and **
|
|
109
|
+
**One-pass first-run:** Allow **`node`** to control **Mail**, **Messages**, **Contacts**, **Calendar**, and **System Events** in the same Automation pass. Contacts or Calendar being allowed does **not** grant Mail, Messages, or System Events — they are separate Apple Events targets. A denied Mail grant hangs `mail_send` / `mail_draft` / `mail_reply` / `mail_forward` until the client disconnects; `dry_run` never talks to Mail, so that deny is invisible until a real compose.
|
|
110
110
|
|
|
111
111
|
**Do not add `node` via the + button in System Settings → Privacy & Security → Contacts or Calendars.** On current macOS those panes often have **no Add button**, and that instruction is not the ship-gate setup — it failed on the Mini.
|
|
112
112
|
|
|
@@ -122,7 +122,7 @@ This is the first-run flow for Mail, Messages, Contacts, and Calendar **writes**
|
|
|
122
122
|
```
|
|
123
123
|
|
|
124
124
|
That is the ship-gate context. An embedded agent shell, IDE terminal, or MCP host app subprocess is **not** the ship-gate host unless the write bridge is up and the work executes inside launchd-owned `node`.
|
|
125
|
-
3. When prompts appear, click **Allow** for **`node`** to control **Mail**, **Messages**, **Contacts**, and **
|
|
125
|
+
3. When prompts appear, click **Allow** for **`node`** to control **Mail**, **Messages**, **Contacts**, **Calendar**, and **System Events** (needed for `mail_send` / `mail_draft` keystrokes under the LaunchAgent). Watch the host — Allow **`node`** (the LaunchAgent binary), not any other app. Use whatever path the LaunchAgent plist / `which node` reports — `/Users/petercoates/.local/node/bin/node` is a Mini *example* only, not a universal path. Do **not** approve the MCP client / host app that launched a short-lived stdio server.
|
|
126
126
|
4. **Keep Contacts, Mail, and Messages running** on the write host (leave the apps open; do not quit them). Cold `tell application "Contacts"` under launchd often fails with `-600` / “application isn't running” instead of auto-launching — that is **not** an Automation deny. The write path launches Contacts.app before CRUD, but write reliability still requires those three apps to stay running. **Calendar does not need to stay open** (Calendar writes go through EventKit).
|
|
127
127
|
5. **Full Disk Access** on `node` is a separate grant and covers **reads** (Mail / Messages / Calendar / AddressBook databases). Write tools need the Automation / Apple Events grants to Mail.app (`mail_send`, `mail_draft`, `mail_reply`, `mail_forward`, and the other mail writes), Messages.app (`messages_send`), Contacts.app, and Calendar.app.
|
|
128
128
|
6. npm Trusted Publisher / publish tokens are unrelated to TCC. Do not confuse them with this setup.
|
|
@@ -308,7 +308,7 @@ Missing `config.json` is fine — env then the 5-minute default apply.
|
|
|
308
308
|
|
|
309
309
|
On Mini, run the **indexer daemon**, not a sleep-pipe wrapper around `apple-tools-mcp`. Claude Desktop and other clients still attach via short-lived stdio MCP (`npx -y apple-tools-mcp` or the global `apple-tools-mcp` bin).
|
|
310
310
|
|
|
311
|
-
The daemon does two jobs: it refreshes the vector index, and it serves the **write bridge** at `~/.apple-tools-mcp/writer.sock` so stdio clients can perform Mail / Messages / Contacts / Calendar writes that their host app cannot be granted (see [step 2b](#2b-grant-automation-for-write-tools-first-run--ship-gate)). When macOS prompts, Allow **`node`** (the LaunchAgent binary) to control Mail.app, Messages.app, Contacts.app,
|
|
311
|
+
The daemon does two jobs: it refreshes the vector index, and it serves the **write bridge** at `~/.apple-tools-mcp/writer.sock` so stdio clients can perform Mail / Messages / Contacts / Calendar writes that their host app cannot be granted (see [step 2b](#2b-grant-automation-for-write-tools-first-run--ship-gate)). When macOS prompts, Allow **`node`** (the LaunchAgent binary) to control Mail.app, Messages.app, Contacts.app, Calendar.app, **and System Events** (mail_send / mail_draft type the body via keystrokes). Do not approve the MCP client / host app that launched a short-lived stdio server, and do not try to add `node` via **+** in the Contacts or Calendars privacy lists.
|
|
312
312
|
|
|
313
313
|
**Keep Contacts.app, Mail.app, and Messages.app running all the time** on the Mini (and on any MacBook that does local writes). Quitting them makes Apple Events to those apps unreliable (`-600` / “application isn't running”), which is a cold-launch miss — not a missing Automation grant. The Contacts write path launches Contacts.app before add/edit/remove, but do not rely on that instead of leaving the apps open. **Calendar.app does not need to stay open**; Calendar writes use EventKit.
|
|
314
314
|
|
|
@@ -464,19 +464,23 @@ Two arguments are available on **every** write tool:
|
|
|
464
464
|
|
|
465
465
|
`body_format: "html"` uses the same compose path with a tag-stripped body pasted into Mail's native editor. It does **not** set Mail's `content` or `html content` — those setters cite-wrap the whole message (`>` prefixes and `<blockquote type="cite">`, same iOS purple bar). Mail may still generate its own HTML alternative from the native compose. The default is plain text.
|
|
466
466
|
|
|
467
|
-
**Compose is not quoted.** `mail_send` / `mail_draft` (plain and html) call `make new outgoing message` **without** AppleScript `content:` so `newMessage` is a real Mail object, then
|
|
467
|
+
**Compose is not quoted.** `mail_send` / `mail_draft` (plain and html) call `make new outgoing message` **without** AppleScript `content:` so `newMessage` is a real Mail object, then type the body into the compose window (System Events keystrokes). Mail's `mailto` command was shipped in 2.0.4 and **fails on Mini/MacBook Mail**: it does not return an outgoing message (`newMessage` undefined, AppleScript **-2753**). On current Mail (Ventura+, FB11734014) `content` / `html content` store the body as a citation: every plain-text line prefixed with `>`, plus a `multipart/alternative` HTML part wrapped in `<blockquote type="cite">`. Desktop Mail often hides the bar with inline styles; iOS Mail paints the whole body purple with a left quote bar — even when the subject is not `Re:`/`Fwd:` and there is no `In-Reply-To`. Reply and forward still quote the original, which is expected (those paths still prepend via `content`; fixing reply/forward cite-wrap is out of scope for 2.1.0).
|
|
468
468
|
|
|
469
|
-
|
|
469
|
+
Keystrokes use System Events, so **node needs Accessibility** (Privacy & Security → Accessibility) **and** Automation → Mail **and** Automation → System Events. Run `apple-tools-mcp permissions` after first install or upgrade so **node → System Events** pops **Allow** in the same sitting as Mail / Messages / Contacts / Calendar. An Accessibility / System Events deny is not a Mail Automation deny (`-1743` / `-10004`).
|
|
470
470
|
|
|
471
|
-
**Compose body focus (2.0.8).** After `make new outgoing message`, To and Subject are AppleScript properties (never paste). Cmd-V
|
|
471
|
+
**Compose body focus (2.0.8).** After `make new outgoing message`, To and Subject are AppleScript properties (never paste). Cmd-V used to run only after an AX window walker left the header. Quote-prefix Sent prove and Sent/Outbox verify stay gated on fail-closed body/header checks — a MacBook 2.0.7 live `mail_send` with Accessibility allowed still hit `BODY_PASTE_MISDIRECTED` when Cmd-V landed in To/Subject (`ATP-207-SENT-PROVE-20260921-084048`).
|
|
472
472
|
|
|
473
|
-
**macOS 26 focus compile (2.0.9).** Dual-host Sent prove on `@2.0.8` never reached paste: `atmSafeToPaste` used bare `focused UI element`, which System Events 26.x rejects at compile.
|
|
473
|
+
**macOS 26 focus compile (2.0.9).** Dual-host Sent prove on `@2.0.8` never reached paste: `atmSafeToPaste` used bare `focused UI element`, which System Events 26.x rejects at compile.
|
|
474
474
|
|
|
475
|
-
**AppleScript reserved identifiers (2.0.10).** Dual-host Sent prove on `@2.0.9` compiled past the focused-UI fix, then failed in `atmFocusMailBody`: `set {tw, th} to size of atmElem` → **-2741** Expected expression but found `th` (AppleScript reserved ordinal).
|
|
475
|
+
**AppleScript reserved identifiers (2.0.10).** Dual-host Sent prove on `@2.0.9` compiled past the focused-UI fix, then failed in `atmFocusMailBody`: `set {tw, th} to size of atmElem` → **-2741** Expected expression but found `th` (AppleScript reserved ordinal).
|
|
476
476
|
|
|
477
|
-
**
|
|
477
|
+
**Keystroke compose (2.1.0).** macOS 26 System Events reports `windows of process Mail = 0`, so the AX window walker cannot focus a tall `AXWebArea`. Fresh compose **Tabs into the body** (max Tab×6 as proven on Mini) and **types the body with Return between paragraphs**. Clipboard Cmd-V is only a fallback if a *readable* AX value is still missing the body needle. Fail-closed: missing AX body focus or changed To/Cc/Bcc/subject is `BODY_FOCUS_FAILED` / `BODY_PASTE_MISDIRECTED`; the outgoing message is deleted and nothing is sent. Apple events are wrapped in `with timeout`; hung `osascript` is **SIGKILL**'d so a wedge fails fast instead of sitting ~60s. If the Mini write-bridge / indexer daemon cannot drive System Events, `mail_send` returns `unsupported: true` so the stdio client falls back **in-process** — it does **not** hang with `MAIL_SEND_TIMEOUT_GUIDANCE` for a message that never sent.
|
|
478
478
|
|
|
479
|
-
**
|
|
479
|
+
**AX body-focus oracle (2.1.1).** Dual-host Mail 16 / macOS 26 showed that Mail AppleScript `content of` the outgoing message is **not** a live oracle for UI-typed/pasted text: `set content` reads back, but after Tab + keystroke/paste into the UI body `content of` stays empty (`typedSeen=false`, bodyLen 0) even when typing still produces a plain Sent `.emlx`. `@2.1.0` calibrated Tab against that property and fail-closed with `BODY_FOCUS_FAILED`. 2.1.1 Tabs until the focused AX role is a body field (`AXWebArea` / non-header `AXTextArea` via `AXFocusedUIElement` / `first UI element whose focused is true` — never `focused UI element`, never `windows of process Mail`). Cmd-V runs only when that AX **value** is readable and still missing the needle. An empty AX value is not a miss (Mail 16 WebArea); header checks still fail-closed. Ship prove that the body landed remains the Sent `.emlx` after send (2.0.7 To+subject verify). Do not “fix Tab count.”
|
|
480
|
+
|
|
481
|
+
**mail_send success is Sent/Outbox verify (2.0.7).** AppleScript `send` returning without throw is not enough. After a real send the tool looks in **Sent** and **Outbox** and reports success only if the message is there. The success text names the delivery state (`mailbox: sent` + `delivery: sent`, or `mailbox: outbox` + `delivery: outbox` while still sending). If verify misses, the tool returns a failure (`isError`) — not success. Hang/timeout recover matches **To + subject** (and Message-ID when compose captured one). It never matches subject alone (short subjects like `test` are unsafe). `from` / account selection is out of scope; Mail's default From is used. `dry_run` / confirm-blocked writes stay `ok: false`, `planned: true`, `delivered: false`.
|
|
482
|
+
|
|
483
|
+
**Manual prove (2.1.1 on the Mac host):** After `apple-tools-mcp permissions` reports System Events granted, `mail_send` a short **multiline** plain message and a short `body_format: "html"` message whose body looks like ordinary paragraphs (for example `<p>Quick note</p>`), neither with a `Re:`/`Fwd:` subject. The caret must land in the **body** (not To/Subject): no `BODY_FOCUS_FAILED` / `BODY_PASTE_MISDIRECTED`. Inspect each Sent `.emlx`: the text/plain part must contain the intended text (newlines preserved) and must not prefix every body line with `>`, and any HTML alternative must not wrap the whole body in `<blockquote type="cite">`. Compose must succeed (no `-2753` / undefined `newMessage`). On Mini, prove the production path (write-bridge with node → System Events allowed, **or** the documented in-process fallback). That quote-prefix dual-host Sent prove is a separate bar from the 2.0.7 verify contract.
|
|
480
484
|
|
|
481
485
|
Emails are addressed by their RFC822 **Message-ID**. Pass `message_id`, or pass the `file_path` from `mail_search` / `mail_recent` and the server reads the Message-ID out of the `.emlx` headers for you. `mail_archive` moves the message to its account's Archive (or All Mail) mailbox; `mail_trash` moves it to that account's Trash.
|
|
482
486
|
|
|
@@ -597,9 +601,9 @@ Ensure Node.js has Full Disk Access (see Installation step 2).
|
|
|
597
601
|
|
|
598
602
|
The message names which process macOS was actually asking about. Work through it in this order:
|
|
599
603
|
|
|
600
|
-
1. **MacBook / Terminal (`permissions` CLI):** there is **no** always-on indexer. Re-run `apple-tools-mcp permissions` from **Terminal.app**, Allow the **printed `process.execPath`**, and check System Settings → Privacy & Security → **Automation** for that node → Contacts, Calendar, Mail, and
|
|
604
|
+
1. **MacBook / Terminal (`permissions` CLI):** there is **no** always-on indexer. Re-run `apple-tools-mcp permissions` from **Terminal.app**, Allow the **printed `process.execPath`**, and check System Settings → Privacy & Security → **Automation** for that node → Contacts, Calendar, Mail, Messages, and System Events. Do **not** start `apple-tools-indexer`.
|
|
601
605
|
2. **Mini only:** `pgrep -fl apple-tools-indexer` / `~/.apple-tools-mcp/writer.sock`. If the LaunchAgent is the product host, start it so writes run under launchd-owned node (see [Mini ship-gate](#mini-ship-gate-host-setup)).
|
|
602
|
-
3. **Did you approve the Automation prompts for `node`?** Re-run `apple-tools-mcp permissions` with the product `node` (it prints `process.execPath`). Open System Settings → Privacy & Security → **Automation** and confirm **that printed node** is allowed to control **Mail**, **Messages**, Contacts, and
|
|
606
|
+
3. **Did you approve the Automation prompts for `node`?** Re-run `apple-tools-mcp permissions` with the product `node` (it prints `process.execPath`). Open System Settings → Privacy & Security → **Automation** and confirm **that printed node** is allowed to control **Mail**, **Messages**, Contacts, Calendar, and **System Events**. On Mini the LaunchAgent binary is whatever the plist / `which node` reports (`/Users/petercoates/.local/node/bin/node` is a Mini *example* only). MacBook Claude nvm is `/Users/petercoates/.nvm/versions/node/v22.21.1/bin/node`, not Homebrew. Watch the host — Allow **`node`**, not any other app. Contacts or Calendar being allowed does **not** grant Mail. A `mail_send` hang is **Automation denied**, not “Mail.app could not be reached”; `dry_run` never talks to Mail so it cannot detect this. Do **not** approve the MCP client / host app that launched a short-lived stdio server, and do **not** try to add `node` via **+** in the Contacts or Calendars privacy lists — those panes often have no Add button. `tccutil reset AppleEvents` re-arms the Automation prompt so you can Allow **`node`** again. Full Disk Access is a separate read grant; npm Trusted Publisher / tokens are unrelated.
|
|
603
607
|
4. **Mini: is the daemon's node binary the one with Full Disk Access?** LaunchAgents do not inherit your shell `PATH`; confirm the plist points at the same path `which node` reports.
|
|
604
608
|
5. **Is it actually the write path?** Contacts or Calendar *reads* failing with `EPERM` is a Full Disk Access / attribution problem, not the entitlement gap — fix FDA for the responsible process. Contacts or Calendar *CRUD* failing with no prompt under Claude Desktop is the [documented host limitation](#reads-and-writes-are-different-mechanisms): Claude.app carries neither the addressbook nor the calendars entitlement. On Mini, start the daemon so the write goes through the bridge. On MacBook, use Terminal.app + the printed node.
|
|
605
609
|
6. **"Contacts.app / Calendar.app could not be reached"** with the app clearly installed is an **Automation / responsible-process** failure, not a missing app — macOS reports a refused Apple event as `-1728` / "can't get application". On MacBook / Terminal, Allow the printed `process.execPath`. On Mini, the write bridge / LaunchAgent may apply. A Contacts `-600` / “application isn't running” is a **cold launch**, not Automation — keep Contacts, Mail, and Messages running; Calendar does not need to stay open.
|
package/lib/appleScript.js
CHANGED
|
@@ -170,6 +170,7 @@ export function needsHostTccAdvice(message) {
|
|
|
170
170
|
const text = String(message || "");
|
|
171
171
|
if (!text) return false;
|
|
172
172
|
if (text.includes(MAIL_SEND_TIMEOUT_GUIDANCE)) return false;
|
|
173
|
+
if (text.includes(MAIL_GUI_SCRIPTING_GUIDANCE)) return false;
|
|
173
174
|
if (text.includes(CONTACTS_APP_NOT_RUNNING_GUIDANCE)) return false;
|
|
174
175
|
if (text.includes(MAIL_APP_NOT_RUNNING_GUIDANCE)) return false;
|
|
175
176
|
if (text.includes(MESSAGES_APP_NOT_RUNNING_GUIDANCE)) return false;
|
|
@@ -339,10 +340,22 @@ export const MAIL_TCC_GUIDANCE =
|
|
|
339
340
|
*/
|
|
340
341
|
export const MAIL_ACCESSIBILITY_GUIDANCE =
|
|
341
342
|
"macOS denied Accessibility (System Events could not drive Mail's compose window). " +
|
|
342
|
-
"mail_send / mail_draft paste the body so Mail does not cite-wrap it (FB11734014); Mail's mailto command does not return an outgoing message on current Mini/MacBook Mail (-2753). " +
|
|
343
|
+
"mail_send / mail_draft type or paste the body so Mail does not cite-wrap it (FB11734014); Mail's mailto command does not return an outgoing message on current Mini/MacBook Mail (-2753). " +
|
|
343
344
|
"Allow node in System Settings → Privacy & Security → Accessibility, and keep Automation → Mail allowed. " +
|
|
344
345
|
"This is not a Mail Automation deny (-1743 / -10004).";
|
|
345
346
|
|
|
347
|
+
/**
|
|
348
|
+
* System Events Apple Events / GUI scripting is missing, so the keystroke
|
|
349
|
+
* compose path cannot run. Distinct from a Mail send hang: nothing was typed
|
|
350
|
+
* or sent. Mini write-bridge should return unsupported so stdio falls back
|
|
351
|
+
* in-process rather than wedging ~60s.
|
|
352
|
+
*/
|
|
353
|
+
export const MAIL_GUI_SCRIPTING_GUIDANCE =
|
|
354
|
+
"System Events GUI scripting is unavailable for this process, so the body cannot be typed into Mail. " +
|
|
355
|
+
"mail_send / mail_draft use System Events keystrokes (Tab into the body, typed Returns) so Mail does not cite-wrap the Sent body (FB11734014). " +
|
|
356
|
+
"Allow node → System Events in System Settings → Privacy & Security → Automation (run apple-tools-mcp permissions after install/upgrade) in addition to node → Mail, and keep Accessibility allowed. " +
|
|
357
|
+
"This is not a Mail send timeout and not a Mail Automation deny (-1743 / -10004).";
|
|
358
|
+
|
|
346
359
|
/**
|
|
347
360
|
* AppleScript hung (ETIMEDOUT / -1712) while finding, opening, replying,
|
|
348
361
|
* or sending. That is not -1743 / -10004, including before send.
|
|
@@ -385,7 +398,7 @@ export function tccGuidanceFor(source) {
|
|
|
385
398
|
export function runAppleScript(script, options = {}) {
|
|
386
399
|
const { timeout = DEFAULT_SCRIPT_TIMEOUT_MS, appName = null, language = null } = options;
|
|
387
400
|
try {
|
|
388
|
-
const output = safeOsascript(script, { timeout, language });
|
|
401
|
+
const output = safeOsascript(script, { timeout, language, killSignal: "SIGKILL" });
|
|
389
402
|
return { ok: true, output: (output || "").trim(), error: null, kind: null };
|
|
390
403
|
} catch (e) {
|
|
391
404
|
const message = e && e.message ? e.message : String(e);
|
package/lib/mailWrite.js
CHANGED
|
@@ -25,10 +25,12 @@ import {
|
|
|
25
25
|
MAIL_TCC_GUIDANCE,
|
|
26
26
|
MAIL_SEND_TIMEOUT_GUIDANCE,
|
|
27
27
|
MAIL_ACCESSIBILITY_GUIDANCE,
|
|
28
|
+
MAIL_GUI_SCRIPTING_GUIDANCE,
|
|
28
29
|
MAIL_APP_NOT_RUNNING_GUIDANCE,
|
|
29
30
|
ATTRIBUTION_GUIDANCE,
|
|
30
31
|
isHardTccDenial
|
|
31
32
|
} from "./appleScript.js";
|
|
33
|
+
import { isIndexerMode } from "./processMode.js";
|
|
32
34
|
import {
|
|
33
35
|
planWrite,
|
|
34
36
|
plannedWriteResult,
|
|
@@ -156,8 +158,21 @@ export function probeMailAutomation() {
|
|
|
156
158
|
export const ACCESSIBILITY_DENIED_SENTINEL = "ACCESSIBILITY_DENIED";
|
|
157
159
|
export const BODY_PASTE_MISDIRECTED_SENTINEL = "BODY_PASTE_MISDIRECTED";
|
|
158
160
|
export const BODY_FOCUS_FAILED_SENTINEL = "BODY_FOCUS_FAILED";
|
|
159
|
-
|
|
161
|
+
export const GUI_SCRIPTING_UNAVAILABLE_SENTINEL = "GUI_SCRIPTING_UNAVAILABLE";
|
|
162
|
+
export const SYSTEM_EVENTS_PROBE_MARKER = "ATM_SYSTEM_EVENTS_PROBE";
|
|
163
|
+
/** Compose body AX height vs To/Subject/Cc fields (those are ~22px). Used to reject short header AXTextAreas after Tab; does not walk windows. */
|
|
160
164
|
export const MAIL_BODY_MIN_AX_HEIGHT = 50;
|
|
165
|
+
/** Mini-proven Tab count from default compose focus into the body. */
|
|
166
|
+
export const MAIL_BODY_TAB_MAX = 6;
|
|
167
|
+
/** Cheap System Events Automation probe used before compose; SIGKILL if it still hangs. */
|
|
168
|
+
export const MAIL_SE_PROBE_TIMEOUT_MS = 4000;
|
|
169
|
+
/** First-run / upgrade permissions probe: long enough for the Allow click. */
|
|
170
|
+
export const SYSTEM_EVENTS_AUTOMATION_PROBE_TIMEOUT_MS = 30000;
|
|
171
|
+
/** Compose osascript budget (make + keystroke + send). Far below the ~60s SE wedge. */
|
|
172
|
+
export const MAIL_COMPOSE_TIMEOUT_MS = 25000;
|
|
173
|
+
export const MAIL_SE_APPLEEVENT_TIMEOUT_SEC = 8;
|
|
174
|
+
export const MAIL_APPLEEVENT_TIMEOUT_SEC = 15;
|
|
175
|
+
export const MAIL_SE_PROBE_APPLEEVENT_TIMEOUT_SEC = 3;
|
|
161
176
|
|
|
162
177
|
/**
|
|
163
178
|
* AppleScript ordinal/keyword tokens that fail compile in `set {a, b}`
|
|
@@ -206,10 +221,10 @@ end atmFocusedElement`;
|
|
|
206
221
|
}
|
|
207
222
|
|
|
208
223
|
/**
|
|
209
|
-
* Body text for native Mail compose (
|
|
224
|
+
* Body text for native Mail compose (keystroke / paste, not AppleScript content).
|
|
210
225
|
*
|
|
211
226
|
* HTML is tag-stripped so a plain-looking HTML body (`<p>…</p>`) becomes
|
|
212
|
-
* typed
|
|
227
|
+
* typed text. Never prefixes lines with `>` and never emits
|
|
213
228
|
* `<blockquote>` — AppleScript `content` / `html content` cite-wrap
|
|
214
229
|
* (FB11734014). Mail's `mailto` command is not used: on current Mini/MacBook
|
|
215
230
|
* Mail it does not return an outgoing message (`newMessage` stays undefined,
|
|
@@ -231,36 +246,95 @@ export function composeBodyNeedle(body) {
|
|
|
231
246
|
return needle.length > 120 ? needle.slice(0, 120) : needle;
|
|
232
247
|
}
|
|
233
248
|
|
|
234
|
-
|
|
249
|
+
export function isSystemEventsProbeScript(script) {
|
|
250
|
+
return String(script || "").includes(SYSTEM_EVENTS_PROBE_MARKER);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function buildSystemEventsProbeScript() {
|
|
254
|
+
const secs = asInteger(MAIL_SE_PROBE_APPLEEVENT_TIMEOUT_SEC, {
|
|
255
|
+
min: 1,
|
|
256
|
+
max: 15,
|
|
257
|
+
field: "seProbeTimeout"
|
|
258
|
+
});
|
|
259
|
+
return `-- ${SYSTEM_EVENTS_PROBE_MARKER}
|
|
260
|
+
with timeout of ${secs} seconds
|
|
261
|
+
tell application "System Events"
|
|
262
|
+
set atmName to name
|
|
263
|
+
if atmName is "" then error "${GUI_SCRIPTING_UNAVAILABLE_SENTINEL}"
|
|
264
|
+
set atmUi to UI elements enabled
|
|
265
|
+
end tell
|
|
266
|
+
end timeout
|
|
267
|
+
if atmUi is false then error "${ACCESSIBILITY_DENIED_SENTINEL}"
|
|
268
|
+
return "OK"`;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function probeSystemEventsGui({ timeout = MAIL_SE_PROBE_TIMEOUT_MS } = {}) {
|
|
272
|
+
return runAppleScript(buildSystemEventsProbeScript(), {
|
|
273
|
+
timeout
|
|
274
|
+
});
|
|
275
|
+
}
|
|
235
276
|
|
|
236
277
|
/**
|
|
237
|
-
*
|
|
238
|
-
* System Events
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
278
|
+
* First-run / upgrade probe: a live System Events Apple Event that pops
|
|
279
|
+
* Automation → System Events. Does not type into the focused UI.
|
|
280
|
+
* Timeout is long enough for the operator to click Allow.
|
|
281
|
+
* @returns {{ ok: boolean, message: string, kind: string|null }}
|
|
282
|
+
*/
|
|
283
|
+
export function probeSystemEventsAutomation() {
|
|
284
|
+
const action = "system_events_automation_probe";
|
|
285
|
+
const summary = "ask System Events for its name (Automation / Apple Events check; nothing is typed)";
|
|
286
|
+
const result = probeSystemEventsGui({ timeout: SYSTEM_EVENTS_AUTOMATION_PROBE_TIMEOUT_MS });
|
|
287
|
+
if (!result.ok) {
|
|
288
|
+
const kind = systemEventsGrantKind(result);
|
|
289
|
+
const guidance = isMailAccessibilityDenial(result)
|
|
290
|
+
? MAIL_ACCESSIBILITY_GUIDANCE
|
|
291
|
+
: MAIL_GUI_SCRIPTING_GUIDANCE;
|
|
292
|
+
return {
|
|
293
|
+
ok: false,
|
|
294
|
+
kind,
|
|
295
|
+
message: `${action} failed — attempted to ${summary}. ${guidance}`
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
return {
|
|
299
|
+
ok: true,
|
|
300
|
+
kind: null,
|
|
301
|
+
message: writeSuccessMessage(
|
|
302
|
+
action,
|
|
303
|
+
"System Events Automation allowed (process name read; nothing was typed)"
|
|
304
|
+
)
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function systemEventsGrantKind(result) {
|
|
309
|
+
if (!result) return "unknown";
|
|
310
|
+
if (result.kind === "timeout" || result.kind === "tcc" || result.kind === "attribution") {
|
|
311
|
+
return result.kind;
|
|
312
|
+
}
|
|
313
|
+
if (isMailGuiScriptingUnavailable(result) || isMailAccessibilityDenial(result)) {
|
|
314
|
+
return "tcc";
|
|
315
|
+
}
|
|
316
|
+
return result.kind || "unknown";
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* AppleScript handlers: Tab into the compose body and type it (typed Returns
|
|
321
|
+
* preserve newlines). Does not walk System Events `windows of process Mail`
|
|
322
|
+
* (macOS 26 reports 0). Calibration / verify uses the focused AX role (and
|
|
323
|
+
* AX value when it is readable) — never live Mail `content of` the outgoing
|
|
324
|
+
* message. Dual-host Mail 16 / macOS 26: `set content` reads back via
|
|
325
|
+
* `content of`, but Tab + keystroke/paste into the UI body never appears in
|
|
326
|
+
* `content of` (`typedSeen=false`, bodyLen 0) even when typing still produces
|
|
327
|
+
* a plain Sent `.emlx`. Never `set content` / `html content` (FB11734014).
|
|
256
328
|
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
* 4. Click the lower two-thirds of the compose window
|
|
329
|
+
* Tab count is unchanged (MAIL_BODY_TAB_MAX, Mini-proven 6). Fail-closed if
|
|
330
|
+
* AX focus is not a body role after Tab. Cmd-V only if AX value is readable
|
|
331
|
+
* and the needle is still missing. Header checks stay. System Events Apple
|
|
332
|
+
* events are wrapped in `with timeout`.
|
|
262
333
|
*/
|
|
263
334
|
export function buildMailBodyPasteHandler() {
|
|
335
|
+
const tabMax = asInteger(MAIL_BODY_TAB_MAX, { min: 1, max: 12, field: "tabMax" });
|
|
336
|
+
const seSecs = asInteger(MAIL_SE_APPLEEVENT_TIMEOUT_SEC, { min: 1, max: 30, field: "seTimeout" });
|
|
337
|
+
const bodyMinH = asInteger(MAIL_BODY_MIN_AX_HEIGHT, { min: 1, max: 500, field: "bodyMinHeight" });
|
|
264
338
|
return `${buildAtmFocusedElementHandler()}
|
|
265
339
|
|
|
266
340
|
on atmSafeToPaste()
|
|
@@ -280,7 +354,7 @@ on atmSafeToPaste()
|
|
|
280
354
|
try
|
|
281
355
|
set {atmW, atmH} to size of fe
|
|
282
356
|
end try
|
|
283
|
-
if r is "AXTextArea" and atmH < ${
|
|
357
|
+
if r is "AXTextArea" and atmH > 0 and atmH < ${bodyMinH} then return false
|
|
284
358
|
ignoring case
|
|
285
359
|
set bits to ""
|
|
286
360
|
try
|
|
@@ -296,148 +370,106 @@ on atmSafeToPaste()
|
|
|
296
370
|
end ignoring
|
|
297
371
|
if r is "AXWebArea" then return true
|
|
298
372
|
if r is "AXTextArea" then return true
|
|
299
|
-
if atmH >= ${
|
|
373
|
+
if atmH >= ${bodyMinH} then return true
|
|
300
374
|
end try
|
|
301
375
|
return false
|
|
302
376
|
end tell
|
|
303
377
|
end tell
|
|
304
378
|
end atmSafeToPaste
|
|
305
379
|
|
|
306
|
-
on
|
|
380
|
+
on atmAxFocusedValue()
|
|
381
|
+
tell application "System Events"
|
|
382
|
+
try
|
|
383
|
+
set fe to my atmFocusedElement()
|
|
384
|
+
if fe is missing value then return ""
|
|
385
|
+
return value of fe as string
|
|
386
|
+
end try
|
|
387
|
+
end tell
|
|
388
|
+
return ""
|
|
389
|
+
end atmAxFocusedValue
|
|
390
|
+
|
|
391
|
+
on atmSplitParagraphs(bodyText)
|
|
392
|
+
set atmSavedDelim to AppleScript's text item delimiters
|
|
393
|
+
set AppleScript's text item delimiters to return
|
|
394
|
+
set atmCrParts to text items of bodyText
|
|
395
|
+
set AppleScript's text item delimiters to linefeed
|
|
396
|
+
set atmNorm to atmCrParts as string
|
|
397
|
+
set atmLines to text items of atmNorm
|
|
398
|
+
set AppleScript's text item delimiters to atmSavedDelim
|
|
399
|
+
return atmLines
|
|
400
|
+
end atmSplitParagraphs
|
|
401
|
+
|
|
402
|
+
on atmSpacesForTabs(atmLine)
|
|
403
|
+
set atmSavedDelim to AppleScript's text item delimiters
|
|
404
|
+
set AppleScript's text item delimiters to tab
|
|
405
|
+
set atmTabParts to text items of atmLine
|
|
406
|
+
set AppleScript's text item delimiters to " "
|
|
407
|
+
set atmOut to atmTabParts as string
|
|
408
|
+
set AppleScript's text item delimiters to atmSavedDelim
|
|
409
|
+
return atmOut
|
|
410
|
+
end atmSpacesForTabs
|
|
411
|
+
|
|
412
|
+
on atmKeystrokeText(atmText)
|
|
413
|
+
set atmSafe to my atmSpacesForTabs(atmText)
|
|
414
|
+
set atmLen to length of atmSafe
|
|
415
|
+
if atmLen is 0 then return
|
|
416
|
+
set atmPos to 1
|
|
307
417
|
tell application "System Events"
|
|
308
418
|
tell process "Mail"
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
try
|
|
315
|
-
if (name of window i as string) is expectedSubject then
|
|
316
|
-
set w to window i
|
|
317
|
-
exit repeat
|
|
318
|
-
end if
|
|
319
|
-
end try
|
|
320
|
-
end repeat
|
|
321
|
-
end if
|
|
322
|
-
try
|
|
323
|
-
perform action "AXRaise" of w
|
|
324
|
-
end try
|
|
325
|
-
try
|
|
326
|
-
click (first UI element of w whose role is "AXWebArea")
|
|
327
|
-
delay 0.08
|
|
328
|
-
if my atmSafeToPaste() then return
|
|
329
|
-
end try
|
|
330
|
-
try
|
|
331
|
-
click (first UI element of scroll area 1 of w whose role is "AXWebArea")
|
|
332
|
-
delay 0.08
|
|
333
|
-
if my atmSafeToPaste() then return
|
|
334
|
-
end try
|
|
335
|
-
try
|
|
336
|
-
click (first UI element of scroll area 1 of splitter group 1 of w whose role is "AXWebArea")
|
|
337
|
-
delay 0.08
|
|
338
|
-
if my atmSafeToPaste() then return
|
|
339
|
-
end try
|
|
340
|
-
try
|
|
341
|
-
click (first UI element of scroll area 1 of group 1 of splitter group 1 of w whose role is "AXWebArea")
|
|
342
|
-
delay 0.08
|
|
343
|
-
if my atmSafeToPaste() then return
|
|
344
|
-
end try
|
|
345
|
-
set atmTallText to missing value
|
|
346
|
-
set atmSubjectElem to missing value
|
|
347
|
-
set atmQueue to UI elements of w
|
|
348
|
-
set atmWalked to 0
|
|
349
|
-
repeat while (count of atmQueue) > 0 and atmWalked < 120
|
|
350
|
-
set atmWalked to atmWalked + 1
|
|
351
|
-
set atmElem to item 1 of atmQueue
|
|
352
|
-
if (count of atmQueue) is 1 then
|
|
353
|
-
set atmQueue to {}
|
|
354
|
-
else
|
|
355
|
-
set atmQueue to items 2 thru -1 of atmQueue
|
|
356
|
-
end if
|
|
357
|
-
try
|
|
358
|
-
if (role of atmElem as string) is "AXWebArea" then
|
|
359
|
-
set {atmW, atmH} to size of atmElem
|
|
360
|
-
if atmH >= ${BODY_MIN_H} then
|
|
361
|
-
click atmElem
|
|
362
|
-
delay 0.08
|
|
363
|
-
if my atmSafeToPaste() then return
|
|
364
|
-
end if
|
|
365
|
-
end if
|
|
366
|
-
end try
|
|
367
|
-
try
|
|
368
|
-
if (role of atmElem as string) is "AXTextArea" then
|
|
369
|
-
set {atmW, atmH} to size of atmElem
|
|
370
|
-
if atmH >= ${BODY_MIN_H} and atmTallText is missing value then set atmTallText to atmElem
|
|
371
|
-
end if
|
|
372
|
-
end try
|
|
373
|
-
try
|
|
374
|
-
set r to (role of atmElem as string)
|
|
375
|
-
if r is "AXTextField" or r is "AXTextArea" then
|
|
376
|
-
set bits to ""
|
|
377
|
-
try
|
|
378
|
-
set bits to bits & (description of atmElem as string)
|
|
379
|
-
end try
|
|
380
|
-
try
|
|
381
|
-
set bits to bits & (name of atmElem as string)
|
|
382
|
-
end try
|
|
383
|
-
ignoring case
|
|
384
|
-
if bits contains "Subject" and atmSubjectElem is missing value then set atmSubjectElem to atmElem
|
|
385
|
-
end ignoring
|
|
386
|
-
end if
|
|
387
|
-
end try
|
|
388
|
-
try
|
|
389
|
-
set atmKids to UI elements of atmElem
|
|
390
|
-
repeat with i from 1 to (count of atmKids)
|
|
391
|
-
set end of atmQueue to item i of atmKids
|
|
392
|
-
end repeat
|
|
393
|
-
end try
|
|
419
|
+
repeat while atmPos is less than or equal to atmLen
|
|
420
|
+
set atmEndPos to atmPos + 31
|
|
421
|
+
if atmEndPos > atmLen then set atmEndPos to atmLen
|
|
422
|
+
keystroke (text atmPos thru atmEndPos of atmSafe)
|
|
423
|
+
set atmPos to atmEndPos + 1
|
|
394
424
|
end repeat
|
|
395
|
-
try
|
|
396
|
-
set ta to text area 1 of scroll area 1 of w
|
|
397
|
-
set {atmW, atmH} to size of ta
|
|
398
|
-
if atmH >= ${BODY_MIN_H} then
|
|
399
|
-
set focused of ta to true
|
|
400
|
-
delay 0.08
|
|
401
|
-
if my atmSafeToPaste() then return
|
|
402
|
-
end if
|
|
403
|
-
end try
|
|
404
|
-
if atmTallText is not missing value then
|
|
405
|
-
try
|
|
406
|
-
set focused of atmTallText to true
|
|
407
|
-
end try
|
|
408
|
-
try
|
|
409
|
-
click atmTallText
|
|
410
|
-
end try
|
|
411
|
-
delay 0.08
|
|
412
|
-
if my atmSafeToPaste() then return
|
|
413
|
-
end if
|
|
414
|
-
try
|
|
415
|
-
click (first text field of w whose description contains "Subject")
|
|
416
|
-
delay 0.08
|
|
417
|
-
key code 48
|
|
418
|
-
delay 0.08
|
|
419
|
-
if my atmSafeToPaste() then return
|
|
420
|
-
end try
|
|
421
|
-
if atmSubjectElem is not missing value then
|
|
422
|
-
try
|
|
423
|
-
click atmSubjectElem
|
|
424
|
-
delay 0.08
|
|
425
|
-
key code 48
|
|
426
|
-
delay 0.08
|
|
427
|
-
if my atmSafeToPaste() then return
|
|
428
|
-
end try
|
|
429
|
-
end if
|
|
430
|
-
set {atmX, atmY} to position of w
|
|
431
|
-
set {atmWinW, atmWinH} to size of w
|
|
432
|
-
click at {(atmX + (atmWinW div 2)) as integer, (atmY + ((atmWinH * 2) div 3)) as integer}
|
|
433
|
-
delay 0.08
|
|
434
|
-
if my atmSafeToPaste() then return
|
|
435
|
-
error "${BODY_FOCUS_FAILED_SENTINEL}"
|
|
436
425
|
end tell
|
|
437
426
|
end tell
|
|
438
|
-
end
|
|
427
|
+
end atmKeystrokeText
|
|
428
|
+
|
|
429
|
+
on atmTabIntoMailBody(msg)
|
|
430
|
+
tell application "Mail" to activate
|
|
431
|
+
delay 0.25
|
|
432
|
+
with timeout of ${seSecs} seconds
|
|
433
|
+
tell application "System Events"
|
|
434
|
+
tell process "Mail" to set frontmost to true
|
|
435
|
+
end tell
|
|
436
|
+
end timeout
|
|
437
|
+
delay 0.08
|
|
438
|
+
repeat with atmN from 0 to ${tabMax}
|
|
439
|
+
if my atmSafeToPaste() then return
|
|
440
|
+
if atmN is ${tabMax} then error "${BODY_FOCUS_FAILED_SENTINEL}"
|
|
441
|
+
with timeout of ${seSecs} seconds
|
|
442
|
+
tell application "System Events"
|
|
443
|
+
tell process "Mail"
|
|
444
|
+
key code 48
|
|
445
|
+
end tell
|
|
446
|
+
end tell
|
|
447
|
+
end timeout
|
|
448
|
+
delay 0.08
|
|
449
|
+
end repeat
|
|
450
|
+
error "${BODY_FOCUS_FAILED_SENTINEL}"
|
|
451
|
+
end atmTabIntoMailBody
|
|
452
|
+
|
|
453
|
+
on atmTypeMailBody(bodyText)
|
|
454
|
+
set atmLines to my atmSplitParagraphs(bodyText)
|
|
455
|
+
set atmLast to count of atmLines
|
|
456
|
+
if atmLast is 0 then return
|
|
457
|
+
with timeout of ${seSecs} seconds
|
|
458
|
+
repeat with atmI from 1 to atmLast
|
|
459
|
+
set atmLine to item atmI of atmLines as string
|
|
460
|
+
my atmKeystrokeText(atmLine)
|
|
461
|
+
if atmI < atmLast then
|
|
462
|
+
tell application "System Events"
|
|
463
|
+
tell process "Mail"
|
|
464
|
+
key code 36
|
|
465
|
+
end tell
|
|
466
|
+
end tell
|
|
467
|
+
end if
|
|
468
|
+
end repeat
|
|
469
|
+
end timeout
|
|
470
|
+
end atmTypeMailBody
|
|
439
471
|
|
|
440
|
-
on
|
|
472
|
+
on atmPasteMailBodyFallback(bodyText)
|
|
441
473
|
set savedClip to ""
|
|
442
474
|
set hadClip to false
|
|
443
475
|
try
|
|
@@ -445,26 +477,47 @@ on atmPasteMailBody(bodyText, expectedSubject)
|
|
|
445
477
|
set hadClip to true
|
|
446
478
|
end try
|
|
447
479
|
set the clipboard to bodyText
|
|
448
|
-
tell application "Mail" to activate
|
|
449
|
-
delay 0.4
|
|
450
480
|
try
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
481
|
+
with timeout of ${seSecs} seconds
|
|
482
|
+
tell application "System Events"
|
|
483
|
+
tell process "Mail"
|
|
484
|
+
keystroke "v" using command down
|
|
485
|
+
end tell
|
|
456
486
|
end tell
|
|
457
|
-
end
|
|
458
|
-
delay 0.
|
|
487
|
+
end timeout
|
|
488
|
+
delay 0.15
|
|
459
489
|
on error errMsg number errNum
|
|
460
490
|
if hadClip then set the clipboard to savedClip
|
|
461
491
|
if errMsg contains "assistive access" or errNum is -25211 then error "${ACCESSIBILITY_DENIED_SENTINEL}"
|
|
462
492
|
error errMsg number errNum
|
|
463
493
|
end try
|
|
464
494
|
if hadClip then set the clipboard to savedClip
|
|
465
|
-
end
|
|
495
|
+
end atmPasteMailBodyFallback
|
|
496
|
+
|
|
497
|
+
on atmFillMailBody(bodyText, needle, msg)
|
|
498
|
+
try
|
|
499
|
+
atmTabIntoMailBody(msg)
|
|
500
|
+
if my atmSafeToPaste() is false then error "${BODY_FOCUS_FAILED_SENTINEL}"
|
|
501
|
+
atmTypeMailBody(bodyText)
|
|
502
|
+
delay 0.12
|
|
503
|
+
if needle is not "" then
|
|
504
|
+
set atmAxVal to my atmAxFocusedValue()
|
|
505
|
+
if atmAxVal is not "" then
|
|
506
|
+
if atmAxVal does not contain needle then
|
|
507
|
+
atmPasteMailBodyFallback(bodyText)
|
|
508
|
+
end if
|
|
509
|
+
end if
|
|
510
|
+
end if
|
|
511
|
+
on error errMsg number errNum
|
|
512
|
+
if errMsg contains "assistive access" or errNum is -25211 then error "${ACCESSIBILITY_DENIED_SENTINEL}"
|
|
513
|
+
if errMsg contains "${GUI_SCRIPTING_UNAVAILABLE_SENTINEL}" then error "${GUI_SCRIPTING_UNAVAILABLE_SENTINEL}"
|
|
514
|
+
error errMsg number errNum
|
|
515
|
+
end try
|
|
516
|
+
end atmFillMailBody`;
|
|
466
517
|
}
|
|
467
518
|
|
|
519
|
+
export const buildMailBodyFillHandler = buildMailBodyPasteHandler;
|
|
520
|
+
|
|
468
521
|
function composeMisdirectChecks({ to, cc = [], bcc = [], subject, body }) {
|
|
469
522
|
const toCount = asInteger(to.length, { min: 1, max: 99, field: "toCount" });
|
|
470
523
|
const ccCount = asInteger(cc.length, { min: 0, max: 99, field: "ccCount" });
|
|
@@ -482,31 +535,10 @@ function composeMisdirectChecks({ to, cc = [], bcc = [], subject, body }) {
|
|
|
482
535
|
})
|
|
483
536
|
.join("\n");
|
|
484
537
|
const bodyCheck = needle
|
|
485
|
-
? ` set
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
end
|
|
489
|
-
if atmGotBody is "" then
|
|
490
|
-
try
|
|
491
|
-
save newMessage
|
|
492
|
-
end try
|
|
493
|
-
delay 0.2
|
|
494
|
-
try
|
|
495
|
-
set atmGotBody to content of newMessage as string
|
|
496
|
-
end try
|
|
497
|
-
end if
|
|
498
|
-
if atmGotBody does not contain ${asString(needle)} then
|
|
499
|
-
try
|
|
500
|
-
set atmFe to my atmFocusedElement()
|
|
501
|
-
if atmFe is not missing value then
|
|
502
|
-
tell application "System Events"
|
|
503
|
-
set atmAxVal to value of atmFe as string
|
|
504
|
-
if atmAxVal contains ${asString(needle)} then set atmGotBody to atmAxVal
|
|
505
|
-
end tell
|
|
506
|
-
end if
|
|
507
|
-
end try
|
|
508
|
-
end if
|
|
509
|
-
if atmGotBody does not contain ${asString(needle)} then error "${BODY_PASTE_MISDIRECTED_SENTINEL}"`
|
|
538
|
+
? ` set atmAxVal to my atmAxFocusedValue()
|
|
539
|
+
if atmAxVal is not "" then
|
|
540
|
+
if atmAxVal does not contain ${asString(needle)} then error "${BODY_PASTE_MISDIRECTED_SENTINEL}"
|
|
541
|
+
end if`
|
|
510
542
|
: "";
|
|
511
543
|
return ` if (count of to recipients of newMessage) is not ${toCount} then error "${BODY_PASTE_MISDIRECTED_SENTINEL}"
|
|
512
544
|
if (count of cc recipients of newMessage) is not ${ccCount} then error "${BODY_PASTE_MISDIRECTED_SENTINEL}"
|
|
@@ -526,9 +558,13 @@ ${bodyCheck}`;
|
|
|
526
558
|
* Do not set AppleScript `content` or `html content`: Ventura+ FB11734014
|
|
527
559
|
* cite-wraps those setters (`>` prefixes + `<blockquote type="cite">`).
|
|
528
560
|
* Recipients and subject are AppleScript properties (never paste). The body
|
|
529
|
-
* is
|
|
530
|
-
*
|
|
531
|
-
*
|
|
561
|
+
* is typed into the native compose editor via System Events (Tab into body
|
|
562
|
+
* until AX focused role is a body field, typed Returns for newlines, Cmd-V
|
|
563
|
+
* only if AX value is readable and the needle is still missing). Do not walk
|
|
564
|
+
* `windows of process Mail`. Do not use Mail `content of` as the oracle that
|
|
565
|
+
* typed/pasted text landed (Mail 16 / macOS 26: that property stays empty).
|
|
566
|
+
* If AX body focus cannot be established, or headers change, abort + delete
|
|
567
|
+
* (nothing sent). Ship prove that the body landed is Sent `.emlx` after send.
|
|
532
568
|
*/
|
|
533
569
|
export function buildComposeScript({ to, cc, bcc, subject, body, send, html = false }) {
|
|
534
570
|
const recipients = [
|
|
@@ -537,9 +573,11 @@ export function buildComposeScript({ to, cc, bcc, subject, body, send, html = fa
|
|
|
537
573
|
recipientLines(bcc, "bcc recipient")
|
|
538
574
|
].filter((block) => block.length > 0).join("\n");
|
|
539
575
|
|
|
576
|
+
const mailSecs = asInteger(MAIL_APPLEEVENT_TIMEOUT_SEC, { min: 1, max: 60, field: "mailTimeout" });
|
|
540
577
|
const plainBody = composeNativeBody(body, { html });
|
|
578
|
+
const needle = composeBodyNeedle(plainBody);
|
|
541
579
|
const pasteAndVerify = plainBody
|
|
542
|
-
? `
|
|
580
|
+
? ` atmFillMailBody(${asString(plainBody)}, ${asString(needle)}, newMessage)
|
|
543
581
|
tell application "Mail"
|
|
544
582
|
${composeMisdirectChecks({ to, cc, bcc, subject, body: plainBody })}
|
|
545
583
|
end tell`
|
|
@@ -548,11 +586,13 @@ ${composeMisdirectChecks({ to, cc, bcc, subject, body: plainBody })}
|
|
|
548
586
|
return `${buildMailBodyPasteHandler()}
|
|
549
587
|
|
|
550
588
|
tell application "Mail"
|
|
551
|
-
|
|
552
|
-
|
|
589
|
+
with timeout of ${mailSecs} seconds
|
|
590
|
+
set newMessage to make new outgoing message with properties {subject:${asString(subject)}, visible:true}
|
|
591
|
+
tell newMessage
|
|
553
592
|
${recipients}
|
|
554
|
-
|
|
555
|
-
|
|
593
|
+
end tell
|
|
594
|
+
activate
|
|
595
|
+
end timeout
|
|
556
596
|
end tell
|
|
557
597
|
try
|
|
558
598
|
${pasteAndVerify}
|
|
@@ -564,10 +604,12 @@ on error errMsg number errNum
|
|
|
564
604
|
end try
|
|
565
605
|
set atmOutgoingId to ""
|
|
566
606
|
tell application "Mail"
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
607
|
+
with timeout of ${mailSecs} seconds
|
|
608
|
+
try
|
|
609
|
+
set atmOutgoingId to message id of newMessage as string
|
|
610
|
+
end try
|
|
611
|
+
${send ? "send newMessage" : "save newMessage"}
|
|
612
|
+
end timeout
|
|
571
613
|
end tell
|
|
572
614
|
if atmOutgoingId is not "" then return atmOutgoingId
|
|
573
615
|
return "OK"`;
|
|
@@ -927,6 +969,14 @@ export function verifyQueuedMessage(match = {}, { attempts = SENT_VERIFY_ATTEMPT
|
|
|
927
969
|
return last;
|
|
928
970
|
}
|
|
929
971
|
|
|
972
|
+
export function isMailGuiScriptingUnavailable(result) {
|
|
973
|
+
if (!result) return false;
|
|
974
|
+
const text = String(result.error || "").toLowerCase();
|
|
975
|
+
if (text.includes("gui_scripting_unavailable")) return true;
|
|
976
|
+
if (result.kind === "timeout" && text.includes("system events")) return true;
|
|
977
|
+
return false;
|
|
978
|
+
}
|
|
979
|
+
|
|
930
980
|
export function isMailAccessibilityDenial(result) {
|
|
931
981
|
if (!result) return false;
|
|
932
982
|
const text = String(result.error || "").toLowerCase();
|
|
@@ -946,6 +996,9 @@ export function isMailBodyPasteMisdirected(result) {
|
|
|
946
996
|
|
|
947
997
|
function failure(action, summary, result, secrets) {
|
|
948
998
|
const err = String((result && result.error) || "").toLowerCase();
|
|
999
|
+
if (err.includes("gui_scripting_unavailable")) {
|
|
1000
|
+
return `${action} failed — attempted to ${summary}. ${MAIL_GUI_SCRIPTING_GUIDANCE}`;
|
|
1001
|
+
}
|
|
949
1002
|
if (err.includes("body_focus_failed")) {
|
|
950
1003
|
return `${action} failed — attempted to ${summary}. The compose caret could not be moved into the message body; nothing was sent.`;
|
|
951
1004
|
}
|
|
@@ -1051,10 +1104,24 @@ function finalizeMailWrite({
|
|
|
1051
1104
|
return { ok: false, delivered: false, mailbox: null, message: failure(action, summary, result, secrets) };
|
|
1052
1105
|
}
|
|
1053
1106
|
|
|
1107
|
+
function guiScriptingUnavailableResult(action, summary, result, { indexerMode } = {}) {
|
|
1108
|
+
const unsupported = Boolean(indexerMode);
|
|
1109
|
+
const message = isMailAccessibilityDenial(result)
|
|
1110
|
+
? `${action} failed — attempted to ${summary}. ${MAIL_ACCESSIBILITY_GUIDANCE}`
|
|
1111
|
+
: `${action} failed — attempted to ${summary}. ${MAIL_GUI_SCRIPTING_GUIDANCE}`;
|
|
1112
|
+
return {
|
|
1113
|
+
ok: false,
|
|
1114
|
+
delivered: false,
|
|
1115
|
+
mailbox: null,
|
|
1116
|
+
...(unsupported ? { unsupported: true } : {}),
|
|
1117
|
+
message
|
|
1118
|
+
};
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1054
1121
|
/**
|
|
1055
1122
|
* Compose and send (or save as draft) a new email.
|
|
1056
1123
|
*/
|
|
1057
|
-
export function mailCompose(args = {}, { draft = false } = {}) {
|
|
1124
|
+
export function mailCompose(args = {}, { draft = false, indexerMode = isIndexerMode() } = {}) {
|
|
1058
1125
|
const action = draft ? "mail_draft" : "mail_send";
|
|
1059
1126
|
|
|
1060
1127
|
const to = validateEmailList(args.to, "to");
|
|
@@ -1093,6 +1160,14 @@ export function mailCompose(args = {}, { draft = false } = {}) {
|
|
|
1093
1160
|
});
|
|
1094
1161
|
if (!plan.proceed) return plannedWriteResult(plan);
|
|
1095
1162
|
|
|
1163
|
+
const plainBody = composeNativeBody(body.text, { html: bodyFormat === "html" });
|
|
1164
|
+
if (plainBody) {
|
|
1165
|
+
const se = probeSystemEventsGui();
|
|
1166
|
+
if (!se.ok) {
|
|
1167
|
+
return guiScriptingUnavailableResult(action, summary, se, { indexerMode });
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1096
1171
|
const script = buildComposeScript({
|
|
1097
1172
|
to: to.addresses,
|
|
1098
1173
|
cc: cc.addresses,
|
|
@@ -1103,7 +1178,14 @@ export function mailCompose(args = {}, { draft = false } = {}) {
|
|
|
1103
1178
|
html: bodyFormat === "html"
|
|
1104
1179
|
});
|
|
1105
1180
|
|
|
1106
|
-
const result = runAppleScript(script, { timeout:
|
|
1181
|
+
const result = runAppleScript(script, { timeout: MAIL_COMPOSE_TIMEOUT_MS, appName: "Mail" });
|
|
1182
|
+
if (
|
|
1183
|
+
!result.ok &&
|
|
1184
|
+
indexerMode &&
|
|
1185
|
+
(isMailGuiScriptingUnavailable(result) || isMailAccessibilityDenial(result))
|
|
1186
|
+
) {
|
|
1187
|
+
return guiScriptingUnavailableResult(action, summary, result, { indexerMode });
|
|
1188
|
+
}
|
|
1107
1189
|
return finalizeMailWrite({
|
|
1108
1190
|
action,
|
|
1109
1191
|
summary,
|
package/lib/permissions.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* First-run / upgrade permissions command.
|
|
3
3
|
*
|
|
4
|
-
* Probes Contacts, Calendar, Mail, and
|
|
5
|
-
* macOS can pop Allow dialogs for that node binary.
|
|
6
|
-
* this command cannot grant silently.
|
|
4
|
+
* Probes Contacts, Calendar, Mail, Messages, and System Events under
|
|
5
|
+
* process.execPath so macOS can pop Allow dialogs for that node binary.
|
|
6
|
+
* The user clicks Allow; this command cannot grant silently.
|
|
7
7
|
*
|
|
8
8
|
* Always runs in this process (never via the write bridge): the point is to
|
|
9
9
|
* attach the dialogs to process.execPath. If the invoked CLI / shebang
|
|
@@ -15,13 +15,13 @@
|
|
|
15
15
|
import fs from "fs";
|
|
16
16
|
import path from "path";
|
|
17
17
|
import { classifyAppleScriptError } from "./appleScript.js";
|
|
18
|
-
import { probeMailAutomation } from "./mailWrite.js";
|
|
18
|
+
import { probeMailAutomation, probeSystemEventsAutomation } from "./mailWrite.js";
|
|
19
19
|
import { probeMessagesAutomation } from "./messagesWrite.js";
|
|
20
20
|
import { probeContactsAutomation } from "./contactsWrite.js";
|
|
21
21
|
import { probeCalendarAutomation } from "./calendarWrite.js";
|
|
22
22
|
import { detectLaunchAgentContext, hostAutomationAdvice } from "./writeRouting.js";
|
|
23
23
|
|
|
24
|
-
export const REQUIRED_SURFACES = ["Contacts", "Calendar", "Mail", "Messages"];
|
|
24
|
+
export const REQUIRED_SURFACES = ["Contacts", "Calendar", "Mail", "Messages", "System Events"];
|
|
25
25
|
|
|
26
26
|
/** Mini ship-gate host example — not a universal path. */
|
|
27
27
|
export const EXAMPLE_MINI_NODE = "/Users/petercoates/.local/node/bin/node";
|
|
@@ -172,6 +172,9 @@ export function classifyGrantStatus(result) {
|
|
|
172
172
|
if (kind === "app_not_running") {
|
|
173
173
|
return GRANT_ERROR;
|
|
174
174
|
}
|
|
175
|
+
if (/accessibility_denied|assistive access|gui_scripting_unavailable/i.test(text)) {
|
|
176
|
+
return GRANT_MISSING;
|
|
177
|
+
}
|
|
175
178
|
if (/tcc|automation deny|not authorized|not permitted|not allowed|timed out|etimedout|responsible-process|attribution/i.test(text)) {
|
|
176
179
|
return GRANT_MISSING;
|
|
177
180
|
}
|
|
@@ -198,7 +201,7 @@ export function exitCodeForGrants(grants) {
|
|
|
198
201
|
|
|
199
202
|
/**
|
|
200
203
|
* Advisory Full Disk Access touch. Read tools need FDA on this node; it is
|
|
201
|
-
* not one of the
|
|
204
|
+
* not one of the required Automation grants and never fails the command.
|
|
202
205
|
*
|
|
203
206
|
* @returns {{ status: "readable"|"missing"|"skipped", message: string }}
|
|
204
207
|
*/
|
|
@@ -247,7 +250,8 @@ export function defaultPermissionsProbes() {
|
|
|
247
250
|
Contacts: probeContactsAutomation,
|
|
248
251
|
Calendar: probeCalendarAutomation,
|
|
249
252
|
Mail: probeMailAutomation,
|
|
250
|
-
Messages: probeMessagesAutomation
|
|
253
|
+
Messages: probeMessagesAutomation,
|
|
254
|
+
"System Events": probeSystemEventsAutomation
|
|
251
255
|
};
|
|
252
256
|
}
|
|
253
257
|
|
|
@@ -267,12 +271,13 @@ function bannerLines(binary, version) {
|
|
|
267
271
|
"dry_run of mail_send / messages_send does not count: this command uses real Apple Events.",
|
|
268
272
|
"Nothing is sent to third parties. Mail composes and discards a temporary outgoing message.",
|
|
269
273
|
"Messages only enumerates accounts. Contacts creates and deletes a throwaway person in-script.",
|
|
270
|
-
"Calendar lists calendars only — no events are created."
|
|
274
|
+
"Calendar lists calendars only — no events are created.",
|
|
275
|
+
"System Events is asked for its process name (mail_send keystrokes; nothing is typed)."
|
|
271
276
|
];
|
|
272
277
|
}
|
|
273
278
|
|
|
274
279
|
/**
|
|
275
|
-
* Run the
|
|
280
|
+
* Run the live Automation probes and print a grant report.
|
|
276
281
|
*
|
|
277
282
|
* @param {object} [options]
|
|
278
283
|
* @param {string} [options.execPath]
|
|
@@ -349,7 +354,7 @@ export async function runPermissionsCommand({
|
|
|
349
354
|
|
|
350
355
|
const code = exitCodeForGrants(grants);
|
|
351
356
|
if (code === 0) {
|
|
352
|
-
log("Result: PASS — Contacts, Calendar, Mail, and
|
|
357
|
+
log("Result: PASS — Contacts, Calendar, Mail, Messages, and System Events are granted for this node.");
|
|
353
358
|
log("Safe to re-run; already-granted surfaces report OK without another click.");
|
|
354
359
|
} else {
|
|
355
360
|
log("Result: INCOMPLETE — one or more required grants are missing or errored.");
|
package/lib/shell.js
CHANGED
|
@@ -131,9 +131,12 @@ export function safeOsascript(script, options = {}) {
|
|
|
131
131
|
args.push("-e", script);
|
|
132
132
|
|
|
133
133
|
// Use -e with the script as an argument (never a shell string).
|
|
134
|
+
// Hung Apple Events ignore SIGTERM (Mini write-bridge ~60s wedge).
|
|
135
|
+
// Timeout must SIGKILL osascript so compose fails fast instead of hanging.
|
|
134
136
|
const result = spawnSync('osascript', args, {
|
|
135
137
|
encoding: 'utf-8',
|
|
136
138
|
timeout,
|
|
139
|
+
killSignal: 'SIGKILL',
|
|
137
140
|
shell: false
|
|
138
141
|
});
|
|
139
142
|
|
package/lib/writeRouting.js
CHANGED
|
@@ -91,7 +91,7 @@ export function terminalAutomationAdvice(execPath = process.execPath) {
|
|
|
91
91
|
return (
|
|
92
92
|
`Run this from Terminal.app (short-lived CLI / MacBook host — no always-on indexer). ` +
|
|
93
93
|
`Click Allow for the printed process.execPath${printed}. ` +
|
|
94
|
-
`Check System Settings → Privacy & Security → Automation for that node → Contacts, Calendar, Mail, and
|
|
94
|
+
`Check System Settings → Privacy & Security → Automation for that node → Contacts, Calendar, Mail, Messages, and System Events. ` +
|
|
95
95
|
`Do not start apple-tools-indexer.`
|
|
96
96
|
);
|
|
97
97
|
}
|
|
@@ -102,7 +102,7 @@ export function terminalAutomationAdvice(execPath = process.execPath) {
|
|
|
102
102
|
export function miniLaunchAgentAdvice() {
|
|
103
103
|
return (
|
|
104
104
|
"This Mac has a Mini write-bridge / LaunchAgent. Grant the daemon's node Full Disk Access (reads) and " +
|
|
105
|
-
"Allow that node in System Settings → Privacy & Security → Automation for Mail.app, Messages.app, Contacts.app,
|
|
105
|
+
"Allow that node in System Settings → Privacy & Security → Automation for Mail.app, Messages.app, Contacts.app, Calendar.app, and System Events (mail_send / mail_draft type the body via keystrokes). " +
|
|
106
106
|
"Do not add node via + in the Contacts or Calendars privacy lists."
|
|
107
107
|
);
|
|
108
108
|
}
|
|
@@ -127,7 +127,7 @@ export function tccFallbackAdvice({
|
|
|
127
127
|
if (mini && bridgeAvailable) {
|
|
128
128
|
return (
|
|
129
129
|
"The indexer daemon was reachable but the write was still denied; grant the daemon's node binary Full Disk Access (reads) and " +
|
|
130
|
-
"Allow node in System Settings > Privacy & Security > Automation for Mail.app, Messages.app, Contacts.app,
|
|
130
|
+
"Allow node in System Settings > Privacy & Security > Automation for Mail.app, Messages.app, Contacts.app, Calendar.app, and System Events. " +
|
|
131
131
|
"A hang or timeout on Mail compose is TCC / Automation denied, not Mail.app missing. " +
|
|
132
132
|
"Do not add node via + in the Contacts or Calendars privacy lists — those panes often have no Add button."
|
|
133
133
|
);
|
package/lib/writeTools.js
CHANGED
|
@@ -57,7 +57,7 @@ export const WRITE_TOOL_DEFINITIONS = [
|
|
|
57
57
|
bcc: { type: "array", items: { type: "string" }, description: "BCC email addresses" },
|
|
58
58
|
subject: { type: "string", description: "Subject line (required)" },
|
|
59
59
|
body: { type: "string", description: "Message body (required)" },
|
|
60
|
-
body_format: { type: "string", enum: ["plain", "html"], description: "Plain text (default) or HTML. HTML is tag-stripped
|
|
60
|
+
body_format: { type: "string", enum: ["plain", "html"], description: "Plain text (default) or HTML. HTML is tag-stripped; the body is typed into Mail's compose window (System Events keystrokes). AppleScript content/html content is not set because it quote-wraps the Sent body" },
|
|
61
61
|
...CONFIRM_PROPS
|
|
62
62
|
},
|
|
63
63
|
required: ["to", "subject", "body"]
|
|
@@ -74,7 +74,7 @@ export const WRITE_TOOL_DEFINITIONS = [
|
|
|
74
74
|
bcc: { type: "array", items: { type: "string" }, description: "BCC email addresses" },
|
|
75
75
|
subject: { type: "string", description: "Subject line" },
|
|
76
76
|
body: { type: "string", description: "Message body" },
|
|
77
|
-
body_format: { type: "string", enum: ["plain", "html"], description: "Plain text (default) or HTML. HTML is tag-stripped
|
|
77
|
+
body_format: { type: "string", enum: ["plain", "html"], description: "Plain text (default) or HTML. HTML is tag-stripped; the body is typed into Mail's compose window (System Events keystrokes). AppleScript content/html content is not set because it quote-wraps the Sent body" },
|
|
78
78
|
...CONFIRM_PROPS
|
|
79
79
|
},
|
|
80
80
|
required: ["to"]
|