apple-tools-mcp 2.0.6 → 2.0.8

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 CHANGED
@@ -446,6 +446,7 @@ Two arguments are available on **every** write tool:
446
446
  - **Deletes always need `confirm: true`** — `mail_trash`, `calendar_remove`, `contacts_remove`, and `contacts_edit` when it clears every email or phone. Without it the call returns a `CONFIRMATION REQUIRED` preview and changes nothing. There is no bulk delete tool and no silent mass delete: one id per call.
447
447
  - **Multi-recipient sends always need `confirm: true`** — `mail_send` / `mail_forward` when `to` + `cc` + `bcc` total more than one address, `mail_reply` with `reply_all: true`, and `messages_send` to multiple handles or to a group chat. A single-recipient send runs on the first call.
448
448
  - **`mail_draft` is exempt from the recipient rule** because a draft is never delivered.
449
+ - **`dry_run` and confirm-blocked calls are not deliveries.** Planned writes return `ok: false`, `planned: true`, `delivered: false`, and MCP sets `isError: true`, so a client that only checks `isError` cannot treat a preview as sent. The text is `DRY RUN` / `CONFIRMATION REQUIRED` and never `verified in Sent`. Re-run with `dry_run=false` (and `confirm=true` when required) to actually send.
449
450
  - Responses name what happened (tool, ids, recipients, titles) and never echo message bodies — including on the error path.
450
451
  - Writes never invent data. A missing or malformed `message_id`, `event_id`, `contact_id`, `chat_id`, or recipient is refused with a message saying so. Calendar times must be explicit local datetimes (`YYYY-MM-DD HH:MM`); natural language such as "next Tuesday" is rejected for writes.
451
452
 
@@ -465,13 +466,17 @@ Two arguments are available on **every** write tool:
465
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 paste the body into the compose window (System Events). 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.
467
468
 
468
- Paste uses System Events, so **node needs Accessibility** (Privacy & Security → Accessibility) in addition to Automation → Mail. That Accessibility deny is not a Mail Automation deny (`-1743` / `-10004`). Body focus uses `text area` / `scroll area` and `UI element whose role is "AXWebArea"` — never the System Events class `web area`, which does not compile on macOS 26.x (**-2741**, “Expected class name but found identifier”).
469
+ Paste uses System Events, so **node needs Accessibility** (Privacy & Security → Accessibility) in addition to Automation → Mail. That Accessibility deny is not a Mail Automation deny (`-1743` / `-10004`). Body focus uses `text area` / `scroll area` / `text field` and `UI element whose role is "AXWebArea"` — never the System Events class `web area`, which does not compile on macOS 26.x (**-2741**, “Expected class name but found identifier”).
469
470
 
470
- **Manual prove (2.0.6 on the Mac host):** `mail_send` a short 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. Inspect each Sent `.emlx`: the text/plain part 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`, no **-2741** on body focus).
471
+ **Compose body focus (2.0.8).** After `make new outgoing message`, To and Subject are AppleScript properties (never paste). Cmd-V runs only after the caret leaves the header: a tall `AXWebArea` (HTML compose body), a tall `text area` (plain compose; short To/Subject fields are skipped), Subject then Tab, or a click in the lower two-thirds of the compose window. If focus is still a header field, the tool raises `BODY_FOCUS_FAILED` and does **not** paste. After paste it checks To/Cc/Bcc counts, the exact subject, and that the native body contains the intended text. A mismatch is `BODY_PASTE_MISDIRECTED`: the outgoing message is deleted and nothing is sent. Quote-prefix Sent prove and Sent/Outbox verify stay gated on this paste-focus contract — 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
+
473
+ **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.
474
+
475
+ **Manual prove (2.0.8 on the Mac host):** `mail_send` a short 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`, and the intended text must be in the native body before send. Inspect each Sent `.emlx`: the text/plain part 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`, no **-2741** on body focus). That quote-prefix dual-host Sent prove is a separate bar from the 2.0.7 verify contract.
471
476
 
472
477
  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.
473
478
 
474
- **Send timeout vs TCC — check Sent before retrying.** `mail_send` / `mail_reply` / `mail_forward` can hang after Mail has already put the message in Sent. That hang is a **timeout** (`ETIMEDOUT` / `-1712` / AppleEvent timed out), not a TCC deny. The same hang-vs-TCC split applies to **find / reply / open before send** — an Allowed `ETIMEDOUT` there is never `MAIL_TCC_GUIDANCE`. Real Mail Automation denials report **`-1743`**, **`-10004`**, or “not authorized to send Apple events”. After a send hang the tool looks in Sent (and Outbox) and returns **success** if the message is there — never label a delivered send as TCC fail or timeout. Replies match `In-Reply-To` or an exact recent `Re:` + original subject (the fallback compose does not set reply headers). Forwards match the intended recipient plus an exact `Fwd:` subject or the original Message-ID in a forwarded body — not `In-Reply-To`, and not an unrelated `Fwd:` that only shares a To. Neither scan treats the original itself as this send. If Sent-verify misses, the error tells you it was a hang and to check Sent. **Clients must Sent-check before retrying a timed-out send**; retrying a message that already landed sends a second copy. This is not a silent TCC grant.
479
+ **Send timeout vs TCC — check Sent before retrying.** `mail_send` / `mail_reply` / `mail_forward` can hang after Mail has already put the message in Sent. That hang is a **timeout** (`ETIMEDOUT` / `-1712` / AppleEvent timed out), not a TCC deny. The same hang-vs-TCC split applies to **find / reply / open before send** — an Allowed `ETIMEDOUT` there is never `MAIL_TCC_GUIDANCE`. Real Mail Automation denials report **`-1743`**, **`-10004`**, or “not authorized to send Apple events”. After a send hang the tool looks in Sent (and Outbox) and returns **success** if the message is there — never label a delivered send as TCC fail or timeout. Replies match `In-Reply-To` or an exact recent `Re:` + original subject (the fallback compose does not set reply headers). Forwards match the intended recipient plus an exact `Fwd:` subject or the original Message-ID in a forwarded body — not `In-Reply-To`, and not an unrelated `Fwd:` that only shares a To. Compose (`mail_send`) matches the intended **To plus exact subject** (and Message-ID when available), never subject alone. Neither scan treats the original itself as this send. A real send that returns without throw is also verified the same way before success. If Sent-verify misses, the error tells you it was a hang (or a verify-miss after send returned) and to check Sent. **Clients must Sent-check before retrying a timed-out send**; retrying a message that already landed sends a second copy. This is not a silent TCC grant.
475
480
 
476
481
  ### Messages write tool
477
482
 
package/index.js CHANGED
@@ -27,7 +27,8 @@ import {
27
27
  WRITE_TOOL_DEFINITIONS,
28
28
  isWriteTool,
29
29
  dispatchWriteTool,
30
- executeWriteToolLocally
30
+ executeWriteToolLocally,
31
+ mcpWriteResult
31
32
  } from "./lib/writeTools.js";
32
33
  import { startWriteBridgeServer, defaultSocketPath } from "./lib/writeBridge.js";
33
34
  import { closeEventKitSession, ensureEventKitSession } from "./lib/eventKitSession.js";
@@ -1373,10 +1374,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1373
1374
  socketPath: WRITE_SOCKET_PATH,
1374
1375
  log: (msg) => console.error(msg)
1375
1376
  });
1376
- return {
1377
- content: [{ type: "text", text: writeResult.message }],
1378
- ...(writeResult.ok === false ? { isError: true } : {})
1379
- };
1377
+ return mcpWriteResult(writeResult);
1380
1378
  }
1381
1379
 
1382
1380
  switch (name) {
@@ -353,7 +353,7 @@ export const MAIL_SEND_TIMEOUT_GUIDANCE =
353
353
  "Mail AppleScript timed out (ETIMEDOUT / -1712 / AppleEvent timed out) — this is a find/reply/send hang (including find/reply/open before send), not a TCC / Automation deny. " +
354
354
  "Real Mail Automation denials report -1743, -10004, or \"not authorized to send Apple events\". " +
355
355
  "A timeout can happen after Mail already delivered the message. Check Sent (and Outbox) for this send before retrying; a retry after a successful delivery sends a second copy. " +
356
- "This tool verifies Sent when a send hangs; if nothing is there yet, wait and look again rather than immediately resending.";
356
+ "This tool verifies Sent/Outbox when a send hangs (To + subject, Message-ID when available — never subject alone); if nothing is there yet, wait and look again rather than immediately resending.";
357
357
 
358
358
  /**
359
359
  * Messages writes are a separate Automation target from Mail / Contacts / Calendar.
@@ -22,6 +22,7 @@ import {
22
22
  } from "./appleScript.js";
23
23
  import {
24
24
  planWrite,
25
+ plannedWriteResult,
25
26
  validateEventId,
26
27
  validateEventKitId,
27
28
  validateCalendarName,
@@ -772,7 +773,7 @@ export function calendarAdd(args = {}) {
772
773
  `${alerts.minutes.length ? ` with alerts ${alerts.minutes.join(", ")} min before` : ""}`;
773
774
 
774
775
  const plan = planWrite({ action, summary, dryRun: isFlagTrue(args.dry_run), confirm: isFlagTrue(args.confirm) });
775
- if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
776
+ if (!plan.proceed) return plannedWriteResult(plan);
776
777
 
777
778
  // Non-recurring creates MUST go through EventKit. Mini 8be6cd5: EventKit
778
779
  // add failed (likely writeOnly cannot list calendars) and we silently
@@ -1037,7 +1038,7 @@ export function calendarEdit(args = {}) {
1037
1038
 
1038
1039
  const summary = `update event ${eventId}: ${changed.join(", ")}`;
1039
1040
  const plan = planWrite({ action, summary, dryRun: isFlagTrue(args.dry_run), confirm: isFlagTrue(args.confirm) });
1040
- if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
1041
+ if (!plan.proceed) return plannedWriteResult(plan);
1041
1042
 
1042
1043
  const preferEventKit = Boolean(eventKitId) || eventId.includes(":");
1043
1044
  if (preferEventKit && !recurrence.rule) {
@@ -1219,7 +1220,7 @@ export function calendarRemove(args = {}) {
1219
1220
  dryRun: isFlagTrue(args.dry_run),
1220
1221
  confirm: isFlagTrue(args.confirm)
1221
1222
  });
1222
- if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
1223
+ if (!plan.proceed) return plannedWriteResult(plan);
1223
1224
 
1224
1225
  // writeOnly cannot re-query; prefer the EventKit session that cached
1225
1226
  // the EKEvent from create. One-shot lookup is a full-access fallback.
@@ -1339,7 +1340,7 @@ export function calendarRsvp(args = {}) {
1339
1340
 
1340
1341
  const summary = `RSVP ${responseRaw} to event ${eventId}`;
1341
1342
  const plan = planWrite({ action, summary, dryRun: isFlagTrue(args.dry_run), confirm: isFlagTrue(args.confirm) });
1342
- if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
1343
+ if (!plan.proceed) return plannedWriteResult(plan);
1343
1344
 
1344
1345
  const result = runAppleScript(
1345
1346
  buildRsvpScript({ eventId, status: RSVP_STATUS[responseRaw], attendeeEmail }),
@@ -20,6 +20,7 @@ import {
20
20
  import { safeOpenApp } from "./shell.js";
21
21
  import {
22
22
  planWrite,
23
+ plannedWriteResult,
23
24
  normalizeList,
24
25
  isEmailAddress,
25
26
  isPhoneNumber,
@@ -254,7 +255,7 @@ export function contactsAdd(args = {}, deps = {}) {
254
255
  `${phones.phones.length ? ` and ${phones.phones.length} phone number(s)` : ""}`;
255
256
 
256
257
  const plan = planWrite({ action, summary, dryRun: isFlagTrue(args.dry_run), confirm: isFlagTrue(args.confirm) });
257
- if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
258
+ if (!plan.proceed) return plannedWriteResult(plan);
258
259
 
259
260
  const properties = {};
260
261
  if (firstName.text) properties["first name"] = firstName.text;
@@ -357,7 +358,7 @@ export function contactsEdit(args = {}, deps = {}) {
357
358
  dryRun: isFlagTrue(args.dry_run),
358
359
  confirm: isFlagTrue(args.confirm)
359
360
  });
360
- if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
361
+ if (!plan.proceed) return plannedWriteResult(plan);
361
362
 
362
363
  const ready = ensureContactsAppReady(deps);
363
364
  if (!ready.ok) return { ok: false, message: failure(action, summary, ready) };
@@ -413,7 +414,7 @@ export function contactsRemove(args = {}, deps = {}) {
413
414
  dryRun: isFlagTrue(args.dry_run),
414
415
  confirm: isFlagTrue(args.confirm)
415
416
  });
416
- if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
417
+ if (!plan.proceed) return plannedWriteResult(plan);
417
418
 
418
419
  const ready = ensureContactsAppReady(deps);
419
420
  if (!ready.ok) return { ok: false, message: failure(action, summary, ready) };
package/lib/mailWrite.js CHANGED
@@ -7,10 +7,12 @@
7
7
  * is resolved to a Message-ID by reading the .emlx headers, so callers never
8
8
  * have to invent an identifier.
9
9
  *
10
- * After a send/reply/forward hang, Sent (and Outbox) is checked before the
11
- * tool reports failure. A message already in Sent is success — never a TCC
12
- * fail. ETIMEDOUT / -1712 is a timeout (find/reply/open before send too);
13
- * -1743 / -10004 is a hard deny.
10
+ * After a real send (and after a send/reply/forward hang), Sent and Outbox
11
+ * are checked before the tool reports success. AppleScript `send` returning
12
+ * without throw is not enough. A message already in Sent or still in Outbox
13
+ * is success — never a TCC fail. Hang recover matches To + subject (and
14
+ * Message-ID when available), never subject alone. ETIMEDOUT / -1712 is a
15
+ * timeout (find/reply/open before send too); -1743 / -10004 is a hard deny.
14
16
  */
15
17
 
16
18
  import fs from "fs";
@@ -29,6 +31,7 @@ import {
29
31
  } from "./appleScript.js";
30
32
  import {
31
33
  planWrite,
34
+ plannedWriteResult,
32
35
  validateEmailList,
33
36
  validateBody,
34
37
  validateSubject,
@@ -152,6 +155,9 @@ export function probeMailAutomation() {
152
155
 
153
156
  export const ACCESSIBILITY_DENIED_SENTINEL = "ACCESSIBILITY_DENIED";
154
157
  export const BODY_PASTE_MISDIRECTED_SENTINEL = "BODY_PASTE_MISDIRECTED";
158
+ export const BODY_FOCUS_FAILED_SENTINEL = "BODY_FOCUS_FAILED";
159
+ /** Compose body AX height vs To/Subject/Cc fields (those are ~22px). */
160
+ export const MAIL_BODY_MIN_AX_HEIGHT = 50;
155
161
 
156
162
  /**
157
163
  * Body text for native Mail compose (clipboard paste, not AppleScript content).
@@ -169,51 +175,117 @@ export function composeNativeBody(body, { html = false } = {}) {
169
175
  }
170
176
 
171
177
  /**
172
- * AppleScript handlers: focus the compose body (not To) and paste.
178
+ * Distinctive first line used to prove the native editor received the body.
179
+ * Full multiline `contains` can miss when Mail stores an HTML alternative.
180
+ */
181
+ export function composeBodyNeedle(body) {
182
+ const text = body === undefined || body === null ? "" : String(body);
183
+ const line = text.split(/\r?\n/).find((candidate) => candidate.trim()) || text;
184
+ const needle = line.trim();
185
+ return needle.length > 120 ? needle.slice(0, 120) : needle;
186
+ }
187
+
188
+ const BODY_MIN_H = asInteger(MAIL_BODY_MIN_AX_HEIGHT, { min: 1, max: 500, field: "bodyMinHeight" });
189
+
190
+ /**
191
+ * AppleScript handlers: focus the compose body (not To/Subject) and paste.
173
192
  * System Events needs Accessibility; Mail make/send still needs Automation.
174
193
  *
175
194
  * macOS 26.x System Events has no `web area` class (compile -2741,
176
195
  * "Expected class name but found identifier"). Hosts compile
177
- * `text area` / `scroll area` and `UI element whose role is "AXWebArea"`.
196
+ * `text area` / `scroll area` / `text field` and `UI element whose role is
197
+ * "AXWebArea"`. Never focus the first AXTextArea blindly — on MacBook Mail
198
+ * that is a header field, and Cmd-V becomes BODY_PASTE_MISDIRECTED.
199
+ *
200
+ * Strategies, each fail-closed unless the caret left the header:
201
+ * 1. Click a tall AXWebArea (HTML compose body)
202
+ * 2. Focus a tall `text area` (plain compose body), never a short header
203
+ * 3. Click Subject then Tab into the body (stable Mini/MacBook order)
204
+ * 4. Click the lower two-thirds of the compose window
178
205
  */
179
206
  export function buildMailBodyPasteHandler() {
180
- return `on atmFocusMailBody()
207
+ return `on atmSafeToPaste()
181
208
  tell application "System Events"
182
209
  tell process "Mail"
183
- set frontmost to true
184
- if (count of windows) < 1 then error "MAIL_COMPOSE_WINDOW_MISSING"
185
- set w to first window
186
- try
187
- set focused of text area 1 of scroll area 1 of w to true
188
- return
189
- end try
190
210
  try
191
- set focused of text area 1 of w to true
192
- return
211
+ set fe to focused UI element
212
+ set r to (role of fe) as string
213
+ if r is "AXTextField" then return false
214
+ if r is "AXComboBox" then return false
215
+ if r is "AXButton" then return false
216
+ if r is "AXMenuButton" then return false
217
+ if r is "AXPopUpButton" then return false
218
+ set eh to 0
219
+ try
220
+ set {ew, eh} to size of fe
221
+ end try
222
+ if r is "AXTextArea" and eh < ${BODY_MIN_H} then return false
223
+ ignoring case
224
+ set bits to ""
225
+ try
226
+ set bits to bits & (description of fe as string) & " "
227
+ end try
228
+ try
229
+ set bits to bits & (name of fe as string) & " "
230
+ end try
231
+ if bits contains "To:" then return false
232
+ if bits contains "Cc:" then return false
233
+ if bits contains "Bcc:" then return false
234
+ if bits contains "Subject" then return false
235
+ end ignoring
236
+ if r is "AXWebArea" then return true
237
+ if r is "AXTextArea" then return true
238
+ if eh >= ${BODY_MIN_H} then return true
193
239
  end try
240
+ return false
241
+ end tell
242
+ end tell
243
+ end atmSafeToPaste
244
+
245
+ on atmFocusMailBody(expectedSubject)
246
+ tell application "System Events"
247
+ tell process "Mail"
248
+ set frontmost to true
249
+ if (count of windows) < 1 then error "MAIL_COMPOSE_WINDOW_MISSING"
250
+ set w to window 1
251
+ if expectedSubject is not "" then
252
+ repeat with i from 1 to (count of windows)
253
+ try
254
+ if (name of window i as string) is expectedSubject then
255
+ set w to window i
256
+ exit repeat
257
+ end if
258
+ end try
259
+ end repeat
260
+ end if
194
261
  try
195
- set focused of (first UI element of w whose role is "AXTextArea") to true
196
- return
262
+ perform action "AXRaise" of w
197
263
  end try
198
264
  try
199
265
  click (first UI element of w whose role is "AXWebArea")
200
- return
266
+ delay 0.08
267
+ if my atmSafeToPaste() then return
201
268
  end try
202
269
  try
203
270
  click (first UI element of scroll area 1 of w whose role is "AXWebArea")
204
- return
271
+ delay 0.08
272
+ if my atmSafeToPaste() then return
205
273
  end try
206
274
  try
207
275
  click (first UI element of scroll area 1 of splitter group 1 of w whose role is "AXWebArea")
208
- return
276
+ delay 0.08
277
+ if my atmSafeToPaste() then return
209
278
  end try
210
279
  try
211
280
  click (first UI element of scroll area 1 of group 1 of splitter group 1 of w whose role is "AXWebArea")
212
- return
281
+ delay 0.08
282
+ if my atmSafeToPaste() then return
213
283
  end try
284
+ set atmTallText to missing value
285
+ set atmSubjectElem to missing value
214
286
  set atmQueue to UI elements of w
215
287
  set atmWalked to 0
216
- repeat while (count of atmQueue) > 0 and atmWalked < 80
288
+ repeat while (count of atmQueue) > 0 and atmWalked < 120
217
289
  set atmWalked to atmWalked + 1
218
290
  set atmElem to item 1 of atmQueue
219
291
  if (count of atmQueue) is 1 then
@@ -223,14 +295,33 @@ export function buildMailBodyPasteHandler() {
223
295
  end if
224
296
  try
225
297
  if (role of atmElem as string) is "AXWebArea" then
226
- click atmElem
227
- return
298
+ set {ew, eh} to size of atmElem
299
+ if eh >= ${BODY_MIN_H} then
300
+ click atmElem
301
+ delay 0.08
302
+ if my atmSafeToPaste() then return
303
+ end if
228
304
  end if
229
305
  end try
230
306
  try
231
307
  if (role of atmElem as string) is "AXTextArea" then
232
- set focused of atmElem to true
233
- return
308
+ set {tw, th} to size of atmElem
309
+ if th >= ${BODY_MIN_H} and atmTallText is missing value then set atmTallText to atmElem
310
+ end if
311
+ end try
312
+ try
313
+ set r to (role of atmElem as string)
314
+ if r is "AXTextField" or r is "AXTextArea" then
315
+ set bits to ""
316
+ try
317
+ set bits to bits & (description of atmElem as string)
318
+ end try
319
+ try
320
+ set bits to bits & (name of atmElem as string)
321
+ end try
322
+ ignoring case
323
+ if bits contains "Subject" and atmSubjectElem is missing value then set atmSubjectElem to atmElem
324
+ end ignoring
234
325
  end if
235
326
  end try
236
327
  try
@@ -240,14 +331,52 @@ export function buildMailBodyPasteHandler() {
240
331
  end repeat
241
332
  end try
242
333
  end repeat
334
+ try
335
+ set ta to text area 1 of scroll area 1 of w
336
+ set {tw, th} to size of ta
337
+ if th >= ${BODY_MIN_H} then
338
+ set focused of ta to true
339
+ delay 0.08
340
+ if my atmSafeToPaste() then return
341
+ end if
342
+ end try
343
+ if atmTallText is not missing value then
344
+ try
345
+ set focused of atmTallText to true
346
+ end try
347
+ try
348
+ click atmTallText
349
+ end try
350
+ delay 0.08
351
+ if my atmSafeToPaste() then return
352
+ end if
353
+ try
354
+ click (first text field of w whose description contains "Subject")
355
+ delay 0.08
356
+ key code 48
357
+ delay 0.08
358
+ if my atmSafeToPaste() then return
359
+ end try
360
+ if atmSubjectElem is not missing value then
361
+ try
362
+ click atmSubjectElem
363
+ delay 0.08
364
+ key code 48
365
+ delay 0.08
366
+ if my atmSafeToPaste() then return
367
+ end try
368
+ end if
243
369
  set {wx, wy} to position of w
244
370
  set {ww, wh} to size of w
245
371
  click at {(wx + (ww div 2)) as integer, (wy + ((wh * 2) div 3)) as integer}
372
+ delay 0.08
373
+ if my atmSafeToPaste() then return
374
+ error "${BODY_FOCUS_FAILED_SENTINEL}"
246
375
  end tell
247
376
  end tell
248
377
  end atmFocusMailBody
249
378
 
250
- on atmPasteMailBody(bodyText)
379
+ on atmPasteMailBody(bodyText, expectedSubject)
251
380
  set savedClip to ""
252
381
  set hadClip to false
253
382
  try
@@ -256,15 +385,16 @@ on atmPasteMailBody(bodyText)
256
385
  end try
257
386
  set the clipboard to bodyText
258
387
  tell application "Mail" to activate
259
- delay 0.25
388
+ delay 0.4
260
389
  try
261
- atmFocusMailBody()
390
+ atmFocusMailBody(expectedSubject)
391
+ if atmSafeToPaste() is false then error "${BODY_FOCUS_FAILED_SENTINEL}"
262
392
  tell application "System Events"
263
393
  tell process "Mail"
264
394
  keystroke "v" using command down
265
395
  end tell
266
396
  end tell
267
- delay 0.15
397
+ delay 0.2
268
398
  on error errMsg number errNum
269
399
  if hadClip then set the clipboard to savedClip
270
400
  if errMsg contains "assistive access" or errNum is -25211 then error "${ACCESSIBILITY_DENIED_SENTINEL}"
@@ -274,8 +404,11 @@ on atmPasteMailBody(bodyText)
274
404
  end atmPasteMailBody`;
275
405
  }
276
406
 
277
- function recipientStillPresentChecks(to) {
407
+ function composeMisdirectChecks({ to, cc = [], bcc = [], subject, body }) {
278
408
  const toCount = asInteger(to.length, { min: 1, max: 99, field: "toCount" });
409
+ const ccCount = asInteger(cc.length, { min: 0, max: 99, field: "ccCount" });
410
+ const bccCount = asInteger(bcc.length, { min: 0, max: 99, field: "bccCount" });
411
+ const needle = composeBodyNeedle(body);
279
412
  const stillThere = to
280
413
  .map((address, i) => {
281
414
  const n = asInteger(i, { min: 0, max: 99, field: "recipientIndex" });
@@ -287,8 +420,38 @@ function recipientStillPresentChecks(to) {
287
420
  if atmFound${n} is false then error "${BODY_PASTE_MISDIRECTED_SENTINEL}"`;
288
421
  })
289
422
  .join("\n");
423
+ const bodyCheck = needle
424
+ ? ` set atmGotBody to ""
425
+ try
426
+ set atmGotBody to content of newMessage as string
427
+ end try
428
+ if atmGotBody is "" then
429
+ try
430
+ save newMessage
431
+ end try
432
+ delay 0.2
433
+ try
434
+ set atmGotBody to content of newMessage as string
435
+ end try
436
+ end if
437
+ if atmGotBody does not contain ${asString(needle)} then
438
+ try
439
+ tell application "System Events"
440
+ tell process "Mail"
441
+ set atmAxVal to value of focused UI element as string
442
+ if atmAxVal contains ${asString(needle)} then set atmGotBody to atmAxVal
443
+ end tell
444
+ end tell
445
+ end try
446
+ end if
447
+ if atmGotBody does not contain ${asString(needle)} then error "${BODY_PASTE_MISDIRECTED_SENTINEL}"`
448
+ : "";
290
449
  return ` if (count of to recipients of newMessage) is not ${toCount} then error "${BODY_PASTE_MISDIRECTED_SENTINEL}"
291
- ${stillThere}`;
450
+ if (count of cc recipients of newMessage) is not ${ccCount} then error "${BODY_PASTE_MISDIRECTED_SENTINEL}"
451
+ if (count of bcc recipients of newMessage) is not ${bccCount} then error "${BODY_PASTE_MISDIRECTED_SENTINEL}"
452
+ ${stillThere}
453
+ if (subject of newMessage as string) is not ${asString(subject)} then error "${BODY_PASTE_MISDIRECTED_SENTINEL}"
454
+ ${bodyCheck}`;
292
455
  }
293
456
 
294
457
  /**
@@ -300,9 +463,10 @@ ${stillThere}`;
300
463
  *
301
464
  * Do not set AppleScript `content` or `html content`: Ventura+ FB11734014
302
465
  * cite-wraps those setters (`>` prefixes + `<blockquote type="cite">`).
303
- * Recipients are AppleScript `make new … recipient`. The body is pasted
304
- * into the compose window (native editor) via System Events. If paste
305
- * lands in To, recipient count changes and we abort + delete (nothing sent).
466
+ * Recipients and subject are AppleScript properties (never paste). The body
467
+ * is pasted into the compose window (native editor) via System Events only
468
+ * after caret focus leaves the header. If paste lands in To/Subject, or the
469
+ * body never receives the intended text, abort + delete (nothing sent).
306
470
  */
307
471
  export function buildComposeScript({ to, cc, bcc, subject, body, send, html = false }) {
308
472
  const recipients = [
@@ -313,9 +477,9 @@ export function buildComposeScript({ to, cc, bcc, subject, body, send, html = fa
313
477
 
314
478
  const plainBody = composeNativeBody(body, { html });
315
479
  const pasteAndVerify = plainBody
316
- ? ` atmPasteMailBody(${asString(plainBody)})
480
+ ? ` atmPasteMailBody(${asString(plainBody)}, ${asString(subject)})
317
481
  tell application "Mail"
318
- ${recipientStillPresentChecks(to)}
482
+ ${composeMisdirectChecks({ to, cc, bcc, subject, body: plainBody })}
319
483
  end tell`
320
484
  : "";
321
485
 
@@ -336,9 +500,14 @@ on error errMsg number errNum
336
500
  end try
337
501
  error errMsg number errNum
338
502
  end try
503
+ set atmOutgoingId to ""
339
504
  tell application "Mail"
505
+ try
506
+ set atmOutgoingId to message id of newMessage as string
507
+ end try
340
508
  ${send ? "send newMessage" : "save newMessage"}
341
509
  end tell
510
+ if atmOutgoingId is not "" then return atmOutgoingId
342
511
  return "OK"`;
343
512
  }
344
513
 
@@ -350,8 +519,31 @@ function summarizeRecipients(to, cc, bcc) {
350
519
  return parts.join("; ");
351
520
  }
352
521
 
522
+ export const SENT_VERIFY_SENT = "SENT";
523
+ export const SENT_VERIFY_OUTBOX = "OUTBOX";
524
+ export const SENT_VERIFY_NOT_FOUND = "NOT_FOUND";
525
+ /** Older hang-recovery scripts returned FOUND to mean Sent. Still accepted. */
353
526
  export const SENT_VERIFY_FOUND = "FOUND";
354
527
  export const SENT_VERIFY_TIMEOUT_MS = 15000;
528
+ export const SENT_VERIFY_ATTEMPTS = 3;
529
+ export const SENT_VERIFY_RETRY_MS = 400;
530
+
531
+ export const MAIL_VERIFY_MISS_GUIDANCE =
532
+ "Mail reported the send call succeeded, but the message was not found in Sent or Outbox. Do not assume it was delivered.";
533
+
534
+ function defaultSentVerifySleep(ms) {
535
+ if (!ms || ms <= 0) return;
536
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
537
+ }
538
+
539
+ /** Test hook: skip the retry delay without changing attempt count. */
540
+ export const sentVerifyClock = {
541
+ sleep: defaultSentVerifySleep
542
+ };
543
+
544
+ export function resetSentVerifyClock() {
545
+ sentVerifyClock.sleep = defaultSentVerifySleep;
546
+ }
355
547
 
356
548
  /**
357
549
  * Hard Mail Automation deny: -1743 / -10004 / "not authorized…", or
@@ -376,10 +568,10 @@ function mailBoxesPreamble() {
376
568
  return ` set cutoff to (current date) - (10 * minutes)
377
569
  set boxes to {}
378
570
  try
379
- set end of boxes to sent mailbox
571
+ set end of boxes to outgoing mailbox
380
572
  end try
381
573
  try
382
- set end of boxes to outgoing mailbox
574
+ set end of boxes to sent mailbox
383
575
  end try
384
576
  repeat with acct in accounts
385
577
  try
@@ -392,6 +584,48 @@ function appleScriptToList(addresses = []) {
392
584
  return `{${addresses.map((address) => asString(address)).join(", ")}}`;
393
585
  }
394
586
 
587
+ function loadMailboxMessagesSnippet() {
588
+ return ` set recentMsgs to {}
589
+ set scanAll to false
590
+ try
591
+ if boxRef is outgoing mailbox then set scanAll to true
592
+ end try
593
+ try
594
+ set nm to name of boxRef as string
595
+ if nm contains "Outbox" then set scanAll to true
596
+ if nm contains "Outgoing" then set scanAll to true
597
+ end try
598
+ if scanAll then
599
+ set recentMsgs to messages of boxRef
600
+ else
601
+ set recentMsgs to (messages of boxRef whose date sent > cutoff)
602
+ end if`;
603
+ }
604
+
605
+ function verifyHitReturn() {
606
+ return ` try
607
+ if boxRef is outgoing mailbox then return "${SENT_VERIFY_OUTBOX}"
608
+ end try
609
+ try
610
+ set nm to name of boxRef as string
611
+ if nm contains "Outbox" then return "${SENT_VERIFY_OUTBOX}"
612
+ if nm contains "Outgoing" then return "${SENT_VERIFY_OUTBOX}"
613
+ end try
614
+ return "${SENT_VERIFY_SENT}"`;
615
+ }
616
+
617
+ function recipientHitSnippet() {
618
+ return ` set hitTo to false
619
+ try
620
+ repeat with recip in (to recipients of msg)
621
+ set recipAddr to address of recip as string
622
+ repeat with wanted in wantedTos
623
+ if recipAddr is (wanted as string) then set hitTo to true
624
+ end repeat
625
+ end repeat
626
+ end try`;
627
+ }
628
+
395
629
  /**
396
630
  * Look in Sent / Outbox for a reply of `messageId`.
397
631
  * Proper Mail replies set In-Reply-To. The fallback `make new outgoing
@@ -414,7 +648,7 @@ tell application "Mail"
414
648
  ${mailBoxesPreamble()}
415
649
  repeat with boxRef in boxes
416
650
  try
417
- set recentMsgs to (messages of boxRef whose date sent > cutoff)
651
+ ${loadMailboxMessagesSnippet()}
418
652
  repeat with msg in recentMsgs
419
653
  try
420
654
  set candId to message id of msg
@@ -423,12 +657,18 @@ ${mailBoxesPreamble()}
423
657
  else
424
658
  try
425
659
  set src to source of msg
426
- if src contains ("In-Reply-To: " & ${needleAngle}) then return "${SENT_VERIFY_FOUND}"
427
- if src contains ("In-Reply-To: " & ${needleBare}) then return "${SENT_VERIFY_FOUND}"
660
+ if src contains ("In-Reply-To: " & ${needleAngle}) then
661
+ ${verifyHitReturn()}
662
+ end if
663
+ if src contains ("In-Reply-To: " & ${needleBare}) then
664
+ ${verifyHitReturn()}
665
+ end if
428
666
  end try
429
667
  if origSubject is not "" then
430
668
  set subj to subject of msg
431
- if subj is ("Re: " & origSubject) then return "${SENT_VERIFY_FOUND}"
669
+ if subj is ("Re: " & origSubject) then
670
+ ${verifyHitReturn()}
671
+ end if
432
672
  end if
433
673
  end if
434
674
  end try
@@ -436,7 +676,7 @@ ${mailBoxesPreamble()}
436
676
  end try
437
677
  end repeat
438
678
  end tell
439
- return "NOT_FOUND"`;
679
+ return "${SENT_VERIFY_NOT_FOUND}"`;
440
680
  }
441
681
 
442
682
  /**
@@ -463,7 +703,7 @@ ${mailBoxesPreamble()}
463
703
  set wantedTos to ${toList}
464
704
  repeat with boxRef in boxes
465
705
  try
466
- set recentMsgs to (messages of boxRef whose date sent > cutoff)
706
+ ${loadMailboxMessagesSnippet()}
467
707
  repeat with msg in recentMsgs
468
708
  try
469
709
  set candId to message id of msg
@@ -474,15 +714,7 @@ ${mailBoxesPreamble()}
474
714
  if src contains ("In-Reply-To: " & ${needleAngle}) or src contains ("In-Reply-To: " & ${needleBare}) then
475
715
  -- A reply to the original is not this forward.
476
716
  else
477
- set hitTo to false
478
- try
479
- repeat with recip in (to recipients of msg)
480
- set recipAddr to address of recip as string
481
- repeat with wanted in wantedTos
482
- if recipAddr is (wanted as string) then set hitTo to true
483
- end repeat
484
- end repeat
485
- end try
717
+ ${recipientHitSnippet()}
486
718
  if hitTo then
487
719
  set subj to subject of msg
488
720
  set exactFwd to false
@@ -498,8 +730,12 @@ ${mailBoxesPreamble()}
498
730
  set mentionsOrigId to false
499
731
  if src contains ${needleAngle} then set mentionsOrigId to true
500
732
  if src contains ${needleBare} then set mentionsOrigId to true
501
- if exactFwd then return "${SENT_VERIFY_FOUND}"
502
- if looksForward and mentionsOrigId then return "${SENT_VERIFY_FOUND}"
733
+ if exactFwd then
734
+ ${verifyHitReturn()}
735
+ end if
736
+ if looksForward and mentionsOrigId then
737
+ ${verifyHitReturn()}
738
+ end if
503
739
  end if
504
740
  end if
505
741
  end if
@@ -508,45 +744,125 @@ ${mailBoxesPreamble()}
508
744
  end try
509
745
  end repeat
510
746
  end tell
511
- return "NOT_FOUND"`;
747
+ return "${SENT_VERIFY_NOT_FOUND}"`;
512
748
  }
513
749
 
514
750
  /**
515
- * Look in Sent / Outbox for a compose whose subject matches exactly and
516
- * was sent in the last 10 minutes.
751
+ * Look in Sent / Outbox for a compose matching To + subject.
752
+ * Never matches subject alone — short subjects like "test" are unsafe.
753
+ * When `messageId` is present, prefer that id together with To.
517
754
  */
518
- export function buildFindSentBySubjectScript(subject) {
519
- const subj = asString(subject);
755
+ export function buildFindSentByRecipientAndSubjectScript(subject, toAddresses = [], messageId = null) {
756
+ const toList = appleScriptToList(toAddresses);
757
+ const subj = asString(subject || "");
758
+ const idLit = messageId ? asString(messageId) : null;
759
+ const matchBody = idLit
760
+ ? `${recipientHitSnippet()}
761
+ set candId to message id of msg
762
+ set hitId to false
763
+ try
764
+ if candId is ${idLit} then set hitId to true
765
+ end try
766
+ if hitId and hitTo then
767
+ ${verifyHitReturn()}
768
+ end if
769
+ if hitTo then
770
+ set subj to subject of msg
771
+ if subj is ${subj} then
772
+ ${verifyHitReturn()}
773
+ end if
774
+ end if`
775
+ : `${recipientHitSnippet()}
776
+ if hitTo then
777
+ set subj to subject of msg
778
+ if subj is ${subj} then
779
+ ${verifyHitReturn()}
780
+ end if
781
+ end if`;
782
+
520
783
  return `tell application "Mail"
521
784
  ${mailBoxesPreamble()}
785
+ set wantedTos to ${toList}
522
786
  repeat with boxRef in boxes
523
787
  try
524
- set hits to (messages of boxRef whose subject is ${subj} and date sent > cutoff)
525
- if (count of hits) > 0 then return "${SENT_VERIFY_FOUND}"
788
+ ${loadMailboxMessagesSnippet()}
789
+ repeat with msg in recentMsgs
790
+ try
791
+ ${matchBody}
792
+ end try
793
+ end repeat
526
794
  end try
527
795
  end repeat
528
796
  end tell
529
- return "NOT_FOUND"`;
797
+ return "${SENT_VERIFY_NOT_FOUND}"`;
530
798
  }
531
799
 
532
800
  /**
533
- * After a send hang, ask Mail whether the message is already in Sent.
534
- * Returns a hit object or null. Verify failure / timeout is not success.
801
+ * @deprecated Subject-only matching is unsafe. Requires To as the second
802
+ * argument; without recipients the scan cannot hit.
535
803
  */
536
- export function recoverIfInSent({ inReplyTo = null, subject = null, forwardTo = null } = {}) {
537
- const script = forwardTo && inReplyTo
538
- ? buildFindSentForwardScript(inReplyTo, forwardTo)
539
- : inReplyTo
540
- ? buildFindSentByInReplyToScript(inReplyTo)
541
- : subject
542
- ? buildFindSentBySubjectScript(subject)
543
- : null;
804
+ export function buildFindSentBySubjectScript(subject, toAddresses = []) {
805
+ return buildFindSentByRecipientAndSubjectScript(subject, toAddresses);
806
+ }
807
+
808
+ export function parseSentVerifyOutput(output) {
809
+ const out = String(output || "").trim();
810
+ if (out === SENT_VERIFY_SENT || out === SENT_VERIFY_FOUND) {
811
+ return { found: true, mailbox: "sent" };
812
+ }
813
+ if (out === SENT_VERIFY_OUTBOX) {
814
+ return { found: true, mailbox: "outbox" };
815
+ }
816
+ return null;
817
+ }
818
+
819
+ export function parseOutgoingMessageId(output) {
820
+ const raw = String(output || "").trim();
821
+ if (!raw || raw === "OK") return null;
822
+ return validateMessageId(raw);
823
+ }
824
+
825
+ function pickSentVerifyScript({
826
+ inReplyTo = null,
827
+ subject = null,
828
+ forwardTo = null,
829
+ to = null,
830
+ messageId = null
831
+ } = {}) {
832
+ if (forwardTo && inReplyTo) return buildFindSentForwardScript(inReplyTo, forwardTo);
833
+ if (inReplyTo) return buildFindSentByInReplyToScript(inReplyTo);
834
+ const toList = Array.isArray(to) ? to : [];
835
+ if (toList.length > 0 && (subject || messageId)) {
836
+ return buildFindSentByRecipientAndSubjectScript(subject || "", toList, messageId);
837
+ }
838
+ return null;
839
+ }
840
+
841
+ /**
842
+ * After a send hang, ask Mail whether the message is already in Sent/Outbox.
843
+ * Compose matches require To + subject (Message-ID when available) — never
844
+ * subject alone. Returns a hit object or null. Verify failure is not success.
845
+ */
846
+ export function recoverIfInSent(match = {}) {
847
+ const script = pickSentVerifyScript(match);
544
848
  if (!script) return null;
545
849
  const result = runAppleScript(script, { timeout: SENT_VERIFY_TIMEOUT_MS, appName: "Mail" });
546
850
  if (!result.ok) return null;
547
- const out = String(result.output || "").trim();
548
- if (out === SENT_VERIFY_FOUND) return { found: true };
549
- return null;
851
+ return parseSentVerifyOutput(result.output);
852
+ }
853
+
854
+ /**
855
+ * Poll Sent/Outbox after a send that returned without throw.
856
+ */
857
+ export function verifyQueuedMessage(match = {}, { attempts = SENT_VERIFY_ATTEMPTS, retryMs = SENT_VERIFY_RETRY_MS } = {}) {
858
+ let last = null;
859
+ const n = Math.max(1, attempts);
860
+ for (let i = 0; i < n; i++) {
861
+ last = recoverIfInSent(match);
862
+ if (last) return last;
863
+ if (i < n - 1) sentVerifyClock.sleep(retryMs);
864
+ }
865
+ return last;
550
866
  }
551
867
 
552
868
  export function isMailAccessibilityDenial(result) {
@@ -555,8 +871,22 @@ export function isMailAccessibilityDenial(result) {
555
871
  return text.includes("accessibility_denied") || text.includes("assistive access");
556
872
  }
557
873
 
874
+ export function isMailBodyFocusFailed(result) {
875
+ if (!result) return false;
876
+ return String(result.error || "").toLowerCase().includes("body_focus_failed");
877
+ }
878
+
879
+ export function isMailBodyPasteMisdirected(result) {
880
+ if (!result) return false;
881
+ const text = String(result.error || "").toLowerCase();
882
+ return text.includes("body_paste_misdirected") || text.includes("body_focus_failed");
883
+ }
884
+
558
885
  function failure(action, summary, result, secrets) {
559
886
  const err = String((result && result.error) || "").toLowerCase();
887
+ if (err.includes("body_focus_failed")) {
888
+ return `${action} failed — attempted to ${summary}. The compose caret could not be moved into the message body; nothing was sent.`;
889
+ }
560
890
  if (err.includes("body_paste_misdirected")) {
561
891
  return `${action} failed — attempted to ${summary}. The body was pasted into a header field instead of the message body; nothing was sent.`;
562
892
  }
@@ -586,9 +916,41 @@ function failure(action, summary, result, secrets) {
586
916
  }
587
917
 
588
918
  /**
589
- * Finish a compose/reply/forward. A send hang is not labeled TCC; if Mail
590
- * already delivered, return success. Hard -1743/-10004 stays a deny.
919
+ * Finish a compose/reply/forward. A real send is not success until Sent or
920
+ * Outbox verify hits. A send hang is not labeled TCC; if Mail already
921
+ * delivered, return success. Hard -1743/-10004 stays a deny.
591
922
  */
923
+ function deliverySummary(successSummary, mailbox, recovered) {
924
+ if (mailbox === "outbox") {
925
+ return recovered
926
+ ? "queued in Outbox (still sending; verified after AppleScript hang)"
927
+ : "queued in Outbox (still sending)";
928
+ }
929
+ return recovered
930
+ ? `${successSummary} (verified in Sent after AppleScript hang)`
931
+ : `${successSummary} (verified in Sent)`;
932
+ }
933
+
934
+ function sendVerifiedSuccess({ action, successSummary, details, verified, recovered }) {
935
+ const mailbox = verified.mailbox;
936
+ const delivered = mailbox === "sent";
937
+ return {
938
+ ok: true,
939
+ delivered,
940
+ mailbox,
941
+ recovered: recovered || undefined,
942
+ message: writeSuccessMessage(action, deliverySummary(successSummary, mailbox, recovered), {
943
+ ...details,
944
+ mailbox,
945
+ delivery: delivered ? "sent" : "outbox"
946
+ })
947
+ };
948
+ }
949
+
950
+ function verifyMissMessage(action, summary) {
951
+ return `${action} failed — attempted to ${summary}. ${MAIL_VERIFY_MISS_GUIDANCE}`;
952
+ }
953
+
592
954
  function finalizeMailWrite({
593
955
  action,
594
956
  summary,
@@ -598,30 +960,33 @@ function finalizeMailWrite({
598
960
  inReplyTo = null,
599
961
  subject = null,
600
962
  forwardTo = null,
963
+ to = null,
964
+ messageId = null,
601
965
  successSummary,
602
966
  details
603
967
  }) {
968
+ const match = { inReplyTo, subject, forwardTo, to, messageId };
969
+
604
970
  if (result.ok) {
605
- return { ok: true, message: writeSuccessMessage(action, successSummary, details) };
971
+ if (!sendNow) {
972
+ return { ok: true, delivered: false, mailbox: null, message: writeSuccessMessage(action, successSummary, details) };
973
+ }
974
+ const verified = verifyQueuedMessage(match);
975
+ if (!verified) {
976
+ return { ok: false, delivered: false, mailbox: null, message: verifyMissMessage(action, summary) };
977
+ }
978
+ return sendVerifiedSuccess({ action, successSummary, details, verified, recovered: false });
606
979
  }
607
980
 
608
981
  if (sendNow && isMailSendTimeout(result)) {
609
- const recovered = recoverIfInSent({ inReplyTo, subject, forwardTo });
982
+ const recovered = recoverIfInSent(match);
610
983
  if (recovered) {
611
- return {
612
- ok: true,
613
- recovered: true,
614
- message: writeSuccessMessage(
615
- action,
616
- `${successSummary} (verified in Sent after AppleScript hang)`,
617
- details
618
- )
619
- };
984
+ return sendVerifiedSuccess({ action, successSummary, details, verified: recovered, recovered: true });
620
985
  }
621
- return { ok: false, message: failure(action, summary, result, secrets) };
986
+ return { ok: false, delivered: false, mailbox: null, message: failure(action, summary, result, secrets) };
622
987
  }
623
988
 
624
- return { ok: false, message: failure(action, summary, result, secrets) };
989
+ return { ok: false, delivered: false, mailbox: null, message: failure(action, summary, result, secrets) };
625
990
  }
626
991
 
627
992
  /**
@@ -664,7 +1029,7 @@ export function mailCompose(args = {}, { draft = false } = {}) {
664
1029
  dryRun: isFlagTrue(args.dry_run),
665
1030
  confirm: isFlagTrue(args.confirm)
666
1031
  });
667
- if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
1032
+ if (!plan.proceed) return plannedWriteResult(plan);
668
1033
 
669
1034
  const script = buildComposeScript({
670
1035
  to: to.addresses,
@@ -684,6 +1049,8 @@ export function mailCompose(args = {}, { draft = false } = {}) {
684
1049
  secrets: [body.text, subject.text],
685
1050
  sendNow: !draft,
686
1051
  subject: draft ? null : subject.text,
1052
+ to: draft ? null : to.addresses,
1053
+ messageId: draft ? null : parseOutgoingMessageId(result.output),
687
1054
  successSummary: draft ? "draft saved to Drafts" : "sent",
688
1055
  details: {
689
1056
  to: to.addresses.join(", "),
@@ -743,7 +1110,7 @@ export function mailReply(args = {}) {
743
1110
  dryRun: isFlagTrue(args.dry_run),
744
1111
  confirm: isFlagTrue(args.confirm)
745
1112
  });
746
- if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
1113
+ if (!plan.proceed) return plannedWriteResult(plan);
747
1114
 
748
1115
  const result = runAppleScript(
749
1116
  buildReplyScript({ messageId: resolved.messageId, body: body.text, replyAll, sendNow }),
@@ -806,7 +1173,7 @@ export function mailForward(args = {}) {
806
1173
  dryRun: isFlagTrue(args.dry_run),
807
1174
  confirm: isFlagTrue(args.confirm)
808
1175
  });
809
- if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
1176
+ if (!plan.proceed) return plannedWriteResult(plan);
810
1177
 
811
1178
  const result = runAppleScript(
812
1179
  buildForwardScript({ messageId: resolved.messageId, to: to.addresses, body: body.text, sendNow }),
@@ -854,7 +1221,7 @@ export function mailMark(args = {}) {
854
1221
  dryRun: isFlagTrue(args.dry_run),
855
1222
  confirm: isFlagTrue(args.confirm)
856
1223
  });
857
- if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
1224
+ if (!plan.proceed) return plannedWriteResult(plan);
858
1225
 
859
1226
  const result = runAppleScript(buildMarkScript({ messageId: resolved.messageId, read: status === "read" }), { appName: "Mail" });
860
1227
  if (!result.ok) return { ok: false, message: failure(action, summary, result, []) };
@@ -899,7 +1266,7 @@ export function mailArchive(args = {}) {
899
1266
  dryRun: isFlagTrue(args.dry_run),
900
1267
  confirm: isFlagTrue(args.confirm)
901
1268
  });
902
- if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
1269
+ if (!plan.proceed) return plannedWriteResult(plan);
903
1270
 
904
1271
  const result = runAppleScript(
905
1272
  buildMoveScript({
@@ -932,7 +1299,7 @@ export function mailTrash(args = {}) {
932
1299
  dryRun: isFlagTrue(args.dry_run),
933
1300
  confirm: isFlagTrue(args.confirm)
934
1301
  });
935
- if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
1302
+ if (!plan.proceed) return plannedWriteResult(plan);
936
1303
 
937
1304
  const result = runAppleScript(
938
1305
  buildMoveScript({
@@ -17,6 +17,7 @@ import { escapeSQL } from "./validators.js";
17
17
  import { runAppleScript, asString, MESSAGES_TCC_GUIDANCE, MESSAGES_APP_NOT_RUNNING_GUIDANCE, ATTRIBUTION_GUIDANCE } from "./appleScript.js";
18
18
  import {
19
19
  planWrite,
20
+ plannedWriteResult,
20
21
  normalizeList,
21
22
  isMessagesHandle,
22
23
  validateChatGuid,
@@ -261,7 +262,7 @@ export function messagesSend(args = {}, deps = {}) {
261
262
  dryRun,
262
263
  confirm
263
264
  });
264
- if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
265
+ if (!plan.proceed) return plannedWriteResult(plan);
265
266
 
266
267
  const result = runAppleScript(
267
268
  buildSendToChatScript({ chatGuid: guid, text: body.text, attachmentPath: attachment.filePath }),
@@ -293,7 +294,7 @@ export function messagesSend(args = {}, deps = {}) {
293
294
 
294
295
  const summary = `send a message to ${handles.join(", ")} over ${serviceRaw}${attachment.filePath ? " with an attachment" : ""}`;
295
296
  const plan = planWrite({ action, summary, recipientCount: handles.length, dryRun, confirm });
296
- if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
297
+ if (!plan.proceed) return plannedWriteResult(plan);
297
298
 
298
299
  const result = runAppleScript(
299
300
  buildSendToHandlesScript({
@@ -215,6 +215,24 @@ export function planWrite({
215
215
  return { decision: "execute", proceed: true, requiresConfirm, message: null };
216
216
  }
217
217
 
218
+ /**
219
+ * Planned / dry_run / confirm-blocked results are not deliveries.
220
+ *
221
+ * MCP CallTool only surfaces `message` plus optional `isError`. Clients that
222
+ * treat a missing `isError` as success would read `ok: true` planned results
223
+ * as delivered. Planned writes therefore return `ok: false` with
224
+ * `planned: true` and `delivered: false`.
225
+ */
226
+ export function plannedWriteResult(plan) {
227
+ return {
228
+ ok: false,
229
+ planned: true,
230
+ delivered: false,
231
+ mailbox: null,
232
+ message: plan && plan.message ? plan.message : "Nothing was changed."
233
+ };
234
+ }
235
+
218
236
  export function truncate(text, max = 200) {
219
237
  if (typeof text !== "string") return "";
220
238
  const oneLine = text.replace(/\s+/g, " ").trim();
package/lib/writeTools.js CHANGED
@@ -48,7 +48,7 @@ export const WRITE_TOOL_DEFINITIONS = [
48
48
  // ============ MAIL WRITES ============
49
49
  {
50
50
  name: "mail_send",
51
- description: "Send an email through Mail.app. Sending to more than one recipient in total (to + cc + bcc) requires confirm=true. Use dry_run=true to preview. A send hang is a timeout, not TCC, unless Mail reports -1743/-10004; the tool checks Sent before failing. Clients must Sent-check before retrying a timed-out send.",
51
+ description: "Send an email through Mail.app. Success means the message was verified in Sent or is still in Outbox (still sending) — AppleScript send returning is not enough. Sending to more than one recipient in total (to + cc + bcc) requires confirm=true. dry_run and unconfirmed multi-recipient calls are not deliveries (MCP isError). A send hang is a timeout, not TCC, unless Mail reports -1743/-10004; hang recover matches To+subject (Message-ID when available), never subject alone. Clients must Sent-check before retrying a timed-out send.",
52
52
  inputSchema: {
53
53
  type: "object",
54
54
  properties: {
@@ -82,7 +82,7 @@ export const WRITE_TOOL_DEFINITIONS = [
82
82
  },
83
83
  {
84
84
  name: "mail_reply",
85
- description: "Reply to an existing email. reply_all=true fans out to every original recipient and requires confirm=true. A send hang is a timeout, not TCC, unless Mail reports -1743/-10004; the tool checks Sent before failing. Clients must Sent-check before retrying a timed-out send.",
85
+ description: "Reply to an existing email. reply_all=true fans out to every original recipient and requires confirm=true. A real send is verified in Sent/Outbox before success. A send hang is a timeout, not TCC, unless Mail reports -1743/-10004; hang recover uses In-Reply-To. Clients must Sent-check before retrying a timed-out send.",
86
86
  inputSchema: {
87
87
  type: "object",
88
88
  properties: {
@@ -97,7 +97,7 @@ export const WRITE_TOOL_DEFINITIONS = [
97
97
  },
98
98
  {
99
99
  name: "mail_forward",
100
- description: "Forward an existing email to new recipients. More than one recipient requires confirm=true. A send hang is a timeout, not TCC, unless Mail reports -1743/-10004; the tool checks Sent before failing. Clients must Sent-check before retrying a timed-out send.",
100
+ description: "Forward an existing email to new recipients. More than one recipient requires confirm=true. A real send is verified in Sent/Outbox before success. A send hang is a timeout, not TCC, unless Mail reports -1743/-10004; hang recover matches intended To plus Fwd: subject or original Message-ID. Clients must Sent-check before retrying a timed-out send.",
101
101
  inputSchema: {
102
102
  type: "object",
103
103
  properties: {
@@ -343,6 +343,23 @@ export function isWriteTool(name) {
343
343
  return Object.prototype.hasOwnProperty.call(WRITE_TOOL_HANDLERS, name);
344
344
  }
345
345
 
346
+ /**
347
+ * MCP CallTool payload for a write. Planned / dry_run / confirm-blocked
348
+ * results set isError so clients that only check isError cannot treat them
349
+ * as delivered. Verified sends (ok: true) do not set isError.
350
+ *
351
+ * @param {{ ok?: boolean, planned?: boolean, message?: string }} writeResult
352
+ */
353
+ export function mcpWriteResult(writeResult) {
354
+ const message = writeResult && writeResult.message ? writeResult.message : "write failed";
355
+ const planned = Boolean(writeResult && writeResult.planned);
356
+ const failed = !writeResult || writeResult.ok === false;
357
+ return {
358
+ content: [{ type: "text", text: message }],
359
+ ...((failed || planned) ? { isError: true } : {})
360
+ };
361
+ }
362
+
346
363
  /**
347
364
  * Run a write in this process.
348
365
  * @returns {{ ok: boolean, message: string }}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-tools-mcp",
3
- "version": "2.0.6",
3
+ "version": "2.0.8",
4
4
  "description": "MCP server for semantic search and write actions across Apple Mail, Messages, Calendar, and Contacts",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -233,8 +233,9 @@ function line(label, value) {
233
233
  }
234
234
 
235
235
  function step(name, result, severity = "error") {
236
- const failed = result.ok === false;
237
- const status = failed ? (severity === "warning" ? "WARN" : "FAIL") : result.planned ? "PLANNED" : "OK";
236
+ const planned = result && result.planned === true;
237
+ const failed = result && result.ok === false && !planned;
238
+ const status = planned ? "PLANNED" : failed ? (severity === "warning" ? "WARN" : "FAIL") : "OK";
238
239
  console.log(`\n[${status}] ${name}`);
239
240
  console.log(` ${result.message}`);
240
241
  return result;
@@ -348,7 +349,7 @@ async function main() {
348
349
  }));
349
350
  results.push(created);
350
351
 
351
- if (created.ok !== false) {
352
+ if (created.ok !== false || created.planned) {
352
353
  const contactId = apply ? extractContactId(created.message) : "ABCD1234-SMOKE:ABPerson";
353
354
  if (apply && !contactId) {
354
355
  console.log(" contacts_add reported success but returned no contact id; skipping edit/delete.");
@@ -405,7 +406,7 @@ async function main() {
405
406
  }));
406
407
  results.push(eventCreated);
407
408
 
408
- if (eventCreated.ok !== false) {
409
+ if (eventCreated.ok !== false || eventCreated.planned) {
409
410
  const eventId = apply ? extractEventId(eventCreated.message) : "ATM-SMOKE-EVENT-UID";
410
411
  const extractedKitId = apply ? extractEventKitId(eventCreated.message) : "EK-SMOKE";
411
412
  const eventKitId = extractedKitId || (eventId && eventId.includes(":") ? eventId : null);
@@ -451,7 +452,7 @@ async function main() {
451
452
  }
452
453
  }
453
454
 
454
- const failed = results.some((r) => r && r.ok === false);
455
+ const failed = results.some((r) => r && r.ok === false && !r.planned);
455
456
  console.log("\n" + "=".repeat(60));
456
457
  if (failed) {
457
458
  console.log("Result: FAILED - see the messages above.");