apple-mail-mcp 2.8.2 → 2.8.3

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.
Files changed (65) hide show
  1. package/README.md +5 -5
  2. package/build/cli.js +12083 -138
  3. package/build/index.js +82684 -1687
  4. package/package.json +3 -4
  5. package/build/cli.d.ts +0 -24
  6. package/build/cli.d.ts.map +0 -1
  7. package/build/index.d.ts +0 -23
  8. package/build/index.d.ts.map +0 -1
  9. package/build/services/appleMailManager.d.ts +0 -680
  10. package/build/services/appleMailManager.d.ts.map +0 -1
  11. package/build/services/appleMailManager.js +0 -3143
  12. package/build/services/fileConfig.d.ts +0 -7
  13. package/build/services/fileConfig.d.ts.map +0 -1
  14. package/build/services/fileConfig.js +0 -52
  15. package/build/services/imapClient.d.ts +0 -312
  16. package/build/services/imapClient.d.ts.map +0 -1
  17. package/build/services/imapClient.js +0 -1023
  18. package/build/services/imapIdle.d.ts +0 -58
  19. package/build/services/imapIdle.d.ts.map +0 -1
  20. package/build/services/imapIdle.js +0 -151
  21. package/build/services/imapMultiAccount.d.ts +0 -124
  22. package/build/services/imapMultiAccount.d.ts.map +0 -1
  23. package/build/services/imapMultiAccount.js +0 -253
  24. package/build/services/messageRouter.d.ts +0 -24
  25. package/build/services/messageRouter.d.ts.map +0 -1
  26. package/build/services/messageRouter.js +0 -31
  27. package/build/services/replyForward.d.ts +0 -87
  28. package/build/services/replyForward.d.ts.map +0 -1
  29. package/build/services/replyForward.js +0 -150
  30. package/build/services/smtpMailer.d.ts +0 -160
  31. package/build/services/smtpMailer.d.ts.map +0 -1
  32. package/build/services/smtpMailer.js +0 -268
  33. package/build/services/templateStore.d.ts +0 -18
  34. package/build/services/templateStore.d.ts.map +0 -1
  35. package/build/services/templateStore.js +0 -91
  36. package/build/tools/doctor.d.ts +0 -23
  37. package/build/tools/doctor.d.ts.map +0 -1
  38. package/build/tools/doctor.js +0 -74
  39. package/build/tools/resourcesAndPrompts.d.ts +0 -14
  40. package/build/tools/resourcesAndPrompts.d.ts.map +0 -1
  41. package/build/tools/resourcesAndPrompts.js +0 -109
  42. package/build/tools/respond.d.ts +0 -48
  43. package/build/tools/respond.d.ts.map +0 -1
  44. package/build/tools/respond.js +0 -95
  45. package/build/tools/thread.d.ts +0 -19
  46. package/build/tools/thread.d.ts.map +0 -1
  47. package/build/tools/thread.js +0 -32
  48. package/build/types.d.ts +0 -434
  49. package/build/types.d.ts.map +0 -1
  50. package/build/types.js +0 -13
  51. package/build/utils/applescript.d.ts +0 -45
  52. package/build/utils/applescript.d.ts.map +0 -1
  53. package/build/utils/applescript.js +0 -446
  54. package/build/utils/attachmentMaterialize.d.ts +0 -9
  55. package/build/utils/attachmentMaterialize.d.ts.map +0 -1
  56. package/build/utils/attachmentMaterialize.js +0 -38
  57. package/build/utils/mimeParse.d.ts +0 -62
  58. package/build/utils/mimeParse.d.ts.map +0 -1
  59. package/build/utils/mimeParse.js +0 -317
  60. package/build/utils/orphan.d.ts +0 -25
  61. package/build/utils/orphan.d.ts.map +0 -1
  62. package/build/utils/orphan.js +0 -26
  63. package/build/utils/serialize.d.ts +0 -30
  64. package/build/utils/serialize.d.ts.map +0 -1
  65. package/build/utils/serialize.js +0 -41
@@ -1,3143 +0,0 @@
1
- /**
2
- * Apple Mail Manager
3
- *
4
- * Handles all interactions with Apple Mail via AppleScript.
5
- * This is the core service layer for the MCP server.
6
- *
7
- * Architecture:
8
- * - Text escaping is handled by dedicated helper functions
9
- * - AppleScript generation uses template builders for consistency
10
- * - All public methods return typed results (no raw strings)
11
- * - Error handling is consistent across all operations
12
- *
13
- * @module services/appleMailManager
14
- */
15
- import { spawnSync } from "child_process";
16
- import { existsSync, writeFileSync, readFileSync, mkdtempSync, rmSync } from "fs";
17
- import { isAbsolute, resolve, sep, join } from "path";
18
- import { homedir } from "os";
19
- import { executeAppleScript } from "../utils/applescript.js";
20
- import { parseMimeAttachments, extractMimeAttachment, extractHtmlBody } from "../utils/mimeParse.js";
21
- import { TemplateStore } from "../services/templateStore.js";
22
- import { materializeAttachments } from "../utils/attachmentMaterialize.js";
23
- // =============================================================================
24
- // Search Tuning (issue #24)
25
- // =============================================================================
26
- /**
27
- * Mailboxes larger than this are skipped during an unscoped (all-mailboxes)
28
- * search rather than scanned. Apple Mail's AppleScript bridge cannot search a
29
- * mailbox of this size before the Apple Event timeout fires — empirically even
30
- * reading the newest 20 messages of a 44k-message Gmail mailbox took ~47s — so
31
- * attempting it only burns the time budget and yields a misleading empty
32
- * result. `count of messages` is cheap (Mail keeps it cached), so the guard is
33
- * effectively free. The skipped mailboxes are reported back to the caller.
34
- *
35
- * Override with APPLE_MAIL_MAX_SEARCH_MAILBOX (set to 0 to disable the guard).
36
- */
37
- function getMailboxScanThreshold() {
38
- const raw = process.env.APPLE_MAIL_MAX_SEARCH_MAILBOX;
39
- if (raw !== undefined) {
40
- const n = Number(raw);
41
- if (Number.isFinite(n) && n >= 0)
42
- return n;
43
- }
44
- return 5000;
45
- }
46
- /**
47
- * Per-account wall-clock budget (seconds) enforced *inside* the AppleScript so
48
- * a single account can't consume minutes. Kept comfortably below the osascript
49
- * process timeout (see searchMessages) so the script exits and reports a
50
- * partial result rather than being SIGKILLed with no diagnostics.
51
- */
52
- const SEARCH_ACCOUNT_BUDGET_SECONDS = 30;
53
- /** osascript process timeout for a per-account search (ms). */
54
- const SEARCH_ACCOUNT_TIMEOUT_MS = 45000;
55
- /**
56
- * Result serialization separators (issue #30).
57
- *
58
- * AppleScript emits structured results as delimited strings that TS then splits.
59
- * The original delimiters were printable triple-pipe tokens, so any field value
60
- * that itself contained one — a subject, sender, attachment filename, or mailbox
61
- * name with a triple-pipe in it — shifted every subsequent field and silently
62
- * corrupted the parse. These are now ASCII control characters
63
- * (Unit/Record/Group Separator) which cannot occur in mail field values, so the
64
- * collision is structurally impossible. The same constant is used by the
65
- * AppleScript emitter (interpolated into the script string) and the TS parser,
66
- * so the two can never drift.
67
- */
68
- const FIELD_SEP = "\x1f"; // US — between fields within a record
69
- const RECORD_SEP = "\x1e"; // RS — between records
70
- const DIAG_MARKER = "\x1dDIAG\x1d"; // GS-wrapped — payload/diagnostics boundary
71
- const DIAG_FIELD_SEP = "\x1dF\x1d"; // between diagnostics fields
72
- const DIAG_ITEM_SEP = "\x1dM\x1d"; // between diagnostics list items
73
- const CONTENT_MARKER = "\x1dCONTENT\x1d"; // subject/plain-text boundary
74
- const HTML_MARKER = "\x1dHTML\x1d"; // plain-text/source boundary
75
- const BATCH_FATAL = "\x1dFATAL\x1d"; // prefix for a whole-batch failure (e.g. bad destination)
76
- /**
77
- * Merge a per-account SearchDiagnostics into an aggregate (all-accounts) one.
78
- *
79
- * Exported for unit testing.
80
- */
81
- export function mergeSearchDiagnostics(into, from) {
82
- into.timedOutAccounts.push(...from.timedOutAccounts);
83
- into.skippedLargeMailboxes.push(...from.skippedLargeMailboxes);
84
- into.notSearchedMailboxes.push(...from.notSearchedMailboxes);
85
- if (from.partial)
86
- into.partial = true;
87
- }
88
- /**
89
- * Split a per-account search payload into its message-list portion and parsed
90
- * diagnostics. The AppleScript appends a trailer of the form (using the
91
- * control-character separators defined above):
92
- *
93
- * <messages>{DIAG_MARKER}timedOut=true{DIAG_FIELD_SEP}skipped=Foo (9000){DIAG_ITEM_SEP}{DIAG_FIELD_SEP}notSearched=Bar{DIAG_ITEM_SEP}
94
- *
95
- * `skipped`/`notSearched` are DIAG_ITEM_SEP-separated mailbox names, each prefixed
96
- * with the account name on the way out so the aggregate result is unambiguous.
97
- *
98
- * Exported (pure, no Mail.app dependency) for unit testing — this is the logic
99
- * that turns a swallowed timeout into a visible partial result (issue #24).
100
- */
101
- export function splitSearchDiagnostics(output, account) {
102
- const markerIdx = output.lastIndexOf(DIAG_MARKER);
103
- const payload = markerIdx >= 0 ? output.slice(0, markerIdx) : output;
104
- const trailer = markerIdx >= 0 ? output.slice(markerIdx + DIAG_MARKER.length) : "";
105
- const diagnostics = {
106
- partial: false,
107
- timedOutAccounts: [],
108
- skippedLargeMailboxes: [],
109
- notSearchedMailboxes: [],
110
- };
111
- if (trailer) {
112
- const fields = trailer.split(DIAG_FIELD_SEP);
113
- const getField = (key) => {
114
- const f = fields.find((x) => x.startsWith(`${key}=`));
115
- return f ? f.slice(key.length + 1) : "";
116
- };
117
- const splitList = (raw) => raw
118
- .split(DIAG_ITEM_SEP)
119
- .map((s) => s.trim())
120
- .filter((s) => s.length > 0);
121
- diagnostics.skippedLargeMailboxes = splitList(getField("skipped")).map((mb) => `${account} / ${mb}`);
122
- diagnostics.notSearchedMailboxes = splitList(getField("notSearched")).map((mb) => `${account} / ${mb}`);
123
- if (getField("timedOut") === "true")
124
- diagnostics.partial = true;
125
- }
126
- if (diagnostics.skippedLargeMailboxes.length > 0 || diagnostics.notSearchedMailboxes.length > 0) {
127
- diagnostics.partial = true;
128
- }
129
- return { payload, diagnostics };
130
- }
131
- // =============================================================================
132
- // Text Processing Utilities
133
- // =============================================================================
134
- /**
135
- * Escapes text for safe embedding in AppleScript string literals.
136
- *
137
- * AppleScript strings use double quotes, so we need to escape:
138
- * 1. Backslashes (\) - escaped as \\
139
- * 2. Double quotes (") - escaped as \"
140
- *
141
- * @param text - Raw text to escape
142
- * @returns Text safe for AppleScript string embedding
143
- */
144
- /**
145
- * Roots under which `save-attachment` is permitted to write.
146
- */
147
- const ALLOWED_SAVE_ROOTS = [homedir(), "/tmp", "/private/tmp", "/Volumes"];
148
- /**
149
- * True if `resolvedPath` is one of the allowed roots or strictly inside one.
150
- *
151
- * Uses a path-segment boundary check rather than a bare `startsWith`, which
152
- * would let a sibling whose name merely shares the prefix slip through —
153
- * `/Volumes-evil` startsWith `/Volumes`, `/Users/robother` startsWith
154
- * `/Users/rob` (audit finding #12). `resolvedPath` must already be absolute
155
- * (caller passes `resolve(...)` output).
156
- */
157
- export function isPathWithinAllowedRoots(resolvedPath) {
158
- return ALLOWED_SAVE_ROOTS.some((root) => {
159
- const base = root.endsWith(sep) ? root.slice(0, -1) : root;
160
- return resolvedPath === base || resolvedPath.startsWith(base + sep);
161
- });
162
- }
163
- /**
164
- * Pattern in a raw AppleScript error that indicates an operation Mail.app's
165
- * scripting interface simply cannot perform on this target — most often a
166
- * server-side (IMAP / Gmail / Workspace / iCloud / Exchange) mailbox or a draft.
167
- * Mail throws `AppleEvent handler failed` (-10000) for these; the GUI can do
168
- * them, the scripting bridge cannot. See issue #42 and the audit doc.
169
- */
170
- const UNSUPPORTED_APPLESCRIPT_OP = /AppleEvent handler failed|-10000/i;
171
- /**
172
- * Turn a raw mailbox delete/rename failure into an actionable, non-retryable
173
- * message when it's the known server-side-mailbox limitation (#42); otherwise
174
- * return the raw error unchanged.
175
- *
176
- * Exported for unit testing.
177
- */
178
- export function describeMailboxOpError(op, raw) {
179
- const trimmed = (raw || "").trim();
180
- if (UNSUPPORTED_APPLESCRIPT_OP.test(trimmed)) {
181
- const verb = op.charAt(0).toUpperCase() + op.slice(1);
182
- return `Mail.app cannot ${op} server-side (IMAP / Gmail / Workspace / iCloud / Exchange) mailboxes via AppleScript — only local "On My Mac" mailboxes support this. ${verb} it in Mail.app directly. (Mail.app error: ${trimmed})`;
183
- }
184
- return trimmed || `Failed to ${op} mailbox`;
185
- }
186
- /** Env var to pin the default account (matched by account name or email). */
187
- export const DEFAULT_ACCOUNT_ENV = "APPLE_MAIL_MCP_DEFAULT_ACCOUNT";
188
- /**
189
- * Choose the account to use when a tool call omits `account`.
190
- *
191
- * Priority: explicit `override` (by name or email) → Mail's default-send
192
- * account *if enabled* → first enabled account → first account → null. The key
193
- * guarantee (issue #47): a **disabled** account is never chosen implicitly — it
194
- * can only be selected via an explicit override (deliberate user intent) or as
195
- * a last resort when no account is enabled. This prevents operations silently
196
- * landing in a configured-but-disabled account (e.g. an unused iCloud account
197
- * that's still addressable via AppleScript).
198
- *
199
- * Pure/exported for unit testing.
200
- */
201
- export function chooseDefaultAccount(accounts, opts = {}) {
202
- const norm = (s) => s.trim().toLowerCase();
203
- const override = opts.override?.trim();
204
- if (override) {
205
- const o = norm(override);
206
- const m = accounts.find((a) => norm(a.name) === o || norm(a.email) === o);
207
- if (m)
208
- return m.name; // honor an explicit pin even if that account is disabled
209
- }
210
- if (opts.defaultSendEmail) {
211
- const e = norm(opts.defaultSendEmail);
212
- const m = accounts.find((a) => norm(a.email) === e);
213
- if (m && m.enabled)
214
- return m.name;
215
- }
216
- const firstEnabled = accounts.find((a) => a.enabled);
217
- if (firstEnabled)
218
- return firstEnabled.name;
219
- return accounts[0]?.name ?? null;
220
- }
221
- export function escapeForAppleScript(text) {
222
- if (!text)
223
- return "";
224
- // Escape backslash and double-quote for the AppleScript string literal, and
225
- // strip ASCII control characters. An AppleScript double-quoted literal cannot
226
- // contain a raw newline, so an interpolated value with a `\n` (or other
227
- // control char) would terminate the literal early and could inject a
228
- // statement; stripping them closes that gap (audit finding #10).
229
- return (text
230
- .replace(/\\/g, "\\\\")
231
- .replace(/"/g, '\\"')
232
- // eslint-disable-next-line no-control-regex
233
- .replace(/[\x00-\x1f\x7f]/g, ""));
234
- }
235
- /**
236
- * Validates attachment file paths and builds AppleScript commands to attach them.
237
- *
238
- * @param attachments - Absolute file paths to attach
239
- * @returns AppleScript commands to add attachments, or empty string if none
240
- * @throws Error if any path is not absolute or does not exist
241
- */
242
- function buildAttachmentCommands(attachments) {
243
- if (!attachments || attachments.length === 0)
244
- return "";
245
- for (const filePath of attachments) {
246
- if (!isAbsolute(filePath)) {
247
- throw new Error(`Attachment path must be absolute: "${filePath}"`);
248
- }
249
- if (!existsSync(filePath)) {
250
- throw new Error(`Attachment file not found: "${filePath}"`);
251
- }
252
- }
253
- let commands = "";
254
- for (const filePath of attachments) {
255
- const safePath = escapeForAppleScript(filePath);
256
- commands += `make new attachment with properties {file name:POSIX file "${safePath}"} at after the last paragraph\n`;
257
- }
258
- return commands;
259
- }
260
- /**
261
- * AppleScript snippet that converts a date variable `d` into a
262
- * locale-independent numeric string: "YYYY-M-D-H-m-s".
263
- * Use: set d to date received of msg, then inline this snippet.
264
- */
265
- const AS_DATE_TO_STRING = `((year of d) as string) & "-" & ((month of d as integer) as string) & "-" & ((day of d) as string) & "-" & ((hours of d) as string) & "-" & ((minutes of d) as string) & "-" & ((seconds of d) as string)`;
266
- /**
267
- * Build the AppleScript loop that turns a message collection into delimited
268
- * rows (C1, audit #11). It first tries a *bulk* read — one Apple Event per
269
- * property for the whole collection (`subject of msgs`, `sender of msgs`, …),
270
- * ~6 events total instead of ~6 per message — and only on error falls back to
271
- * the original per-message reads. The per-iteration `try` preserves the
272
- * malformed-message isolation (#13): one bad message can't abort the batch,
273
- * and if a bulk read throws (the audit's regression worry) we degrade to the
274
- * safe per-message path automatically.
275
- *
276
- * Expects `outputText`, `msgCount` (and, when `dedup`, `seenIds`) already in
277
- * scope at the call site; appends rows and advances `msgCount` up to `limit`.
278
- */
279
- function buildMessageRowLoop(opts) {
280
- const { collection, limit, dedup, dateFilter, trailing = "", offset, withAttachments } = opts;
281
- const dedupOpen = dedup
282
- ? `if seenIds does not contain msgId then\n set end of seenIds to msgId`
283
- : "";
284
- const dedupClose = dedup ? `end if` : "";
285
- const offsetOpen = offset !== undefined
286
- ? `if skipped < ${offset} then\n set skipped to skipped + 1\n else`
287
- : "";
288
- const offsetClose = offset !== undefined ? `end if` : "";
289
- const dateOpen = dateFilter
290
- ? `set msgDate to d\n if not (${dateFilter}) then\n -- outside date range; skip\n else`
291
- : "";
292
- const dateClose = dateFilter ? `end if` : "";
293
- const attBulk = withAttachments ? `\n set _atts to mail attachments of _msgs` : "";
294
- const attRow = withAttachments
295
- ? `
296
- set msgHasAtt to "false"
297
- try
298
- if _bulkOK then
299
- if (count of (item _i of _atts)) > 0 then set msgHasAtt to "true"
300
- else
301
- if (count of mail attachments of (item _i of _msgs)) > 0 then set msgHasAtt to "true"
302
- end if
303
- end try`
304
- : "";
305
- const attField = withAttachments ? ` & "${FIELD_SEP}" & msgHasAtt` : "";
306
- return `
307
- set _msgs to ${collection}
308
- set _bulkOK to true
309
- try
310
- set _ids to id of _msgs
311
- set _subjs to subject of _msgs
312
- set _sndrs to sender of _msgs
313
- set _dates to date received of _msgs
314
- set _reads to read status of _msgs
315
- set _flags to flagged status of _msgs${attBulk}
316
- on error
317
- set _bulkOK to false
318
- end try
319
- repeat with _i from 1 to (count of _msgs)
320
- if msgCount >= ${limit} then exit repeat
321
- try
322
- if _bulkOK then
323
- set msgId to (item _i of _ids) as string
324
- else
325
- set msgId to id of (item _i of _msgs) as string
326
- end if
327
- ${dedupOpen}
328
- ${offsetOpen}
329
- if _bulkOK then
330
- set d to item _i of _dates
331
- else
332
- set d to date received of (item _i of _msgs)
333
- end if
334
- ${dateOpen}
335
- if _bulkOK then
336
- set msgSubject to item _i of _subjs
337
- set msgSender to item _i of _sndrs
338
- set msgRead to (item _i of _reads) as string
339
- set msgFlagged to (item _i of _flags) as string
340
- else
341
- set _m to item _i of _msgs
342
- set msgSubject to subject of _m
343
- set msgSender to sender of _m
344
- set msgRead to read status of _m as string
345
- set msgFlagged to flagged status of _m as string
346
- end if${attRow}
347
- set msgDateStr to ${AS_DATE_TO_STRING}
348
- if msgCount > 0 then set outputText to outputText & "${RECORD_SEP}"
349
- set outputText to outputText & msgId & "${FIELD_SEP}" & msgSubject & "${FIELD_SEP}" & msgSender & "${FIELD_SEP}" & msgDateStr & "${FIELD_SEP}" & msgRead & "${FIELD_SEP}" & msgFlagged${trailing}${attField}
350
- set msgCount to msgCount + 1
351
- ${dateClose}
352
- ${offsetClose}
353
- ${dedupClose}
354
- end try
355
- end repeat`;
356
- }
357
- /**
358
- * Parses a locale-independent date string "YYYY-M-D-H-m-s"
359
- * produced by the AppleScript snippet above.
360
- *
361
- * Falls back to the locale-dependent `as string` format for
362
- * backwards compatibility, and finally to current date.
363
- *
364
- * @param dateStr - Date string from AppleScript
365
- * @returns Parsed Date, or current date if parsing fails
366
- */
367
- function parseAppleScriptDate(dateStr) {
368
- // Try locale-independent numeric format first: "YYYY-M-D-H-m-s"
369
- const numParts = dateStr.split("-").map(Number);
370
- if (numParts.length === 6 && numParts.every((n) => !isNaN(n))) {
371
- return new Date(numParts[0], numParts[1] - 1, numParts[2], numParts[3], numParts[4], numParts[5]);
372
- }
373
- // Fallback: try legacy locale-dependent format
374
- const withoutPrefix = dateStr.replace(/^date\s+/, "");
375
- const normalized = withoutPrefix.replace(" at ", " ");
376
- const parsed = new Date(normalized);
377
- return isNaN(parsed.getTime()) ? new Date() : parsed;
378
- }
379
- /**
380
- * Emits AppleScript that builds a date into the variable `varName` from numeric
381
- * components.
382
- *
383
- * This is locale-independent, unlike `date "May 30, 2026"` string coercion,
384
- * which AppleScript parses using the system locale. On a non-English locale
385
- * (e.g. pt_PT) the English month name throws "Invalid date and time (-30720)";
386
- * because the comparison happens inside the per-message `try` in searchMessages,
387
- * that error is swallowed and every message is skipped, so the search returns
388
- * zero results even when matches exist. See issue #15.
389
- *
390
- * `day` is reset to 1 before assigning month/year so an existing day-of-month
391
- * (e.g. 31) cannot overflow into the next month when the month is changed.
392
- *
393
- * Exported for unit testing.
394
- */
395
- export function buildAppleScriptDate(varName, d) {
396
- return [
397
- `set ${varName} to current date`,
398
- `set day of ${varName} to 1`,
399
- `set year of ${varName} to ${d.getFullYear()}`,
400
- `set month of ${varName} to ${d.getMonth() + 1}`,
401
- `set day of ${varName} to ${d.getDate()}`,
402
- `set hours of ${varName} to ${d.getHours()}`,
403
- `set minutes of ${varName} to ${d.getMinutes()}`,
404
- `set seconds of ${varName} to ${d.getSeconds()}`,
405
- ].join("\n ");
406
- }
407
- /**
408
- * Builds an AppleScript command scoped to a specific account.
409
- */
410
- function buildAccountScopedScript(account, command) {
411
- return `
412
- tell application "Mail"
413
- tell account "${escapeForAppleScript(account)}"
414
- ${command}
415
- end tell
416
- end tell
417
- `;
418
- }
419
- /**
420
- * Builds an AppleScript command at the application level.
421
- */
422
- function buildAppLevelScript(command) {
423
- return `
424
- tell application "Mail"
425
- ${command}
426
- end tell
427
- `;
428
- }
429
- /**
430
- * Common mailbox name variations across different account types.
431
- * Maps normalized (lowercase) names to possible actual names.
432
- */
433
- const MAILBOX_ALIASES = {
434
- inbox: ["INBOX", "Inbox", "inbox"],
435
- sent: ["Sent", "Sent Items", "Sent Messages", "SENT", "sent"],
436
- drafts: ["Drafts", "DRAFTS", "drafts", "Draft"],
437
- trash: ["Trash", "Deleted Items", "Deleted Messages", "TRASH", "trash"],
438
- junk: ["Junk", "Junk Email", "Spam", "JUNK", "junk"],
439
- archive: ["Archive", "ARCHIVE", "archive", "All Mail"],
440
- };
441
- /**
442
- * The mailbox names (as `list-mailboxes` reports them) that hold a Gmail-style
443
- * account's actually-received mail. On Gmail/Google-Workspace accounts the
444
- * literal "INBOX" mailbox that Mail.app exposes is a virtual shell that holds
445
- * ~0 messages — real inbox mail lives under the "All Mail" (`\All`) and
446
- * "Important" (`\Important`) special mailboxes (nested in Mail.app's `[Gmail]`
447
- * container, so they DON'T resolve by a flat `mailbox "All Mail"` lookup and
448
- * must be found by iterating and matching `name of mb`). "All Mail" is the
449
- * superset that contains every inbox message, so scoping/scanning "INBOX" to
450
- * this set makes scoped search/get-thread/stats see the same mail an unscoped
451
- * call reports. See BUG A / issue: Gmail virtual-INBOX handling.
452
- */
453
- const GMAIL_INBOX_MAILBOXES = ["All Mail", "Important"];
454
- /** Lowercased names that a caller might use to mean "the inbox". */
455
- const INBOX_SCOPE_NAMES = new Set(["inbox"]);
456
- /** True when `mailbox` (case-insensitively) refers to the inbox. */
457
- function isInboxScope(mailbox) {
458
- return INBOX_SCOPE_NAMES.has(mailbox.trim().toLowerCase());
459
- }
460
- /**
461
- * Detect a Gmail-style account from its mailbox-name list: it exposes an
462
- * "All Mail" special mailbox. Returns the subset of GMAIL_INBOX_MAILBOXES that
463
- * actually exist on the account (so we only scan mailboxes that are present),
464
- * or `null` when the account is not Gmail-style (no "All Mail") — callers then
465
- * keep the ordinary single-INBOX behavior. Matching is case-insensitive.
466
- */
467
- function gmailReceivingMailboxes(mailboxNames) {
468
- const lower = mailboxNames.map((n) => n.toLowerCase());
469
- if (!lower.includes("all mail"))
470
- return null;
471
- const present = GMAIL_INBOX_MAILBOXES.filter((want) => lower.includes(want.toLowerCase()));
472
- return present.length > 0 ? present : null;
473
- }
474
- /**
475
- * AppleScript literal list of quoted, lowercased mailbox names, e.g.
476
- * `{"all mail", "important"}` — for a case-insensitive `name of mb` membership
477
- * test inside a generated script.
478
- */
479
- function appleScriptLowerNameList(names) {
480
- return "{" + names.map((n) => `"${escapeForAppleScript(n.toLowerCase())}"`).join(", ") + "}";
481
- }
482
- /**
483
- * Build the AppleScript `whose` clause for searchMessages from a filter set.
484
- *
485
- * - `query` is a subject-OR-sender substring match, parenthesized so it groups
486
- * correctly when ANDed with other filters.
487
- * - `from` and `subject` are substring matches (`sender`/`subject` contains).
488
- * - `isRead` / `isFlagged` are boolean status checks.
489
- * - Returns "" when no filters are set. Every interpolated value is escaped.
490
- *
491
- * Exported for unit testing: the bug this addresses (filters declared in the
492
- * tool schema but silently dropped) lived in this logic, so it gets direct
493
- * coverage independent of Mail.app.
494
- */
495
- export function buildSearchCondition(filters) {
496
- const { query, from, subject, isRead, isFlagged } = filters;
497
- const conditions = [];
498
- if (query) {
499
- const safeQuery = escapeForAppleScript(query);
500
- conditions.push(`(subject contains "${safeQuery}" or sender contains "${safeQuery}")`);
501
- }
502
- if (from) {
503
- conditions.push(`sender contains "${escapeForAppleScript(from)}"`);
504
- }
505
- if (subject) {
506
- conditions.push(`subject contains "${escapeForAppleScript(subject)}"`);
507
- }
508
- if (typeof isRead === "boolean") {
509
- conditions.push(`read status is ${isRead ? "true" : "false"}`);
510
- }
511
- if (typeof isFlagged === "boolean") {
512
- conditions.push(`flagged status is ${isFlagged ? "true" : "false"}`);
513
- }
514
- return conditions.length > 0 ? `whose ${conditions.join(" and ")}` : "";
515
- }
516
- export class AppleMailManager {
517
- /**
518
- * Default account used when no account is specified.
519
- */
520
- defaultAccount = null;
521
- /**
522
- * TTL cache for expensive AppleScript queries that rarely change.
523
- * Caches account list and per-account mailbox names to avoid
524
- * redundant AppleScript roundtrips on every tool call.
525
- */
526
- cache = {
527
- accounts: null,
528
- mailboxNames: new Map(),
529
- };
530
- /** Cache TTL in milliseconds (60 seconds). */
531
- CACHE_TTL_MS = 60_000;
532
- /**
533
- * Returns cached accounts or fetches fresh data if cache is expired/empty.
534
- */
535
- getCachedAccounts() {
536
- const now = Date.now();
537
- if (this.cache.accounts && now < this.cache.accounts.expiry) {
538
- return this.cache.accounts.data;
539
- }
540
- const accounts = this.fetchAccounts();
541
- this.cache.accounts = { data: accounts, expiry: now + this.CACHE_TTL_MS };
542
- return accounts;
543
- }
544
- /**
545
- * Returns cached mailbox names for an account, or fetches fresh.
546
- * This caches only the name list used by resolveMailbox(), not the
547
- * full Mailbox objects with counts (which change frequently).
548
- */
549
- getCachedMailboxNames(account) {
550
- const now = Date.now();
551
- const cached = this.cache.mailboxNames.get(account);
552
- if (cached && now < cached.expiry) {
553
- return cached.data;
554
- }
555
- const names = this.fetchMailboxNames(account);
556
- this.cache.mailboxNames.set(account, { data: names, expiry: now + this.CACHE_TTL_MS });
557
- return names;
558
- }
559
- /**
560
- * Invalidate all caches. Call after operations that change
561
- * mailbox structure (create/delete/rename mailbox).
562
- */
563
- invalidateCache() {
564
- this.cache.accounts = null;
565
- this.cache.mailboxNames.clear();
566
- }
567
- /**
568
- * Reads the live `enabled` flag for an account directly from Mail (bypassing
569
- * the 60 s account cache) so a guard reflects an account that was enabled or
570
- * disabled out-of-band. Returns true/false when known, or null when the probe
571
- * is inconclusive — account not found, or the probe itself failed. Callers
572
- * treat null as "can't tell, don't block".
573
- */
574
- isAccountEnabled(account) {
575
- const safeAccount = escapeForAppleScript(account);
576
- const result = executeAppleScript(buildAppLevelScript(`
577
- try
578
- return (enabled of account "${safeAccount}") as text
579
- on error
580
- return "missing"
581
- end try
582
- `));
583
- if (!result.success)
584
- return null;
585
- const out = result.output.trim();
586
- if (out === "true")
587
- return true;
588
- if (out === "false")
589
- return false;
590
- return null;
591
- }
592
- /**
593
- * Reads an account's `account type` from Mail (e.g. "imap", "iCloud", "pop",
594
- * ".Mac", or "unknown" for Exchange). Returns the lowercased type string, or
595
- * null when the probe is inconclusive (account not found / probe failed).
596
- * Used to decide whether AppleScript can safely create/delete/rename a
597
- * mailbox on the account (BUG B).
598
- */
599
- accountTypeOf(account) {
600
- const safeAccount = escapeForAppleScript(account);
601
- const result = executeAppleScript(buildAppLevelScript(`
602
- try
603
- return (account type of account "${safeAccount}") as text
604
- on error
605
- return "missing"
606
- end try
607
- `));
608
- if (!result.success)
609
- return null;
610
- const out = result.output.trim().toLowerCase();
611
- if (!out || out === "missing")
612
- return null;
613
- return out;
614
- }
615
- /**
616
- * True when the account stores its mailboxes server-side (IMAP / iCloud /
617
- * Exchange), so AppleScript CANNOT reliably create, delete, or rename its
618
- * folders — those ops must go through the IMAP backend. POP accounts keep
619
- * everything local, so their mailboxes ARE AppleScript-writable.
620
- *
621
- * Returns null when the type can't be determined (fail open: an inconclusive
622
- * probe should not block an operation).
623
- */
624
- isServerSideAccount(account) {
625
- const type = this.accountTypeOf(account);
626
- if (type === null)
627
- return null;
628
- if (type === "pop")
629
- return false;
630
- // imap, icloud (".mac"), exchange (reported as "unknown"), and anything else
631
- // that isn't a plain local POP store is treated as server-side.
632
- return true;
633
- }
634
- /**
635
- * Guard for AppleScript create-mailbox on a server-side account (BUG B). When
636
- * the account stores mailboxes server-side, AppleScript can CREATE a folder
637
- * but cannot later delete or rename it — so a bare create would orphan a
638
- * mailbox the server can never remove. If IMAP is configured for the account
639
- * the tool layer routes the op to IMAP before reaching here; if it isn't, we
640
- * refuse rather than create something we can't remove. Returns an error string
641
- * when the op must be refused, else null (POP / local / indeterminate accounts
642
- * fall through to AppleScript).
643
- */
644
- serverSideCreateGuard(account, op) {
645
- if (this.isServerSideAccount(account) === true) {
646
- const verb = op === "rename" ? "rename" : "create";
647
- return `Account "${account}" stores its mailboxes on the server (IMAP / iCloud / Exchange), and Mail.app cannot ${verb} server-side mailboxes via AppleScript — a ${verb} would ${op === "rename" ? "leave a half-created orphan" : "orphan a mailbox that can never be removed"}. Configure IMAP for this account (APPLE_MAIL_MCP_IMAP_*) so mailbox create/delete/rename route through the server, or manage the folder in Mail.app directly.`;
648
- }
649
- return null;
650
- }
651
- /**
652
- * Guard for AppleScript-backed structural operations (create / delete / rename
653
- * mailbox). When the target account is disabled in Mail, Mail holds no live
654
- * server session for it, so the operation fails inside Mail with an opaque
655
- * AppleEvent -10000 — and a multi-step op like rename can leave half-built
656
- * state behind (an orphaned destination mailbox). Detect the disabled account
657
- * up front and refuse with an actionable message instead of attempting the
658
- * doomed op.
659
- *
660
- * Returns an error string when the account is known-disabled, else null —
661
- * including when the state can't be determined. We fail open: an inconclusive
662
- * probe never blocks an otherwise-valid operation.
663
- *
664
- * Applies only to the AppleScript backend. Direct-IMAP accounts talk to the
665
- * server independent of Mail's enabled toggle and are routed before reaching
666
- * the manager.
667
- */
668
- disabledAccountGuard(account) {
669
- if (this.isAccountEnabled(account) === false) {
670
- return `Account "${account}" is disabled in Mail, so Mail has no live connection to it — this operation would fail server-side (AppleEvent -10000) and could leave a mailbox half-changed. Enable the account (Mail ▸ Settings ▸ Accounts ▸ "Enable this account") and retry, or target an enabled account.`;
671
- }
672
- return null;
673
- }
674
- /**
675
- * Best-effort rollback for a failed rename: delete a just-created destination
676
- * mailbox, but ONLY if it is empty, so any messages that did move are never
677
- * destroyed. Returns true if the empty orphan was removed.
678
- */
679
- deleteMailboxIfEmpty(name, account) {
680
- const safeName = escapeForAppleScript(name);
681
- const safeAccount = escapeForAppleScript(account);
682
- const result = executeAppleScript(buildAppLevelScript(`
683
- try
684
- set mb to mailbox "${safeName}" of account "${safeAccount}"
685
- if (count of messages of mb) is 0 then
686
- delete mb
687
- return "deleted"
688
- else
689
- return "kept"
690
- end if
691
- on error errMsg
692
- return "error:" & errMsg
693
- end try
694
- `));
695
- return result.success && result.output.trim() === "deleted";
696
- }
697
- /**
698
- * Resolves the account to use for an operation when the caller omits one.
699
- *
700
- * Order (see chooseDefaultAccount): the APPLE_MAIL_MCP_DEFAULT_ACCOUNT env
701
- * override → Mail.app's configured default-send account (if enabled) → the
702
- * first enabled account. A disabled account is never chosen implicitly (#47).
703
- */
704
- resolveAccount(account) {
705
- if (account)
706
- return account;
707
- if (this.defaultAccount)
708
- return this.defaultAccount;
709
- const accounts = this.getCachedAccounts();
710
- // Mail.app's default send account (inspect a throwaway outgoing message).
711
- let defaultSendEmail;
712
- const defaultResult = executeAppleScript(buildAppLevelScript(`
713
- set newMsg to make new outgoing message
714
- set fromAddr to sender of newMsg
715
- delete newMsg
716
- return fromAddr
717
- `));
718
- if (defaultResult.success && defaultResult.output.trim()) {
719
- // sender returns "Name <email>" — pull out the address
720
- const senderOutput = defaultResult.output.trim();
721
- const emailMatch = senderOutput.match(/<([^>]+)>/);
722
- defaultSendEmail = emailMatch ? emailMatch[1] : senderOutput;
723
- }
724
- const chosen = chooseDefaultAccount(accounts, {
725
- override: process.env[DEFAULT_ACCOUNT_ENV],
726
- defaultSendEmail,
727
- });
728
- if (chosen) {
729
- this.defaultAccount = chosen;
730
- return chosen;
731
- }
732
- // No accounts at all — return something rather than throw; downstream
733
- // AppleScript will surface a clear "account not found".
734
- return accounts[0]?.name ?? "iCloud";
735
- }
736
- /**
737
- * Resolves a mailbox name to its actual name in the account.
738
- *
739
- * Different account types (IMAP, Exchange, iCloud) use different
740
- * mailbox naming conventions:
741
- * - IMAP/Gmail: "INBOX", "Sent", "Drafts"
742
- * - Exchange: "Inbox", "Sent Items", "Deleted Items"
743
- * - iCloud: "INBOX", "Sent", "Trash"
744
- *
745
- * This method tries to find a matching mailbox by:
746
- * 1. Exact match
747
- * 2. Case-insensitive match
748
- * 3. Known aliases (e.g., "Sent" -> "Sent Items")
749
- *
750
- * @param mailbox - Requested mailbox name
751
- * @param account - Account to search in
752
- * @returns Actual mailbox name, or original if not found
753
- */
754
- resolveMailbox(mailbox, account) {
755
- const actualMailboxes = this.getCachedMailboxNames(account);
756
- if (actualMailboxes.length === 0) {
757
- return mailbox; // Fall back to original
758
- }
759
- // 1. Try exact match
760
- if (actualMailboxes.includes(mailbox)) {
761
- return mailbox;
762
- }
763
- // 2. Try case-insensitive match
764
- const lowerMailbox = mailbox.toLowerCase();
765
- const caseMatch = actualMailboxes.find((mb) => mb.toLowerCase() === lowerMailbox);
766
- if (caseMatch) {
767
- return caseMatch;
768
- }
769
- // 3. Try known aliases
770
- const aliases = MAILBOX_ALIASES[lowerMailbox];
771
- if (aliases) {
772
- for (const alias of aliases) {
773
- if (actualMailboxes.includes(alias)) {
774
- return alias;
775
- }
776
- // Also try case-insensitive alias match
777
- const aliasMatch = actualMailboxes.find((mb) => mb.toLowerCase() === alias.toLowerCase());
778
- if (aliasMatch) {
779
- return aliasMatch;
780
- }
781
- }
782
- }
783
- // No match found, return original and let AppleScript handle the error
784
- return mailbox;
785
- }
786
- // ===========================================================================
787
- // Message Operations
788
- // ===========================================================================
789
- /**
790
- * Search for messages matching criteria.
791
- *
792
- * @param query - Text to search for in subject or sender
793
- * @param mailbox - Mailbox to search in (e.g., "INBOX")
794
- * @param account - Account to search in
795
- * @param limit - Maximum number of results
796
- * @returns Array of matching messages
797
- */
798
- searchMessages(query, mailbox, account, limit = 50, dateFrom, dateTo, from, subject, isRead, isFlagged) {
799
- return this.searchMessagesWithDiagnostics(query, mailbox, account, limit, dateFrom, dateTo, from, subject, isRead, isFlagged).messages;
800
- }
801
- /**
802
- * Search for messages, returning both the matches and diagnostics describing
803
- * how complete the search was.
804
- *
805
- * This is the correctness fix for issue #24. The previous implementation ran
806
- * an unbounded `messages of mb whose <predicate>` over every mailbox in an
807
- * account; on large IMAP/Gmail mailboxes (tens of thousands of messages) that
808
- * single Apple Event exceeded the timeout, the error was swallowed by a `try`,
809
- * and the function returned a clean — but wrong — empty result. Callers/agents
810
- * then confidently reported "no such mail."
811
- *
812
- * Two changes fix that:
813
- * 1. Cheap count-guard: mailboxes larger than the scan threshold are skipped
814
- * (Apple Mail can't search them before timing out anyway) and reported.
815
- * 2. Honest diagnostics: per-account/per-mailbox timeouts are surfaced as a
816
- * `partial` result with the affected scopes named, instead of an empty
817
- * "success."
818
- */
819
- searchMessagesWithDiagnostics(query, mailbox, account, limit = 50, dateFrom, dateTo, from, subject, isRead, isFlagged) {
820
- // If no account specified, search across all accounts and merge diagnostics.
821
- if (!account) {
822
- const accounts = this.listAccounts();
823
- const allMessages = [];
824
- const diagnostics = {
825
- partial: false,
826
- timedOutAccounts: [],
827
- skippedLargeMailboxes: [],
828
- notSearchedMailboxes: [],
829
- };
830
- for (const acct of accounts) {
831
- if (allMessages.length >= limit)
832
- break;
833
- const remaining = limit - allMessages.length;
834
- const res = this.searchMessagesWithDiagnostics(query, mailbox, acct.name, remaining, dateFrom, dateTo, from, subject, isRead, isFlagged);
835
- allMessages.push(...res.messages);
836
- mergeSearchDiagnostics(diagnostics, res.diagnostics);
837
- }
838
- return { messages: allMessages.slice(0, limit), diagnostics };
839
- }
840
- const targetAccount = this.resolveAccount(account);
841
- // `query` is a subject-OR-sender substring match; from/subject/isRead/isFlagged
842
- // are additional AND filters. Date filtering stays post-fetch below — `whose`
843
- // date comparisons are unreliable in Mail.app AppleScript. See buildSearchCondition.
844
- const searchCondition = buildSearchCondition({ query, from, subject, isRead, isFlagged });
845
- // Build the date-bound comparison. The comparison dates are constructed in
846
- // AppleScript from numeric components (see buildAppleScriptDate) and compared
847
- // against `msgDate` (set per-message below) rather than coerced from a
848
- // locale-formatted string — `date "May 30, 2026"` throws on non-English system
849
- // locales, and that swallowed error silently zeroes out results. See issue #15.
850
- // dateFrom/dateTo are already validated by DATE_FILTER_SCHEMA as parseable dates.
851
- let dateSetup = "";
852
- let dateFilter = "";
853
- if (dateFrom || dateTo) {
854
- const dateChecks = [];
855
- if (dateFrom) {
856
- dateSetup += buildAppleScriptDate("_dateFrom", new Date(dateFrom)) + "\n ";
857
- dateChecks.push("msgDate >= _dateFrom");
858
- }
859
- if (dateTo) {
860
- const to = new Date(dateTo);
861
- // A date-only upper bound (no time component) is treated as end-of-day so
862
- // messages received later that same day are still included.
863
- if (!/\d:\d/.test(dateTo))
864
- to.setHours(23, 59, 59, 0);
865
- dateSetup += buildAppleScriptDate("_dateTo", to) + "\n ";
866
- dateChecks.push("msgDate <= _dateTo");
867
- }
868
- dateFilter = dateChecks.join(" and ");
869
- }
870
- const scanThreshold = getMailboxScanThreshold();
871
- let searchCommand;
872
- if (mailbox) {
873
- // Search a specific mailbox. The caller explicitly chose this mailbox, so
874
- // we don't apply the count-guard skip — but we still wrap the scan so a
875
- // timeout is reported as a partial result rather than a false empty.
876
- const targetMailbox = this.resolveMailbox(mailbox, targetAccount);
877
- // Gmail virtual-INBOX (BUG A1): a Gmail-style account's literal "INBOX"
878
- // mailbox is an empty shell — the mail actually received lives under the
879
- // "All Mail"/"Important" special mailboxes. When the caller scopes to the
880
- // inbox on such an account, scan that receiving set (matched by `name of
881
- // mb`, since the flat names don't resolve via `mailbox "All Mail"`) and
882
- // dedup, so scoped search/get-thread find the same messages the unscoped
883
- // call reports as inbox mail.
884
- const gmailInbox = isInboxScope(mailbox)
885
- ? gmailReceivingMailboxes(this.getCachedMailboxNames(targetAccount))
886
- : null;
887
- if (gmailInbox) {
888
- const nameList = appleScriptLowerNameList(gmailInbox);
889
- searchCommand = `
890
- ${dateSetup}set outputText to ""
891
- set _timedOut to false
892
- set _notSearched to ""
893
- set _wantNames to ${nameList}
894
- set msgCount to 0
895
- set seenIds to {}
896
- repeat with mb in mailboxes
897
- if msgCount >= ${limit} then exit repeat
898
- set mbName to ""
899
- try
900
- set mbName to name of mb
901
- end try
902
- ignoring case
903
- if _wantNames contains mbName then
904
- try
905
- ${buildMessageRowLoop({ collection: `messages of mb ${searchCondition}`, limit, dedup: true, dateFilter })}
906
- on error _errMsg number _errNum
907
- set _timedOut to true
908
- set _notSearched to _notSearched & mbName & "${DIAG_ITEM_SEP}"
909
- end try
910
- end if
911
- end ignoring
912
- end repeat
913
- return outputText & "${DIAG_MARKER}timedOut=" & (_timedOut as string) & "${DIAG_FIELD_SEP}skipped=${DIAG_FIELD_SEP}notSearched=" & _notSearched
914
- `;
915
- }
916
- else {
917
- searchCommand = `
918
- ${dateSetup}set outputText to ""
919
- set _timedOut to false
920
- set _notSearched to ""
921
- set theMailbox to mailbox "${escapeForAppleScript(targetMailbox)}"
922
- set msgCount to 0
923
- try
924
- ${buildMessageRowLoop({ collection: `messages of theMailbox ${searchCondition}`, limit, dateFilter })}
925
- on error _errMsg number _errNum
926
- set _timedOut to true
927
- set _notSearched to "${escapeForAppleScript(targetMailbox)}${DIAG_ITEM_SEP}"
928
- end try
929
- return outputText & "${DIAG_MARKER}timedOut=" & (_timedOut as string) & "${DIAG_FIELD_SEP}skipped=${DIAG_FIELD_SEP}notSearched=" & _notSearched
930
- `;
931
- }
932
- }
933
- else {
934
- // Search ALL mailboxes — iterate every mailbox in the account, dedup by
935
- // message ID. Skip mailboxes that exceed the scan threshold (they can't be
936
- // searched before timing out), enforce a per-account wall-clock budget, and
937
- // capture per-mailbox timeouts. All three are reported via the DIAG trailer.
938
- const scanGuard = scanThreshold > 0 ? `mbCount > ${scanThreshold}` : "false";
939
- searchCommand = `
940
- ${dateSetup}set outputText to ""
941
- set msgCount to 0
942
- set seenIds to {}
943
- set _timedOut to false
944
- set _skipped to ""
945
- set _notSearched to ""
946
- set _startedAt to current date
947
- repeat with mb in mailboxes
948
- if msgCount >= ${limit} then exit repeat
949
- set mbName to ""
950
- try
951
- set mbName to name of mb
952
- end try
953
- if ((current date) - _startedAt) > ${SEARCH_ACCOUNT_BUDGET_SECONDS} then
954
- set _timedOut to true
955
- set _notSearched to _notSearched & mbName & "${DIAG_ITEM_SEP}"
956
- else
957
- set mbCount to 0
958
- try
959
- set mbCount to count of messages of mb
960
- end try
961
- if (${scanGuard}) then
962
- set _timedOut to true
963
- set _skipped to _skipped & mbName & " (" & (mbCount as string) & ")${DIAG_ITEM_SEP}"
964
- else
965
- try
966
- ${buildMessageRowLoop({ collection: `messages of mb ${searchCondition}`, limit, dedup: true, dateFilter, trailing: ` & "${FIELD_SEP}" & mbName` })}
967
- on error _errMsg number _errNum
968
- set _timedOut to true
969
- set _notSearched to _notSearched & mbName & "${DIAG_ITEM_SEP}"
970
- end try
971
- end if
972
- end if
973
- end repeat
974
- return outputText & "${DIAG_MARKER}timedOut=" & (_timedOut as string) & "${DIAG_FIELD_SEP}skipped=" & _skipped & "${DIAG_FIELD_SEP}notSearched=" & _notSearched
975
- `;
976
- }
977
- const script = buildAccountScopedScript(targetAccount, searchCommand);
978
- const result = executeAppleScript(script, { timeoutMs: SEARCH_ACCOUNT_TIMEOUT_MS });
979
- if (!result.success) {
980
- // Whole-account script failed (most often the osascript process timeout /
981
- // SIGKILL on an unresponsive account). Surface it as a timeout rather than
982
- // a false empty result — that confusion is the heart of issue #24.
983
- console.error(`Failed to search messages in "${targetAccount}": ${result.error}`);
984
- return {
985
- messages: [],
986
- diagnostics: {
987
- partial: true,
988
- timedOutAccounts: [targetAccount],
989
- skippedLargeMailboxes: [],
990
- notSearchedMailboxes: [],
991
- },
992
- };
993
- }
994
- return this.parseSearchResult(result.output, mailbox || "INBOX", targetAccount);
995
- }
996
- /**
997
- * Split a per-account search payload into its message list and the DIAG
998
- * trailer, parse both, and return a SearchResult. See searchMessagesWithDiagnostics.
999
- */
1000
- parseSearchResult(output, mailbox, account) {
1001
- const { payload, diagnostics } = splitSearchDiagnostics(output, account);
1002
- const messages = payload.trim() ? this.parseMessageList(payload, mailbox, account) : [];
1003
- return { messages, diagnostics };
1004
- }
1005
- /**
1006
- * Get a message by ID.
1007
- *
1008
- * Note: Mail.app message IDs are unique per mailbox. This method searches
1009
- * all mailboxes in all accounts to find the message.
1010
- */
1011
- getMessageById(id, deepAttachmentCheck = false) {
1012
- // MIME-embedded attachments are invisible to AppleScript's `mail attachments`
1013
- // object, so the only way to detect them is to scan the raw `source of msg`.
1014
- // That reads the entire message (can be MB-sized), so it's the slowest part
1015
- // of this path — now opt-in via `deepAttachmentCheck` rather than run on
1016
- // every attachmentless message (#32). Default off: hasAttachments reflects
1017
- // the fast attachment count only.
1018
- const deepScan = deepAttachmentCheck
1019
- ? `if hasAtt is "false" then
1020
- try
1021
- set rawSrc to source of msg
1022
- if rawSrc contains "Content-Disposition: attachment" then set hasAtt to "true"
1023
- end try
1024
- end if`
1025
- : "";
1026
- const script = buildAppLevelScript(`
1027
- try
1028
- repeat with acct in accounts
1029
- repeat with mb in mailboxes of acct
1030
- try
1031
- set matchingMsgs to (messages of mb whose id is ${Number(id)})
1032
- if (count of matchingMsgs) > 0 then
1033
- set msg to item 1 of matchingMsgs
1034
- set msgSubject to subject of msg
1035
- set msgSender to sender of msg
1036
- set d to date received of msg
1037
- set msgDate to ${AS_DATE_TO_STRING}
1038
- set msgRead to read status of msg as string
1039
- set msgFlagged to flagged status of msg as string
1040
- set msgJunk to junk mail status of msg as string
1041
- set msgDeleted to deleted status of msg as string
1042
- set msgMailbox to name of mb
1043
- set msgAccount to name of acct
1044
- set hasAtt to "false"
1045
- try
1046
- set attCount to count of mail attachments of msg
1047
- if attCount > 0 then set hasAtt to "true"
1048
- end try
1049
- ${deepScan}
1050
- return msgSubject & "${FIELD_SEP}" & msgSender & "${FIELD_SEP}" & msgDate & "${FIELD_SEP}" & msgRead & "${FIELD_SEP}" & msgFlagged & "${FIELD_SEP}" & msgJunk & "${FIELD_SEP}" & msgDeleted & "${FIELD_SEP}" & msgMailbox & "${FIELD_SEP}" & msgAccount & "${FIELD_SEP}" & hasAtt
1051
- end if
1052
- end try
1053
- end repeat
1054
- end repeat
1055
- return ""
1056
- on error errMsg
1057
- return ""
1058
- end try
1059
- `);
1060
- const result = executeAppleScript(script, { timeoutMs: 60000 }); // Longer timeout for search
1061
- if (!result.success || !result.output.trim()) {
1062
- console.error(`Failed to get message ${id}: ${result.error}`);
1063
- return null;
1064
- }
1065
- const parts = result.output.split(FIELD_SEP);
1066
- if (parts.length < 9)
1067
- return null;
1068
- return {
1069
- id: id.toString(),
1070
- subject: parts[0],
1071
- sender: parts[1],
1072
- recipients: [],
1073
- dateReceived: parseAppleScriptDate(parts[2]),
1074
- isRead: parts[3] === "true",
1075
- isFlagged: parts[4] === "true",
1076
- isJunk: parts[5] === "true",
1077
- isDeleted: parts[6] === "true",
1078
- mailbox: parts[7],
1079
- account: parts[8],
1080
- hasAttachments: parts.length > 9 ? parts[9] === "true" : false,
1081
- };
1082
- }
1083
- /**
1084
- * Get the content of a message.
1085
- *
1086
- * @param id - Message ID
1087
- * @param includeHtml - When true, also fetch the raw MIME source and extract
1088
- * the `text/html` body part into `htmlContent`. This is opt-in because the
1089
- * source can be MB-sized (it includes base64 attachments) and the plain-text
1090
- * path doesn't need it; fetching it unconditionally was both slow and, worse,
1091
- * returned the entire raw MIME blob mislabeled as HTML (#32).
1092
- */
1093
- getMessageContent(id, includeHtml = false) {
1094
- // Only `source of msg` is fetched when HTML is requested. `content of msg`
1095
- // is the plain-text body and is always cheap.
1096
- const sourceFetch = includeHtml
1097
- ? `set htmlSource to ""\n try\n set htmlSource to source of msg\n end try`
1098
- : `set htmlSource to ""`;
1099
- const script = buildAppLevelScript(`
1100
- try
1101
- repeat with acct in accounts
1102
- repeat with mb in mailboxes of acct
1103
- try
1104
- set matchingMsgs to (messages of mb whose id is ${Number(id)})
1105
- if (count of matchingMsgs) > 0 then
1106
- set msg to item 1 of matchingMsgs
1107
- set msgSubject to subject of msg
1108
- set msgContent to content of msg
1109
- ${sourceFetch}
1110
- return msgSubject & "${CONTENT_MARKER}" & msgContent & "${HTML_MARKER}" & htmlSource
1111
- end if
1112
- end try
1113
- end repeat
1114
- end repeat
1115
- return ""
1116
- on error errMsg
1117
- return ""
1118
- end try
1119
- `);
1120
- const result = executeAppleScript(script, { timeoutMs: 60000 });
1121
- if (!result.success || !result.output.trim()) {
1122
- console.error(`Failed to get message content: ${result.error}`);
1123
- return null;
1124
- }
1125
- const htmlSplit = result.output.split(HTML_MARKER);
1126
- const contentPart = htmlSplit[0];
1127
- const rawSource = htmlSplit.length > 1 ? htmlSplit[1] : "";
1128
- const parts = contentPart.split(CONTENT_MARKER);
1129
- if (parts.length < 2)
1130
- return null;
1131
- // Extract the actual text/html body from the raw MIME source rather than
1132
- // returning the whole source. Falls back to undefined when the message has
1133
- // no HTML part (e.g. a plain-text-only email).
1134
- const htmlContent = includeHtml && rawSource ? extractHtmlBody(rawSource) || undefined : undefined;
1135
- return {
1136
- id: id.toString(),
1137
- subject: parts[0],
1138
- plainText: parts[1],
1139
- htmlContent,
1140
- };
1141
- }
1142
- /**
1143
- * Get the raw MIME source of a message.
1144
- * Used as fallback for attachment extraction when AppleScript
1145
- * mail attachments returns empty.
1146
- *
1147
- * Timeout is 2x the default (120s) because `source of msg` returns
1148
- * the entire raw message including base64-encoded attachments —
1149
- * a 20MB attachment can take several seconds over Exchange/IMAP.
1150
- */
1151
- getRawSource(id) {
1152
- const script = buildAppLevelScript(`
1153
- try
1154
- repeat with acct in accounts
1155
- repeat with mb in mailboxes of acct
1156
- try
1157
- set matchingMsgs to (messages of mb whose id is ${Number(id)})
1158
- if (count of matchingMsgs) > 0 then
1159
- set msg to item 1 of matchingMsgs
1160
- return source of msg
1161
- end if
1162
- end try
1163
- end repeat
1164
- end repeat
1165
- return ""
1166
- on error errMsg
1167
- return ""
1168
- end try
1169
- `);
1170
- const result = executeAppleScript(script, { timeoutMs: 120000 });
1171
- if (!result.success || !result.output.trim()) {
1172
- return null;
1173
- }
1174
- return result.output;
1175
- }
1176
- /**
1177
- * List messages in a mailbox.
1178
- *
1179
- * @param mailbox - Mailbox to list from (default: INBOX)
1180
- * @param account - Account to list from
1181
- * @param limit - Maximum number of messages
1182
- * @returns Array of messages
1183
- */
1184
- listMessages(mailbox, account, limit = 50, from, offset = 0) {
1185
- return this.listMessagesWithDiagnostics(mailbox, account, limit, from, offset).messages;
1186
- }
1187
- /**
1188
- * List messages, returning matches plus coverage diagnostics.
1189
- *
1190
- * Like `searchMessages`, the unscoped (all-mailboxes) path used to iterate
1191
- * `messages of mb` over every mailbox with a swallowing per-mailbox `try`,
1192
- * so a large IMAP/Gmail mailbox timed out and the method returned `[]` — a
1193
- * false "No messages found." This applies the same #24 discipline: skip
1194
- * mailboxes above the scan threshold (reported), enforce a per-account
1195
- * wall-clock budget, capture per-mailbox timeouts, and surface all of it as a
1196
- * partial result. (by-id lookups don't need this — `whose id is` is indexed
1197
- * and returns instantly even on a 44k-message mailbox.)
1198
- */
1199
- listMessagesWithDiagnostics(mailbox, account, limit = 50, from, offset = 0) {
1200
- // If no account specified, list across all accounts and merge diagnostics.
1201
- if (!account) {
1202
- const accounts = this.listAccounts();
1203
- const allMessages = [];
1204
- const diagnostics = {
1205
- partial: false,
1206
- timedOutAccounts: [],
1207
- skippedLargeMailboxes: [],
1208
- notSearchedMailboxes: [],
1209
- };
1210
- for (const acct of accounts) {
1211
- if (allMessages.length >= limit)
1212
- break;
1213
- const remaining = limit - allMessages.length;
1214
- const res = this.listMessagesWithDiagnostics(mailbox, acct.name, remaining, from, offset);
1215
- allMessages.push(...res.messages);
1216
- mergeSearchDiagnostics(diagnostics, res.diagnostics);
1217
- }
1218
- return { messages: allMessages.slice(0, limit), diagnostics };
1219
- }
1220
- const targetAccount = this.resolveAccount(account);
1221
- const safeFrom = from ? escapeForAppleScript(from) : "";
1222
- const fromFilter = from ? `whose sender contains "${safeFrom}"` : "";
1223
- const scanThreshold = getMailboxScanThreshold();
1224
- let listCommand;
1225
- if (mailbox) {
1226
- // List from a specific mailbox. Caller-scoped, so no count-guard skip, but
1227
- // wrap the scan so a timeout is reported as partial, not a false empty.
1228
- const targetMailbox = this.resolveMailbox(mailbox, targetAccount);
1229
- listCommand = `
1230
- set outputText to ""
1231
- set _timedOut to false
1232
- set _notSearched to ""
1233
- set theMailbox to mailbox "${escapeForAppleScript(targetMailbox)}"
1234
- set msgCount to 0
1235
- set skipped to 0
1236
- try
1237
- ${buildMessageRowLoop({ collection: `messages of theMailbox ${fromFilter}`, limit, offset, withAttachments: true })}
1238
- on error _errMsg number _errNum
1239
- set _timedOut to true
1240
- set _notSearched to "${escapeForAppleScript(targetMailbox)}${DIAG_ITEM_SEP}"
1241
- end try
1242
- return outputText & "${DIAG_MARKER}timedOut=" & (_timedOut as string) & "${DIAG_FIELD_SEP}skipped=${DIAG_FIELD_SEP}notSearched=" & _notSearched
1243
- `;
1244
- }
1245
- else {
1246
- // List from ALL mailboxes — skip mailboxes over the scan threshold, enforce
1247
- // the per-account budget, capture per-mailbox timeouts; dedup by message ID.
1248
- const scanGuard = scanThreshold > 0 ? `mbCount > ${scanThreshold}` : "false";
1249
- listCommand = `
1250
- set outputText to ""
1251
- set msgCount to 0
1252
- set skipped to 0
1253
- set seenIds to {}
1254
- set _timedOut to false
1255
- set _skipped to ""
1256
- set _notSearched to ""
1257
- set _startedAt to current date
1258
- repeat with mb in mailboxes
1259
- if msgCount >= ${limit} then exit repeat
1260
- set mbName to ""
1261
- try
1262
- set mbName to name of mb
1263
- end try
1264
- if ((current date) - _startedAt) > ${SEARCH_ACCOUNT_BUDGET_SECONDS} then
1265
- set _timedOut to true
1266
- set _notSearched to _notSearched & mbName & "${DIAG_ITEM_SEP}"
1267
- else
1268
- set mbCount to 0
1269
- try
1270
- set mbCount to count of messages of mb
1271
- end try
1272
- if (${scanGuard}) then
1273
- set _timedOut to true
1274
- set _skipped to _skipped & mbName & " (" & (mbCount as string) & ")${DIAG_ITEM_SEP}"
1275
- else
1276
- try
1277
- ${buildMessageRowLoop({ collection: `messages of mb ${fromFilter}`, limit, dedup: true, offset, withAttachments: true, trailing: ` & "${FIELD_SEP}" & mbName` })}
1278
- on error _errMsg number _errNum
1279
- set _timedOut to true
1280
- set _notSearched to _notSearched & mbName & "${DIAG_ITEM_SEP}"
1281
- end try
1282
- end if
1283
- end if
1284
- end repeat
1285
- return outputText & "${DIAG_MARKER}timedOut=" & (_timedOut as string) & "${DIAG_FIELD_SEP}skipped=" & _skipped & "${DIAG_FIELD_SEP}notSearched=" & _notSearched
1286
- `;
1287
- }
1288
- const script = buildAccountScopedScript(targetAccount, listCommand);
1289
- const result = executeAppleScript(script, { timeoutMs: SEARCH_ACCOUNT_TIMEOUT_MS });
1290
- if (!result.success) {
1291
- // Whole-account failure — surface as a timeout, not a false empty (#24/#29).
1292
- console.error(`Failed to list messages in "${targetAccount}": ${result.error}`);
1293
- return {
1294
- messages: [],
1295
- diagnostics: {
1296
- partial: true,
1297
- timedOutAccounts: [targetAccount],
1298
- skippedLargeMailboxes: [],
1299
- notSearchedMailboxes: [],
1300
- },
1301
- };
1302
- }
1303
- return this.parseSearchResult(result.output, mailbox || "INBOX", targetAccount);
1304
- }
1305
- /**
1306
- * Parse message list output from AppleScript.
1307
- *
1308
- * Two emission schemas, disambiguated by length:
1309
- * 7 fields: single-mailbox — ...|hasAtt (mailbox from caller)
1310
- * 8 fields: all-mailboxes — ...|mailbox|hasAtt
1311
- *
1312
- * `hasAttachments` here is the fast-path AppleScript count only; it will
1313
- * false-negative for MIME-embedded attachments (a known AppleScript
1314
- * limitation). Use getMessage or list-attachments for authoritative info.
1315
- */
1316
- parseMessageList(output, mailbox, account) {
1317
- const items = output.split(RECORD_SEP);
1318
- const messages = [];
1319
- for (const item of items) {
1320
- const parts = item.split(FIELD_SEP);
1321
- if (parts.length < 6)
1322
- continue;
1323
- let msgMailbox = mailbox;
1324
- let hasAttachments = false;
1325
- if (parts.length >= 8) {
1326
- msgMailbox = parts[6];
1327
- hasAttachments = parts[7] === "true";
1328
- }
1329
- else if (parts.length === 7) {
1330
- hasAttachments = parts[6] === "true";
1331
- }
1332
- messages.push({
1333
- id: parts[0].trim(),
1334
- subject: parts[1],
1335
- sender: parts[2],
1336
- recipients: [],
1337
- dateReceived: parseAppleScriptDate(parts[3]),
1338
- isRead: parts[4] === "true",
1339
- isFlagged: parts[5] === "true",
1340
- isJunk: false,
1341
- isDeleted: false,
1342
- mailbox: msgMailbox,
1343
- account,
1344
- hasAttachments,
1345
- });
1346
- }
1347
- return messages;
1348
- }
1349
- /**
1350
- * Send an email.
1351
- *
1352
- * @param to - Recipient email addresses
1353
- * @param subject - Email subject
1354
- * @param body - Email body (plain text)
1355
- * @param cc - CC recipients
1356
- * @param bcc - BCC recipients
1357
- * @param account - Account to send from
1358
- * @returns true if sent successfully
1359
- */
1360
- // ───────────────────────────────────────────────────────────────────
1361
- // KNOWN BUG: outgoing emails sent via AppleScript on macOS 15+ get wrapped
1362
- // in <blockquote type="cite"> under the Apple-Mail-URLShareWrapperClass
1363
- // template, so they render to recipients as quoted/forwarded content.
1364
- // Plain-text alternative gets `>` prefixes on every line.
1365
- //
1366
- // Reproduces with EVERY AppleScript message-creation pattern I tried:
1367
- // • make new outgoing message with properties {content: ..., ...}
1368
- // • make new outgoing message (no content) + `set content of newMessage`
1369
- // • setting `default message format` to plain format first
1370
- //
1371
- // Apple radar FB11734014 (open since Ventura, no movement).
1372
- // Discussion: https://forums.macrumors.com/threads/applescript-creating-a-
1373
- // new-message-in-mail-app-is-causing-weird-formatting-issues.2385052/
1374
- //
1375
- // FIX (v1.6.0): send-email now accepts `transport: "smtp"`, which bypasses
1376
- // Mail.app and submits clean MIME directly via nodemailer (creds from the
1377
- // Keychain). See src/services/smtpMailer.ts. This AppleScript path remains
1378
- // the default for back-compat and for users who don't configure SMTP, so the
1379
- // wrapping behavior below is unchanged for them.
1380
- // Tracking issue: https://github.com/sweetrb/apple-mail-mcp/issues/12
1381
- // ───────────────────────────────────────────────────────────────────
1382
- sendEmail(to, subject, body, cc, bcc, account, attachments) {
1383
- const safeSubject = escapeForAppleScript(subject);
1384
- const safeBody = escapeForAppleScript(body);
1385
- // Build recipient additions
1386
- let recipientCommands = "";
1387
- for (const addr of to) {
1388
- recipientCommands += `make new to recipient at end of to recipients with properties {address:"${escapeForAppleScript(addr)}"}\n`;
1389
- }
1390
- if (cc) {
1391
- for (const addr of cc) {
1392
- recipientCommands += `make new cc recipient at end of cc recipients with properties {address:"${escapeForAppleScript(addr)}"}\n`;
1393
- }
1394
- }
1395
- if (bcc) {
1396
- for (const addr of bcc) {
1397
- recipientCommands += `make new bcc recipient at end of bcc recipients with properties {address:"${escapeForAppleScript(addr)}"}\n`;
1398
- }
1399
- }
1400
- // Inline (base64) attachments are written to temp files first (B4), then
1401
- // cleaned up after the send. Plain paths pass through unchanged.
1402
- const mat = materializeAttachments(attachments);
1403
- try {
1404
- return this.sendEmailWithPaths(recipientCommands, safeSubject, safeBody, account, buildAttachmentCommands(mat.paths));
1405
- }
1406
- finally {
1407
- mat.cleanup();
1408
- }
1409
- }
1410
- sendEmailWithPaths(recipientCommands, safeSubject, safeBody, account, attachmentCommands) {
1411
- let sendCommand;
1412
- if (account) {
1413
- const safeAccount = escapeForAppleScript(account);
1414
- sendCommand = `
1415
- set newMessage to make new outgoing message with properties {subject:"${safeSubject}", content:"${safeBody}", visible:true}
1416
- tell newMessage
1417
- ${recipientCommands}
1418
- set sender to "${safeAccount}"
1419
- ${attachmentCommands}
1420
- end tell
1421
- send newMessage
1422
- return "sent"
1423
- `;
1424
- }
1425
- else {
1426
- sendCommand = `
1427
- set newMessage to make new outgoing message with properties {subject:"${safeSubject}", content:"${safeBody}", visible:true}
1428
- tell newMessage
1429
- ${recipientCommands}
1430
- ${attachmentCommands}
1431
- end tell
1432
- send newMessage
1433
- return "sent"
1434
- `;
1435
- }
1436
- const script = buildAppLevelScript(sendCommand);
1437
- const result = executeAppleScript(script, { timeoutMs: 60000, maxRetries: 2 });
1438
- if (!result.success) {
1439
- console.error(`Failed to send email: ${result.error}`);
1440
- return false;
1441
- }
1442
- return result.output.includes("sent");
1443
- }
1444
- /**
1445
- * Send individual personalized emails to a list of recipients (mail merge).
1446
- *
1447
- * Replaces {{placeholder}} tokens in subject and body with per-recipient values.
1448
- * Each recipient receives their own individual email.
1449
- *
1450
- * @param recipients - List of recipient objects with email and variable values
1451
- * @param subject - Email subject (may contain {{placeholders}})
1452
- * @param body - Email body (may contain {{placeholders}})
1453
- * @param account - Account to send from
1454
- * @param delayMs - Delay between sends in milliseconds (default: 500, max: 10000)
1455
- * @returns Array of per-recipient results
1456
- */
1457
- sendSerialEmail(recipients, subject, body, account, delayMs = 500) {
1458
- const effectiveDelay = Math.min(Math.max(delayMs, 0), 10000);
1459
- const results = [];
1460
- for (let i = 0; i < recipients.length; i++) {
1461
- const recipient = recipients[i];
1462
- try {
1463
- // Replace all {{Key}} placeholders with recipient's values
1464
- let personalizedSubject = subject;
1465
- let personalizedBody = body;
1466
- for (const [key, value] of Object.entries(recipient.variables)) {
1467
- const safeKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1468
- const placeholder = new RegExp(`\\{\\{${safeKey}\\}\\}`, "g");
1469
- personalizedSubject = personalizedSubject.replace(placeholder, value);
1470
- personalizedBody = personalizedBody.replace(placeholder, value);
1471
- }
1472
- const success = this.sendEmail([recipient.email], personalizedSubject, personalizedBody, undefined, undefined, account);
1473
- results.push({
1474
- email: recipient.email,
1475
- success,
1476
- error: success ? undefined : "Failed to send email",
1477
- });
1478
- }
1479
- catch (error) {
1480
- results.push({
1481
- email: recipient.email,
1482
- success: false,
1483
- error: error instanceof Error ? error.message : "Unknown error",
1484
- });
1485
- }
1486
- // Brief delay between sends to avoid overwhelming Mail.app
1487
- if (effectiveDelay > 0 && i < recipients.length - 1) {
1488
- spawnSync("sleep", [(effectiveDelay / 1000).toString()], { stdio: "ignore" });
1489
- }
1490
- }
1491
- return results;
1492
- }
1493
- /**
1494
- * Create a draft email (saved to Drafts folder, not sent).
1495
- *
1496
- * @param to - Recipient email addresses
1497
- * @param subject - Email subject
1498
- * @param body - Email body (plain text)
1499
- * @param cc - CC recipients
1500
- * @param bcc - BCC recipients
1501
- * @param account - Account to create draft in
1502
- * @returns true if draft created successfully
1503
- */
1504
- createDraft(to, subject, body, cc, bcc, account, attachments) {
1505
- const safeSubject = escapeForAppleScript(subject);
1506
- const safeBody = escapeForAppleScript(body);
1507
- // Build recipient additions
1508
- let recipientCommands = "";
1509
- for (const addr of to) {
1510
- recipientCommands += `make new to recipient at end of to recipients with properties {address:"${escapeForAppleScript(addr)}"}\n`;
1511
- }
1512
- if (cc) {
1513
- for (const addr of cc) {
1514
- recipientCommands += `make new cc recipient at end of cc recipients with properties {address:"${escapeForAppleScript(addr)}"}\n`;
1515
- }
1516
- }
1517
- if (bcc) {
1518
- for (const addr of bcc) {
1519
- recipientCommands += `make new bcc recipient at end of bcc recipients with properties {address:"${escapeForAppleScript(addr)}"}\n`;
1520
- }
1521
- }
1522
- // Inline (base64) attachments → temp files (B4); cleaned up after.
1523
- const mat = materializeAttachments(attachments);
1524
- const attachmentCommands = buildAttachmentCommands(mat.paths);
1525
- try {
1526
- return this.createDraftWithCommands(recipientCommands, safeSubject, safeBody, account, attachmentCommands);
1527
- }
1528
- finally {
1529
- mat.cleanup();
1530
- }
1531
- }
1532
- createDraftWithCommands(recipientCommands, safeSubject, safeBody, account, attachmentCommands) {
1533
- let draftCommand;
1534
- if (account) {
1535
- const safeAccount = escapeForAppleScript(account);
1536
- draftCommand = `
1537
- set newMessage to make new outgoing message with properties {subject:"${safeSubject}", content:"${safeBody}", visible:false}
1538
- tell newMessage
1539
- ${recipientCommands}
1540
- set sender to "${safeAccount}"
1541
- ${attachmentCommands}
1542
- end tell
1543
- return "draft created"
1544
- `;
1545
- }
1546
- else {
1547
- draftCommand = `
1548
- set newMessage to make new outgoing message with properties {subject:"${safeSubject}", content:"${safeBody}", visible:false}
1549
- tell newMessage
1550
- ${recipientCommands}
1551
- ${attachmentCommands}
1552
- end tell
1553
- return "draft created"
1554
- `;
1555
- }
1556
- const script = buildAppLevelScript(draftCommand);
1557
- const result = executeAppleScript(script, { timeoutMs: 60000, maxRetries: 2 });
1558
- if (!result.success) {
1559
- console.error(`Failed to create draft: ${result.error}`);
1560
- return false;
1561
- }
1562
- return result.output.includes("draft created");
1563
- }
1564
- /**
1565
- * Reply to a message.
1566
- *
1567
- * @param id - Message ID to reply to
1568
- * @param body - Reply body
1569
- * @param replyAll - If true, reply to all recipients
1570
- * @param send - If true, send immediately; if false, save as draft
1571
- * @returns true if reply created/sent successfully
1572
- */
1573
- replyToMessage(id, body, replyAll = false, send = true) {
1574
- const safeBody = escapeForAppleScript(body);
1575
- const replyAllClause = replyAll ? " with reply to all" : "";
1576
- const sendAction = send ? "send theReply" : "";
1577
- const script = buildAppLevelScript(`
1578
- try
1579
- repeat with acct in accounts
1580
- repeat with mb in mailboxes of acct
1581
- try
1582
- set matchingMsgs to (messages of mb whose id is ${Number(id)})
1583
- if (count of matchingMsgs) > 0 then
1584
- set msg to item 1 of matchingMsgs
1585
- set theReply to reply msg without opening window${replyAllClause}
1586
- set content of theReply to "${safeBody}"
1587
- ${sendAction}
1588
- return "ok"
1589
- end if
1590
- end try
1591
- end repeat
1592
- end repeat
1593
- return "error:Message not found"
1594
- on error errMsg
1595
- return "error:" & errMsg
1596
- end try
1597
- `);
1598
- const result = executeAppleScript(script, { timeoutMs: 60000 });
1599
- if (!result.success || result.output.startsWith("error:")) {
1600
- console.error(`Failed to reply to message: ${result.error || result.output}`);
1601
- return false;
1602
- }
1603
- return true;
1604
- }
1605
- /**
1606
- * Forward a message.
1607
- *
1608
- * @param id - Message ID to forward
1609
- * @param to - Recipients to forward to
1610
- * @param body - Optional body to prepend
1611
- * @param send - If true, send immediately; if false, save as draft
1612
- * @returns true if forward created/sent successfully
1613
- */
1614
- forwardMessage(id, to, body, send = true) {
1615
- const safeBody = body ? escapeForAppleScript(body) : "";
1616
- const sendAction = send ? "send theForward" : "";
1617
- // Build recipient additions
1618
- let recipientCommands = "";
1619
- for (const addr of to) {
1620
- recipientCommands += `make new to recipient at end of to recipients of theForward with properties {address:"${escapeForAppleScript(addr)}"}\n`;
1621
- }
1622
- const script = buildAppLevelScript(`
1623
- try
1624
- repeat with acct in accounts
1625
- repeat with mb in mailboxes of acct
1626
- try
1627
- set matchingMsgs to (messages of mb whose id is ${Number(id)})
1628
- if (count of matchingMsgs) > 0 then
1629
- set msg to item 1 of matchingMsgs
1630
- set theForward to forward msg without opening window
1631
- ${recipientCommands}
1632
- ${safeBody ? `set content of theForward to "${safeBody}"` : ""}
1633
- ${sendAction}
1634
- return "ok"
1635
- end if
1636
- end try
1637
- end repeat
1638
- end repeat
1639
- return "error:Message not found"
1640
- on error errMsg
1641
- return "error:" & errMsg
1642
- end try
1643
- `);
1644
- const result = executeAppleScript(script, { timeoutMs: 60000 });
1645
- if (!result.success || result.output.startsWith("error:")) {
1646
- console.error(`Failed to forward message: ${result.error || result.output}`);
1647
- return false;
1648
- }
1649
- return true;
1650
- }
1651
- /**
1652
- * Helper to find and operate on a message by ID.
1653
- */
1654
- findMessageScript(id, operation) {
1655
- return buildAppLevelScript(`
1656
- try
1657
- repeat with acct in accounts
1658
- repeat with mb in mailboxes of acct
1659
- try
1660
- set matchingMsgs to (messages of mb whose id is ${Number(id)})
1661
- if (count of matchingMsgs) > 0 then
1662
- set msg to item 1 of matchingMsgs
1663
- ${operation}
1664
- return "ok"
1665
- end if
1666
- end try
1667
- end repeat
1668
- end repeat
1669
- return "error:Message not found"
1670
- on error errMsg
1671
- return "error:" & errMsg
1672
- end try
1673
- `);
1674
- }
1675
- /**
1676
- * Mark a message as read.
1677
- */
1678
- markAsRead(id) {
1679
- const script = this.findMessageScript(id, "set read status of msg to true");
1680
- const result = executeAppleScript(script, { timeoutMs: 60000 });
1681
- if (!result.success || result.output.startsWith("error:")) {
1682
- console.error(`Failed to mark message as read: ${result.error || result.output}`);
1683
- return false;
1684
- }
1685
- return true;
1686
- }
1687
- /**
1688
- * Mark a message as unread.
1689
- */
1690
- markAsUnread(id) {
1691
- const script = this.findMessageScript(id, "set read status of msg to false");
1692
- const result = executeAppleScript(script, { timeoutMs: 60000 });
1693
- if (!result.success || result.output.startsWith("error:")) {
1694
- console.error(`Failed to mark message as unread: ${result.error || result.output}`);
1695
- return false;
1696
- }
1697
- return true;
1698
- }
1699
- /**
1700
- * AppleScript statement(s) to flag a message variable, optionally setting its
1701
- * color. `colorIndex` is Apple's flag-index palette (0 red, 1 orange,
1702
- * 2 yellow, 3 green, 4 blue, 5 purple, 6 gray); it is validated to 0-6 by the
1703
- * schema layer and is a number, so it is safe to interpolate. Omitting it
1704
- * applies Mail's default flag without touching the color.
1705
- */
1706
- flagOperation(varName, colorIndex) {
1707
- const setFlag = `set flagged status of ${varName} to true`;
1708
- return colorIndex === undefined
1709
- ? setFlag
1710
- : `${setFlag}\n set flag index of ${varName} to ${colorIndex}`;
1711
- }
1712
- /**
1713
- * Flag a message, optionally with a color (see {@link flagOperation}).
1714
- */
1715
- flagMessage(id, colorIndex) {
1716
- const script = this.findMessageScript(id, this.flagOperation("msg", colorIndex));
1717
- const result = executeAppleScript(script, { timeoutMs: 60000 });
1718
- if (!result.success || result.output.startsWith("error:")) {
1719
- console.error(`Failed to flag message: ${result.error || result.output}`);
1720
- return false;
1721
- }
1722
- return true;
1723
- }
1724
- /**
1725
- * Resolve a message's numeric Mail.app id from its RFC822 Message-ID (the
1726
- * backend-independent join key). This bridges an `imap:` id to the numeric id
1727
- * required to apply a flag *color* — IMAP flags are colorless, so a smart
1728
- * mailbox keyed on flag color can only ever match a message flagged via the
1729
- * AppleScript numeric-id path.
1730
- *
1731
- * The Message-ID is matched both bracketless and `<bracketed>` (Mail returns
1732
- * it bracketless; IMAP envelopes carry the brackets). When `accountName` is
1733
- * given the search is scoped to that account, checking its INBOX first (swept
1734
- * messages live there) to avoid scanning huge All Mail/Archive mailboxes.
1735
- *
1736
- * @returns the numeric id as a string, or null if no message matches.
1737
- */
1738
- findNumericIdByMessageId(messageId, accountName) {
1739
- const mid = messageId.trim().replace(/^<+/, "").replace(/>+$/, "").trim();
1740
- if (!mid)
1741
- return null;
1742
- const q = (s) => s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
1743
- const midLit = `"${q(mid)}"`;
1744
- const bracketedLit = `"${q(`<${mid}>`)}"`;
1745
- const matchClause = (mbVar) => `(messages of ${mbVar} whose message id is ${midLit} or message id is ${bracketedLit})`;
1746
- const acctList = accountName
1747
- ? `set acctList to (every account whose name is "${q(accountName)}")
1748
- if (count of acctList) is 0 then set acctList to accounts`
1749
- : `set acctList to accounts`;
1750
- const script = buildAppLevelScript(`
1751
- try
1752
- ${acctList}
1753
- repeat with acct in acctList
1754
- -- INBOX first: swept messages live there; avoids scanning huge mailboxes.
1755
- try
1756
- set inMb to mailbox "INBOX" of acct
1757
- set inHits to ${matchClause("inMb")}
1758
- if (count of inHits) > 0 then return (id of (item 1 of inHits)) as string
1759
- end try
1760
- repeat with mb in mailboxes of acct
1761
- try
1762
- set matchingMsgs to ${matchClause("mb")}
1763
- if (count of matchingMsgs) > 0 then
1764
- return (id of (item 1 of matchingMsgs)) as string
1765
- end if
1766
- end try
1767
- end repeat
1768
- end repeat
1769
- return "error:Message not found"
1770
- on error errMsg
1771
- return "error:" & errMsg
1772
- end try
1773
- `);
1774
- const result = executeAppleScript(script, { timeoutMs: 60000 });
1775
- if (!result.success || result.output.startsWith("error:"))
1776
- return null;
1777
- const out = result.output.trim();
1778
- return /^\d+$/.test(out) ? out : null;
1779
- }
1780
- /**
1781
- * Unflag a message.
1782
- */
1783
- unflagMessage(id) {
1784
- const script = this.findMessageScript(id, "set flagged status of msg to false");
1785
- const result = executeAppleScript(script, { timeoutMs: 60000 });
1786
- if (!result.success || result.output.startsWith("error:")) {
1787
- console.error(`Failed to unflag message: ${result.error || result.output}`);
1788
- return false;
1789
- }
1790
- return true;
1791
- }
1792
- /**
1793
- * Delete a message.
1794
- */
1795
- deleteMessage(id) {
1796
- const script = this.findMessageScript(id, "delete msg");
1797
- const result = executeAppleScript(script, { timeoutMs: 60000 });
1798
- if (result.success && !result.output.startsWith("error:")) {
1799
- return { success: true };
1800
- }
1801
- const raw = result.success
1802
- ? result.output.replace(/^error:/, "")
1803
- : result.error || "Unknown error";
1804
- const error = this.classifyMessageMutationError(id, raw, "delete");
1805
- console.error(`Failed to delete message: ${error}`);
1806
- return { success: false, error };
1807
- }
1808
- /**
1809
- * Classify a failed message mutation (delete/move) into an actionable error.
1810
- *
1811
- * Mail.app's scripting bridge cannot delete or move drafts, and cannot mutate
1812
- * messages in some server-side special mailboxes — it throws `AppleEvent
1813
- * handler failed` rather than a useful message (#42). When that pattern is
1814
- * seen, look up the message's mailbox (cheap, indexed `whose id is`) to give a
1815
- * draft-specific or server-specific hint. Other errors (e.g. "Message not
1816
- * found", "ambiguous destination") pass through unchanged.
1817
- */
1818
- classifyMessageMutationError(id, raw, op) {
1819
- const trimmed = (raw || "").trim();
1820
- if (!UNSUPPORTED_APPLESCRIPT_OP.test(trimmed))
1821
- return trimmed || `Failed to ${op} message`;
1822
- let mailbox = "";
1823
- try {
1824
- mailbox = this.getMessageById(id)?.mailbox ?? "";
1825
- }
1826
- catch {
1827
- /* best-effort; fall through to the generic server-side message */
1828
- }
1829
- if (/draft/i.test(mailbox)) {
1830
- return `Mail.app cannot ${op} drafts via AppleScript; ${op} it in Mail.app directly. (Mail.app error: ${trimmed})`;
1831
- }
1832
- return `Mail.app cannot ${op} this message via AppleScript (server-side or special mailbox${mailbox ? ` "${mailbox}"` : ""}); ${op} it in Mail.app directly. (Mail.app error: ${trimmed})`;
1833
- }
1834
- /**
1835
- * Move a message to a different mailbox.
1836
- */
1837
- /**
1838
- * Move a message to a destination mailbox, with full nested-mailbox support.
1839
- *
1840
- * Resolving the destination as `mailbox "X" of account "Y"` only finds
1841
- * top-level mailboxes, so nested destinations (e.g. a "Moore" subfolder)
1842
- * silently failed. Instead we walk the target account's full mailbox tree and
1843
- * match by name. Resolution is:
1844
- * - account-scoped (won't move to a same-named mailbox in another account)
1845
- * - ambiguity-aware: if the name matches more than one mailbox in the
1846
- * account we refuse to guess and return an error — silently moving mail to
1847
- * the wrong folder is worse than failing.
1848
- * The source message is located by walking every account's tree breadth-first
1849
- * (top-level mailboxes like Inbox are checked first), so messages in nested
1850
- * mailboxes are found too.
1851
- *
1852
- * Returns a result object so batch callers can surface the specific failure
1853
- * (destination not found / ambiguous / message not found).
1854
- */
1855
- moveMessageInternal(id, mailbox, account) {
1856
- const targetAccount = this.resolveAccount(account);
1857
- const targetMailbox = this.resolveMailbox(mailbox, targetAccount);
1858
- const safeMailbox = escapeForAppleScript(targetMailbox);
1859
- const safeAccount = escapeForAppleScript(targetAccount);
1860
- const script = buildAppLevelScript(`
1861
- try
1862
- -- \`mailboxes of account\` is already flat: it includes nested mailboxes
1863
- -- (named by path, e.g. "Processed/Vendors"). Descending via \`mailboxes of mb\`
1864
- -- is unreliable (it double-prepends the parent path), so we DON'T recurse —
1865
- -- we match against this flat list by exact name and use the reference directly
1866
- -- (addressing \`mailbox "X" of account "Y"\` only finds some top-level mailboxes).
1867
- set destName to "${safeMailbox}"
1868
- set destMatches to {}
1869
- repeat with mb in (mailboxes of account "${safeAccount}")
1870
- if (name of mb) is destName then set end of destMatches to mb
1871
- end repeat
1872
- if (count of destMatches) is 0 then return "error:Destination mailbox \\"" & destName & "\\" not found in account \\"${safeAccount}\\""
1873
- if (count of destMatches) > 1 then return "error:Destination mailbox \\"" & destName & "\\" is ambiguous (" & (count of destMatches) & " matches) in account \\"${safeAccount}\\"; disambiguate or move by full path"
1874
- set destMailbox to item 1 of destMatches
1875
-
1876
- -- Find the message by id. The flat mailbox list already covers nested
1877
- -- mailboxes, so this reaches messages in subfolders without recursing.
1878
- repeat with acct in accounts
1879
- repeat with mb in (mailboxes of acct)
1880
- try
1881
- set matchingMsgs to (messages of mb whose id is ${Number(id)})
1882
- if (count of matchingMsgs) > 0 then
1883
- move (item 1 of matchingMsgs) to destMailbox
1884
- return "ok"
1885
- end if
1886
- end try
1887
- end repeat
1888
- end repeat
1889
- return "error:Message not found"
1890
- on error errMsg
1891
- return "error:" & errMsg
1892
- end try
1893
- `);
1894
- const result = executeAppleScript(script, { timeoutMs: 90000 });
1895
- if (!result.success) {
1896
- return { success: false, error: result.error || "AppleScript execution failed" };
1897
- }
1898
- if (result.output.startsWith("error:")) {
1899
- return { success: false, error: result.output.slice("error:".length) };
1900
- }
1901
- return { success: true };
1902
- }
1903
- moveMessage(id, mailbox, account) {
1904
- const res = this.moveMessageInternal(id, mailbox, account);
1905
- if (res.success)
1906
- return { success: true };
1907
- const error = this.classifyMessageMutationError(id, res.error || "Failed to move message", "move");
1908
- console.error(`Failed to move message: ${error}`);
1909
- return { success: false, error };
1910
- }
1911
- // ===========================================================================
1912
- // Batch Operations
1913
- // ===========================================================================
1914
- /**
1915
- * Run one operation over many message IDs in a SINGLE osascript invocation.
1916
- *
1917
- * Previously each batch method looped and called the per-id method, so a
1918
- * 100-id batch spawned 100 osascript processes — each one re-resolving
1919
- * accounts and walking the whole account→mailbox tree — all serialized
1920
- * through the gate (issue #31). This walks the tree exactly once: for each
1921
- * mailbox it probes the still-pending IDs with `whose id is` (indexed, so
1922
- * effectively free) and applies `operation` to any match, tracking found IDs
1923
- * so it can stop early once all are accounted for. Per-id outcomes come back
1924
- * as control-char-delimited `id<FS>status` records (status: `ok`,
1925
- * `notfound`, or `error:<msg>`), and results are returned in input order.
1926
- *
1927
- * `setup` runs once before the walk (used by move to resolve the destination);
1928
- * it may bail the whole batch by returning a `BATCH_FATAL`-prefixed string.
1929
- */
1930
- runBatchOperation(ids, operation, setup = "") {
1931
- // Keep the numeric IDs paired with their original string form and 1-based
1932
- // position. The AppleScript reports outcomes by POSITION, not by id: a Mail
1933
- // id large enough to exceed AppleScript's 2^29 integer range coerces to
1934
- // scientific notation under `as string` (999999999 -> "9.99999999E+8"), so
1935
- // echoing the id back can't be matched to the input. Positions are always
1936
- // small integers, so they round-trip cleanly.
1937
- const valid = [];
1938
- for (const id of ids) {
1939
- const num = Number(id);
1940
- if (Number.isFinite(num))
1941
- valid.push({ id, num });
1942
- }
1943
- if (valid.length === 0) {
1944
- return ids.map((id) => ({ id, success: false, error: "Invalid message ID" }));
1945
- }
1946
- const script = buildAppLevelScript(`
1947
- try
1948
- ${setup}
1949
- set _out to ""
1950
- set _done to {}
1951
- set _ids to {${valid.map((v) => v.num).join(", ")}}
1952
- set _total to count of _ids
1953
- repeat with acct in accounts
1954
- if (count of _done) is _total then exit repeat
1955
- repeat with mb in (mailboxes of acct)
1956
- if (count of _done) is _total then exit repeat
1957
- repeat with _idx from 1 to _total
1958
- if _idx is not in _done then
1959
- set _theId to item _idx of _ids
1960
- try
1961
- set _m to (messages of mb whose id is _theId)
1962
- if (count of _m) > 0 then
1963
- set _msg to item 1 of _m
1964
- ${operation}
1965
- set end of _done to _idx
1966
- set _out to _out & (_idx as string) & "${FIELD_SEP}ok${RECORD_SEP}"
1967
- end if
1968
- on error _e
1969
- set end of _done to _idx
1970
- set _out to _out & (_idx as string) & "${FIELD_SEP}error:" & _e & "${RECORD_SEP}"
1971
- end try
1972
- end if
1973
- end repeat
1974
- end repeat
1975
- end repeat
1976
- repeat with _idx from 1 to _total
1977
- if _idx is not in _done then set _out to _out & (_idx as string) & "${FIELD_SEP}notfound${RECORD_SEP}"
1978
- end repeat
1979
- return _out
1980
- on error errMsg
1981
- return "${BATCH_FATAL}" & errMsg
1982
- end try
1983
- `);
1984
- // Generous timeout: one walk over the tree with indexed id probes. Scale a
1985
- // little with batch size, capped.
1986
- const timeoutMs = Math.min(180000, 60000 + valid.length * 500);
1987
- const result = executeAppleScript(script, { timeoutMs });
1988
- if (!result.success) {
1989
- const err = result.error || "Batch operation failed";
1990
- return ids.map((id) => ({ id, success: false, error: err }));
1991
- }
1992
- if (result.output.startsWith(BATCH_FATAL)) {
1993
- const err = result.output.slice(BATCH_FATAL.length);
1994
- return ids.map((id) => ({ id, success: false, error: err }));
1995
- }
1996
- // Map by-position outcomes back to the original id strings.
1997
- const byId = new Map();
1998
- for (const rec of result.output.split(RECORD_SEP)) {
1999
- if (!rec)
2000
- continue;
2001
- const sep = rec.indexOf(FIELD_SEP);
2002
- if (sep < 0)
2003
- continue;
2004
- const pos = Number(rec.slice(0, sep));
2005
- const status = rec.slice(sep + FIELD_SEP.length);
2006
- const entry = valid[pos - 1];
2007
- if (!entry)
2008
- continue;
2009
- const id = entry.id;
2010
- if (status === "ok") {
2011
- byId.set(id, { id, success: true });
2012
- }
2013
- else if (status === "notfound") {
2014
- byId.set(id, { id, success: false, error: "Message not found" });
2015
- }
2016
- else if (status.startsWith("error:")) {
2017
- byId.set(id, { id, success: false, error: status.slice("error:".length) });
2018
- }
2019
- else {
2020
- byId.set(id, { id, success: false, error: status || "Unknown error" });
2021
- }
2022
- }
2023
- return ids.map((id) => byId.get(id) ??
2024
- (Number.isFinite(Number(id))
2025
- ? { id, success: false, error: "No result returned" }
2026
- : { id, success: false, error: "Invalid message ID" }));
2027
- }
2028
- /**
2029
- * Delete multiple messages at once (single tree walk — see runBatchOperation).
2030
- */
2031
- batchDeleteMessages(ids) {
2032
- return this.runBatchOperation(ids, "delete _msg");
2033
- }
2034
- /**
2035
- * Move multiple messages to a mailbox at once (single tree walk).
2036
- *
2037
- * The destination is resolved once (account-scoped, ambiguity-aware — a name
2038
- * matching more than one mailbox fails the whole batch rather than guessing),
2039
- * then every matched message is moved in the same walk.
2040
- */
2041
- batchMoveMessages(ids, mailbox, account) {
2042
- const targetAccount = this.resolveAccount(account);
2043
- const targetMailbox = this.resolveMailbox(mailbox, targetAccount);
2044
- const safeMailbox = escapeForAppleScript(targetMailbox);
2045
- const safeAccount = escapeForAppleScript(targetAccount);
2046
- // Resolved once, before the walk. `mailboxes of account` is already flat
2047
- // (includes nested mailboxes by path), so we match by exact name and use the
2048
- // reference directly. A bad/ambiguous destination fails the whole batch.
2049
- const setup = `
2050
- set destName to "${safeMailbox}"
2051
- set destMatches to {}
2052
- repeat with _dmb in (mailboxes of account "${safeAccount}")
2053
- if (name of _dmb) is destName then set end of destMatches to _dmb
2054
- end repeat
2055
- if (count of destMatches) is 0 then return "${BATCH_FATAL}Destination mailbox \\"" & destName & "\\" not found in account \\"${safeAccount}\\""
2056
- if (count of destMatches) > 1 then return "${BATCH_FATAL}Destination mailbox \\"" & destName & "\\" is ambiguous (" & (count of destMatches) & " matches) in account \\"${safeAccount}\\"; move by full path"
2057
- set destMailbox to item 1 of destMatches`;
2058
- return this.runBatchOperation(ids, "move _msg to destMailbox", setup);
2059
- }
2060
- /**
2061
- * Mark multiple messages as read at once (single tree walk).
2062
- */
2063
- batchMarkAsRead(ids) {
2064
- return this.runBatchOperation(ids, "set read status of _msg to true");
2065
- }
2066
- /**
2067
- * Mark multiple messages as unread at once (single tree walk).
2068
- */
2069
- batchMarkAsUnread(ids) {
2070
- return this.runBatchOperation(ids, "set read status of _msg to false");
2071
- }
2072
- /**
2073
- * Flag multiple messages at once (single tree walk).
2074
- */
2075
- batchFlagMessages(ids, colorIndex) {
2076
- return this.runBatchOperation(ids, this.flagOperation("_msg", colorIndex));
2077
- }
2078
- /**
2079
- * Unflag multiple messages at once (single tree walk).
2080
- */
2081
- batchUnflagMessages(ids) {
2082
- return this.runBatchOperation(ids, "set flagged status of _msg to false");
2083
- }
2084
- /**
2085
- * List attachments for a message.
2086
- * Tries AppleScript first, falls back to MIME source parsing
2087
- * when AppleScript returns empty (known issue across all account types).
2088
- */
2089
- listAttachments(id) {
2090
- // Attempt 1: AppleScript mail attachments
2091
- const script = buildAppLevelScript(`
2092
- try
2093
- repeat with acct in accounts
2094
- repeat with mb in mailboxes of acct
2095
- try
2096
- set matchingMsgs to (messages of mb whose id is ${Number(id)})
2097
- if (count of matchingMsgs) > 0 then
2098
- set msg to item 1 of matchingMsgs
2099
- set outputText to ""
2100
- set attCount to 0
2101
- repeat with att in mail attachments of msg
2102
- set attName to name of att
2103
- set attType to MIME type of att
2104
- set attSize to file size of att as string
2105
- if attCount > 0 then set outputText to outputText & "${RECORD_SEP}"
2106
- set outputText to outputText & attName & "${FIELD_SEP}" & attType & "${FIELD_SEP}" & attSize
2107
- set attCount to attCount + 1
2108
- end repeat
2109
- return outputText
2110
- end if
2111
- end try
2112
- end repeat
2113
- end repeat
2114
- return ""
2115
- on error errMsg
2116
- return ""
2117
- end try
2118
- `);
2119
- const result = executeAppleScript(script, { timeoutMs: 60000 });
2120
- if (result.success && result.output.trim()) {
2121
- const items = result.output.split(RECORD_SEP);
2122
- const attachments = [];
2123
- for (const item of items) {
2124
- const parts = item.split(FIELD_SEP);
2125
- if (parts.length < 3)
2126
- continue;
2127
- attachments.push({
2128
- id: `${id}-${parts[0]}`,
2129
- name: parts[0],
2130
- mimeType: parts[1],
2131
- size: parseInt(parts[2]) || 0,
2132
- });
2133
- }
2134
- if (attachments.length > 0)
2135
- return attachments;
2136
- }
2137
- // Attempt 2: MIME source fallback
2138
- const rawSource = this.getRawSource(id);
2139
- if (!rawSource)
2140
- return [];
2141
- const mimeAttachments = parseMimeAttachments(rawSource);
2142
- return mimeAttachments.map((att) => ({
2143
- id: `${id}-${att.name}`,
2144
- name: att.name,
2145
- mimeType: att.mimeType,
2146
- size: att.size,
2147
- }));
2148
- }
2149
- /**
2150
- * Save an attachment from a message to disk.
2151
- * Tries AppleScript first, falls back to MIME source extraction
2152
- * when AppleScript can't find the attachment.
2153
- */
2154
- saveAttachment(id, attachmentName, savePath) {
2155
- // Validate attachment name: block path separators, traversal, null bytes, and backslashes
2156
- if (/[/\\\0]/.test(attachmentName) || attachmentName.includes("..")) {
2157
- console.error(`Invalid attachment name: "${attachmentName}"`);
2158
- return false;
2159
- }
2160
- // Resolve the save path to prevent symlink / ".." traversal bypass
2161
- const resolvedPath = resolve(savePath);
2162
- if (!isPathWithinAllowedRoots(resolvedPath)) {
2163
- console.error(`Save path "${savePath}" is outside allowed directories`);
2164
- return false;
2165
- }
2166
- const safeName = escapeForAppleScript(attachmentName);
2167
- const safePath = escapeForAppleScript(resolvedPath);
2168
- const numericId = Number(id);
2169
- // Attempt 1: AppleScript save
2170
- const script = buildAppLevelScript(`
2171
- try
2172
- repeat with acct in accounts
2173
- repeat with mb in mailboxes of acct
2174
- try
2175
- set matchingMsgs to (messages of mb whose id is ${numericId})
2176
- if (count of matchingMsgs) > 0 then
2177
- set msg to item 1 of matchingMsgs
2178
- repeat with att in mail attachments of msg
2179
- if name of att is "${safeName}" then
2180
- set savePath to POSIX file "${safePath}/${safeName}"
2181
- save att in savePath
2182
- return "ok"
2183
- end if
2184
- end repeat
2185
- return "error:Attachment not found"
2186
- end if
2187
- end try
2188
- end repeat
2189
- end repeat
2190
- return "error:Message not found"
2191
- on error errMsg
2192
- return "error:" & errMsg
2193
- end try
2194
- `);
2195
- const result = executeAppleScript(script, { timeoutMs: 60000 });
2196
- if (result.success && result.output === "ok") {
2197
- return true;
2198
- }
2199
- // Attempt 2: MIME source fallback
2200
- const rawSource = this.getRawSource(id);
2201
- if (!rawSource) {
2202
- console.error(`Failed to save attachment: could not retrieve message source`);
2203
- return false;
2204
- }
2205
- const attachment = extractMimeAttachment(rawSource, attachmentName);
2206
- if (!attachment) {
2207
- console.error(`Failed to save attachment: "${attachmentName}" not found in MIME source`);
2208
- return false;
2209
- }
2210
- try {
2211
- const outPath = resolve(resolvedPath, attachmentName);
2212
- // Verify the resolved output path is still within allowed directories
2213
- if (!isPathWithinAllowedRoots(outPath)) {
2214
- console.error(`Output path "${outPath}" is outside allowed directories`);
2215
- return false;
2216
- }
2217
- writeFileSync(outPath, attachment.data);
2218
- return true;
2219
- }
2220
- catch (err) {
2221
- console.error(`Failed to write attachment to disk: ${err}`);
2222
- return false;
2223
- }
2224
- }
2225
- /**
2226
- * Fetch an attachment's bytes as base64 (B4) — the read counterpart to
2227
- * sending inline base64 content. Reuses saveAttachment via a throwaway temp
2228
- * dir (under an allowed root), then reads and encodes the file.
2229
- */
2230
- getAttachmentBase64(id, attachmentName) {
2231
- let dir = null;
2232
- try {
2233
- dir = mkdtempSync("/private/tmp/amcp-fetch-");
2234
- const dest = join(dir, attachmentName.replace(/[/\\]/g, "_"));
2235
- const ok = this.saveAttachment(id, attachmentName, dest);
2236
- if (!ok) {
2237
- return {
2238
- success: false,
2239
- error: `Attachment "${attachmentName}" not found on message ${id}`,
2240
- };
2241
- }
2242
- const buf = readFileSync(dest);
2243
- return { success: true, base64: buf.toString("base64"), bytes: buf.length };
2244
- }
2245
- catch (e) {
2246
- return { success: false, error: e instanceof Error ? e.message : String(e) };
2247
- }
2248
- finally {
2249
- if (dir)
2250
- rmSync(dir, { recursive: true, force: true });
2251
- }
2252
- }
2253
- // ===========================================================================
2254
- // Mailbox Operations
2255
- // ===========================================================================
2256
- /**
2257
- * List all mailboxes for an account.
2258
- */
2259
- listMailboxes(account) {
2260
- const targetAccount = this.resolveAccount(account);
2261
- const listCommand = `
2262
- set mailboxList to {}
2263
- repeat with mb in mailboxes
2264
- set mbName to name of mb
2265
- set mbUnread to unread count of mb
2266
- set mbCount to count of messages of mb
2267
- set end of mailboxList to mbName & "${FIELD_SEP}" & mbUnread & "${FIELD_SEP}" & mbCount
2268
- end repeat
2269
- set AppleScript's text item delimiters to "${RECORD_SEP}"
2270
- return mailboxList as text
2271
- `;
2272
- const script = buildAccountScopedScript(targetAccount, listCommand);
2273
- // Counts every mailbox's message total, so it needs more than the default
2274
- // 30s on accounts with many/large mailboxes; a timeout here silently
2275
- // returned an empty list (audit finding #8).
2276
- const result = executeAppleScript(script, { timeoutMs: 60000 });
2277
- if (!result.success) {
2278
- console.error(`Failed to list mailboxes: ${result.error}`);
2279
- return [];
2280
- }
2281
- if (!result.output.trim())
2282
- return [];
2283
- const items = result.output.split(RECORD_SEP);
2284
- const mailboxes = [];
2285
- for (const item of items) {
2286
- const parts = item.split(FIELD_SEP);
2287
- if (parts.length < 3)
2288
- continue;
2289
- mailboxes.push({
2290
- name: parts[0],
2291
- account: targetAccount,
2292
- unreadCount: parseInt(parts[1]) || 0,
2293
- messageCount: parseInt(parts[2]) || 0,
2294
- });
2295
- }
2296
- return mailboxes;
2297
- }
2298
- /**
2299
- * Get unread count for a mailbox.
2300
- */
2301
- getUnreadCount(mailbox, account) {
2302
- const targetAccount = this.resolveAccount(account);
2303
- let command;
2304
- if (mailbox) {
2305
- const targetMailbox = this.resolveMailbox(mailbox, targetAccount);
2306
- const safeMailbox = escapeForAppleScript(targetMailbox);
2307
- command = `return unread count of mailbox "${safeMailbox}"`;
2308
- }
2309
- else {
2310
- // Get total unread across all mailboxes
2311
- command = `
2312
- set total to 0
2313
- repeat with mb in mailboxes
2314
- set total to total + (unread count of mb)
2315
- end repeat
2316
- return total
2317
- `;
2318
- }
2319
- const script = buildAccountScopedScript(targetAccount, command);
2320
- // Summing unread across every mailbox can exceed the default 30s; a timeout
2321
- // previously degraded silently to 0 ("all read") — audit finding #8.
2322
- const result = executeAppleScript(script, { timeoutMs: 60000 });
2323
- if (!result.success) {
2324
- console.error(`Failed to get unread count: ${result.error}`);
2325
- return 0;
2326
- }
2327
- return parseInt(result.output) || 0;
2328
- }
2329
- /**
2330
- * Create a new mailbox.
2331
- */
2332
- createMailbox(name, account) {
2333
- const targetAccount = this.resolveAccount(account);
2334
- const disabled = this.disabledAccountGuard(targetAccount);
2335
- if (disabled) {
2336
- console.error(`Refusing to create mailbox: ${disabled}`);
2337
- return { success: false, error: disabled };
2338
- }
2339
- // BUG B: never create a mailbox on a server-side account we couldn't later
2340
- // delete/rename via AppleScript (IMAP-configured accounts are routed to IMAP
2341
- // upstream and never reach here).
2342
- const serverSide = this.serverSideCreateGuard(targetAccount, "create");
2343
- if (serverSide) {
2344
- console.error(`Refusing to create mailbox: ${serverSide}`);
2345
- return { success: false, error: serverSide };
2346
- }
2347
- const safeName = escapeForAppleScript(name);
2348
- const safeAccount = escapeForAppleScript(targetAccount);
2349
- const script = buildAppLevelScript(`
2350
- try
2351
- make new mailbox with properties {name:"${safeName}"} at account "${safeAccount}"
2352
- return "ok"
2353
- on error errMsg
2354
- return "error:" & errMsg
2355
- end try
2356
- `);
2357
- const result = executeAppleScript(script);
2358
- if (!result.success || result.output.startsWith("error:")) {
2359
- const raw = result.success
2360
- ? result.output.replace(/^error:/, "")
2361
- : result.error || "Unknown error";
2362
- const error = describeMailboxOpError("create", raw);
2363
- console.error(`Failed to create mailbox: ${error}`);
2364
- return { success: false, error };
2365
- }
2366
- this.invalidateCache();
2367
- return { success: true };
2368
- }
2369
- /**
2370
- * Delete a mailbox.
2371
- */
2372
- deleteMailbox(name, account) {
2373
- const targetAccount = this.resolveAccount(account);
2374
- const disabled = this.disabledAccountGuard(targetAccount);
2375
- if (disabled) {
2376
- console.error(`Refusing to delete mailbox: ${disabled}`);
2377
- return { success: false, error: disabled };
2378
- }
2379
- const targetMailbox = this.resolveMailbox(name, targetAccount);
2380
- const safeName = escapeForAppleScript(targetMailbox);
2381
- const safeAccount = escapeForAppleScript(targetAccount);
2382
- const script = buildAppLevelScript(`
2383
- try
2384
- delete mailbox "${safeName}" of account "${safeAccount}"
2385
- return "ok"
2386
- on error errMsg
2387
- return "error:" & errMsg
2388
- end try
2389
- `);
2390
- const result = executeAppleScript(script);
2391
- if (!result.success || result.output.startsWith("error:")) {
2392
- const raw = result.success
2393
- ? result.output.replace(/^error:/, "")
2394
- : result.error || "Unknown error";
2395
- const error = describeMailboxOpError("delete", raw);
2396
- console.error(`Failed to delete mailbox: ${error}`);
2397
- return { success: false, error };
2398
- }
2399
- this.invalidateCache();
2400
- return { success: true };
2401
- }
2402
- /**
2403
- * Rename a mailbox by creating a new one, moving messages, and deleting the old one.
2404
- */
2405
- renameMailbox(oldName, newName, account) {
2406
- const targetAccount = this.resolveAccount(account);
2407
- // BUG B: refuse a server-side rename UP FRONT, before creating the
2408
- // destination. AppleScript can create the destination but can't delete the
2409
- // source server-side, which historically left a half-created orphan. IMAP-
2410
- // configured accounts are routed to IMAP upstream and never reach here.
2411
- const serverSide = this.serverSideCreateGuard(targetAccount, "rename");
2412
- if (serverSide) {
2413
- console.error(`Refusing to rename mailbox: ${serverSide}`);
2414
- return { success: false, error: serverSide };
2415
- }
2416
- // Create the new mailbox. createMailbox runs the disabled-account guard, so
2417
- // a disabled target is refused here before anything is built — no orphan can
2418
- // be created. Propagate its (more specific) error rather than a generic one.
2419
- const created = this.createMailbox(newName, targetAccount);
2420
- if (!created.success) {
2421
- return {
2422
- success: false,
2423
- error: created.error ??
2424
- `Could not create the destination mailbox "${newName}" needed for the rename.`,
2425
- };
2426
- }
2427
- // Move all messages from old to new
2428
- const resolvedOld = this.resolveMailbox(oldName, targetAccount);
2429
- const resolvedNew = this.resolveMailbox(newName, targetAccount);
2430
- const safeOld = escapeForAppleScript(resolvedOld);
2431
- const safeNew = escapeForAppleScript(resolvedNew);
2432
- const safeAccount = escapeForAppleScript(targetAccount);
2433
- // Mail.app has no reliable in-place mailbox rename across account types, so
2434
- // rename is emulated as create-new + move-all + delete-old. The risk (issue
2435
- // #33) is that the old code iterated `messages of srcMailbox` *while moving*
2436
- // (mutating the collection it was iterating, which can skip messages) and
2437
- // then deleted the source unconditionally — so a move that errored or timed
2438
- // out part-way lost the un-moved remainder. This version:
2439
- // - snapshots the message references up front (move can't disturb iteration),
2440
- // - moves each within its own `try` so one bad message doesn't abort the rest,
2441
- // - and deletes the source ONLY if it is empty afterwards (every message
2442
- // moved). On a partial move the source is left intact and we report how
2443
- // many remain, so no mail is lost.
2444
- const moveScript = buildAppLevelScript(`
2445
- try
2446
- set srcMailbox to mailbox "${safeOld}" of account "${safeAccount}"
2447
- set destMailbox to mailbox "${safeNew}" of account "${safeAccount}"
2448
- set srcCount to count of messages of srcMailbox
2449
- set msgs to (every message of srcMailbox)
2450
- repeat with m in msgs
2451
- try
2452
- move m to destMailbox
2453
- end try
2454
- end repeat
2455
- set srcAfter to count of messages of srcMailbox
2456
- if srcAfter is 0 then
2457
- delete mailbox "${safeOld}" of account "${safeAccount}"
2458
- return "ok${FIELD_SEP}" & srcCount
2459
- else
2460
- return "partial${FIELD_SEP}" & (srcCount - srcAfter) & "${FIELD_SEP}" & srcCount & "${FIELD_SEP}" & srcAfter
2461
- end if
2462
- on error errMsg
2463
- return "error:" & errMsg
2464
- end try
2465
- `);
2466
- // Moving a large source mailbox is slow; give it room. If it's still killed,
2467
- // the source is never deleted (delete only runs after a verified-empty
2468
- // check), so a truncated move is recoverable rather than lossy.
2469
- const result = executeAppleScript(moveScript, { timeoutMs: 120000 });
2470
- if (!result.success || result.output.startsWith("error:")) {
2471
- const raw = result.success
2472
- ? result.output.replace(/^error:/, "")
2473
- : result.error || "Unknown error";
2474
- // Roll back the destination we just created so a failed rename doesn't
2475
- // leave an orphan (as a partial-failure once did — the _amcp_rename_test_*
2476
- // ghosts). deleteMailboxIfEmpty only removes it when empty, so any
2477
- // messages that did move are never destroyed; the source is untouched
2478
- // (its delete only runs after a verified-empty move).
2479
- const rolledBack = this.deleteMailboxIfEmpty(resolvedNew, targetAccount);
2480
- let error = describeMailboxOpError("rename", raw);
2481
- error += rolledBack
2482
- ? ` The empty destination mailbox "${resolvedNew}" was rolled back, so no orphan was left.`
2483
- : ` The destination mailbox "${resolvedNew}" was created and could not be auto-removed; delete it manually if it is an empty leftover.`;
2484
- console.error(`Failed to rename mailbox: ${error}`);
2485
- this.invalidateCache();
2486
- return { success: false, error };
2487
- }
2488
- if (result.output.startsWith("partial")) {
2489
- const parts = result.output.split(FIELD_SEP);
2490
- const remaining = parts[3] ?? "?";
2491
- const total = parts[2] ?? "?";
2492
- const error = `Only ${parts[1] ?? "?"} of ${total} messages moved, ${remaining} remain in "${resolvedOld}"; the source was NOT deleted (both mailboxes left intact). Retry to move the rest.`;
2493
- console.error(`Failed to rename mailbox: ${error}`);
2494
- this.invalidateCache(); // the new mailbox now exists and holds the moved messages
2495
- return { success: false, error };
2496
- }
2497
- this.invalidateCache();
2498
- return { success: true };
2499
- }
2500
- // ===========================================================================
2501
- // Account Operations
2502
- // ===========================================================================
2503
- /**
2504
- * List all mail accounts (uses cache).
2505
- */
2506
- listAccounts() {
2507
- return this.getCachedAccounts();
2508
- }
2509
- /**
2510
- * Fetches account list directly from Mail.app via AppleScript.
2511
- * Used internally by the cache; prefer getCachedAccounts() or listAccounts().
2512
- */
2513
- fetchAccounts() {
2514
- const script = buildAppLevelScript(`
2515
- set accountList to {}
2516
- repeat with acct in accounts
2517
- set acctName to name of acct
2518
- set acctEmail to email addresses of acct
2519
- set acctEnabled to enabled of acct
2520
- set emailStr to ""
2521
- if (count of acctEmail) > 0 then
2522
- set emailStr to item 1 of acctEmail
2523
- end if
2524
- set end of accountList to acctName & "${FIELD_SEP}" & emailStr & "${FIELD_SEP}" & acctEnabled
2525
- end repeat
2526
- set AppleScript's text item delimiters to "${RECORD_SEP}"
2527
- return accountList as text
2528
- `);
2529
- const result = executeAppleScript(script);
2530
- if (!result.success) {
2531
- console.error(`Failed to list accounts: ${result.error}`);
2532
- return [];
2533
- }
2534
- if (!result.output.trim())
2535
- return [];
2536
- const items = result.output.split(RECORD_SEP);
2537
- const accounts = [];
2538
- for (const item of items) {
2539
- const parts = item.split(FIELD_SEP);
2540
- if (parts.length < 3)
2541
- continue;
2542
- accounts.push({
2543
- name: parts[0],
2544
- email: parts[1],
2545
- enabled: parts[2] === "true",
2546
- });
2547
- }
2548
- return accounts;
2549
- }
2550
- /**
2551
- * Fetches mailbox names for an account directly from Mail.app.
2552
- * Used internally by the cache; prefer getCachedMailboxNames().
2553
- */
2554
- fetchMailboxNames(account) {
2555
- const script = buildAccountScopedScript(account, `
2556
- set mbNames to {}
2557
- repeat with mb in mailboxes
2558
- set end of mbNames to name of mb
2559
- end repeat
2560
- return mbNames
2561
- `);
2562
- const result = executeAppleScript(script);
2563
- if (!result.success || !result.output) {
2564
- return [];
2565
- }
2566
- return result.output.split(", ").map((s) => s.trim());
2567
- }
2568
- // ===========================================================================
2569
- // Mail Rules
2570
- // ===========================================================================
2571
- /**
2572
- * List all mail rules.
2573
- */
2574
- listRules() {
2575
- const script = buildAppLevelScript(`
2576
- set ruleList to {}
2577
- repeat with r in rules
2578
- set ruleName to name of r
2579
- set ruleEnabled to enabled of r
2580
- set end of ruleList to ruleName & "${FIELD_SEP}" & (ruleEnabled as string)
2581
- end repeat
2582
- set AppleScript's text item delimiters to "${RECORD_SEP}"
2583
- return ruleList as text
2584
- `);
2585
- const result = executeAppleScript(script);
2586
- if (!result.success || !result.output.trim()) {
2587
- return [];
2588
- }
2589
- const items = result.output.split(RECORD_SEP);
2590
- const rules = [];
2591
- for (const item of items) {
2592
- const parts = item.split(FIELD_SEP);
2593
- if (parts.length < 2)
2594
- continue;
2595
- rules.push({
2596
- name: parts[0],
2597
- enabled: parts[1] === "true",
2598
- });
2599
- }
2600
- return rules;
2601
- }
2602
- /**
2603
- * Enable or disable a mail rule.
2604
- */
2605
- setRuleEnabled(ruleName, enabled) {
2606
- const safeName = escapeForAppleScript(ruleName);
2607
- const script = buildAppLevelScript(`
2608
- try
2609
- repeat with r in rules
2610
- if name of r is "${safeName}" then
2611
- set enabled of r to ${enabled}
2612
- return "ok"
2613
- end if
2614
- end repeat
2615
- return "error:Rule not found"
2616
- on error errMsg
2617
- return "error:" & errMsg
2618
- end try
2619
- `);
2620
- const result = executeAppleScript(script);
2621
- if (!result.success || result.output.startsWith("error:")) {
2622
- console.error(`Failed to set rule state: ${result.error || result.output}`);
2623
- return false;
2624
- }
2625
- return true;
2626
- }
2627
- /**
2628
- * Create a mail rule (B2). Builds conditions (from/to/cc/subject/content with
2629
- * a match operator) and actions (mark read/flagged, delete, move to a
2630
- * mailbox) on a real Mail.app rule. Returns an error string on failure.
2631
- */
2632
- createRule(opts) {
2633
- const safeName = escapeForAppleScript(opts.name);
2634
- if (!opts.conditions?.length) {
2635
- return { success: false, error: "A rule needs at least one condition." };
2636
- }
2637
- const ruleTypeMap = {
2638
- from: "from header",
2639
- to: "to header",
2640
- cc: "cc header",
2641
- subject: "subject header",
2642
- content: "message content",
2643
- };
2644
- const qualifierMap = {
2645
- contains: "does contain value",
2646
- notContains: "does not contain value",
2647
- equals: "equal to value",
2648
- beginsWith: "begins with value",
2649
- endsWith: "ends with value",
2650
- };
2651
- const conditionStmts = opts.conditions
2652
- .map((c) => {
2653
- const rt = ruleTypeMap[c.field];
2654
- const q = qualifierMap[c.operator];
2655
- return ` make new rule condition at end of rule conditions of newRule with properties {rule type:${rt}, qualifier:${q}, expression:"${escapeForAppleScript(c.value)}"}`;
2656
- })
2657
- .join("\n");
2658
- const actionStmts = [];
2659
- const a = opts.actions ?? {};
2660
- if (a.markRead)
2661
- actionStmts.push(` set mark read of newRule to true`);
2662
- if (a.markFlagged)
2663
- actionStmts.push(` set mark flagged of newRule to true`);
2664
- if (a.delete)
2665
- actionStmts.push(` set delete message of newRule to true`);
2666
- if (a.moveTo) {
2667
- const safeMbox = escapeForAppleScript(a.moveTo);
2668
- const mboxRef = a.moveToAccount
2669
- ? `mailbox "${safeMbox}" of account "${escapeForAppleScript(a.moveToAccount)}"`
2670
- : `mailbox "${safeMbox}"`;
2671
- actionStmts.push(` set should move message of newRule to true`);
2672
- actionStmts.push(` set move message of newRule to ${mboxRef}`);
2673
- }
2674
- if (!actionStmts.length) {
2675
- return { success: false, error: "A rule needs at least one action." };
2676
- }
2677
- const enabled = opts.enabled !== false;
2678
- const matchAll = opts.matchAll !== false; // default: all conditions must match
2679
- const script = buildAppLevelScript(`
2680
- try
2681
- repeat with existing in rules
2682
- if name of existing is "${safeName}" then return "error:A rule named '${safeName}' already exists."
2683
- end repeat
2684
- set newRule to make new rule at end of rules with properties {name:"${safeName}", enabled:${enabled}}
2685
- set all conditions must be met of newRule to ${matchAll}
2686
- ${conditionStmts}
2687
- ${actionStmts.join("\n")}
2688
- return "ok"
2689
- on error errMsg
2690
- return "error:" & errMsg
2691
- end try
2692
- `);
2693
- const result = executeAppleScript(script);
2694
- if (!result.success || result.output.startsWith("error:")) {
2695
- const error = result.output?.replace(/^error:/, "") || result.error || "Unknown error";
2696
- return { success: false, error };
2697
- }
2698
- return { success: true };
2699
- }
2700
- /**
2701
- * Delete a mail rule by name (B2). Returns false if no such rule exists.
2702
- */
2703
- deleteRule(ruleName) {
2704
- const safeName = escapeForAppleScript(ruleName);
2705
- // Delete via a `whose` filter rather than iterating + `delete r`: mutating
2706
- // the rules collection mid-`repeat` invalidates the loop reference
2707
- // ("Can't get item N of every rule").
2708
- const script = buildAppLevelScript(`
2709
- try
2710
- set matches to (every rule whose name is "${safeName}")
2711
- if (count of matches) is 0 then return "error:Rule not found"
2712
- delete (every rule whose name is "${safeName}")
2713
- return "ok"
2714
- on error errMsg
2715
- return "error:" & errMsg
2716
- end try
2717
- `);
2718
- const result = executeAppleScript(script);
2719
- if (!result.success || result.output.startsWith("error:")) {
2720
- console.error(`Failed to delete rule: ${result.error || result.output}`);
2721
- return false;
2722
- }
2723
- return true;
2724
- }
2725
- // ===========================================================================
2726
- // Contacts Integration
2727
- // ===========================================================================
2728
- /**
2729
- * Search contacts by name or email.
2730
- */
2731
- searchContacts(query) {
2732
- const safeQuery = escapeForAppleScript(query);
2733
- const script = `
2734
- tell application "Contacts"
2735
- set matchedContacts to {}
2736
- set foundPeople to (every person whose name contains "${safeQuery}") & (every person whose value of emails contains "${safeQuery}")
2737
-
2738
- -- Deduplicate by tracking IDs
2739
- set seenIds to {}
2740
- repeat with p in foundPeople
2741
- set pid to id of p
2742
- if seenIds does not contain pid then
2743
- set end of seenIds to pid
2744
- -- Per-person try: one malformed contact (e.g. no emails) must not
2745
- -- abort the whole search and return an empty list (audit finding #13).
2746
- try
2747
- set pName to name of p
2748
- set pEmails to ""
2749
- repeat with e in emails of p
2750
- if pEmails is not "" then set pEmails to pEmails & ","
2751
- set pEmails to pEmails & (value of e)
2752
- end repeat
2753
- set pPhones to ""
2754
- repeat with ph in phones of p
2755
- if pPhones is not "" then set pPhones to pPhones & ","
2756
- set pPhones to pPhones & (value of ph)
2757
- end repeat
2758
- set end of matchedContacts to pName & "${FIELD_SEP}" & pEmails & "${FIELD_SEP}" & pPhones
2759
- end try
2760
- end if
2761
- end repeat
2762
-
2763
- set AppleScript's text item delimiters to "${RECORD_SEP}"
2764
- return matchedContacts as text
2765
- end tell
2766
- `;
2767
- const result = executeAppleScript(script);
2768
- if (!result.success || !result.output.trim()) {
2769
- return [];
2770
- }
2771
- const items = result.output.split(RECORD_SEP);
2772
- const contacts = [];
2773
- for (const item of items) {
2774
- const parts = item.split(FIELD_SEP);
2775
- if (parts.length < 3)
2776
- continue;
2777
- contacts.push({
2778
- name: parts[0],
2779
- emails: parts[1] ? parts[1].split(",").filter(Boolean) : [],
2780
- phones: parts[2] ? parts[2].split(",").filter(Boolean) : [],
2781
- });
2782
- }
2783
- return contacts;
2784
- }
2785
- // ===========================================================================
2786
- // Email Templates
2787
- // ===========================================================================
2788
- // Templates persist to disk (B3 / #14) so they survive server restarts.
2789
- templateStore = new TemplateStore();
2790
- /**
2791
- * List all stored templates.
2792
- */
2793
- listTemplates() {
2794
- return this.templateStore.list();
2795
- }
2796
- /**
2797
- * Get a template by ID.
2798
- */
2799
- getTemplate(id) {
2800
- return this.templateStore.get(id);
2801
- }
2802
- /**
2803
- * Create or update a template (persisted).
2804
- */
2805
- saveTemplate(name, subject, body, to, cc, id) {
2806
- return this.templateStore.save(name, subject, body, to, cc, id);
2807
- }
2808
- /**
2809
- * Delete a template (persisted).
2810
- */
2811
- deleteTemplate(id) {
2812
- return this.templateStore.delete(id);
2813
- }
2814
- /**
2815
- * Use a template to create a draft.
2816
- */
2817
- useTemplate(id, overrides) {
2818
- const template = this.templateStore.get(id);
2819
- if (!template)
2820
- return false;
2821
- // Use `??` (not `||`) for subject/body so an intentional empty-string
2822
- // override is honored rather than falling back to the template value
2823
- // (audit finding #14).
2824
- const to = overrides?.to ?? template.to ?? [];
2825
- const cc = overrides?.cc ?? template.cc;
2826
- const subject = overrides?.subject ?? template.subject;
2827
- const body = overrides?.body ?? template.body;
2828
- if (to.length === 0)
2829
- return false;
2830
- return this.createDraft(to, subject, body, cc);
2831
- }
2832
- // ===========================================================================
2833
- // Diagnostics
2834
- // ===========================================================================
2835
- /**
2836
- * Run health check on Mail.app connectivity.
2837
- */
2838
- healthCheck() {
2839
- const checks = [];
2840
- // Check 1: Mail.app is accessible
2841
- const mailCheck = executeAppleScript('tell application "Mail" to return "ok"');
2842
- if (mailCheck.success && mailCheck.output === "ok") {
2843
- checks.push({
2844
- name: "mail_app",
2845
- passed: true,
2846
- message: "Mail.app is accessible",
2847
- });
2848
- }
2849
- else {
2850
- const errorHint = mailCheck.error?.includes("not authorized")
2851
- ? " (check Automation permissions in System Preferences)"
2852
- : "";
2853
- checks.push({
2854
- name: "mail_app",
2855
- passed: false,
2856
- message: `Mail.app is not accessible${errorHint}`,
2857
- });
2858
- return { healthy: false, checks };
2859
- }
2860
- // Check 2: AppleScript permissions
2861
- const permCheck = executeAppleScript('tell application "Mail" to get name of account 1');
2862
- if (permCheck.success) {
2863
- checks.push({
2864
- name: "permissions",
2865
- passed: true,
2866
- message: "AppleScript automation permissions granted",
2867
- });
2868
- }
2869
- else {
2870
- const isPermError = permCheck.error?.includes("not authorized") || permCheck.error?.includes("not permitted");
2871
- checks.push({
2872
- name: "permissions",
2873
- passed: !isPermError,
2874
- message: isPermError
2875
- ? "AppleScript permissions denied. Grant access in System Preferences > Privacy & Security > Automation"
2876
- : `Permission check returned: ${permCheck.error}`,
2877
- });
2878
- if (isPermError) {
2879
- return { healthy: false, checks };
2880
- }
2881
- }
2882
- // Check 3: At least one account accessible
2883
- const accounts = this.listAccounts();
2884
- if (accounts.length > 0) {
2885
- const accountNames = accounts.map((a) => a.name).join(", ");
2886
- checks.push({
2887
- name: "accounts",
2888
- passed: true,
2889
- message: `Found ${accounts.length} account(s): ${accountNames}`,
2890
- });
2891
- }
2892
- else {
2893
- checks.push({
2894
- name: "accounts",
2895
- passed: false,
2896
- message: "No Mail accounts found. Set up an account in Mail.app first.",
2897
- });
2898
- return { healthy: false, checks };
2899
- }
2900
- // Check 4: Basic operations work
2901
- const mailboxes = this.listMailboxes(accounts[0].name);
2902
- checks.push({
2903
- name: "operations",
2904
- passed: true,
2905
- message: `Basic operations working (${mailboxes.length} mailbox(es) in ${accounts[0].name})`,
2906
- });
2907
- return {
2908
- healthy: checks.every((c) => c.passed),
2909
- checks,
2910
- };
2911
- }
2912
- /**
2913
- * Get mail statistics.
2914
- */
2915
- getMailStats() {
2916
- const accounts = this.listAccounts();
2917
- const accountStats = [];
2918
- let totalMessages = 0;
2919
- let totalUnread = 0;
2920
- for (const account of accounts) {
2921
- const mailboxes = this.listMailboxes(account.name);
2922
- let accountMessages = 0;
2923
- let accountUnread = 0;
2924
- const mailboxStats = mailboxes.map((mb) => {
2925
- accountMessages += mb.messageCount;
2926
- accountUnread += mb.unreadCount;
2927
- return {
2928
- name: mb.name,
2929
- messageCount: mb.messageCount,
2930
- unreadCount: mb.unreadCount,
2931
- };
2932
- });
2933
- totalMessages += accountMessages;
2934
- totalUnread += accountUnread;
2935
- accountStats.push({
2936
- name: account.name,
2937
- totalMessages: accountMessages,
2938
- unreadMessages: accountUnread,
2939
- mailboxCount: mailboxes.length,
2940
- mailboxes: mailboxStats,
2941
- });
2942
- }
2943
- // Get recently received stats
2944
- const recentlyReceived = this.getRecentlyReceivedStats();
2945
- return {
2946
- totalMessages,
2947
- totalUnread,
2948
- accounts: accountStats,
2949
- recentlyReceived,
2950
- };
2951
- }
2952
- /**
2953
- * Get counts of recently received messages.
2954
- *
2955
- * Counts messages in each account's receiving mailbox for performance
2956
- * (scanning all mailboxes is too slow for large accounts): the literal
2957
- * "INBOX" for ordinary accounts, or the "All Mail" superset for Gmail-style
2958
- * accounts whose literal "INBOX" is an empty virtual shell (BUG A2).
2959
- *
2960
- * @returns Counts of messages received in last 24h, 7d, and 30d
2961
- */
2962
- getRecentlyReceivedStats() {
2963
- // Get message counts for different time periods
2964
- const now = new Date();
2965
- const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
2966
- const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
2967
- const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
2968
- // Thresholds are built from numeric components via buildAppleScriptDate
2969
- // rather than `date "January 5, 2026"` string coercion. The English-month
2970
- // literal throws "Invalid date and time (-30720)" on non-English system
2971
- // locales; that throw was swallowed by the per-inbox `try` below, so this
2972
- // method silently returned 0/0/0 on those Macs — the same locale regression
2973
- // fixed for searchMessages in #15 / surfaced again in #28.
2974
- // Scan each account's receiving mailbox for performance — scanning every
2975
- // mailbox is too slow. For an ordinary account that's the literal "INBOX".
2976
- // For a Gmail-style account (BUG A2) the literal "INBOX" is an empty shell,
2977
- // so counting only it reported near-zero recent mail; instead we detect the
2978
- // "All Mail" special mailbox (matched by `name of mb`, since it's nested in
2979
- // the [Gmail] container and doesn't resolve by a flat name) and count that
2980
- // superset of received mail.
2981
- //
2982
- // A `whose date received >=` filter is O(n) over the whole mailbox with no
2983
- // AppleScript index, so on a huge "All Mail" (tens of thousands of messages)
2984
- // three such counts blow past any reasonable timeout. Two safeguards keep
2985
- // this fast and non-hanging:
2986
- // - a per-mailbox COUNT GUARD (the same APPLE_MAIL_MAX_SEARCH_MAILBOX
2987
- // threshold search uses): a receiving mailbox above the threshold is
2988
- // skipped rather than scanned. IMAP-configured accounts already get
2989
- // fast, correct recent counts via IMAP SEARCH SINCE upstream, so the
2990
- // skip only affects an un-IMAP-configured huge Gmail account — which
2991
- // previously also reported 0 here, just after a 60 s hang.
2992
- // - a single 30-day `whose` pass whose small result set is partitioned
2993
- // in-AppleScript into 24 h / 7 d / 30 d buckets — one O(n) scan, not
2994
- // three.
2995
- const scanThreshold = getMailboxScanThreshold();
2996
- const gmailNameList = appleScriptLowerNameList(["all mail"]);
2997
- const countGuard = scanThreshold > 0
2998
- ? `if (count of messages of theInbox) > ${scanThreshold} then error "too-large"`
2999
- : "";
3000
- // One 30-day pass, then bucket the (small) result by date without rescanning.
3001
- const bucketScan = `
3002
- ${countGuard}
3003
- set recent30 to (messages of theInbox whose date received >= thirtyDaysAgo)
3004
- repeat with _m in recent30
3005
- set _d to date received of _m
3006
- set last30d to last30d + 1
3007
- if _d >= sevenDaysAgo then set last7d to last7d + 1
3008
- if _d >= oneDayAgo then set last24h to last24h + 1
3009
- end repeat`;
3010
- const script = buildAppLevelScript(`
3011
- set last24h to 0
3012
- set last7d to 0
3013
- set last30d to 0
3014
- ${buildAppleScriptDate("oneDayAgo", oneDayAgo)}
3015
- ${buildAppleScriptDate("sevenDaysAgo", sevenDaysAgo)}
3016
- ${buildAppleScriptDate("thirtyDaysAgo", thirtyDaysAgo)}
3017
-
3018
- repeat with acct in accounts
3019
- try
3020
- -- Detect a Gmail-style account (has an "All Mail" special mailbox);
3021
- -- if so, count its receiving superset instead of the empty "INBOX".
3022
- set _gmailInbox to missing value
3023
- set _wantNames to ${gmailNameList}
3024
- repeat with mb in mailboxes of acct
3025
- set mbName to ""
3026
- try
3027
- set mbName to name of mb
3028
- end try
3029
- ignoring case
3030
- if _wantNames contains mbName then
3031
- set _gmailInbox to mb
3032
- exit repeat
3033
- end if
3034
- end ignoring
3035
- end repeat
3036
-
3037
- if _gmailInbox is not missing value then
3038
- try
3039
- set theInbox to _gmailInbox
3040
- ${bucketScan}
3041
- end try
3042
- else
3043
- -- Ordinary account: try common inbox names.
3044
- set inboxNames to {"INBOX", "Inbox", "inbox"}
3045
- repeat with inboxName in inboxNames
3046
- try
3047
- set theInbox to mailbox inboxName of acct
3048
- ${bucketScan}
3049
- exit repeat
3050
- end try
3051
- end repeat
3052
- end if
3053
- end try
3054
- end repeat
3055
-
3056
- return (last24h as string) & "${FIELD_SEP}" & (last7d as string) & "${FIELD_SEP}" & (last30d as string)
3057
- `);
3058
- const result = executeAppleScript(script, { timeoutMs: 60000 });
3059
- if (!result.success || !result.output.trim()) {
3060
- console.error(`Failed to get recently received stats: ${result.error}`);
3061
- return { last24h: 0, last7d: 0, last30d: 0 };
3062
- }
3063
- const parts = result.output.split(FIELD_SEP);
3064
- if (parts.length < 3) {
3065
- return { last24h: 0, last7d: 0, last30d: 0 };
3066
- }
3067
- return {
3068
- last24h: parseInt(parts[0]) || 0,
3069
- last7d: parseInt(parts[1]) || 0,
3070
- last30d: parseInt(parts[2]) || 0,
3071
- };
3072
- }
3073
- /**
3074
- * Get sync status for Mail.app.
3075
- *
3076
- * Checks for sync activity indicators like:
3077
- * - Activity monitor status
3078
- * - Network activity status
3079
- * - Background refresh indicators
3080
- *
3081
- * @returns Sync status information
3082
- */
3083
- getSyncStatus() {
3084
- // Check for Mail.app background activity and sync status
3085
- // Mail.app doesn't expose sync status directly through AppleScript,
3086
- // so we check for recent changes and activity indicators
3087
- const script = buildAppLevelScript(`
3088
- set syncInfo to ""
3089
-
3090
- -- Check if Mail.app is running
3091
- tell application "System Events"
3092
- set mailRunning to (name of processes) contains "Mail"
3093
- end tell
3094
-
3095
- if not mailRunning then
3096
- return "not_running"
3097
- end if
3098
-
3099
- -- Check for background activity by looking at message counts changing
3100
- -- This is a proxy for sync activity since Mail doesn't expose sync status
3101
- set accountCount to count of accounts
3102
- set totalMailboxes to 0
3103
- repeat with acct in accounts
3104
- set totalMailboxes to totalMailboxes + (count of mailboxes of acct)
3105
- end repeat
3106
-
3107
- return "running${FIELD_SEP}" & accountCount & "${FIELD_SEP}" & totalMailboxes
3108
- `);
3109
- // Counts mailboxes across every account; give it headroom over the 30s
3110
- // default so a slow account doesn't silently report "not syncing" (#8).
3111
- const result = executeAppleScript(script, { timeoutMs: 60000 });
3112
- if (!result.success) {
3113
- return {
3114
- syncDetected: false,
3115
- pendingUpload: 0,
3116
- recentActivity: false,
3117
- secondsSinceLastChange: -1,
3118
- error: result.error,
3119
- };
3120
- }
3121
- if (result.output === "not_running") {
3122
- return {
3123
- syncDetected: false,
3124
- pendingUpload: 0,
3125
- recentActivity: false,
3126
- secondsSinceLastChange: -1,
3127
- error: "Mail.app is not running",
3128
- };
3129
- }
3130
- // Parse the response
3131
- const parts = result.output.split(FIELD_SEP);
3132
- const isRunning = parts[0] === "running";
3133
- const accountCount = parseInt(parts[1]) || 0;
3134
- // Mail.app is running with accounts configured - assume sync is active
3135
- // (Mail.app syncs automatically when running)
3136
- return {
3137
- syncDetected: isRunning && accountCount > 0,
3138
- pendingUpload: 0, // Not exposed by Mail.app
3139
- recentActivity: isRunning,
3140
- secondsSinceLastChange: 0,
3141
- };
3142
- }
3143
- }