apple-tools-mcp 1.2.1 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,500 @@
1
+ /**
2
+ * Apple Mail write operations: send, reply, forward, draft, mark read/unread,
3
+ * archive, trash.
4
+ *
5
+ * Messages are addressed by their RFC822 Message-ID (Mail's `message id`
6
+ * property). `file_path` from mail_search / mail_recent is also accepted and
7
+ * is resolved to a Message-ID by reading the .emlx headers, so callers never
8
+ * have to invent an identifier.
9
+ */
10
+
11
+ import fs from "fs";
12
+ import path from "path";
13
+ import { validateEmailPath, unfoldRfc822Headers, safeMatch, stripHtmlTags } from "./validators.js";
14
+ import { runAppleScript, asString, MAIL_TCC_GUIDANCE, ATTRIBUTION_GUIDANCE } from "./appleScript.js";
15
+ import {
16
+ planWrite,
17
+ validateEmailList,
18
+ validateBody,
19
+ validateSubject,
20
+ validateMessageId,
21
+ writeErrorMessage,
22
+ writeSuccessMessage,
23
+ isFlagTrue,
24
+ truncate
25
+ } from "./writeGuards.js";
26
+
27
+ const MAIL_DIR = path.join(process.env.HOME || "", "Library", "Mail");
28
+
29
+ /**
30
+ * Resolve the Mail message id from either an explicit id or an .emlx path.
31
+ * @returns {{ messageId: string|null, error: string|null }}
32
+ */
33
+ export function resolveMailMessageId({ messageId, filePath } = {}) {
34
+ if (messageId) {
35
+ const valid = validateMessageId(messageId);
36
+ if (!valid) return { messageId: null, error: "message_id is not a valid RFC822 Message-ID" };
37
+ return { messageId: valid, error: null };
38
+ }
39
+
40
+ if (!filePath) {
41
+ return {
42
+ messageId: null,
43
+ error: "message_id is required (or file_path from mail_search / mail_recent). This tool will not guess which message you meant."
44
+ };
45
+ }
46
+
47
+ let resolvedPath;
48
+ try {
49
+ resolvedPath = validateEmailPath(filePath, MAIL_DIR);
50
+ } catch (e) {
51
+ return { messageId: null, error: `file_path rejected: ${e.message}` };
52
+ }
53
+
54
+ let raw;
55
+ try {
56
+ raw = fs.readFileSync(resolvedPath, "utf-8");
57
+ } catch (e) {
58
+ return { messageId: null, error: `Could not read the email file (${e.code || "read error"})` };
59
+ }
60
+
61
+ const headerMatch = safeMatch(unfoldRfc822Headers(raw), /^Message-ID:\s*(.+)$/im, 200000);
62
+ const found = headerMatch && headerMatch[1] ? validateMessageId(headerMatch[1].trim()) : null;
63
+ if (!found) {
64
+ return { messageId: null, error: "That email has no usable Message-ID header; pass message_id explicitly." };
65
+ }
66
+ return { messageId: found, error: null };
67
+ }
68
+
69
+ /**
70
+ * AppleScript handler that locates a message by Message-ID. Checks inbox
71
+ * first, then every mailbox of every account.
72
+ */
73
+ function findMessageHandler() {
74
+ return `on atmFindMessage(msgId)
75
+ tell application "Mail"
76
+ try
77
+ set quickHits to (messages of inbox whose message id is msgId)
78
+ if (count of quickHits) > 0 then return item 1 of quickHits
79
+ end try
80
+ repeat with acct in accounts
81
+ try
82
+ repeat with mb in (every mailbox of acct)
83
+ try
84
+ set hits to (messages of mb whose message id is msgId)
85
+ if (count of hits) > 0 then return item 1 of hits
86
+ end try
87
+ end repeat
88
+ end try
89
+ end repeat
90
+ end tell
91
+ error "MESSAGE_NOT_FOUND"
92
+ end atmFindMessage`;
93
+ }
94
+
95
+ function recipientLines(addresses, kind) {
96
+ return addresses
97
+ .map((address) => ` make new ${kind} at end of ${kind}s with properties {address:${asString(address)}}`)
98
+ .join("\n");
99
+ }
100
+
101
+ /**
102
+ * Ship-gate / first-run probe: the compose verb that hangs when node → Mail
103
+ * Automation is denied. `tell Mail to get name` is not enough — Mini
104
+ * diagnosis showed that returns while `make new outgoing message` blocks.
105
+ * Nothing is sent; the outgoing message is deleted immediately.
106
+ */
107
+ export const MAIL_AUTOMATION_PROBE_SUBJECT = "ATM Mail Automation probe";
108
+
109
+ export function buildMailAutomationProbeScript() {
110
+ return `tell application "Mail"
111
+ set probe to make new outgoing message with properties {subject:${asString(MAIL_AUTOMATION_PROBE_SUBJECT)}, content:"", visible:false}
112
+ delete probe
113
+ end tell
114
+ return "OK"`;
115
+ }
116
+
117
+ /**
118
+ * Live Mail Apple Events check. Not a dry_run: that path never talks to Mail.
119
+ * @returns {{ ok: boolean, message: string }}
120
+ */
121
+ export function probeMailAutomation() {
122
+ const action = "mail_automation_probe";
123
+ const summary = "compose a temporary outgoing message in Mail (Automation / Apple Events check; nothing is sent)";
124
+ const result = runAppleScript(buildMailAutomationProbeScript(), { timeout: 30000, appName: "Mail" });
125
+ if (!result.ok) {
126
+ return { ok: false, message: failure(action, summary, result, []) };
127
+ }
128
+ return {
129
+ ok: true,
130
+ message: writeSuccessMessage(
131
+ action,
132
+ "Mail Automation allowed (composed and discarded a temporary outgoing message; nothing was sent)"
133
+ )
134
+ };
135
+ }
136
+
137
+ /**
138
+ * Build the outgoing-message script shared by send and draft.
139
+ *
140
+ * Mail renders `html content` when it is set; `content` stays populated as
141
+ * the plain-text alternative for clients that do not display HTML.
142
+ */
143
+ export function buildComposeScript({ to, cc, bcc, subject, body, send, html = false }) {
144
+ const recipients = [
145
+ recipientLines(to, "to recipient"),
146
+ recipientLines(cc, "cc recipient"),
147
+ recipientLines(bcc, "bcc recipient")
148
+ ].filter((block) => block.length > 0).join("\n");
149
+
150
+ const htmlLine = html
151
+ ? ` try
152
+ set html content of newMessage to ${asString(body)}
153
+ end try\n`
154
+ : "";
155
+
156
+ return `tell application "Mail"
157
+ set newMessage to make new outgoing message with properties {subject:${asString(subject)}, content:${asString(html ? stripHtmlTags(body) : body)}, visible:false}
158
+ ${htmlLine} tell newMessage
159
+ ${recipients}
160
+ end tell
161
+ ${send ? "send newMessage" : "save newMessage"}
162
+ end tell
163
+ return "OK"`;
164
+ }
165
+
166
+ function summarizeRecipients(to, cc, bcc) {
167
+ const parts = [];
168
+ if (to.length) parts.push(`to ${to.join(", ")}`);
169
+ if (cc.length) parts.push(`cc ${cc.join(", ")}`);
170
+ if (bcc.length) parts.push(`bcc ${bcc.length} recipient${bcc.length === 1 ? "" : "s"}`);
171
+ return parts.join("; ");
172
+ }
173
+
174
+ function failure(action, summary, result, secrets) {
175
+ if (result.kind === "tcc" || result.kind === "timeout") {
176
+ return `${action} failed — attempted to ${summary}. ${MAIL_TCC_GUIDANCE}`;
177
+ }
178
+ if (result.kind === "not_found") {
179
+ 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
+ }
181
+ if (result.kind === "attribution") {
182
+ return `${action} failed — attempted to ${summary}. ${ATTRIBUTION_GUIDANCE}`;
183
+ }
184
+ if (result.kind === "app_unavailable") {
185
+ return `${action} failed — attempted to ${summary}. Mail.app could not be reached on this host.`;
186
+ }
187
+ return writeErrorMessage(action, summary, new Error(result.error || "unknown error"), secrets);
188
+ }
189
+
190
+ /**
191
+ * Compose and send (or save as draft) a new email.
192
+ */
193
+ export function mailCompose(args = {}, { draft = false } = {}) {
194
+ const action = draft ? "mail_draft" : "mail_send";
195
+
196
+ const to = validateEmailList(args.to, "to");
197
+ if (to.error) return { ok: false, message: `${action} refused: ${to.error}` };
198
+ const cc = validateEmailList(args.cc, "cc");
199
+ if (cc.error) return { ok: false, message: `${action} refused: ${cc.error}` };
200
+ const bcc = validateEmailList(args.bcc, "bcc");
201
+ if (bcc.error) return { ok: false, message: `${action} refused: ${bcc.error}` };
202
+
203
+ if (to.addresses.length === 0) {
204
+ return { ok: false, message: `${action} refused: at least one valid address in "to" is required. This tool never invents recipients.` };
205
+ }
206
+
207
+ const subject = validateSubject(args.subject, { required: !draft });
208
+ if (subject.error) return { ok: false, message: `${action} refused: ${subject.error}` };
209
+ const body = validateBody(args.body, { required: !draft });
210
+ if (body.error) return { ok: false, message: `${action} refused: ${body.error}` };
211
+
212
+ const bodyFormat = args.body_format === undefined ? "plain" : String(args.body_format).toLowerCase();
213
+ if (bodyFormat !== "plain" && bodyFormat !== "html") {
214
+ return { ok: false, message: `${action} refused: body_format must be "plain" or "html"` };
215
+ }
216
+
217
+ const recipientCount = to.addresses.length + cc.addresses.length + bcc.addresses.length;
218
+ const summary = draft
219
+ ? `save a draft ${summarizeRecipients(to.addresses, cc.addresses, bcc.addresses)} with subject "${truncate(subject.text, 120)}"`
220
+ : `send mail ${summarizeRecipients(to.addresses, cc.addresses, bcc.addresses)} with subject "${truncate(subject.text, 120)}"`;
221
+
222
+ // A draft is not delivery, so it does not need multi-recipient confirmation.
223
+ const plan = planWrite({
224
+ action,
225
+ summary,
226
+ recipientCount: draft ? 0 : recipientCount,
227
+ dryRun: isFlagTrue(args.dry_run),
228
+ confirm: isFlagTrue(args.confirm)
229
+ });
230
+ if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
231
+
232
+ const script = buildComposeScript({
233
+ to: to.addresses,
234
+ cc: cc.addresses,
235
+ bcc: bcc.addresses,
236
+ subject: subject.text,
237
+ body: body.text,
238
+ send: !draft,
239
+ html: bodyFormat === "html"
240
+ });
241
+
242
+ 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`, {
250
+ to: to.addresses.join(", "),
251
+ cc: cc.addresses.join(", ") || undefined,
252
+ bcc: bcc.addresses.length ? `${bcc.addresses.length} recipient(s)` : undefined,
253
+ subject: truncate(subject.text, 150)
254
+ })
255
+ };
256
+ }
257
+
258
+ export function buildReplyScript({ messageId, body, replyAll, sendNow }) {
259
+ return `${findMessageHandler()}
260
+
261
+ set theMessage to atmFindMessage(${asString(messageId)})
262
+ tell application "Mail"
263
+ set theReply to missing value
264
+ try
265
+ set theReply to reply theMessage without opening window ${replyAll ? "with reply to all" : "without reply to all"}
266
+ end try
267
+ if theReply is missing value then
268
+ -- Some Mail versions do not return the outgoing message from a reply.
269
+ -- Fall back to a new message addressed to the original sender.
270
+ set origSubject to subject of theMessage
271
+ set origSender to extract address from (sender of theMessage)
272
+ set theReply to make new outgoing message with properties {subject:("Re: " & origSubject), content:${asString(body)}, visible:false}
273
+ tell theReply
274
+ make new to recipient at end of to recipients with properties {address:origSender}
275
+ end tell
276
+ else
277
+ tell theReply
278
+ set content to ${asString(body)} & return & content
279
+ end tell
280
+ end if
281
+ ${sendNow ? "send theReply" : "save theReply"}
282
+ end tell
283
+ return "OK"`;
284
+ }
285
+
286
+ export function mailReply(args = {}) {
287
+ const action = "mail_reply";
288
+ const resolved = resolveMailMessageId({ messageId: args.message_id, filePath: args.file_path });
289
+ if (resolved.error) return { ok: false, message: `${action} refused: ${resolved.error}` };
290
+
291
+ const body = validateBody(args.body, { required: true });
292
+ if (body.error) return { ok: false, message: `${action} refused: ${body.error}` };
293
+
294
+ const replyAll = isFlagTrue(args.reply_all);
295
+ const sendNow = !isFlagTrue(args.save_as_draft);
296
+ const summary = `${sendNow ? "send" : "draft"} a ${replyAll ? "reply-all" : "reply"} to message ${truncate(resolved.messageId, 120)}`;
297
+
298
+ // reply-all fans out to every original recipient, so treat it like a
299
+ // multi-recipient send and require confirmation.
300
+ const plan = planWrite({
301
+ action,
302
+ summary,
303
+ recipientCount: replyAll && sendNow ? 2 : 0,
304
+ dryRun: isFlagTrue(args.dry_run),
305
+ confirm: isFlagTrue(args.confirm)
306
+ });
307
+ if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
308
+
309
+ const result = runAppleScript(
310
+ buildReplyScript({ messageId: resolved.messageId, body: body.text, replyAll, sendNow }),
311
+ { timeout: 60000, appName: "Mail" }
312
+ );
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", {
318
+ message_id: resolved.messageId,
319
+ reply_all: String(replyAll)
320
+ })
321
+ };
322
+ }
323
+
324
+ export function buildForwardScript({ messageId, to, body, sendNow }) {
325
+ return `${findMessageHandler()}
326
+
327
+ set theMessage to atmFindMessage(${asString(messageId)})
328
+ tell application "Mail"
329
+ set theForward to missing value
330
+ try
331
+ set theForward to forward theMessage without opening window
332
+ end try
333
+ if theForward is missing value then error "FORWARD_UNSUPPORTED"
334
+ tell theForward
335
+ set content to ${asString(body)} & return & content
336
+ ${to.map((address) => ` make new to recipient at end of to recipients with properties {address:${asString(address)}}`).join("\n")}
337
+ end tell
338
+ ${sendNow ? "send theForward" : "save theForward"}
339
+ end tell
340
+ return "OK"`;
341
+ }
342
+
343
+ export function mailForward(args = {}) {
344
+ const action = "mail_forward";
345
+ const resolved = resolveMailMessageId({ messageId: args.message_id, filePath: args.file_path });
346
+ if (resolved.error) return { ok: false, message: `${action} refused: ${resolved.error}` };
347
+
348
+ const to = validateEmailList(args.to, "to");
349
+ if (to.error) return { ok: false, message: `${action} refused: ${to.error}` };
350
+ if (to.addresses.length === 0) {
351
+ return { ok: false, message: `${action} refused: at least one valid address in "to" is required.` };
352
+ }
353
+ const body = validateBody(args.body, { required: false });
354
+ if (body.error) return { ok: false, message: `${action} refused: ${body.error}` };
355
+
356
+ const sendNow = !isFlagTrue(args.save_as_draft);
357
+ const summary = `${sendNow ? "forward" : "draft a forward of"} message ${truncate(resolved.messageId, 120)} to ${to.addresses.join(", ")}`;
358
+
359
+ const plan = planWrite({
360
+ action,
361
+ summary,
362
+ recipientCount: sendNow ? to.addresses.length : 0,
363
+ dryRun: isFlagTrue(args.dry_run),
364
+ confirm: isFlagTrue(args.confirm)
365
+ });
366
+ if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
367
+
368
+ const result = runAppleScript(
369
+ buildForwardScript({ messageId: resolved.messageId, to: to.addresses, body: body.text, sendNow }),
370
+ { timeout: 60000, appName: "Mail" }
371
+ );
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", {
377
+ message_id: resolved.messageId,
378
+ to: to.addresses.join(", ")
379
+ })
380
+ };
381
+ }
382
+
383
+ export function buildMarkScript({ messageId, read }) {
384
+ return `${findMessageHandler()}
385
+
386
+ set theMessage to atmFindMessage(${asString(messageId)})
387
+ tell application "Mail"
388
+ set read status of theMessage to ${read ? "true" : "false"}
389
+ end tell
390
+ return "OK"`;
391
+ }
392
+
393
+ export function mailMark(args = {}) {
394
+ const action = "mail_mark";
395
+ const status = args.status === undefined ? "read" : String(args.status).toLowerCase();
396
+ if (status !== "read" && status !== "unread") {
397
+ return { ok: false, message: `${action} refused: status must be "read" or "unread"` };
398
+ }
399
+ const resolved = resolveMailMessageId({ messageId: args.message_id, filePath: args.file_path });
400
+ if (resolved.error) return { ok: false, message: `${action} refused: ${resolved.error}` };
401
+
402
+ const summary = `mark message ${truncate(resolved.messageId, 120)} as ${status}`;
403
+ const plan = planWrite({
404
+ action,
405
+ summary,
406
+ dryRun: isFlagTrue(args.dry_run),
407
+ confirm: isFlagTrue(args.confirm)
408
+ });
409
+ if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
410
+
411
+ const result = runAppleScript(buildMarkScript({ messageId: resolved.messageId, read: status === "read" }), { appName: "Mail" });
412
+ if (!result.ok) return { ok: false, message: failure(action, summary, result, []) };
413
+
414
+ return { ok: true, message: writeSuccessMessage(action, `marked as ${status}`, { message_id: resolved.messageId }) };
415
+ }
416
+
417
+ export function buildMoveScript({ messageId, mailboxNames, allowDeleteFallback }) {
418
+ const candidates = mailboxNames
419
+ .map((name) => ` if targetBox is missing value then
420
+ try
421
+ set targetBox to mailbox ${asString(name)} of acct
422
+ end try
423
+ end if`)
424
+ .join("\n");
425
+
426
+ return `${findMessageHandler()}
427
+
428
+ set theMessage to atmFindMessage(${asString(messageId)})
429
+ tell application "Mail"
430
+ set acct to account of (mailbox of theMessage)
431
+ set targetBox to missing value
432
+ ${candidates}
433
+ if targetBox is missing value then
434
+ ${allowDeleteFallback ? "delete theMessage" : 'error "ARCHIVE_MAILBOX_NOT_FOUND"'}
435
+ else
436
+ set mailbox of theMessage to targetBox
437
+ end if
438
+ end tell
439
+ return "OK"`;
440
+ }
441
+
442
+ export function mailArchive(args = {}) {
443
+ const action = "mail_archive";
444
+ const resolved = resolveMailMessageId({ messageId: args.message_id, filePath: args.file_path });
445
+ if (resolved.error) return { ok: false, message: `${action} refused: ${resolved.error}` };
446
+
447
+ const summary = `archive message ${truncate(resolved.messageId, 120)}`;
448
+ const plan = planWrite({
449
+ action,
450
+ summary,
451
+ dryRun: isFlagTrue(args.dry_run),
452
+ confirm: isFlagTrue(args.confirm)
453
+ });
454
+ if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
455
+
456
+ const result = runAppleScript(
457
+ buildMoveScript({
458
+ messageId: resolved.messageId,
459
+ mailboxNames: ["Archive", "All Mail", "Archived"],
460
+ allowDeleteFallback: false
461
+ }),
462
+ { appName: "Mail" }
463
+ );
464
+ if (!result.ok) {
465
+ if (result.kind === "not_found" && String(result.error).includes("ARCHIVE_MAILBOX_NOT_FOUND")) {
466
+ return { ok: false, message: `${action} failed — attempted to ${summary}. That account has no Archive mailbox.` };
467
+ }
468
+ return { ok: false, message: failure(action, summary, result, []) };
469
+ }
470
+
471
+ return { ok: true, message: writeSuccessMessage(action, "moved to Archive", { message_id: resolved.messageId }) };
472
+ }
473
+
474
+ export function mailTrash(args = {}) {
475
+ const action = "mail_trash";
476
+ const resolved = resolveMailMessageId({ messageId: args.message_id, filePath: args.file_path });
477
+ if (resolved.error) return { ok: false, message: `${action} refused: ${resolved.error}` };
478
+
479
+ const summary = `move message ${truncate(resolved.messageId, 120)} to Trash`;
480
+ const plan = planWrite({
481
+ action,
482
+ summary,
483
+ destructive: true,
484
+ dryRun: isFlagTrue(args.dry_run),
485
+ confirm: isFlagTrue(args.confirm)
486
+ });
487
+ if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
488
+
489
+ const result = runAppleScript(
490
+ buildMoveScript({
491
+ messageId: resolved.messageId,
492
+ mailboxNames: ["Trash", "Deleted Messages", "Bin"],
493
+ allowDeleteFallback: true
494
+ }),
495
+ { appName: "Mail" }
496
+ );
497
+ if (!result.ok) return { ok: false, message: failure(action, summary, result, []) };
498
+
499
+ return { ok: true, message: writeSuccessMessage(action, "moved to Trash", { message_id: resolved.messageId }) };
500
+ }