apple-tools-mcp 2.0.0 → 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.
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, 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,
@@ -98,6 +111,43 @@ function recipientLines(addresses, kind) {
98
111
  .join("\n");
99
112
  }
100
113
 
114
+ /**
115
+ * Ship-gate / first-run probe: the compose verb that hangs when node → Mail
116
+ * Automation is denied. `tell Mail to get name` is not enough — Mini
117
+ * diagnosis showed that returns while `make new outgoing message` blocks.
118
+ * Nothing is sent; the outgoing message is deleted immediately.
119
+ */
120
+ export const MAIL_AUTOMATION_PROBE_SUBJECT = "ATM Mail Automation probe";
121
+
122
+ export function buildMailAutomationProbeScript() {
123
+ return `tell application "Mail"
124
+ set probe to make new outgoing message with properties {subject:${asString(MAIL_AUTOMATION_PROBE_SUBJECT)}, content:"", visible:false}
125
+ delete probe
126
+ end tell
127
+ return "OK"`;
128
+ }
129
+
130
+ /**
131
+ * Live Mail Apple Events check. Not a dry_run: that path never talks to Mail.
132
+ * @returns {{ ok: boolean, message: string }}
133
+ */
134
+ export function probeMailAutomation() {
135
+ const action = "mail_automation_probe";
136
+ const summary = "compose a temporary outgoing message in Mail (Automation / Apple Events check; nothing is sent)";
137
+ const result = runAppleScript(buildMailAutomationProbeScript(), { timeout: 30000, appName: "Mail" });
138
+ if (!result.ok) {
139
+ return { ok: false, message: failure(action, summary, result, []), kind: result.kind };
140
+ }
141
+ return {
142
+ ok: true,
143
+ kind: null,
144
+ message: writeSuccessMessage(
145
+ action,
146
+ "Mail Automation allowed (composed and discarded a temporary outgoing message; nothing was sent)"
147
+ )
148
+ };
149
+ }
150
+
101
151
  /**
102
152
  * Build the outgoing-message script shared by send and draft.
103
153
  *
@@ -135,13 +185,219 @@ function summarizeRecipients(to, cc, bcc) {
135
185
  return parts.join("; ");
136
186
  }
137
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
+
138
387
  function failure(action, summary, result, secrets) {
139
- if (result.kind === "tcc") {
140
- return `${action} failed — attempted to ${summary}. ${TCC_GUIDANCE}`;
388
+ if (result.kind === "tcc" || isHardTccDenial(result && result.error)) {
389
+ return `${action} failed — attempted to ${summary}. ${MAIL_TCC_GUIDANCE}`;
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}`;
141
394
  }
142
395
  if (result.kind === "not_found") {
143
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.`;
144
397
  }
398
+ if (result.kind === "app_not_running") {
399
+ return `${action} failed — attempted to ${summary}. ${MAIL_APP_NOT_RUNNING_GUIDANCE}`;
400
+ }
145
401
  if (result.kind === "attribution") {
146
402
  return `${action} failed — attempted to ${summary}. ${ATTRIBUTION_GUIDANCE}`;
147
403
  }
@@ -151,6 +407,45 @@ function failure(action, summary, result, secrets) {
151
407
  return writeErrorMessage(action, summary, new Error(result.error || "unknown error"), secrets);
152
408
  }
153
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
+
154
449
  /**
155
450
  * Compose and send (or save as draft) a new email.
156
451
  */
@@ -204,19 +499,21 @@ export function mailCompose(args = {}, { draft = false } = {}) {
204
499
  });
205
500
 
206
501
  const result = runAppleScript(script, { timeout: 60000, appName: "Mail" });
207
- if (!result.ok) {
208
- return { ok: false, message: failure(action, summary, result, [body.text, subject.text]) };
209
- }
210
-
211
- return {
212
- ok: true,
213
- 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: {
214
511
  to: to.addresses.join(", "),
215
512
  cc: cc.addresses.join(", ") || undefined,
216
513
  bcc: bcc.addresses.length ? `${bcc.addresses.length} recipient(s)` : undefined,
217
514
  subject: truncate(subject.text, 150)
218
- })
219
- };
515
+ }
516
+ });
220
517
  }
221
518
 
222
519
  export function buildReplyScript({ messageId, body, replyAll, sendNow }) {
@@ -274,15 +571,19 @@ export function mailReply(args = {}) {
274
571
  buildReplyScript({ messageId: resolved.messageId, body: body.text, replyAll, sendNow }),
275
572
  { timeout: 60000, appName: "Mail" }
276
573
  );
277
- if (!result.ok) return { ok: false, message: failure(action, summary, result, [body.text]) };
278
-
279
- return {
280
- ok: true,
281
- 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: {
282
583
  message_id: resolved.messageId,
283
584
  reply_all: String(replyAll)
284
- })
285
- };
585
+ }
586
+ });
286
587
  }
287
588
 
288
589
  export function buildForwardScript({ messageId, to, body, sendNow }) {
@@ -333,15 +634,20 @@ export function mailForward(args = {}) {
333
634
  buildForwardScript({ messageId: resolved.messageId, to: to.addresses, body: body.text, sendNow }),
334
635
  { timeout: 60000, appName: "Mail" }
335
636
  );
336
- if (!result.ok) return { ok: false, message: failure(action, summary, result, [body.text]) };
337
-
338
- return {
339
- ok: true,
340
- 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: {
341
647
  message_id: resolved.messageId,
342
648
  to: to.addresses.join(", ")
343
- })
344
- };
649
+ }
650
+ });
345
651
  }
346
652
 
347
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, 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,
@@ -95,6 +95,39 @@ export function validateAttachmentPath(value) {
95
95
  return { filePath: resolved, error: null };
96
96
  }
97
97
 
98
+ /**
99
+ * Ship-gate / first-run probe: the Messages Apple Events used before send
100
+ * (enumerate accounts / service type). `messages_send` dry_run never talks
101
+ * to Messages. Nothing is sent.
102
+ */
103
+ export function buildMessagesAutomationProbeScript() {
104
+ return `tell application "Messages"
105
+ repeat with acc in accounts
106
+ try
107
+ get service type of acc
108
+ end try
109
+ end repeat
110
+ end tell
111
+ return "OK"`;
112
+ }
113
+
114
+ export function probeMessagesAutomation() {
115
+ const action = "messages_automation_probe";
116
+ const summary = "enumerate Messages accounts (Automation / Apple Events check; nothing is sent)";
117
+ const result = runAppleScript(buildMessagesAutomationProbeScript(), { timeout: 30000, appName: "Messages" });
118
+ if (!result.ok) {
119
+ return { ok: false, message: failure(action, summary, result, []), kind: result.kind };
120
+ }
121
+ return {
122
+ ok: true,
123
+ kind: null,
124
+ message: writeSuccessMessage(
125
+ action,
126
+ "Messages Automation allowed (enumerated accounts; nothing was sent)"
127
+ )
128
+ };
129
+ }
130
+
98
131
  function serviceHandler() {
99
132
  return `on atmService(kind)
100
133
  tell application "Messages"
@@ -159,8 +192,11 @@ return "OK"`;
159
192
  }
160
193
 
161
194
  function failure(action, summary, result, secrets) {
162
- if (result.kind === "tcc") {
163
- return `${action} failed — attempted to ${summary}. ${TCC_GUIDANCE}`;
195
+ if (result.kind === "tcc" || result.kind === "timeout") {
196
+ return `${action} failed — attempted to ${summary}. ${MESSAGES_TCC_GUIDANCE}`;
197
+ }
198
+ if (result.kind === "app_not_running") {
199
+ return `${action} failed — attempted to ${summary}. ${MESSAGES_APP_NOT_RUNNING_GUIDANCE}`;
164
200
  }
165
201
  if (result.kind === "attribution") {
166
202
  return `${action} failed — attempted to ${summary}. ${ATTRIBUTION_GUIDANCE}`;