apple-tools-mcp 2.0.5 → 2.0.7
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 +6 -3
- package/index.js +3 -5
- package/lib/appleScript.js +1 -1
- package/lib/calendarWrite.js +5 -4
- package/lib/contactsWrite.js +4 -3
- package/lib/mailWrite.js +302 -69
- package/lib/messagesWrite.js +3 -2
- package/lib/writeGuards.js +18 -0
- package/lib/writeTools.js +20 -3
- package/package.json +1 -1
- package/scripts/smoke-writes.js +6 -5
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,15 @@ 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`).
|
|
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` 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
|
-
**
|
|
471
|
+
**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.
|
|
472
|
+
|
|
473
|
+
**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). That quote-prefix dual-host Sent prove is a separate bar from the 2.0.7 verify contract.
|
|
471
474
|
|
|
472
475
|
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
476
|
|
|
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.
|
|
477
|
+
**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
478
|
|
|
476
479
|
### Messages write tool
|
|
477
480
|
|
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) {
|
package/lib/appleScript.js
CHANGED
|
@@ -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.
|
package/lib/calendarWrite.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
1343
|
+
if (!plan.proceed) return plannedWriteResult(plan);
|
|
1343
1344
|
|
|
1344
1345
|
const result = runAppleScript(
|
|
1345
1346
|
buildRsvpScript({ eventId, status: RSVP_STATUS[responseRaw], attendeeEmail }),
|
package/lib/contactsWrite.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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,
|
|
@@ -171,6 +174,10 @@ export function composeNativeBody(body, { html = false } = {}) {
|
|
|
171
174
|
/**
|
|
172
175
|
* AppleScript handlers: focus the compose body (not To) and paste.
|
|
173
176
|
* System Events needs Accessibility; Mail make/send still needs Automation.
|
|
177
|
+
*
|
|
178
|
+
* macOS 26.x System Events has no `web area` class (compile -2741,
|
|
179
|
+
* "Expected class name but found identifier"). Hosts compile
|
|
180
|
+
* `text area` / `scroll area` and `UI element whose role is "AXWebArea"`.
|
|
174
181
|
*/
|
|
175
182
|
export function buildMailBodyPasteHandler() {
|
|
176
183
|
return `on atmFocusMailBody()
|
|
@@ -184,21 +191,58 @@ export function buildMailBodyPasteHandler() {
|
|
|
184
191
|
return
|
|
185
192
|
end try
|
|
186
193
|
try
|
|
187
|
-
|
|
194
|
+
set focused of text area 1 of w to true
|
|
195
|
+
return
|
|
196
|
+
end try
|
|
197
|
+
try
|
|
198
|
+
set focused of (first UI element of w whose role is "AXTextArea") to true
|
|
199
|
+
return
|
|
200
|
+
end try
|
|
201
|
+
try
|
|
202
|
+
click (first UI element of w whose role is "AXWebArea")
|
|
188
203
|
return
|
|
189
204
|
end try
|
|
190
205
|
try
|
|
191
|
-
click (first
|
|
206
|
+
click (first UI element of scroll area 1 of w whose role is "AXWebArea")
|
|
192
207
|
return
|
|
193
208
|
end try
|
|
194
209
|
try
|
|
195
|
-
click (first
|
|
210
|
+
click (first UI element of scroll area 1 of splitter group 1 of w whose role is "AXWebArea")
|
|
196
211
|
return
|
|
197
212
|
end try
|
|
198
213
|
try
|
|
199
|
-
click (first
|
|
214
|
+
click (first UI element of scroll area 1 of group 1 of splitter group 1 of w whose role is "AXWebArea")
|
|
200
215
|
return
|
|
201
216
|
end try
|
|
217
|
+
set atmQueue to UI elements of w
|
|
218
|
+
set atmWalked to 0
|
|
219
|
+
repeat while (count of atmQueue) > 0 and atmWalked < 80
|
|
220
|
+
set atmWalked to atmWalked + 1
|
|
221
|
+
set atmElem to item 1 of atmQueue
|
|
222
|
+
if (count of atmQueue) is 1 then
|
|
223
|
+
set atmQueue to {}
|
|
224
|
+
else
|
|
225
|
+
set atmQueue to items 2 thru -1 of atmQueue
|
|
226
|
+
end if
|
|
227
|
+
try
|
|
228
|
+
if (role of atmElem as string) is "AXWebArea" then
|
|
229
|
+
click atmElem
|
|
230
|
+
return
|
|
231
|
+
end if
|
|
232
|
+
end try
|
|
233
|
+
try
|
|
234
|
+
if (role of atmElem as string) is "AXTextArea" then
|
|
235
|
+
set focused of atmElem to true
|
|
236
|
+
return
|
|
237
|
+
end if
|
|
238
|
+
end try
|
|
239
|
+
try
|
|
240
|
+
set atmKids to UI elements of atmElem
|
|
241
|
+
repeat with i from 1 to (count of atmKids)
|
|
242
|
+
set end of atmQueue to item i of atmKids
|
|
243
|
+
end repeat
|
|
244
|
+
end try
|
|
245
|
+
end repeat
|
|
202
246
|
set {wx, wy} to position of w
|
|
203
247
|
set {ww, wh} to size of w
|
|
204
248
|
click at {(wx + (ww div 2)) as integer, (wy + ((wh * 2) div 3)) as integer}
|
|
@@ -295,9 +339,14 @@ on error errMsg number errNum
|
|
|
295
339
|
end try
|
|
296
340
|
error errMsg number errNum
|
|
297
341
|
end try
|
|
342
|
+
set atmOutgoingId to ""
|
|
298
343
|
tell application "Mail"
|
|
344
|
+
try
|
|
345
|
+
set atmOutgoingId to message id of newMessage as string
|
|
346
|
+
end try
|
|
299
347
|
${send ? "send newMessage" : "save newMessage"}
|
|
300
348
|
end tell
|
|
349
|
+
if atmOutgoingId is not "" then return atmOutgoingId
|
|
301
350
|
return "OK"`;
|
|
302
351
|
}
|
|
303
352
|
|
|
@@ -309,8 +358,31 @@ function summarizeRecipients(to, cc, bcc) {
|
|
|
309
358
|
return parts.join("; ");
|
|
310
359
|
}
|
|
311
360
|
|
|
361
|
+
export const SENT_VERIFY_SENT = "SENT";
|
|
362
|
+
export const SENT_VERIFY_OUTBOX = "OUTBOX";
|
|
363
|
+
export const SENT_VERIFY_NOT_FOUND = "NOT_FOUND";
|
|
364
|
+
/** Older hang-recovery scripts returned FOUND to mean Sent. Still accepted. */
|
|
312
365
|
export const SENT_VERIFY_FOUND = "FOUND";
|
|
313
366
|
export const SENT_VERIFY_TIMEOUT_MS = 15000;
|
|
367
|
+
export const SENT_VERIFY_ATTEMPTS = 3;
|
|
368
|
+
export const SENT_VERIFY_RETRY_MS = 400;
|
|
369
|
+
|
|
370
|
+
export const MAIL_VERIFY_MISS_GUIDANCE =
|
|
371
|
+
"Mail reported the send call succeeded, but the message was not found in Sent or Outbox. Do not assume it was delivered.";
|
|
372
|
+
|
|
373
|
+
function defaultSentVerifySleep(ms) {
|
|
374
|
+
if (!ms || ms <= 0) return;
|
|
375
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Test hook: skip the retry delay without changing attempt count. */
|
|
379
|
+
export const sentVerifyClock = {
|
|
380
|
+
sleep: defaultSentVerifySleep
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
export function resetSentVerifyClock() {
|
|
384
|
+
sentVerifyClock.sleep = defaultSentVerifySleep;
|
|
385
|
+
}
|
|
314
386
|
|
|
315
387
|
/**
|
|
316
388
|
* Hard Mail Automation deny: -1743 / -10004 / "not authorized…", or
|
|
@@ -335,10 +407,10 @@ function mailBoxesPreamble() {
|
|
|
335
407
|
return ` set cutoff to (current date) - (10 * minutes)
|
|
336
408
|
set boxes to {}
|
|
337
409
|
try
|
|
338
|
-
set end of boxes to
|
|
410
|
+
set end of boxes to outgoing mailbox
|
|
339
411
|
end try
|
|
340
412
|
try
|
|
341
|
-
set end of boxes to
|
|
413
|
+
set end of boxes to sent mailbox
|
|
342
414
|
end try
|
|
343
415
|
repeat with acct in accounts
|
|
344
416
|
try
|
|
@@ -351,6 +423,48 @@ function appleScriptToList(addresses = []) {
|
|
|
351
423
|
return `{${addresses.map((address) => asString(address)).join(", ")}}`;
|
|
352
424
|
}
|
|
353
425
|
|
|
426
|
+
function loadMailboxMessagesSnippet() {
|
|
427
|
+
return ` set recentMsgs to {}
|
|
428
|
+
set scanAll to false
|
|
429
|
+
try
|
|
430
|
+
if boxRef is outgoing mailbox then set scanAll to true
|
|
431
|
+
end try
|
|
432
|
+
try
|
|
433
|
+
set nm to name of boxRef as string
|
|
434
|
+
if nm contains "Outbox" then set scanAll to true
|
|
435
|
+
if nm contains "Outgoing" then set scanAll to true
|
|
436
|
+
end try
|
|
437
|
+
if scanAll then
|
|
438
|
+
set recentMsgs to messages of boxRef
|
|
439
|
+
else
|
|
440
|
+
set recentMsgs to (messages of boxRef whose date sent > cutoff)
|
|
441
|
+
end if`;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function verifyHitReturn() {
|
|
445
|
+
return ` try
|
|
446
|
+
if boxRef is outgoing mailbox then return "${SENT_VERIFY_OUTBOX}"
|
|
447
|
+
end try
|
|
448
|
+
try
|
|
449
|
+
set nm to name of boxRef as string
|
|
450
|
+
if nm contains "Outbox" then return "${SENT_VERIFY_OUTBOX}"
|
|
451
|
+
if nm contains "Outgoing" then return "${SENT_VERIFY_OUTBOX}"
|
|
452
|
+
end try
|
|
453
|
+
return "${SENT_VERIFY_SENT}"`;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function recipientHitSnippet() {
|
|
457
|
+
return ` set hitTo to false
|
|
458
|
+
try
|
|
459
|
+
repeat with recip in (to recipients of msg)
|
|
460
|
+
set recipAddr to address of recip as string
|
|
461
|
+
repeat with wanted in wantedTos
|
|
462
|
+
if recipAddr is (wanted as string) then set hitTo to true
|
|
463
|
+
end repeat
|
|
464
|
+
end repeat
|
|
465
|
+
end try`;
|
|
466
|
+
}
|
|
467
|
+
|
|
354
468
|
/**
|
|
355
469
|
* Look in Sent / Outbox for a reply of `messageId`.
|
|
356
470
|
* Proper Mail replies set In-Reply-To. The fallback `make new outgoing
|
|
@@ -373,7 +487,7 @@ tell application "Mail"
|
|
|
373
487
|
${mailBoxesPreamble()}
|
|
374
488
|
repeat with boxRef in boxes
|
|
375
489
|
try
|
|
376
|
-
|
|
490
|
+
${loadMailboxMessagesSnippet()}
|
|
377
491
|
repeat with msg in recentMsgs
|
|
378
492
|
try
|
|
379
493
|
set candId to message id of msg
|
|
@@ -382,12 +496,18 @@ ${mailBoxesPreamble()}
|
|
|
382
496
|
else
|
|
383
497
|
try
|
|
384
498
|
set src to source of msg
|
|
385
|
-
if src contains ("In-Reply-To: " & ${needleAngle}) then
|
|
386
|
-
|
|
499
|
+
if src contains ("In-Reply-To: " & ${needleAngle}) then
|
|
500
|
+
${verifyHitReturn()}
|
|
501
|
+
end if
|
|
502
|
+
if src contains ("In-Reply-To: " & ${needleBare}) then
|
|
503
|
+
${verifyHitReturn()}
|
|
504
|
+
end if
|
|
387
505
|
end try
|
|
388
506
|
if origSubject is not "" then
|
|
389
507
|
set subj to subject of msg
|
|
390
|
-
if subj is ("Re: " & origSubject) then
|
|
508
|
+
if subj is ("Re: " & origSubject) then
|
|
509
|
+
${verifyHitReturn()}
|
|
510
|
+
end if
|
|
391
511
|
end if
|
|
392
512
|
end if
|
|
393
513
|
end try
|
|
@@ -395,7 +515,7 @@ ${mailBoxesPreamble()}
|
|
|
395
515
|
end try
|
|
396
516
|
end repeat
|
|
397
517
|
end tell
|
|
398
|
-
return "
|
|
518
|
+
return "${SENT_VERIFY_NOT_FOUND}"`;
|
|
399
519
|
}
|
|
400
520
|
|
|
401
521
|
/**
|
|
@@ -422,7 +542,7 @@ ${mailBoxesPreamble()}
|
|
|
422
542
|
set wantedTos to ${toList}
|
|
423
543
|
repeat with boxRef in boxes
|
|
424
544
|
try
|
|
425
|
-
|
|
545
|
+
${loadMailboxMessagesSnippet()}
|
|
426
546
|
repeat with msg in recentMsgs
|
|
427
547
|
try
|
|
428
548
|
set candId to message id of msg
|
|
@@ -433,15 +553,7 @@ ${mailBoxesPreamble()}
|
|
|
433
553
|
if src contains ("In-Reply-To: " & ${needleAngle}) or src contains ("In-Reply-To: " & ${needleBare}) then
|
|
434
554
|
-- A reply to the original is not this forward.
|
|
435
555
|
else
|
|
436
|
-
|
|
437
|
-
try
|
|
438
|
-
repeat with recip in (to recipients of msg)
|
|
439
|
-
set recipAddr to address of recip as string
|
|
440
|
-
repeat with wanted in wantedTos
|
|
441
|
-
if recipAddr is (wanted as string) then set hitTo to true
|
|
442
|
-
end repeat
|
|
443
|
-
end repeat
|
|
444
|
-
end try
|
|
556
|
+
${recipientHitSnippet()}
|
|
445
557
|
if hitTo then
|
|
446
558
|
set subj to subject of msg
|
|
447
559
|
set exactFwd to false
|
|
@@ -457,8 +569,12 @@ ${mailBoxesPreamble()}
|
|
|
457
569
|
set mentionsOrigId to false
|
|
458
570
|
if src contains ${needleAngle} then set mentionsOrigId to true
|
|
459
571
|
if src contains ${needleBare} then set mentionsOrigId to true
|
|
460
|
-
if exactFwd then
|
|
461
|
-
|
|
572
|
+
if exactFwd then
|
|
573
|
+
${verifyHitReturn()}
|
|
574
|
+
end if
|
|
575
|
+
if looksForward and mentionsOrigId then
|
|
576
|
+
${verifyHitReturn()}
|
|
577
|
+
end if
|
|
462
578
|
end if
|
|
463
579
|
end if
|
|
464
580
|
end if
|
|
@@ -467,45 +583,125 @@ ${mailBoxesPreamble()}
|
|
|
467
583
|
end try
|
|
468
584
|
end repeat
|
|
469
585
|
end tell
|
|
470
|
-
return "
|
|
586
|
+
return "${SENT_VERIFY_NOT_FOUND}"`;
|
|
471
587
|
}
|
|
472
588
|
|
|
473
589
|
/**
|
|
474
|
-
* Look in Sent / Outbox for a compose
|
|
475
|
-
*
|
|
590
|
+
* Look in Sent / Outbox for a compose matching To + subject.
|
|
591
|
+
* Never matches subject alone — short subjects like "test" are unsafe.
|
|
592
|
+
* When `messageId` is present, prefer that id together with To.
|
|
476
593
|
*/
|
|
477
|
-
export function
|
|
478
|
-
const
|
|
594
|
+
export function buildFindSentByRecipientAndSubjectScript(subject, toAddresses = [], messageId = null) {
|
|
595
|
+
const toList = appleScriptToList(toAddresses);
|
|
596
|
+
const subj = asString(subject || "");
|
|
597
|
+
const idLit = messageId ? asString(messageId) : null;
|
|
598
|
+
const matchBody = idLit
|
|
599
|
+
? `${recipientHitSnippet()}
|
|
600
|
+
set candId to message id of msg
|
|
601
|
+
set hitId to false
|
|
602
|
+
try
|
|
603
|
+
if candId is ${idLit} then set hitId to true
|
|
604
|
+
end try
|
|
605
|
+
if hitId and hitTo then
|
|
606
|
+
${verifyHitReturn()}
|
|
607
|
+
end if
|
|
608
|
+
if hitTo then
|
|
609
|
+
set subj to subject of msg
|
|
610
|
+
if subj is ${subj} then
|
|
611
|
+
${verifyHitReturn()}
|
|
612
|
+
end if
|
|
613
|
+
end if`
|
|
614
|
+
: `${recipientHitSnippet()}
|
|
615
|
+
if hitTo then
|
|
616
|
+
set subj to subject of msg
|
|
617
|
+
if subj is ${subj} then
|
|
618
|
+
${verifyHitReturn()}
|
|
619
|
+
end if
|
|
620
|
+
end if`;
|
|
621
|
+
|
|
479
622
|
return `tell application "Mail"
|
|
480
623
|
${mailBoxesPreamble()}
|
|
624
|
+
set wantedTos to ${toList}
|
|
481
625
|
repeat with boxRef in boxes
|
|
482
626
|
try
|
|
483
|
-
|
|
484
|
-
|
|
627
|
+
${loadMailboxMessagesSnippet()}
|
|
628
|
+
repeat with msg in recentMsgs
|
|
629
|
+
try
|
|
630
|
+
${matchBody}
|
|
631
|
+
end try
|
|
632
|
+
end repeat
|
|
485
633
|
end try
|
|
486
634
|
end repeat
|
|
487
635
|
end tell
|
|
488
|
-
return "
|
|
636
|
+
return "${SENT_VERIFY_NOT_FOUND}"`;
|
|
489
637
|
}
|
|
490
638
|
|
|
491
639
|
/**
|
|
492
|
-
*
|
|
493
|
-
*
|
|
640
|
+
* @deprecated Subject-only matching is unsafe. Requires To as the second
|
|
641
|
+
* argument; without recipients the scan cannot hit.
|
|
494
642
|
*/
|
|
495
|
-
export function
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
643
|
+
export function buildFindSentBySubjectScript(subject, toAddresses = []) {
|
|
644
|
+
return buildFindSentByRecipientAndSubjectScript(subject, toAddresses);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
export function parseSentVerifyOutput(output) {
|
|
648
|
+
const out = String(output || "").trim();
|
|
649
|
+
if (out === SENT_VERIFY_SENT || out === SENT_VERIFY_FOUND) {
|
|
650
|
+
return { found: true, mailbox: "sent" };
|
|
651
|
+
}
|
|
652
|
+
if (out === SENT_VERIFY_OUTBOX) {
|
|
653
|
+
return { found: true, mailbox: "outbox" };
|
|
654
|
+
}
|
|
655
|
+
return null;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
export function parseOutgoingMessageId(output) {
|
|
659
|
+
const raw = String(output || "").trim();
|
|
660
|
+
if (!raw || raw === "OK") return null;
|
|
661
|
+
return validateMessageId(raw);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function pickSentVerifyScript({
|
|
665
|
+
inReplyTo = null,
|
|
666
|
+
subject = null,
|
|
667
|
+
forwardTo = null,
|
|
668
|
+
to = null,
|
|
669
|
+
messageId = null
|
|
670
|
+
} = {}) {
|
|
671
|
+
if (forwardTo && inReplyTo) return buildFindSentForwardScript(inReplyTo, forwardTo);
|
|
672
|
+
if (inReplyTo) return buildFindSentByInReplyToScript(inReplyTo);
|
|
673
|
+
const toList = Array.isArray(to) ? to : [];
|
|
674
|
+
if (toList.length > 0 && (subject || messageId)) {
|
|
675
|
+
return buildFindSentByRecipientAndSubjectScript(subject || "", toList, messageId);
|
|
676
|
+
}
|
|
677
|
+
return null;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* After a send hang, ask Mail whether the message is already in Sent/Outbox.
|
|
682
|
+
* Compose matches require To + subject (Message-ID when available) — never
|
|
683
|
+
* subject alone. Returns a hit object or null. Verify failure is not success.
|
|
684
|
+
*/
|
|
685
|
+
export function recoverIfInSent(match = {}) {
|
|
686
|
+
const script = pickSentVerifyScript(match);
|
|
503
687
|
if (!script) return null;
|
|
504
688
|
const result = runAppleScript(script, { timeout: SENT_VERIFY_TIMEOUT_MS, appName: "Mail" });
|
|
505
689
|
if (!result.ok) return null;
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
690
|
+
return parseSentVerifyOutput(result.output);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* Poll Sent/Outbox after a send that returned without throw.
|
|
695
|
+
*/
|
|
696
|
+
export function verifyQueuedMessage(match = {}, { attempts = SENT_VERIFY_ATTEMPTS, retryMs = SENT_VERIFY_RETRY_MS } = {}) {
|
|
697
|
+
let last = null;
|
|
698
|
+
const n = Math.max(1, attempts);
|
|
699
|
+
for (let i = 0; i < n; i++) {
|
|
700
|
+
last = recoverIfInSent(match);
|
|
701
|
+
if (last) return last;
|
|
702
|
+
if (i < n - 1) sentVerifyClock.sleep(retryMs);
|
|
703
|
+
}
|
|
704
|
+
return last;
|
|
509
705
|
}
|
|
510
706
|
|
|
511
707
|
export function isMailAccessibilityDenial(result) {
|
|
@@ -545,9 +741,41 @@ function failure(action, summary, result, secrets) {
|
|
|
545
741
|
}
|
|
546
742
|
|
|
547
743
|
/**
|
|
548
|
-
* Finish a compose/reply/forward. A send
|
|
549
|
-
*
|
|
744
|
+
* Finish a compose/reply/forward. A real send is not success until Sent or
|
|
745
|
+
* Outbox verify hits. A send hang is not labeled TCC; if Mail already
|
|
746
|
+
* delivered, return success. Hard -1743/-10004 stays a deny.
|
|
550
747
|
*/
|
|
748
|
+
function deliverySummary(successSummary, mailbox, recovered) {
|
|
749
|
+
if (mailbox === "outbox") {
|
|
750
|
+
return recovered
|
|
751
|
+
? "queued in Outbox (still sending; verified after AppleScript hang)"
|
|
752
|
+
: "queued in Outbox (still sending)";
|
|
753
|
+
}
|
|
754
|
+
return recovered
|
|
755
|
+
? `${successSummary} (verified in Sent after AppleScript hang)`
|
|
756
|
+
: `${successSummary} (verified in Sent)`;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
function sendVerifiedSuccess({ action, successSummary, details, verified, recovered }) {
|
|
760
|
+
const mailbox = verified.mailbox;
|
|
761
|
+
const delivered = mailbox === "sent";
|
|
762
|
+
return {
|
|
763
|
+
ok: true,
|
|
764
|
+
delivered,
|
|
765
|
+
mailbox,
|
|
766
|
+
recovered: recovered || undefined,
|
|
767
|
+
message: writeSuccessMessage(action, deliverySummary(successSummary, mailbox, recovered), {
|
|
768
|
+
...details,
|
|
769
|
+
mailbox,
|
|
770
|
+
delivery: delivered ? "sent" : "outbox"
|
|
771
|
+
})
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function verifyMissMessage(action, summary) {
|
|
776
|
+
return `${action} failed — attempted to ${summary}. ${MAIL_VERIFY_MISS_GUIDANCE}`;
|
|
777
|
+
}
|
|
778
|
+
|
|
551
779
|
function finalizeMailWrite({
|
|
552
780
|
action,
|
|
553
781
|
summary,
|
|
@@ -557,30 +785,33 @@ function finalizeMailWrite({
|
|
|
557
785
|
inReplyTo = null,
|
|
558
786
|
subject = null,
|
|
559
787
|
forwardTo = null,
|
|
788
|
+
to = null,
|
|
789
|
+
messageId = null,
|
|
560
790
|
successSummary,
|
|
561
791
|
details
|
|
562
792
|
}) {
|
|
793
|
+
const match = { inReplyTo, subject, forwardTo, to, messageId };
|
|
794
|
+
|
|
563
795
|
if (result.ok) {
|
|
564
|
-
|
|
796
|
+
if (!sendNow) {
|
|
797
|
+
return { ok: true, delivered: false, mailbox: null, message: writeSuccessMessage(action, successSummary, details) };
|
|
798
|
+
}
|
|
799
|
+
const verified = verifyQueuedMessage(match);
|
|
800
|
+
if (!verified) {
|
|
801
|
+
return { ok: false, delivered: false, mailbox: null, message: verifyMissMessage(action, summary) };
|
|
802
|
+
}
|
|
803
|
+
return sendVerifiedSuccess({ action, successSummary, details, verified, recovered: false });
|
|
565
804
|
}
|
|
566
805
|
|
|
567
806
|
if (sendNow && isMailSendTimeout(result)) {
|
|
568
|
-
const recovered = recoverIfInSent(
|
|
807
|
+
const recovered = recoverIfInSent(match);
|
|
569
808
|
if (recovered) {
|
|
570
|
-
return {
|
|
571
|
-
ok: true,
|
|
572
|
-
recovered: true,
|
|
573
|
-
message: writeSuccessMessage(
|
|
574
|
-
action,
|
|
575
|
-
`${successSummary} (verified in Sent after AppleScript hang)`,
|
|
576
|
-
details
|
|
577
|
-
)
|
|
578
|
-
};
|
|
809
|
+
return sendVerifiedSuccess({ action, successSummary, details, verified: recovered, recovered: true });
|
|
579
810
|
}
|
|
580
|
-
return { ok: false, message: failure(action, summary, result, secrets) };
|
|
811
|
+
return { ok: false, delivered: false, mailbox: null, message: failure(action, summary, result, secrets) };
|
|
581
812
|
}
|
|
582
813
|
|
|
583
|
-
return { ok: false, message: failure(action, summary, result, secrets) };
|
|
814
|
+
return { ok: false, delivered: false, mailbox: null, message: failure(action, summary, result, secrets) };
|
|
584
815
|
}
|
|
585
816
|
|
|
586
817
|
/**
|
|
@@ -623,7 +854,7 @@ export function mailCompose(args = {}, { draft = false } = {}) {
|
|
|
623
854
|
dryRun: isFlagTrue(args.dry_run),
|
|
624
855
|
confirm: isFlagTrue(args.confirm)
|
|
625
856
|
});
|
|
626
|
-
if (!plan.proceed) return
|
|
857
|
+
if (!plan.proceed) return plannedWriteResult(plan);
|
|
627
858
|
|
|
628
859
|
const script = buildComposeScript({
|
|
629
860
|
to: to.addresses,
|
|
@@ -643,6 +874,8 @@ export function mailCompose(args = {}, { draft = false } = {}) {
|
|
|
643
874
|
secrets: [body.text, subject.text],
|
|
644
875
|
sendNow: !draft,
|
|
645
876
|
subject: draft ? null : subject.text,
|
|
877
|
+
to: draft ? null : to.addresses,
|
|
878
|
+
messageId: draft ? null : parseOutgoingMessageId(result.output),
|
|
646
879
|
successSummary: draft ? "draft saved to Drafts" : "sent",
|
|
647
880
|
details: {
|
|
648
881
|
to: to.addresses.join(", "),
|
|
@@ -702,7 +935,7 @@ export function mailReply(args = {}) {
|
|
|
702
935
|
dryRun: isFlagTrue(args.dry_run),
|
|
703
936
|
confirm: isFlagTrue(args.confirm)
|
|
704
937
|
});
|
|
705
|
-
if (!plan.proceed) return
|
|
938
|
+
if (!plan.proceed) return plannedWriteResult(plan);
|
|
706
939
|
|
|
707
940
|
const result = runAppleScript(
|
|
708
941
|
buildReplyScript({ messageId: resolved.messageId, body: body.text, replyAll, sendNow }),
|
|
@@ -765,7 +998,7 @@ export function mailForward(args = {}) {
|
|
|
765
998
|
dryRun: isFlagTrue(args.dry_run),
|
|
766
999
|
confirm: isFlagTrue(args.confirm)
|
|
767
1000
|
});
|
|
768
|
-
if (!plan.proceed) return
|
|
1001
|
+
if (!plan.proceed) return plannedWriteResult(plan);
|
|
769
1002
|
|
|
770
1003
|
const result = runAppleScript(
|
|
771
1004
|
buildForwardScript({ messageId: resolved.messageId, to: to.addresses, body: body.text, sendNow }),
|
|
@@ -813,7 +1046,7 @@ export function mailMark(args = {}) {
|
|
|
813
1046
|
dryRun: isFlagTrue(args.dry_run),
|
|
814
1047
|
confirm: isFlagTrue(args.confirm)
|
|
815
1048
|
});
|
|
816
|
-
if (!plan.proceed) return
|
|
1049
|
+
if (!plan.proceed) return plannedWriteResult(plan);
|
|
817
1050
|
|
|
818
1051
|
const result = runAppleScript(buildMarkScript({ messageId: resolved.messageId, read: status === "read" }), { appName: "Mail" });
|
|
819
1052
|
if (!result.ok) return { ok: false, message: failure(action, summary, result, []) };
|
|
@@ -858,7 +1091,7 @@ export function mailArchive(args = {}) {
|
|
|
858
1091
|
dryRun: isFlagTrue(args.dry_run),
|
|
859
1092
|
confirm: isFlagTrue(args.confirm)
|
|
860
1093
|
});
|
|
861
|
-
if (!plan.proceed) return
|
|
1094
|
+
if (!plan.proceed) return plannedWriteResult(plan);
|
|
862
1095
|
|
|
863
1096
|
const result = runAppleScript(
|
|
864
1097
|
buildMoveScript({
|
|
@@ -891,7 +1124,7 @@ export function mailTrash(args = {}) {
|
|
|
891
1124
|
dryRun: isFlagTrue(args.dry_run),
|
|
892
1125
|
confirm: isFlagTrue(args.confirm)
|
|
893
1126
|
});
|
|
894
|
-
if (!plan.proceed) return
|
|
1127
|
+
if (!plan.proceed) return plannedWriteResult(plan);
|
|
895
1128
|
|
|
896
1129
|
const result = runAppleScript(
|
|
897
1130
|
buildMoveScript({
|
package/lib/messagesWrite.js
CHANGED
|
@@ -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
|
|
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
|
|
297
|
+
if (!plan.proceed) return plannedWriteResult(plan);
|
|
297
298
|
|
|
298
299
|
const result = runAppleScript(
|
|
299
300
|
buildSendToHandlesScript({
|
package/lib/writeGuards.js
CHANGED
|
@@ -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.
|
|
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;
|
|
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;
|
|
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
package/scripts/smoke-writes.js
CHANGED
|
@@ -233,8 +233,9 @@ function line(label, value) {
|
|
|
233
233
|
}
|
|
234
234
|
|
|
235
235
|
function step(name, result, severity = "error") {
|
|
236
|
-
const
|
|
237
|
-
const
|
|
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.");
|