apple-mail-mcp 2.8.2 → 2.8.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -5
- package/build/cli.js +12083 -138
- package/build/index.js +82686 -1687
- package/package.json +3 -4
- package/build/cli.d.ts +0 -24
- package/build/cli.d.ts.map +0 -1
- package/build/index.d.ts +0 -23
- package/build/index.d.ts.map +0 -1
- package/build/services/appleMailManager.d.ts +0 -680
- package/build/services/appleMailManager.d.ts.map +0 -1
- package/build/services/appleMailManager.js +0 -3143
- package/build/services/fileConfig.d.ts +0 -7
- package/build/services/fileConfig.d.ts.map +0 -1
- package/build/services/fileConfig.js +0 -52
- package/build/services/imapClient.d.ts +0 -312
- package/build/services/imapClient.d.ts.map +0 -1
- package/build/services/imapClient.js +0 -1023
- package/build/services/imapIdle.d.ts +0 -58
- package/build/services/imapIdle.d.ts.map +0 -1
- package/build/services/imapIdle.js +0 -151
- package/build/services/imapMultiAccount.d.ts +0 -124
- package/build/services/imapMultiAccount.d.ts.map +0 -1
- package/build/services/imapMultiAccount.js +0 -253
- package/build/services/messageRouter.d.ts +0 -24
- package/build/services/messageRouter.d.ts.map +0 -1
- package/build/services/messageRouter.js +0 -31
- package/build/services/replyForward.d.ts +0 -87
- package/build/services/replyForward.d.ts.map +0 -1
- package/build/services/replyForward.js +0 -150
- package/build/services/smtpMailer.d.ts +0 -160
- package/build/services/smtpMailer.d.ts.map +0 -1
- package/build/services/smtpMailer.js +0 -268
- package/build/services/templateStore.d.ts +0 -18
- package/build/services/templateStore.d.ts.map +0 -1
- package/build/services/templateStore.js +0 -91
- package/build/tools/doctor.d.ts +0 -23
- package/build/tools/doctor.d.ts.map +0 -1
- package/build/tools/doctor.js +0 -74
- package/build/tools/resourcesAndPrompts.d.ts +0 -14
- package/build/tools/resourcesAndPrompts.d.ts.map +0 -1
- package/build/tools/resourcesAndPrompts.js +0 -109
- package/build/tools/respond.d.ts +0 -48
- package/build/tools/respond.d.ts.map +0 -1
- package/build/tools/respond.js +0 -95
- package/build/tools/thread.d.ts +0 -19
- package/build/tools/thread.d.ts.map +0 -1
- package/build/tools/thread.js +0 -32
- package/build/types.d.ts +0 -434
- package/build/types.d.ts.map +0 -1
- package/build/types.js +0 -13
- package/build/utils/applescript.d.ts +0 -45
- package/build/utils/applescript.d.ts.map +0 -1
- package/build/utils/applescript.js +0 -446
- package/build/utils/attachmentMaterialize.d.ts +0 -9
- package/build/utils/attachmentMaterialize.d.ts.map +0 -1
- package/build/utils/attachmentMaterialize.js +0 -38
- package/build/utils/mimeParse.d.ts +0 -62
- package/build/utils/mimeParse.d.ts.map +0 -1
- package/build/utils/mimeParse.js +0 -317
- package/build/utils/orphan.d.ts +0 -25
- package/build/utils/orphan.d.ts.map +0 -1
- package/build/utils/orphan.js +0 -26
- package/build/utils/serialize.d.ts +0 -30
- package/build/utils/serialize.d.ts.map +0 -1
- 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
|
-
}
|