apple-tools-mcp 1.2.0 → 2.0.0

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,464 @@
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, 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
+ * Build the outgoing-message script shared by send and draft.
103
+ *
104
+ * Mail renders `html content` when it is set; `content` stays populated as
105
+ * the plain-text alternative for clients that do not display HTML.
106
+ */
107
+ export function buildComposeScript({ to, cc, bcc, subject, body, send, html = false }) {
108
+ const recipients = [
109
+ recipientLines(to, "to recipient"),
110
+ recipientLines(cc, "cc recipient"),
111
+ recipientLines(bcc, "bcc recipient")
112
+ ].filter((block) => block.length > 0).join("\n");
113
+
114
+ const htmlLine = html
115
+ ? ` try
116
+ set html content of newMessage to ${asString(body)}
117
+ end try\n`
118
+ : "";
119
+
120
+ return `tell application "Mail"
121
+ set newMessage to make new outgoing message with properties {subject:${asString(subject)}, content:${asString(html ? stripHtmlTags(body) : body)}, visible:false}
122
+ ${htmlLine} tell newMessage
123
+ ${recipients}
124
+ end tell
125
+ ${send ? "send newMessage" : "save newMessage"}
126
+ end tell
127
+ return "OK"`;
128
+ }
129
+
130
+ function summarizeRecipients(to, cc, bcc) {
131
+ const parts = [];
132
+ if (to.length) parts.push(`to ${to.join(", ")}`);
133
+ if (cc.length) parts.push(`cc ${cc.join(", ")}`);
134
+ if (bcc.length) parts.push(`bcc ${bcc.length} recipient${bcc.length === 1 ? "" : "s"}`);
135
+ return parts.join("; ");
136
+ }
137
+
138
+ function failure(action, summary, result, secrets) {
139
+ if (result.kind === "tcc") {
140
+ return `${action} failed — attempted to ${summary}. ${TCC_GUIDANCE}`;
141
+ }
142
+ if (result.kind === "not_found") {
143
+ 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
+ }
145
+ if (result.kind === "attribution") {
146
+ return `${action} failed — attempted to ${summary}. ${ATTRIBUTION_GUIDANCE}`;
147
+ }
148
+ if (result.kind === "app_unavailable") {
149
+ return `${action} failed — attempted to ${summary}. Mail.app could not be reached on this host.`;
150
+ }
151
+ return writeErrorMessage(action, summary, new Error(result.error || "unknown error"), secrets);
152
+ }
153
+
154
+ /**
155
+ * Compose and send (or save as draft) a new email.
156
+ */
157
+ export function mailCompose(args = {}, { draft = false } = {}) {
158
+ const action = draft ? "mail_draft" : "mail_send";
159
+
160
+ const to = validateEmailList(args.to, "to");
161
+ if (to.error) return { ok: false, message: `${action} refused: ${to.error}` };
162
+ const cc = validateEmailList(args.cc, "cc");
163
+ if (cc.error) return { ok: false, message: `${action} refused: ${cc.error}` };
164
+ const bcc = validateEmailList(args.bcc, "bcc");
165
+ if (bcc.error) return { ok: false, message: `${action} refused: ${bcc.error}` };
166
+
167
+ if (to.addresses.length === 0) {
168
+ return { ok: false, message: `${action} refused: at least one valid address in "to" is required. This tool never invents recipients.` };
169
+ }
170
+
171
+ const subject = validateSubject(args.subject, { required: !draft });
172
+ if (subject.error) return { ok: false, message: `${action} refused: ${subject.error}` };
173
+ const body = validateBody(args.body, { required: !draft });
174
+ if (body.error) return { ok: false, message: `${action} refused: ${body.error}` };
175
+
176
+ const bodyFormat = args.body_format === undefined ? "plain" : String(args.body_format).toLowerCase();
177
+ if (bodyFormat !== "plain" && bodyFormat !== "html") {
178
+ return { ok: false, message: `${action} refused: body_format must be "plain" or "html"` };
179
+ }
180
+
181
+ const recipientCount = to.addresses.length + cc.addresses.length + bcc.addresses.length;
182
+ const summary = draft
183
+ ? `save a draft ${summarizeRecipients(to.addresses, cc.addresses, bcc.addresses)} with subject "${truncate(subject.text, 120)}"`
184
+ : `send mail ${summarizeRecipients(to.addresses, cc.addresses, bcc.addresses)} with subject "${truncate(subject.text, 120)}"`;
185
+
186
+ // A draft is not delivery, so it does not need multi-recipient confirmation.
187
+ const plan = planWrite({
188
+ action,
189
+ summary,
190
+ recipientCount: draft ? 0 : recipientCount,
191
+ dryRun: isFlagTrue(args.dry_run),
192
+ confirm: isFlagTrue(args.confirm)
193
+ });
194
+ if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
195
+
196
+ const script = buildComposeScript({
197
+ to: to.addresses,
198
+ cc: cc.addresses,
199
+ bcc: bcc.addresses,
200
+ subject: subject.text,
201
+ body: body.text,
202
+ send: !draft,
203
+ html: bodyFormat === "html"
204
+ });
205
+
206
+ 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`, {
214
+ to: to.addresses.join(", "),
215
+ cc: cc.addresses.join(", ") || undefined,
216
+ bcc: bcc.addresses.length ? `${bcc.addresses.length} recipient(s)` : undefined,
217
+ subject: truncate(subject.text, 150)
218
+ })
219
+ };
220
+ }
221
+
222
+ export function buildReplyScript({ messageId, body, replyAll, sendNow }) {
223
+ return `${findMessageHandler()}
224
+
225
+ set theMessage to atmFindMessage(${asString(messageId)})
226
+ tell application "Mail"
227
+ set theReply to missing value
228
+ try
229
+ set theReply to reply theMessage without opening window ${replyAll ? "with reply to all" : "without reply to all"}
230
+ end try
231
+ if theReply is missing value then
232
+ -- Some Mail versions do not return the outgoing message from a reply.
233
+ -- Fall back to a new message addressed to the original sender.
234
+ set origSubject to subject of theMessage
235
+ set origSender to extract address from (sender of theMessage)
236
+ set theReply to make new outgoing message with properties {subject:("Re: " & origSubject), content:${asString(body)}, visible:false}
237
+ tell theReply
238
+ make new to recipient at end of to recipients with properties {address:origSender}
239
+ end tell
240
+ else
241
+ tell theReply
242
+ set content to ${asString(body)} & return & content
243
+ end tell
244
+ end if
245
+ ${sendNow ? "send theReply" : "save theReply"}
246
+ end tell
247
+ return "OK"`;
248
+ }
249
+
250
+ export function mailReply(args = {}) {
251
+ const action = "mail_reply";
252
+ const resolved = resolveMailMessageId({ messageId: args.message_id, filePath: args.file_path });
253
+ if (resolved.error) return { ok: false, message: `${action} refused: ${resolved.error}` };
254
+
255
+ const body = validateBody(args.body, { required: true });
256
+ if (body.error) return { ok: false, message: `${action} refused: ${body.error}` };
257
+
258
+ const replyAll = isFlagTrue(args.reply_all);
259
+ const sendNow = !isFlagTrue(args.save_as_draft);
260
+ const summary = `${sendNow ? "send" : "draft"} a ${replyAll ? "reply-all" : "reply"} to message ${truncate(resolved.messageId, 120)}`;
261
+
262
+ // reply-all fans out to every original recipient, so treat it like a
263
+ // multi-recipient send and require confirmation.
264
+ const plan = planWrite({
265
+ action,
266
+ summary,
267
+ recipientCount: replyAll && sendNow ? 2 : 0,
268
+ dryRun: isFlagTrue(args.dry_run),
269
+ confirm: isFlagTrue(args.confirm)
270
+ });
271
+ if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
272
+
273
+ const result = runAppleScript(
274
+ buildReplyScript({ messageId: resolved.messageId, body: body.text, replyAll, sendNow }),
275
+ { timeout: 60000, appName: "Mail" }
276
+ );
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", {
282
+ message_id: resolved.messageId,
283
+ reply_all: String(replyAll)
284
+ })
285
+ };
286
+ }
287
+
288
+ export function buildForwardScript({ messageId, to, body, sendNow }) {
289
+ return `${findMessageHandler()}
290
+
291
+ set theMessage to atmFindMessage(${asString(messageId)})
292
+ tell application "Mail"
293
+ set theForward to missing value
294
+ try
295
+ set theForward to forward theMessage without opening window
296
+ end try
297
+ if theForward is missing value then error "FORWARD_UNSUPPORTED"
298
+ tell theForward
299
+ set content to ${asString(body)} & return & content
300
+ ${to.map((address) => ` make new to recipient at end of to recipients with properties {address:${asString(address)}}`).join("\n")}
301
+ end tell
302
+ ${sendNow ? "send theForward" : "save theForward"}
303
+ end tell
304
+ return "OK"`;
305
+ }
306
+
307
+ export function mailForward(args = {}) {
308
+ const action = "mail_forward";
309
+ const resolved = resolveMailMessageId({ messageId: args.message_id, filePath: args.file_path });
310
+ if (resolved.error) return { ok: false, message: `${action} refused: ${resolved.error}` };
311
+
312
+ const to = validateEmailList(args.to, "to");
313
+ if (to.error) return { ok: false, message: `${action} refused: ${to.error}` };
314
+ if (to.addresses.length === 0) {
315
+ return { ok: false, message: `${action} refused: at least one valid address in "to" is required.` };
316
+ }
317
+ const body = validateBody(args.body, { required: false });
318
+ if (body.error) return { ok: false, message: `${action} refused: ${body.error}` };
319
+
320
+ const sendNow = !isFlagTrue(args.save_as_draft);
321
+ const summary = `${sendNow ? "forward" : "draft a forward of"} message ${truncate(resolved.messageId, 120)} to ${to.addresses.join(", ")}`;
322
+
323
+ const plan = planWrite({
324
+ action,
325
+ summary,
326
+ recipientCount: sendNow ? to.addresses.length : 0,
327
+ dryRun: isFlagTrue(args.dry_run),
328
+ confirm: isFlagTrue(args.confirm)
329
+ });
330
+ if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
331
+
332
+ const result = runAppleScript(
333
+ buildForwardScript({ messageId: resolved.messageId, to: to.addresses, body: body.text, sendNow }),
334
+ { timeout: 60000, appName: "Mail" }
335
+ );
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", {
341
+ message_id: resolved.messageId,
342
+ to: to.addresses.join(", ")
343
+ })
344
+ };
345
+ }
346
+
347
+ export function buildMarkScript({ messageId, read }) {
348
+ return `${findMessageHandler()}
349
+
350
+ set theMessage to atmFindMessage(${asString(messageId)})
351
+ tell application "Mail"
352
+ set read status of theMessage to ${read ? "true" : "false"}
353
+ end tell
354
+ return "OK"`;
355
+ }
356
+
357
+ export function mailMark(args = {}) {
358
+ const action = "mail_mark";
359
+ const status = args.status === undefined ? "read" : String(args.status).toLowerCase();
360
+ if (status !== "read" && status !== "unread") {
361
+ return { ok: false, message: `${action} refused: status must be "read" or "unread"` };
362
+ }
363
+ const resolved = resolveMailMessageId({ messageId: args.message_id, filePath: args.file_path });
364
+ if (resolved.error) return { ok: false, message: `${action} refused: ${resolved.error}` };
365
+
366
+ const summary = `mark message ${truncate(resolved.messageId, 120)} as ${status}`;
367
+ const plan = planWrite({
368
+ action,
369
+ summary,
370
+ dryRun: isFlagTrue(args.dry_run),
371
+ confirm: isFlagTrue(args.confirm)
372
+ });
373
+ if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
374
+
375
+ const result = runAppleScript(buildMarkScript({ messageId: resolved.messageId, read: status === "read" }), { appName: "Mail" });
376
+ if (!result.ok) return { ok: false, message: failure(action, summary, result, []) };
377
+
378
+ return { ok: true, message: writeSuccessMessage(action, `marked as ${status}`, { message_id: resolved.messageId }) };
379
+ }
380
+
381
+ export function buildMoveScript({ messageId, mailboxNames, allowDeleteFallback }) {
382
+ const candidates = mailboxNames
383
+ .map((name) => ` if targetBox is missing value then
384
+ try
385
+ set targetBox to mailbox ${asString(name)} of acct
386
+ end try
387
+ end if`)
388
+ .join("\n");
389
+
390
+ return `${findMessageHandler()}
391
+
392
+ set theMessage to atmFindMessage(${asString(messageId)})
393
+ tell application "Mail"
394
+ set acct to account of (mailbox of theMessage)
395
+ set targetBox to missing value
396
+ ${candidates}
397
+ if targetBox is missing value then
398
+ ${allowDeleteFallback ? "delete theMessage" : 'error "ARCHIVE_MAILBOX_NOT_FOUND"'}
399
+ else
400
+ set mailbox of theMessage to targetBox
401
+ end if
402
+ end tell
403
+ return "OK"`;
404
+ }
405
+
406
+ export function mailArchive(args = {}) {
407
+ const action = "mail_archive";
408
+ const resolved = resolveMailMessageId({ messageId: args.message_id, filePath: args.file_path });
409
+ if (resolved.error) return { ok: false, message: `${action} refused: ${resolved.error}` };
410
+
411
+ const summary = `archive message ${truncate(resolved.messageId, 120)}`;
412
+ const plan = planWrite({
413
+ action,
414
+ summary,
415
+ dryRun: isFlagTrue(args.dry_run),
416
+ confirm: isFlagTrue(args.confirm)
417
+ });
418
+ if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
419
+
420
+ const result = runAppleScript(
421
+ buildMoveScript({
422
+ messageId: resolved.messageId,
423
+ mailboxNames: ["Archive", "All Mail", "Archived"],
424
+ allowDeleteFallback: false
425
+ }),
426
+ { appName: "Mail" }
427
+ );
428
+ if (!result.ok) {
429
+ if (result.kind === "not_found" && String(result.error).includes("ARCHIVE_MAILBOX_NOT_FOUND")) {
430
+ return { ok: false, message: `${action} failed — attempted to ${summary}. That account has no Archive mailbox.` };
431
+ }
432
+ return { ok: false, message: failure(action, summary, result, []) };
433
+ }
434
+
435
+ return { ok: true, message: writeSuccessMessage(action, "moved to Archive", { message_id: resolved.messageId }) };
436
+ }
437
+
438
+ export function mailTrash(args = {}) {
439
+ const action = "mail_trash";
440
+ const resolved = resolveMailMessageId({ messageId: args.message_id, filePath: args.file_path });
441
+ if (resolved.error) return { ok: false, message: `${action} refused: ${resolved.error}` };
442
+
443
+ const summary = `move message ${truncate(resolved.messageId, 120)} to Trash`;
444
+ const plan = planWrite({
445
+ action,
446
+ summary,
447
+ destructive: true,
448
+ dryRun: isFlagTrue(args.dry_run),
449
+ confirm: isFlagTrue(args.confirm)
450
+ });
451
+ if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
452
+
453
+ const result = runAppleScript(
454
+ buildMoveScript({
455
+ messageId: resolved.messageId,
456
+ mailboxNames: ["Trash", "Deleted Messages", "Bin"],
457
+ allowDeleteFallback: true
458
+ }),
459
+ { appName: "Mail" }
460
+ );
461
+ if (!result.ok) return { ok: false, message: failure(action, summary, result, []) };
462
+
463
+ return { ok: true, message: writeSuccessMessage(action, "moved to Trash", { message_id: resolved.messageId }) };
464
+ }