apple-tools-mcp 2.0.1 → 2.0.2

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.
@@ -9,7 +9,15 @@
9
9
  * writing that database directly corrupts iCloud sync.
10
10
  */
11
11
 
12
- import { runAppleScript, asString, CONTACTS_TCC_GUIDANCE, ATTRIBUTION_GUIDANCE } from "./appleScript.js";
12
+ import {
13
+ runAppleScript,
14
+ asString,
15
+ formatOsascriptDiagnostic,
16
+ CONTACTS_TCC_GUIDANCE,
17
+ CONTACTS_APP_NOT_RUNNING_GUIDANCE,
18
+ ATTRIBUTION_GUIDANCE
19
+ } from "./appleScript.js";
20
+ import { safeOpenApp } from "./shell.js";
13
21
  import {
14
22
  planWrite,
15
23
  normalizeList,
@@ -75,6 +83,117 @@ function childLines(items, kind, personVar) {
75
83
  .join("\n");
76
84
  }
77
85
 
86
+ export const CONTACTS_READY_ATTEMPTS = 16;
87
+ export const CONTACTS_READY_INTERVAL_MS = 500;
88
+ export const CONTACTS_OPEN_RETRY_EVERY = 4;
89
+ /** Wall-clock cap so ensure + CRUD stay under the 90s write-bridge timeout. */
90
+ export const CONTACTS_READY_BUDGET_MS = 20000;
91
+ export const CONTACTS_LAUNCH_TIMEOUT_MS = 4000;
92
+ export const CONTACTS_READY_SCRIPT_TIMEOUT_MS = 2000;
93
+
94
+ export function buildContactsLaunchScript() {
95
+ // launch only — activate waits until Contacts is frontmost and a slow
96
+ // cold start can ETIMEDOUT, which must not abort the ready poll as TCC.
97
+ return `tell application "Contacts" to launch`;
98
+ }
99
+
100
+ export function buildContactsReadyScript() {
101
+ return `tell application "Contacts" to get name`;
102
+ }
103
+
104
+ export function contactsAppIsReady(output) {
105
+ const text = String(output || "").trim().toLowerCase();
106
+ return text === "contacts" || text === "ready";
107
+ }
108
+
109
+ export function sleepMs(ms) {
110
+ if (!Number.isFinite(ms) || ms <= 0) return;
111
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Math.min(ms, 5000));
112
+ }
113
+
114
+ /**
115
+ * Cold `tell application "Contacts"` under launchd often returns -600
116
+ * instead of auto-launching. Prefer `open -a Contacts` in *this* process
117
+ * (the write-bridge daemon when routed), then `launch` (not `activate`),
118
+ * then poll until a trivial AppleScript (`get name`) responds.
119
+ * A launch/ready ETIMEDOUT is a slow cold start, not Automation — keep polling.
120
+ */
121
+ function isFatalContactsReadyKind(kind) {
122
+ return kind === "tcc" || kind === "attribution" || kind === "app_unavailable";
123
+ }
124
+
125
+ function tryOpenContacts(openApp) {
126
+ try {
127
+ openApp("Contacts");
128
+ return { ok: true };
129
+ } catch (e) {
130
+ return { ok: false, error: e && e.message ? e.message : String(e) };
131
+ }
132
+ }
133
+
134
+ export function ensureContactsAppReady({
135
+ run = runAppleScript,
136
+ sleep = sleepMs,
137
+ openApp = safeOpenApp,
138
+ attempts = CONTACTS_READY_ATTEMPTS,
139
+ intervalMs = CONTACTS_READY_INTERVAL_MS,
140
+ openRetryEvery = CONTACTS_OPEN_RETRY_EVERY,
141
+ budgetMs = CONTACTS_READY_BUDGET_MS,
142
+ launchTimeoutMs = CONTACTS_LAUNCH_TIMEOUT_MS,
143
+ readyTimeoutMs = CONTACTS_READY_SCRIPT_TIMEOUT_MS,
144
+ now = Date.now
145
+ } = {}) {
146
+ const started = now();
147
+ const remaining = () => budgetMs - (now() - started);
148
+ const slice = (maxMs) => Math.max(0, Math.min(maxMs, remaining()));
149
+
150
+ tryOpenContacts(openApp);
151
+
152
+ const launchBudget = slice(launchTimeoutMs);
153
+ const launch = launchBudget > 0
154
+ ? run(buildContactsLaunchScript(), { timeout: launchBudget, appName: "Contacts" })
155
+ : { ok: false, kind: "app_not_running", error: "Contacts ensure budget exhausted before launch", output: "" };
156
+ if (!launch.ok && isFatalContactsReadyKind(launch.kind)) {
157
+ return launch;
158
+ }
159
+
160
+ const tries = Math.max(1, attempts);
161
+ const retryEvery = Math.max(0, openRetryEvery);
162
+ let last = launch;
163
+ for (let i = 0; i < tries; i++) {
164
+ if (remaining() <= 0) break;
165
+ if (i > 0 && retryEvery > 0 && i % retryEvery === 0) {
166
+ tryOpenContacts(openApp);
167
+ }
168
+ const readyBudget = slice(readyTimeoutMs);
169
+ if (readyBudget <= 0) break;
170
+ const ready = run(buildContactsReadyScript(), { timeout: readyBudget, appName: "Contacts" });
171
+ last = ready;
172
+ if (ready.ok && contactsAppIsReady(ready.output)) {
173
+ return { ok: true, kind: null };
174
+ }
175
+ if (!ready.ok && isFatalContactsReadyKind(ready.kind)) {
176
+ return ready;
177
+ }
178
+ const pause = slice(intervalMs);
179
+ if (i < tries - 1 && pause > 0) sleep(pause);
180
+ }
181
+
182
+ if (last && last.ok === false && isFatalContactsReadyKind(last.kind)) {
183
+ return last;
184
+ }
185
+ if (last && last.ok === false && last.kind === "app_not_running") {
186
+ return last;
187
+ }
188
+
189
+ return {
190
+ ok: false,
191
+ kind: "app_not_running",
192
+ error: (last && last.error) || (launch && launch.error) || "Contacts.app did not become ready after launch",
193
+ output: ""
194
+ };
195
+ }
196
+
78
197
  function failure(action, summary, result, secrets = []) {
79
198
  if (result.kind === "tcc" || result.kind === "timeout") {
80
199
  return `${action} failed — attempted to ${summary}. ${CONTACTS_TCC_GUIDANCE}`;
@@ -83,6 +202,9 @@ function failure(action, summary, result, secrets = []) {
83
202
  if (raw.includes("CONTACT_NOT_FOUND")) {
84
203
  return `${action} failed — attempted to ${summary}. No contact with that id was found; use the Contact ID from contacts_search or contacts_lookup.`;
85
204
  }
205
+ if (result.kind === "app_not_running") {
206
+ return `${action} failed — attempted to ${summary}. ${CONTACTS_APP_NOT_RUNNING_GUIDANCE} ${formatOsascriptDiagnostic(result)}`;
207
+ }
86
208
  if (result.kind === "attribution") {
87
209
  return `${action} failed — attempted to ${summary}. ${ATTRIBUTION_GUIDANCE}`;
88
210
  }
@@ -105,7 +227,7 @@ end tell
105
227
  return newId`;
106
228
  }
107
229
 
108
- export function contactsAdd(args = {}) {
230
+ export function contactsAdd(args = {}, deps = {}) {
109
231
  const action = "contacts_add";
110
232
 
111
233
  const firstName = validateSubject(args.first_name);
@@ -140,6 +262,9 @@ export function contactsAdd(args = {}) {
140
262
  if (organization.text) properties.organization = organization.text;
141
263
  if (jobTitle.text) properties["job title"] = jobTitle.text;
142
264
 
265
+ const ready = ensureContactsAppReady(deps);
266
+ if (!ready.ok) return { ok: false, message: failure(action, summary, ready) };
267
+
143
268
  const result = runAppleScript(
144
269
  buildAddContactScript({ properties, emails: emails.emails, phones: phones.phones }),
145
270
  { timeout: 60000, appName: "Contacts" }
@@ -181,7 +306,7 @@ end tell
181
306
  return editedName`;
182
307
  }
183
308
 
184
- export function contactsEdit(args = {}) {
309
+ export function contactsEdit(args = {}, deps = {}) {
185
310
  const action = "contacts_edit";
186
311
 
187
312
  const contactId = validateContactId(args.contact_id);
@@ -234,6 +359,9 @@ export function contactsEdit(args = {}) {
234
359
  });
235
360
  if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
236
361
 
362
+ const ready = ensureContactsAppReady(deps);
363
+ if (!ready.ok) return { ok: false, message: failure(action, summary, ready) };
364
+
237
365
  const result = runAppleScript(
238
366
  buildEditContactScript({
239
367
  contactId,
@@ -269,7 +397,7 @@ end tell
269
397
  return removedName`;
270
398
  }
271
399
 
272
- export function contactsRemove(args = {}) {
400
+ export function contactsRemove(args = {}, deps = {}) {
273
401
  const action = "contacts_remove";
274
402
 
275
403
  const contactId = validateContactId(args.contact_id);
@@ -287,6 +415,9 @@ export function contactsRemove(args = {}) {
287
415
  });
288
416
  if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
289
417
 
418
+ const ready = ensureContactsAppReady(deps);
419
+ if (!ready.ok) return { ok: false, message: failure(action, summary, ready) };
420
+
290
421
  const result = runAppleScript(buildRemoveContactScript(contactId), { timeout: 60000, appName: "Contacts" });
291
422
  if (!result.ok) return { ok: false, message: failure(action, summary, result) };
292
423
 
@@ -298,3 +429,59 @@ export function contactsRemove(args = {}) {
298
429
  })
299
430
  };
300
431
  }
432
+
433
+ /**
434
+ * First-run / upgrade probe: the Contacts write verb that triggers
435
+ * Automation → Contacts. Creates a clearly named throwaway person and
436
+ * deletes it in the same script so a completed run leaves no junk.
437
+ */
438
+ export const CONTACTS_AUTOMATION_PROBE_FIRST = "ATM";
439
+ export const CONTACTS_AUTOMATION_PROBE_LAST = "Permissions Probe";
440
+
441
+ export function buildContactsAutomationProbeScript() {
442
+ return `tell application "Contacts"
443
+ set probe to make new person with properties {first name:${asString(CONTACTS_AUTOMATION_PROBE_FIRST)}, last name:${asString(CONTACTS_AUTOMATION_PROBE_LAST)}}
444
+ delete probe
445
+ save
446
+ end tell
447
+ return "OK"`;
448
+ }
449
+
450
+ export function buildContactsProbeCleanupScript() {
451
+ return `tell application "Contacts"
452
+ set leftovers to (every person whose first name is ${asString(CONTACTS_AUTOMATION_PROBE_FIRST)} and last name is ${asString(CONTACTS_AUTOMATION_PROBE_LAST)})
453
+ repeat with p in leftovers
454
+ delete p
455
+ end repeat
456
+ save
457
+ end tell
458
+ return "OK"`;
459
+ }
460
+
461
+ /**
462
+ * Live Contacts Apple Events check. Not a dry_run.
463
+ * @returns {{ ok: boolean, message: string, kind: string|null }}
464
+ */
465
+ export function probeContactsAutomation(deps = {}) {
466
+ const action = "contacts_automation_probe";
467
+ const summary = "create and delete a throwaway Contacts person (Automation / AddressBook check)";
468
+ const ready = ensureContactsAppReady(deps);
469
+ if (!ready.ok) {
470
+ runAppleScript(buildContactsProbeCleanupScript(), { timeout: 30000, appName: "Contacts" });
471
+ return { ok: false, message: failure(action, summary, ready), kind: ready.kind };
472
+ }
473
+ const result = runAppleScript(buildContactsAutomationProbeScript(), { timeout: 60000, appName: "Contacts" });
474
+ // Best-effort cleanup if the probe created the person then failed mid-script.
475
+ runAppleScript(buildContactsProbeCleanupScript(), { timeout: 30000, appName: "Contacts" });
476
+ if (!result.ok) {
477
+ return { ok: false, message: failure(action, summary, result), kind: result.kind };
478
+ }
479
+ return {
480
+ ok: true,
481
+ kind: null,
482
+ message: writeSuccessMessage(
483
+ action,
484
+ "Contacts Automation allowed (created and deleted a throwaway contact)"
485
+ )
486
+ };
487
+ }
package/lib/mailWrite.js CHANGED
@@ -6,12 +6,25 @@
6
6
  * property). `file_path` from mail_search / mail_recent is also accepted and
7
7
  * is resolved to a Message-ID by reading the .emlx headers, so callers never
8
8
  * have to invent an identifier.
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.
9
14
  */
10
15
 
11
16
  import fs from "fs";
12
17
  import path from "path";
13
18
  import { validateEmailPath, unfoldRfc822Headers, safeMatch, stripHtmlTags } from "./validators.js";
14
- import { runAppleScript, asString, MAIL_TCC_GUIDANCE, ATTRIBUTION_GUIDANCE } from "./appleScript.js";
19
+ import {
20
+ runAppleScript,
21
+ asString,
22
+ MAIL_TCC_GUIDANCE,
23
+ MAIL_SEND_TIMEOUT_GUIDANCE,
24
+ MAIL_APP_NOT_RUNNING_GUIDANCE,
25
+ ATTRIBUTION_GUIDANCE,
26
+ isHardTccDenial
27
+ } from "./appleScript.js";
15
28
  import {
16
29
  planWrite,
17
30
  validateEmailList,
@@ -123,10 +136,11 @@ export function probeMailAutomation() {
123
136
  const summary = "compose a temporary outgoing message in Mail (Automation / Apple Events check; nothing is sent)";
124
137
  const result = runAppleScript(buildMailAutomationProbeScript(), { timeout: 30000, appName: "Mail" });
125
138
  if (!result.ok) {
126
- return { ok: false, message: failure(action, summary, result, []) };
139
+ return { ok: false, message: failure(action, summary, result, []), kind: result.kind };
127
140
  }
128
141
  return {
129
142
  ok: true,
143
+ kind: null,
130
144
  message: writeSuccessMessage(
131
145
  action,
132
146
  "Mail Automation allowed (composed and discarded a temporary outgoing message; nothing was sent)"
@@ -171,13 +185,219 @@ function summarizeRecipients(to, cc, bcc) {
171
185
  return parts.join("; ");
172
186
  }
173
187
 
188
+ export const SENT_VERIFY_FOUND = "FOUND";
189
+ export const SENT_VERIFY_TIMEOUT_MS = 15000;
190
+
191
+ /**
192
+ * Hard Mail Automation deny: -1743 / -10004 / "not authorized…", or
193
+ * classify kind `tcc`. A spawnSync hang is `timeout`, not this.
194
+ */
195
+ export function isMailHardTcc(result) {
196
+ if (!result) return false;
197
+ if (result.kind === "tcc") return true;
198
+ return isHardTccDenial(result.error);
199
+ }
200
+
201
+ /**
202
+ * Find/reply/send hang (ETIMEDOUT / -1712). Not a hard TCC deny.
203
+ */
204
+ export function isMailSendTimeout(result) {
205
+ if (!result) return false;
206
+ if (result.kind !== "timeout") return false;
207
+ return !isHardTccDenial(result.error);
208
+ }
209
+
210
+ function mailBoxesPreamble() {
211
+ return ` set cutoff to (current date) - (10 * minutes)
212
+ set boxes to {}
213
+ try
214
+ set end of boxes to sent mailbox
215
+ end try
216
+ try
217
+ set end of boxes to outgoing mailbox
218
+ end try
219
+ repeat with acct in accounts
220
+ try
221
+ set end of boxes to sent mailbox of acct
222
+ end try
223
+ end repeat`;
224
+ }
225
+
226
+ function appleScriptToList(addresses = []) {
227
+ return `{${addresses.map((address) => asString(address)).join(", ")}}`;
228
+ }
229
+
230
+ /**
231
+ * Look in Sent / Outbox for a reply of `messageId`.
232
+ * Proper Mail replies set In-Reply-To. The fallback `make new outgoing
233
+ * message` path does not, so also match exact "Re: " & original subject.
234
+ * Never treat the original itself (or a Re: that merely contains the
235
+ * original subject) as this send.
236
+ */
237
+ export function buildFindSentByInReplyToScript(messageId) {
238
+ const idLit = asString(messageId);
239
+ const needleBare = asString(messageId);
240
+ const needleAngle = asString(`<${messageId}>`);
241
+ return `${findMessageHandler()}
242
+
243
+ set origSubject to ""
244
+ try
245
+ set origMsg to atmFindMessage(${idLit})
246
+ tell application "Mail" to set origSubject to subject of origMsg
247
+ end try
248
+ tell application "Mail"
249
+ ${mailBoxesPreamble()}
250
+ repeat with boxRef in boxes
251
+ try
252
+ set recentMsgs to (messages of boxRef whose date sent > cutoff)
253
+ repeat with msg in recentMsgs
254
+ try
255
+ set candId to message id of msg
256
+ if candId is ${needleBare} then
257
+ -- The original, not the reply we just sent.
258
+ else
259
+ try
260
+ set src to source of msg
261
+ if src contains ("In-Reply-To: " & ${needleAngle}) then return "${SENT_VERIFY_FOUND}"
262
+ if src contains ("In-Reply-To: " & ${needleBare}) then return "${SENT_VERIFY_FOUND}"
263
+ end try
264
+ if origSubject is not "" then
265
+ set subj to subject of msg
266
+ if subj is ("Re: " & origSubject) then return "${SENT_VERIFY_FOUND}"
267
+ end if
268
+ end if
269
+ end try
270
+ end repeat
271
+ end try
272
+ end repeat
273
+ end tell
274
+ return "NOT_FOUND"`;
275
+ }
276
+
277
+ /**
278
+ * Look in Sent / Outbox for a forward of `messageId` to `toAddresses`.
279
+ * Mail.app forwards do not set In-Reply-To / References — those headers are
280
+ * replies. Require the intended recipient plus either an exact Fwd:/Fw:
281
+ * subject or the original Message-ID in a forwarded body. Skip the original
282
+ * itself, In-Reply-To hits, and unrelated Fwd: mail that only shares a To.
283
+ */
284
+ export function buildFindSentForwardScript(messageId, toAddresses = []) {
285
+ const idLit = asString(messageId);
286
+ const needleBare = asString(messageId);
287
+ const needleAngle = asString(`<${messageId}>`);
288
+ const toList = appleScriptToList(toAddresses);
289
+ return `${findMessageHandler()}
290
+
291
+ set origSubject to ""
292
+ try
293
+ set origMsg to atmFindMessage(${idLit})
294
+ tell application "Mail" to set origSubject to subject of origMsg
295
+ end try
296
+ tell application "Mail"
297
+ ${mailBoxesPreamble()}
298
+ set wantedTos to ${toList}
299
+ repeat with boxRef in boxes
300
+ try
301
+ set recentMsgs to (messages of boxRef whose date sent > cutoff)
302
+ repeat with msg in recentMsgs
303
+ try
304
+ set candId to message id of msg
305
+ if candId is ${needleBare} then
306
+ -- The original, not the forward we just sent.
307
+ else
308
+ set src to source of msg
309
+ if src contains ("In-Reply-To: " & ${needleAngle}) or src contains ("In-Reply-To: " & ${needleBare}) then
310
+ -- A reply to the original is not this forward.
311
+ else
312
+ set hitTo to false
313
+ try
314
+ repeat with recip in (to recipients of msg)
315
+ set recipAddr to address of recip as string
316
+ repeat with wanted in wantedTos
317
+ if recipAddr is (wanted as string) then set hitTo to true
318
+ end repeat
319
+ end repeat
320
+ end try
321
+ if hitTo then
322
+ set subj to subject of msg
323
+ set exactFwd to false
324
+ if origSubject is not "" then
325
+ if subj is ("Fwd: " & origSubject) then set exactFwd to true
326
+ if subj is ("Fw: " & origSubject) then set exactFwd to true
327
+ if subj is ("FW: " & origSubject) then set exactFwd to true
328
+ if subj is ("Forward: " & origSubject) then set exactFwd to true
329
+ end if
330
+ set looksForward to exactFwd
331
+ if subj starts with "Fwd:" or subj starts with "Fw:" or subj starts with "FW:" or subj starts with "Forward:" then set looksForward to true
332
+ if src contains "Begin forwarded message" then set looksForward to true
333
+ set mentionsOrigId to false
334
+ if src contains ${needleAngle} then set mentionsOrigId to true
335
+ if src contains ${needleBare} then set mentionsOrigId to true
336
+ if exactFwd then return "${SENT_VERIFY_FOUND}"
337
+ if looksForward and mentionsOrigId then return "${SENT_VERIFY_FOUND}"
338
+ end if
339
+ end if
340
+ end if
341
+ end try
342
+ end repeat
343
+ end try
344
+ end repeat
345
+ end tell
346
+ return "NOT_FOUND"`;
347
+ }
348
+
349
+ /**
350
+ * Look in Sent / Outbox for a compose whose subject matches exactly and
351
+ * was sent in the last 10 minutes.
352
+ */
353
+ export function buildFindSentBySubjectScript(subject) {
354
+ const subj = asString(subject);
355
+ return `tell application "Mail"
356
+ ${mailBoxesPreamble()}
357
+ repeat with boxRef in boxes
358
+ try
359
+ set hits to (messages of boxRef whose subject is ${subj} and date sent > cutoff)
360
+ if (count of hits) > 0 then return "${SENT_VERIFY_FOUND}"
361
+ end try
362
+ end repeat
363
+ end tell
364
+ return "NOT_FOUND"`;
365
+ }
366
+
367
+ /**
368
+ * After a send hang, ask Mail whether the message is already in Sent.
369
+ * Returns a hit object or null. Verify failure / timeout is not success.
370
+ */
371
+ export function recoverIfInSent({ inReplyTo = null, subject = null, forwardTo = null } = {}) {
372
+ const script = forwardTo && inReplyTo
373
+ ? buildFindSentForwardScript(inReplyTo, forwardTo)
374
+ : inReplyTo
375
+ ? buildFindSentByInReplyToScript(inReplyTo)
376
+ : subject
377
+ ? buildFindSentBySubjectScript(subject)
378
+ : null;
379
+ if (!script) return null;
380
+ const result = runAppleScript(script, { timeout: SENT_VERIFY_TIMEOUT_MS, appName: "Mail" });
381
+ if (!result.ok) return null;
382
+ const out = String(result.output || "").trim();
383
+ if (out === SENT_VERIFY_FOUND) return { found: true };
384
+ return null;
385
+ }
386
+
174
387
  function failure(action, summary, result, secrets) {
175
- if (result.kind === "tcc" || result.kind === "timeout") {
388
+ if (result.kind === "tcc" || isHardTccDenial(result && result.error)) {
176
389
  return `${action} failed — attempted to ${summary}. ${MAIL_TCC_GUIDANCE}`;
177
390
  }
391
+ if (result.kind === "timeout") {
392
+ // Allowed hang / ETIMEDOUT / -1712 is never TCC — including pre-send find/reply/open.
393
+ return `${action} failed — attempted to ${summary}. ${MAIL_SEND_TIMEOUT_GUIDANCE}`;
394
+ }
178
395
  if (result.kind === "not_found") {
179
396
  return `${action} failed — attempted to ${summary}. The message could not be found in Mail. Pass a message_id from a current mail_search result.`;
180
397
  }
398
+ if (result.kind === "app_not_running") {
399
+ return `${action} failed — attempted to ${summary}. ${MAIL_APP_NOT_RUNNING_GUIDANCE}`;
400
+ }
181
401
  if (result.kind === "attribution") {
182
402
  return `${action} failed — attempted to ${summary}. ${ATTRIBUTION_GUIDANCE}`;
183
403
  }
@@ -187,6 +407,45 @@ function failure(action, summary, result, secrets) {
187
407
  return writeErrorMessage(action, summary, new Error(result.error || "unknown error"), secrets);
188
408
  }
189
409
 
410
+ /**
411
+ * Finish a compose/reply/forward. A send hang is not labeled TCC; if Mail
412
+ * already delivered, return success. Hard -1743/-10004 stays a deny.
413
+ */
414
+ function finalizeMailWrite({
415
+ action,
416
+ summary,
417
+ result,
418
+ secrets,
419
+ sendNow,
420
+ inReplyTo = null,
421
+ subject = null,
422
+ forwardTo = null,
423
+ successSummary,
424
+ details
425
+ }) {
426
+ if (result.ok) {
427
+ return { ok: true, message: writeSuccessMessage(action, successSummary, details) };
428
+ }
429
+
430
+ if (sendNow && isMailSendTimeout(result)) {
431
+ const recovered = recoverIfInSent({ inReplyTo, subject, forwardTo });
432
+ if (recovered) {
433
+ return {
434
+ ok: true,
435
+ recovered: true,
436
+ message: writeSuccessMessage(
437
+ action,
438
+ `${successSummary} (verified in Sent after AppleScript hang)`,
439
+ details
440
+ )
441
+ };
442
+ }
443
+ return { ok: false, message: failure(action, summary, result, secrets) };
444
+ }
445
+
446
+ return { ok: false, message: failure(action, summary, result, secrets) };
447
+ }
448
+
190
449
  /**
191
450
  * Compose and send (or save as draft) a new email.
192
451
  */
@@ -240,19 +499,21 @@ export function mailCompose(args = {}, { draft = false } = {}) {
240
499
  });
241
500
 
242
501
  const result = runAppleScript(script, { timeout: 60000, appName: "Mail" });
243
- if (!result.ok) {
244
- return { ok: false, message: failure(action, summary, result, [body.text, subject.text]) };
245
- }
246
-
247
- return {
248
- ok: true,
249
- message: writeSuccessMessage(action, draft ? `draft saved to Drafts` : `sent`, {
502
+ return finalizeMailWrite({
503
+ action,
504
+ summary,
505
+ result,
506
+ secrets: [body.text, subject.text],
507
+ sendNow: !draft,
508
+ subject: draft ? null : subject.text,
509
+ successSummary: draft ? "draft saved to Drafts" : "sent",
510
+ details: {
250
511
  to: to.addresses.join(", "),
251
512
  cc: cc.addresses.join(", ") || undefined,
252
513
  bcc: bcc.addresses.length ? `${bcc.addresses.length} recipient(s)` : undefined,
253
514
  subject: truncate(subject.text, 150)
254
- })
255
- };
515
+ }
516
+ });
256
517
  }
257
518
 
258
519
  export function buildReplyScript({ messageId, body, replyAll, sendNow }) {
@@ -310,15 +571,19 @@ export function mailReply(args = {}) {
310
571
  buildReplyScript({ messageId: resolved.messageId, body: body.text, replyAll, sendNow }),
311
572
  { timeout: 60000, appName: "Mail" }
312
573
  );
313
- if (!result.ok) return { ok: false, message: failure(action, summary, result, [body.text]) };
314
-
315
- return {
316
- ok: true,
317
- message: writeSuccessMessage(action, sendNow ? "reply sent" : "reply saved to Drafts", {
574
+ return finalizeMailWrite({
575
+ action,
576
+ summary,
577
+ result,
578
+ secrets: [body.text],
579
+ sendNow,
580
+ inReplyTo: sendNow ? resolved.messageId : null,
581
+ successSummary: sendNow ? "reply sent" : "reply saved to Drafts",
582
+ details: {
318
583
  message_id: resolved.messageId,
319
584
  reply_all: String(replyAll)
320
- })
321
- };
585
+ }
586
+ });
322
587
  }
323
588
 
324
589
  export function buildForwardScript({ messageId, to, body, sendNow }) {
@@ -369,15 +634,20 @@ export function mailForward(args = {}) {
369
634
  buildForwardScript({ messageId: resolved.messageId, to: to.addresses, body: body.text, sendNow }),
370
635
  { timeout: 60000, appName: "Mail" }
371
636
  );
372
- if (!result.ok) return { ok: false, message: failure(action, summary, result, [body.text]) };
373
-
374
- return {
375
- ok: true,
376
- message: writeSuccessMessage(action, sendNow ? "forwarded" : "forward saved to Drafts", {
637
+ return finalizeMailWrite({
638
+ action,
639
+ summary,
640
+ result,
641
+ secrets: [body.text],
642
+ sendNow,
643
+ inReplyTo: sendNow ? resolved.messageId : null,
644
+ forwardTo: sendNow ? to.addresses : null,
645
+ successSummary: sendNow ? "forwarded" : "forward saved to Drafts",
646
+ details: {
377
647
  message_id: resolved.messageId,
378
648
  to: to.addresses.join(", ")
379
- })
380
- };
649
+ }
650
+ });
381
651
  }
382
652
 
383
653
  export function buildMarkScript({ messageId, read }) {
@@ -14,7 +14,7 @@ import fs from "fs";
14
14
  import path from "path";
15
15
  import { safeSqlite3Json } from "./shell.js";
16
16
  import { escapeSQL } from "./validators.js";
17
- import { runAppleScript, asString, MESSAGES_TCC_GUIDANCE, ATTRIBUTION_GUIDANCE } from "./appleScript.js";
17
+ import { runAppleScript, asString, MESSAGES_TCC_GUIDANCE, MESSAGES_APP_NOT_RUNNING_GUIDANCE, ATTRIBUTION_GUIDANCE } from "./appleScript.js";
18
18
  import {
19
19
  planWrite,
20
20
  normalizeList,
@@ -116,10 +116,11 @@ export function probeMessagesAutomation() {
116
116
  const summary = "enumerate Messages accounts (Automation / Apple Events check; nothing is sent)";
117
117
  const result = runAppleScript(buildMessagesAutomationProbeScript(), { timeout: 30000, appName: "Messages" });
118
118
  if (!result.ok) {
119
- return { ok: false, message: failure(action, summary, result, []) };
119
+ return { ok: false, message: failure(action, summary, result, []), kind: result.kind };
120
120
  }
121
121
  return {
122
122
  ok: true,
123
+ kind: null,
123
124
  message: writeSuccessMessage(
124
125
  action,
125
126
  "Messages Automation allowed (enumerated accounts; nothing was sent)"
@@ -194,6 +195,9 @@ function failure(action, summary, result, secrets) {
194
195
  if (result.kind === "tcc" || result.kind === "timeout") {
195
196
  return `${action} failed — attempted to ${summary}. ${MESSAGES_TCC_GUIDANCE}`;
196
197
  }
198
+ if (result.kind === "app_not_running") {
199
+ return `${action} failed — attempted to ${summary}. ${MESSAGES_APP_NOT_RUNNING_GUIDANCE}`;
200
+ }
197
201
  if (result.kind === "attribution") {
198
202
  return `${action} failed — attempted to ${summary}. ${ATTRIBUTION_GUIDANCE}`;
199
203
  }