apple-mail-mcp 2.8.2 → 2.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +5 -5
  2. package/build/cli.js +12083 -138
  3. package/build/index.js +82684 -1687
  4. package/package.json +3 -4
  5. package/build/cli.d.ts +0 -24
  6. package/build/cli.d.ts.map +0 -1
  7. package/build/index.d.ts +0 -23
  8. package/build/index.d.ts.map +0 -1
  9. package/build/services/appleMailManager.d.ts +0 -680
  10. package/build/services/appleMailManager.d.ts.map +0 -1
  11. package/build/services/appleMailManager.js +0 -3143
  12. package/build/services/fileConfig.d.ts +0 -7
  13. package/build/services/fileConfig.d.ts.map +0 -1
  14. package/build/services/fileConfig.js +0 -52
  15. package/build/services/imapClient.d.ts +0 -312
  16. package/build/services/imapClient.d.ts.map +0 -1
  17. package/build/services/imapClient.js +0 -1023
  18. package/build/services/imapIdle.d.ts +0 -58
  19. package/build/services/imapIdle.d.ts.map +0 -1
  20. package/build/services/imapIdle.js +0 -151
  21. package/build/services/imapMultiAccount.d.ts +0 -124
  22. package/build/services/imapMultiAccount.d.ts.map +0 -1
  23. package/build/services/imapMultiAccount.js +0 -253
  24. package/build/services/messageRouter.d.ts +0 -24
  25. package/build/services/messageRouter.d.ts.map +0 -1
  26. package/build/services/messageRouter.js +0 -31
  27. package/build/services/replyForward.d.ts +0 -87
  28. package/build/services/replyForward.d.ts.map +0 -1
  29. package/build/services/replyForward.js +0 -150
  30. package/build/services/smtpMailer.d.ts +0 -160
  31. package/build/services/smtpMailer.d.ts.map +0 -1
  32. package/build/services/smtpMailer.js +0 -268
  33. package/build/services/templateStore.d.ts +0 -18
  34. package/build/services/templateStore.d.ts.map +0 -1
  35. package/build/services/templateStore.js +0 -91
  36. package/build/tools/doctor.d.ts +0 -23
  37. package/build/tools/doctor.d.ts.map +0 -1
  38. package/build/tools/doctor.js +0 -74
  39. package/build/tools/resourcesAndPrompts.d.ts +0 -14
  40. package/build/tools/resourcesAndPrompts.d.ts.map +0 -1
  41. package/build/tools/resourcesAndPrompts.js +0 -109
  42. package/build/tools/respond.d.ts +0 -48
  43. package/build/tools/respond.d.ts.map +0 -1
  44. package/build/tools/respond.js +0 -95
  45. package/build/tools/thread.d.ts +0 -19
  46. package/build/tools/thread.d.ts.map +0 -1
  47. package/build/tools/thread.js +0 -32
  48. package/build/types.d.ts +0 -434
  49. package/build/types.d.ts.map +0 -1
  50. package/build/types.js +0 -13
  51. package/build/utils/applescript.d.ts +0 -45
  52. package/build/utils/applescript.d.ts.map +0 -1
  53. package/build/utils/applescript.js +0 -446
  54. package/build/utils/attachmentMaterialize.d.ts +0 -9
  55. package/build/utils/attachmentMaterialize.d.ts.map +0 -1
  56. package/build/utils/attachmentMaterialize.js +0 -38
  57. package/build/utils/mimeParse.d.ts +0 -62
  58. package/build/utils/mimeParse.d.ts.map +0 -1
  59. package/build/utils/mimeParse.js +0 -317
  60. package/build/utils/orphan.d.ts +0 -25
  61. package/build/utils/orphan.d.ts.map +0 -1
  62. package/build/utils/orphan.js +0 -26
  63. package/build/utils/serialize.d.ts +0 -30
  64. package/build/utils/serialize.d.ts.map +0 -1
  65. package/build/utils/serialize.js +0 -41
@@ -1,1023 +0,0 @@
1
- /**
2
- * IMAP backend (issue #43).
3
- *
4
- * AppleScript-over-Mail.app is the default. When an account is explicitly
5
- * configured for IMAP (env below), operations route here instead:
6
- * - read: search-messages / list-messages (server-side SEARCH, orders of
7
- * magnitude faster and correct on large Gmail mailboxes where
8
- * AppleScript times out with a false-empty) and get-message;
9
- * - folders: create / rename / delete-mailbox (work on the server hierarchy
10
- * that AppleScript can't touch — #42);
11
- * - message: mark/flag/move/delete, keyed by the composite `imap:` id the
12
- * read path emits (see encodeImapId/decodeImapId below).
13
- * Everything is opt-in and additive; un-configured accounts use AppleScript.
14
- *
15
- * Opt-in via env (mirrors the SMTP transport pattern):
16
- * APPLE_MAIL_MCP_IMAP_USER (required — enables IMAP; the login address)
17
- * APPLE_MAIL_MCP_IMAP_ACCOUNT (Mail account name to match for routing; default = USER)
18
- * APPLE_MAIL_MCP_IMAP_HOST (default imap.gmail.com)
19
- * APPLE_MAIL_MCP_IMAP_PORT (default 993, implicit TLS)
20
- * APPLE_MAIL_MCP_IMAP_PASSWORD (else Keychain via the two vars below)
21
- * APPLE_MAIL_MCP_IMAP_KEYCHAIN_SERVICE / _KEYCHAIN_ACCOUNT
22
- *
23
- * @module services/imapClient
24
- */
25
- import { ImapFlow } from "imapflow";
26
- import { readKeychainPassword } from "../services/smtpMailer.js";
27
- import { extractHtmlBody, extractTextBody } from "../utils/mimeParse.js";
28
- export const IMAP_ENV = {
29
- user: "APPLE_MAIL_MCP_IMAP_USER",
30
- account: "APPLE_MAIL_MCP_IMAP_ACCOUNT",
31
- host: "APPLE_MAIL_MCP_IMAP_HOST",
32
- port: "APPLE_MAIL_MCP_IMAP_PORT",
33
- password: "APPLE_MAIL_MCP_IMAP_PASSWORD",
34
- keychainService: "APPLE_MAIL_MCP_IMAP_KEYCHAIN_SERVICE",
35
- keychainAccount: "APPLE_MAIL_MCP_IMAP_KEYCHAIN_ACCOUNT",
36
- // C2 multi-account: JSON array of additional accounts, e.g.
37
- // [{"account":"Work","user":"me@co.com","host":"imap.co.com","keychainService":"imap.co.com"}]
38
- accounts: "APPLE_MAIL_MCP_IMAP_ACCOUNTS",
39
- };
40
- // ---------------------------------------------------------------------------
41
- // Composite IMAP message id (Phase 3): a self-describing token the IMAP read
42
- // path emits so the same id round-trips back to get-message and the message
43
- // mutations. AppleScript message ids are bare numbers; an IMAP id is
44
- // `imap:<base64url({a:account,p:mailboxPath,u:uid})>`. UIDs are per-mailbox, so
45
- // the mailbox path must travel with the uid. base64url keeps it schema-safe.
46
- // ---------------------------------------------------------------------------
47
- export function encodeImapId(account, path, uid) {
48
- const payload = Buffer.from(JSON.stringify({ a: account, p: path, u: uid }), "utf8").toString("base64url");
49
- return `imap:${payload}`;
50
- }
51
- export function decodeImapId(id) {
52
- if (!id || !id.startsWith("imap:"))
53
- return null;
54
- try {
55
- const obj = JSON.parse(Buffer.from(id.slice("imap:".length), "base64url").toString("utf8"));
56
- if (typeof obj.u !== "number" || typeof obj.p !== "string")
57
- return null;
58
- return { account: String(obj.a ?? ""), path: obj.p, uid: obj.u };
59
- }
60
- catch {
61
- return null;
62
- }
63
- }
64
- function str(v) {
65
- return typeof v === "string" && v.trim() ? v.trim() : undefined;
66
- }
67
- /**
68
- * Enumerate all configured IMAP accounts (C2): the legacy single-account env
69
- * vars plus any in the `APPLE_MAIL_MCP_IMAP_ACCOUNTS` JSON array. Does not
70
- * resolve passwords. The legacy account takes precedence on label collisions.
71
- */
72
- function listImapAccountSpecs(env = process.env) {
73
- const specs = [];
74
- const user = env[IMAP_ENV.user]?.trim();
75
- if (user) {
76
- specs.push({
77
- accountLabel: env[IMAP_ENV.account]?.trim() || user,
78
- user,
79
- host: env[IMAP_ENV.host]?.trim() || "imap.gmail.com",
80
- port: env[IMAP_ENV.port] ? Number.parseInt(env[IMAP_ENV.port], 10) : 993,
81
- password: env[IMAP_ENV.password],
82
- keychainService: env[IMAP_ENV.keychainService]?.trim(),
83
- keychainAccount: env[IMAP_ENV.keychainAccount]?.trim(),
84
- });
85
- }
86
- const json = env[IMAP_ENV.accounts]?.trim();
87
- if (json) {
88
- try {
89
- const arr = JSON.parse(json);
90
- if (Array.isArray(arr)) {
91
- for (const raw of arr) {
92
- const a = raw;
93
- const u = str(a.user);
94
- if (!u)
95
- continue;
96
- const label = str(a.account) || str(a.accountLabel) || u;
97
- if (specs.some((s) => s.accountLabel === label))
98
- continue; // legacy wins
99
- const port = a.port ? Number(a.port) : 993;
100
- specs.push({
101
- accountLabel: label,
102
- user: u,
103
- host: str(a.host) || "imap.gmail.com",
104
- port,
105
- password: str(a.password),
106
- keychainService: str(a.keychainService),
107
- keychainAccount: str(a.keychainAccount),
108
- });
109
- }
110
- }
111
- }
112
- catch (e) {
113
- console.error(`Invalid ${IMAP_ENV.accounts} JSON, ignoring: ${String(e)}`);
114
- }
115
- }
116
- return specs;
117
- }
118
- function specToConfig(spec) {
119
- if (!Number.isInteger(spec.port) || spec.port <= 0) {
120
- throw new Error(`Invalid IMAP port for account "${spec.accountLabel}": "${spec.port}".`);
121
- }
122
- let pass = spec.password;
123
- if (!pass && spec.keychainService) {
124
- pass =
125
- readKeychainPassword(spec.keychainService, spec.keychainAccount || spec.user) ?? undefined;
126
- }
127
- if (!pass) {
128
- throw new Error(`No IMAP password for account "${spec.accountLabel}". Set a password or a Keychain service/account.`);
129
- }
130
- return {
131
- host: spec.host,
132
- port: spec.port,
133
- secure: spec.port === 993,
134
- user: spec.user,
135
- pass,
136
- accountLabel: spec.accountLabel,
137
- };
138
- }
139
- /** True when `account` matches any configured IMAP account (label or user). */
140
- export function isImapAccount(account, env = process.env) {
141
- if (!account)
142
- return false;
143
- return listImapAccountSpecs(env).some((s) => s.accountLabel === account || s.user === account);
144
- }
145
- /**
146
- * Read-side routing gate (v2.6.0 — prefer-IMAP reads). Returns true when a read
147
- * tool should go to IMAP rather than AppleScript:
148
- * - IMAP is configured at all, AND
149
- * - either the caller named no account (→ merge across all accounts), or the
150
- * named account is itself a configured IMAP account.
151
- * An explicitly-named NON-IMAP account returns false → AppleScript. When IMAP is
152
- * not configured at all this is always false, so behavior is unchanged.
153
- *
154
- * NOTE: the 3 mailbox-WRITE ops (create/delete/rename-mailbox) deliberately keep
155
- * using `isImapAccount` — they only route to IMAP for an explicitly-named IMAP
156
- * account, never on an omitted account.
157
- */
158
- export function shouldUseImap(account, env = process.env) {
159
- return (listImapAccountSpecs(env).length > 0 && (account === undefined || isImapAccount(account, env)));
160
- }
161
- /** Account labels of every configured IMAP account (C2), for diagnostics. */
162
- export function listImapAccountLabels(env = process.env) {
163
- return listImapAccountSpecs(env).map((s) => s.accountLabel);
164
- }
165
- /**
166
- * Resolve full configs (passwords included) for every configured IMAP account
167
- * (C2/B5). Accounts whose password can't be resolved are skipped (logged), so a
168
- * single misconfigured account doesn't take down the rest (e.g. IDLE watchers).
169
- */
170
- export function resolveImapConfigs(env = process.env) {
171
- const out = [];
172
- for (const spec of listImapAccountSpecs(env)) {
173
- try {
174
- out.push(specToConfig(spec));
175
- }
176
- catch (e) {
177
- console.error(`Skipping IMAP account "${spec.accountLabel}": ${String(e)}`);
178
- }
179
- }
180
- return out;
181
- }
182
- /**
183
- * Resolve the full IMAP config (password included) for `account`. With no
184
- * `account`, returns the default/first configured account. Throws if IMAP is
185
- * unconfigured or no account matches.
186
- */
187
- export function resolveImapConfig(env = process.env, account) {
188
- const specs = listImapAccountSpecs(env);
189
- if (specs.length === 0) {
190
- throw new Error(`IMAP not configured. Set ${IMAP_ENV.user} (login address) to enable it.`);
191
- }
192
- let spec;
193
- if (account) {
194
- spec = specs.find((s) => s.accountLabel === account || s.user === account);
195
- if (!spec) {
196
- throw new Error(`No IMAP account matching "${account}". Configured: ${specs.map((s) => s.accountLabel).join(", ")}.`);
197
- }
198
- }
199
- else {
200
- spec = specs[0];
201
- }
202
- return specToConfig(spec);
203
- }
204
- const defaultConnect = async (cfg) => {
205
- const client = new ImapFlow({
206
- host: cfg.host,
207
- port: cfg.port,
208
- secure: cfg.secure,
209
- auth: { user: cfg.user, pass: cfg.pass },
210
- logger: false,
211
- });
212
- // ImapFlow is an EventEmitter: once connect() resolves, a later socket error
213
- // on this pooled, long-lived client (idle Gmail/iCloud timeout, server BYE,
214
- // network drop) emits 'error'. With no listener that is an *uncaught*
215
- // exception that crashes the whole MCP server. Attach one before connect so
216
- // the error is swallowed; the pool's liveness probe reconnects on next use.
217
- // Same defect class as defaultIdleConnect in imapIdle.ts.
218
- client.on("error", () => { });
219
- await client.connect();
220
- return client;
221
- };
222
- /** Map common (Gmail) mailbox names to their IMAP paths. */
223
- export function resolveMailboxPath(mailbox, mode) {
224
- if (!mailbox)
225
- return mode === "search" ? "[Gmail]/All Mail" : "INBOX";
226
- const map = {
227
- "all mail": "[Gmail]/All Mail",
228
- "sent mail": "[Gmail]/Sent Mail",
229
- sent: "[Gmail]/Sent Mail",
230
- trash: "[Gmail]/Trash",
231
- drafts: "[Gmail]/Drafts",
232
- spam: "[Gmail]/Spam",
233
- junk: "[Gmail]/Spam",
234
- starred: "[Gmail]/Starred",
235
- important: "[Gmail]/Important",
236
- };
237
- return map[mailbox.trim().toLowerCase()] ?? mailbox;
238
- }
239
- function buildCriteria(a, listMode) {
240
- const c = {};
241
- if (a.query)
242
- c.or = [{ subject: a.query }, { from: a.query }];
243
- if (a.from)
244
- c.from = a.from;
245
- if (a.subject)
246
- c.subject = a.subject;
247
- if (a.isRead === true)
248
- c.seen = true;
249
- if (a.isRead === false)
250
- c.unseen = true;
251
- if (a.unreadOnly && listMode)
252
- c.unseen = true;
253
- if (a.isFlagged === true)
254
- c.flagged = true;
255
- if (a.isFlagged === false)
256
- c.unflagged = true;
257
- if (a.dateFrom)
258
- c.since = new Date(a.dateFrom);
259
- if (a.dateTo)
260
- c.before = new Date(a.dateTo);
261
- if (Object.keys(c).length === 0)
262
- c.all = true;
263
- return c;
264
- }
265
- function formatRow(m, account, path) {
266
- const env = m.envelope ?? {};
267
- const subject = env.subject || "(no subject)";
268
- const a = env.from?.[0];
269
- const from = a
270
- ? a.name
271
- ? `${a.name} <${a.address ?? ""}>`
272
- : (a.address ?? "(unknown)")
273
- : "(unknown)";
274
- const date = env.date ? new Date(env.date).toLocaleDateString() : "";
275
- const read = m.flags?.has("\\Seen") ? "read" : "unread";
276
- // Emit the self-describing IMAP id so get-message and the message mutations
277
- // can route this row back to IMAP (Phase 3).
278
- return ` - ID: ${encodeImapId(account, path, m.uid)} | ${date} | ${subject} (from: ${from}) [${read}]`;
279
- }
280
- /**
281
- * JSON-friendly summary of an IMAP message for `structuredContent`, mirroring the
282
- * AppleScript path's `messageSummary` shape so the search/list/thread tools emit
283
- * the same structured payload regardless of backend (A1).
284
- */
285
- function structuredRow(m, account, path) {
286
- const env = m.envelope ?? {};
287
- return {
288
- id: encodeImapId(account, path, m.uid),
289
- subject: env.subject || "(no subject)",
290
- sender: senderName(env.from),
291
- dateReceived: env.date ? new Date(env.date).toISOString() : "",
292
- isRead: m.flags?.has("\\Seen") ?? false,
293
- isFlagged: m.flags?.has("\\Flagged") ?? false,
294
- mailbox: path,
295
- account,
296
- hasAttachments: false,
297
- // Message-ID (when the envelope carries it) is the strongest cross-/intra-
298
- // backend dedup key for the multi-account merge (imapMultiAccount.ts). The
299
- // AppleScript path does not expose it, so cross-backend dedup falls back to
300
- // the subject|sender|date composite key.
301
- ...(env.messageId ? { messageId: env.messageId } : {}),
302
- };
303
- }
304
- async function run(args, listMode, deps) {
305
- // Reads are idempotent → safe to retry once if a pooled connection is dead.
306
- // Route to the account named in the search args (C2 multi-account).
307
- return useClient({ ...deps, account: deps.account ?? args.account }, async (client, cfg) => {
308
- const path = resolveMailboxPath(args.mailbox, listMode ? "list" : "search");
309
- const lock = await client.getMailboxLock(path);
310
- try {
311
- const found = await client.search(buildCriteria(args, listMode), { uid: true });
312
- const uids = Array.isArray(found) ? found : [];
313
- if (uids.length === 0) {
314
- return {
315
- text: `No messages found via IMAP in "${path}" (account ${cfg.accountLabel}).`,
316
- messages: [],
317
- count: 0,
318
- partial: false,
319
- };
320
- }
321
- const limit = args.limit ?? 50;
322
- const offset = args.offset ?? 0;
323
- // UIDs are ascending → newest are the highest. Apply offset+limit from the newest end.
324
- const newest = uids
325
- .slice()
326
- .reverse()
327
- .slice(offset, offset + limit);
328
- const byUid = new Map();
329
- for await (const msg of client.fetch(newest.join(","), { envelope: true, flags: true }, { uid: true })) {
330
- byUid.set(msg.uid, msg);
331
- }
332
- const ordered = newest
333
- .map((u) => byUid.get(u))
334
- .filter((m) => m !== undefined);
335
- const rows = ordered.map((m) => formatRow(m, cfg.accountLabel, path));
336
- const messages = ordered.map((m) => structuredRow(m, cfg.accountLabel, path));
337
- const verb = listMode ? "listed" : "matched";
338
- const text = `Found ${rows.length} message(s) via IMAP (server-side, account ${cfg.accountLabel}, mailbox "${path}"; ${uids.length} total ${verb}):\n` +
339
- rows.join("\n") +
340
- `\n\nNote: these IMAP IDs (imap:…) work with get-message and the message mutations (mark/flag/move/delete-message), which route back to IMAP.`;
341
- return { text, messages, count: messages.length, partial: false };
342
- }
343
- finally {
344
- lock.release();
345
- }
346
- }, true);
347
- }
348
- export function imapSearchMessages(args, deps = {}) {
349
- return run(args, false, deps);
350
- }
351
- export function imapListMessages(args, deps = {}) {
352
- return run(args, true, deps);
353
- }
354
- // ===========================================================================
355
- // Counts & stats via IMAP STATUS (2.1 optimizations I3/I4/I6)
356
- //
357
- // STATUS is a single server round-trip that returns authoritative message/unseen
358
- // counts without enumerating messages — far faster and more reliable than
359
- // AppleScript on large mailboxes (where the per-message walk times out, #8/#24).
360
- // ===========================================================================
361
- /** Unread count via IMAP STATUS (UNSEEN). No mailbox → sum across all mailboxes. */
362
- export function imapUnreadCount(mailbox, deps = {}) {
363
- return useClient(deps, async (client) => {
364
- if (mailbox) {
365
- const s = await client.status(resolveMailboxPath(mailbox, "list"), { unseen: true });
366
- return s.unseen ?? 0;
367
- }
368
- let total = 0;
369
- for (const b of await client.list()) {
370
- try {
371
- const s = await client.status(b.path, { unseen: true });
372
- total += s.unseen ?? 0;
373
- }
374
- catch {
375
- // skip mailboxes that can't be STATUS'd (e.g. \Noselect parents)
376
- }
377
- }
378
- return total;
379
- }, true);
380
- }
381
- /** List mailboxes with per-mailbox message/unseen counts via LIST + STATUS (I6). */
382
- export function imapListMailboxes(deps = {}) {
383
- return useClient(deps, async (client) => {
384
- const out = [];
385
- for (const b of await client.list()) {
386
- let messages = 0;
387
- let unseen = 0;
388
- try {
389
- const s = await client.status(b.path, { messages: true, unseen: true });
390
- messages = s.messages ?? 0;
391
- unseen = s.unseen ?? 0;
392
- }
393
- catch {
394
- // \Noselect or otherwise un-status-able mailbox → report zeros
395
- }
396
- out.push({ path: b.path, name: b.name, messages, unseen });
397
- }
398
- return out;
399
- }, true);
400
- }
401
- /** Aggregate stats via STATUS (counts) + INBOX SEARCH SINCE (recent) (I3). */
402
- export function imapMailStats(deps = {}) {
403
- return useClient(deps, async (client) => {
404
- const perMailbox = [];
405
- let totalMessages = 0;
406
- let totalUnread = 0;
407
- for (const b of await client.list()) {
408
- try {
409
- const s = await client.status(b.path, { messages: true, unseen: true });
410
- const messages = s.messages ?? 0;
411
- const unseen = s.unseen ?? 0;
412
- totalMessages += messages;
413
- totalUnread += unseen;
414
- perMailbox.push({ mailbox: b.path, messages, unseen });
415
- }
416
- catch {
417
- // skip un-status-able mailbox
418
- }
419
- }
420
- // Recent counts against INBOX (the meaningful "received" surface).
421
- const since = (days) => new Date(Date.now() - days * 86_400_000);
422
- const countSince = async (days) => {
423
- try {
424
- const lock = await client.getMailboxLock("INBOX");
425
- try {
426
- const found = await client.search({ since: since(days) }, { uid: true });
427
- return Array.isArray(found) ? found.length : 0;
428
- }
429
- finally {
430
- lock.release();
431
- }
432
- }
433
- catch {
434
- return 0;
435
- }
436
- };
437
- const [last24h, last7d, last30d] = await Promise.all([
438
- countSince(1),
439
- countSince(7),
440
- countSince(30),
441
- ]);
442
- return { totalMessages, totalUnread, perMailbox, recent: { last24h, last7d, last30d } };
443
- }, true);
444
- }
445
- function errText(e) {
446
- return e instanceof Error ? e.message : String(e);
447
- }
448
- // ---------------------------------------------------------------------------
449
- // Connection pool (issue #50 / A3)
450
- //
451
- // The MCP server is long-lived and every tool call is serialized through the
452
- // AppleScript gate, so instead of connecting + logging out (~seconds) on every
453
- // IMAP call, one connection is kept alive and reused. A NOOP verifies liveness
454
- // before reuse; an idle timer closes it after inactivity. An injected
455
- // `deps.connect` (tests) bypasses the pool and connects per call.
456
- // ---------------------------------------------------------------------------
457
- let poolConnect = defaultConnect;
458
- // One kept-alive connection per account (C2): keyed by host:port:user so each
459
- // configured IMAP account keeps its own pooled connection instead of thrashing
460
- // a single slot when calls alternate between accounts.
461
- const pools = new Map();
462
- function poolKey(cfg) {
463
- return `${cfg.host}:${cfg.port}:${cfg.user}`;
464
- }
465
- function imapIdleMs() {
466
- const raw = process.env.APPLE_MAIL_MCP_IMAP_IDLE_MS;
467
- if (raw !== undefined) {
468
- const n = Number(raw);
469
- if (Number.isFinite(n) && n >= 0)
470
- return n;
471
- }
472
- // Default 30s (v2.6.1): close the pooled connection sooner so this instance
473
- // gives its IMAP slot back quickly — important when several instances coexist
474
- // against Gmail's ~15-per-account cap and Apple Mail also needs slots. Tune
475
- // with APPLE_MAIL_MCP_IMAP_IDLE_MS (0 = never close).
476
- return 30_000;
477
- }
478
- async function dropPool(key) {
479
- const e = pools.get(key);
480
- if (!e)
481
- return;
482
- if (e.idle)
483
- clearTimeout(e.idle);
484
- pools.delete(key);
485
- await e.client.logout().catch(() => undefined);
486
- }
487
- /**
488
- * Close and log out every pooled IMAP connection. Exported so the server can
489
- * call it on shutdown (SIGINT/SIGTERM/stdin-EOF) — otherwise a killed or
490
- * orphaned instance leaves its pooled sockets occupying slots against the
491
- * server's per-account connection limit until they're reaped by a TCP timeout.
492
- */
493
- export async function dropAllPools() {
494
- await Promise.all([...pools.keys()].map((k) => dropPool(k)));
495
- }
496
- function scheduleIdleClose(key) {
497
- const e = pools.get(key);
498
- if (!e)
499
- return;
500
- if (e.idle)
501
- clearTimeout(e.idle);
502
- const ms = imapIdleMs();
503
- if (ms <= 0)
504
- return;
505
- e.idle = setTimeout(() => void dropPool(key), ms);
506
- e.idle.unref?.();
507
- }
508
- // Single-flight connect guard: concurrent acquisitions of the same account
509
- // await ONE in-flight connect instead of each opening (and orphaning) its own
510
- // socket — the race that can leak connections past the per-account limit.
511
- const connecting = new Map();
512
- async function acquirePooled(cfg) {
513
- const key = poolKey(cfg);
514
- const existing = pools.get(key);
515
- if (existing) {
516
- if (existing.idle)
517
- clearTimeout(existing.idle);
518
- try {
519
- await existing.client.noop(); // verify the kept-alive connection is still usable
520
- return existing.client;
521
- }
522
- catch {
523
- await dropPool(key);
524
- }
525
- }
526
- const inFlight = connecting.get(key);
527
- if (inFlight)
528
- return inFlight;
529
- const p = (async () => {
530
- const client = await poolConnect(cfg);
531
- pools.set(key, { client });
532
- return client;
533
- })();
534
- connecting.set(key, p);
535
- try {
536
- return await p;
537
- }
538
- finally {
539
- connecting.delete(key);
540
- }
541
- }
542
- /**
543
- * Health probe for the setup doctor (C3): reports whether IMAP is configured and,
544
- * if so, whether a connection + NOOP succeeds (auth/network/Keychain all good).
545
- */
546
- export async function imapHealthCheck(deps = {}) {
547
- if (!deps.config && !process.env[IMAP_ENV.user]?.trim()) {
548
- return { configured: false, ok: false };
549
- }
550
- let cfg;
551
- try {
552
- cfg = deps.config ?? resolveImapConfig(process.env, deps.account);
553
- }
554
- catch (e) {
555
- return { configured: true, ok: false, error: errText(e) };
556
- }
557
- try {
558
- await useClient(deps, async (client) => {
559
- await client.noop();
560
- });
561
- return { configured: true, ok: true, account: cfg.accountLabel, host: cfg.host };
562
- }
563
- catch (e) {
564
- return {
565
- configured: true,
566
- ok: false,
567
- account: cfg.accountLabel,
568
- host: cfg.host,
569
- error: errText(e),
570
- };
571
- }
572
- }
573
- /** Test seam: override the pool's connect factory; pass null to restore. */
574
- export function __setPoolConnect(fn) {
575
- poolConnect = fn ?? defaultConnect;
576
- }
577
- /** Test seam: close and clear all pooled connections. */
578
- export async function __resetPool() {
579
- await dropAllPools();
580
- }
581
- /**
582
- * Run `fn` with an IMAP client. Default (production) path reuses the pooled,
583
- * kept-alive connection; an injected `deps.connect` connects fresh and logs out
584
- * per call. `retryOnDrop` reconnects once if a pooled connection dies mid-op —
585
- * only safe for idempotent reads, so mutations leave it false.
586
- */
587
- async function useClient(deps, fn, retryOnDrop = false) {
588
- const cfg = deps.config ?? resolveImapConfig(process.env, deps.account);
589
- if (deps.connect) {
590
- const client = await deps.connect(cfg);
591
- try {
592
- return await fn(client, cfg);
593
- }
594
- finally {
595
- await client.logout().catch(() => undefined);
596
- }
597
- }
598
- const key = poolKey(cfg);
599
- try {
600
- const client = await acquirePooled(cfg);
601
- const r = await fn(client, cfg);
602
- scheduleIdleClose(key);
603
- return r;
604
- }
605
- catch (e) {
606
- await dropPool(key);
607
- if (retryOnDrop) {
608
- const client = await acquirePooled(cfg);
609
- try {
610
- const r = await fn(client, cfg);
611
- scheduleIdleClose(key);
612
- return r;
613
- }
614
- catch (e2) {
615
- await dropPool(key);
616
- throw e2;
617
- }
618
- }
619
- throw e;
620
- }
621
- }
622
- /** Connect, run `fn`, manage the connection (pooled in production). */
623
- function withClient(deps, fn) {
624
- return useClient(deps, fn);
625
- }
626
- /**
627
- * Resolve a user-supplied mailbox name to an actual server path by listing the
628
- * mailboxes and matching on full path, then leaf name (case-insensitive).
629
- * Returns null when no such mailbox exists.
630
- */
631
- async function findMailboxPath(client, name) {
632
- const wanted = name.trim().toLowerCase();
633
- const boxes = await client.list();
634
- const byPath = boxes.find((b) => b.path.toLowerCase() === wanted);
635
- if (byPath)
636
- return byPath.path;
637
- const byName = boxes.find((b) => b.name.toLowerCase() === wanted);
638
- return byName ? byName.path : null;
639
- }
640
- export function imapCreateMailbox(name, deps = {}) {
641
- return withClient(deps, async (client) => {
642
- try {
643
- const res = await client.mailboxCreate(name);
644
- return res.created
645
- ? { success: true, info: `Created mailbox "${res.path}".` }
646
- : { success: true, info: `Mailbox "${res.path}" already existed.` };
647
- }
648
- catch (e) {
649
- return { success: false, error: `IMAP create failed for "${name}": ${errText(e)}` };
650
- }
651
- });
652
- }
653
- export function imapDeleteMailbox(name, deps = {}) {
654
- return withClient(deps, async (client, cfg) => {
655
- const path = await findMailboxPath(client, name);
656
- if (!path) {
657
- return {
658
- success: false,
659
- error: `Mailbox "${name}" not found on IMAP account ${cfg.accountLabel}.`,
660
- };
661
- }
662
- try {
663
- await client.mailboxDelete(path);
664
- return {
665
- success: true,
666
- info: `Deleted mailbox "${path}" via IMAP (account ${cfg.accountLabel}).`,
667
- };
668
- }
669
- catch (e) {
670
- return { success: false, error: `IMAP delete failed for "${path}": ${errText(e)}` };
671
- }
672
- });
673
- }
674
- export function imapRenameMailbox(oldName, newName, deps = {}) {
675
- return withClient(deps, async (client, cfg) => {
676
- const path = await findMailboxPath(client, oldName);
677
- if (!path) {
678
- return {
679
- success: false,
680
- error: `Mailbox "${oldName}" not found on IMAP account ${cfg.accountLabel}.`,
681
- };
682
- }
683
- try {
684
- const res = await client.mailboxRename(path, newName);
685
- return { success: true, info: `Renamed "${res.path}" to "${res.newPath}" via IMAP.` };
686
- }
687
- catch (e) {
688
- return {
689
- success: false,
690
- error: `IMAP rename failed for "${path}" -> "${newName}": ${errText(e)}`,
691
- };
692
- }
693
- });
694
- }
695
- // ===========================================================================
696
- // Phase 3 — message-level operations by composite IMAP id (issue #43)
697
- //
698
- // get-message / mark / flag / move / delete-message route here when the message
699
- // id is an `imap:` token (emitted by the IMAP read path). The token carries the
700
- // mailbox path + UID, so the op opens that mailbox and acts on the UID.
701
- // ===========================================================================
702
- /** Connect, open the message's mailbox, run `fn`, release + log out. */
703
- async function withMailbox(path, deps, fn) {
704
- return withClient(deps, async (client) => {
705
- const lock = await client.getMailboxLock(path);
706
- try {
707
- return await fn(client);
708
- }
709
- finally {
710
- lock.release();
711
- }
712
- });
713
- }
714
- /** Read a message by composite IMAP id; returns "Subject: …\n\n<body>". */
715
- export async function imapGetMessage(id, preferHtml, deps = {}) {
716
- const ref = decodeImapId(id);
717
- if (!ref)
718
- return { success: false, error: `Not an IMAP message id: "${id}".` };
719
- return withMailbox(ref.path, { ...deps, account: deps.account ?? ref.account }, async (client) => {
720
- const msg = await client.fetchOne(String(ref.uid), { envelope: true, source: true }, { uid: true });
721
- if (!msg)
722
- return { success: false, error: `IMAP message UID ${ref.uid} not found in "${ref.path}".` };
723
- const subject = msg.envelope?.subject || "(no subject)";
724
- const src = msg.source ? msg.source.toString() : "";
725
- const body = (preferHtml ? extractHtmlBody(src) : extractTextBody(src)) ??
726
- extractTextBody(src) ??
727
- extractHtmlBody(src) ??
728
- "(no readable body)";
729
- return { success: true, info: `Subject: ${subject}\n\n${body}` };
730
- });
731
- }
732
- /** Normalize an RFC822 Message-ID for backend-independent matching: trim and
733
- * drop any surrounding angle brackets (IMAP envelopes carry `<id>`, Mail.app's
734
- * AppleScript `message id` property returns it bracketless). */
735
- export function normalizeMessageId(mid) {
736
- return mid.trim().replace(/^<+/, "").replace(/>+$/, "").trim();
737
- }
738
- /**
739
- * Fetch the RFC822 Message-ID for an `imap:` id. This is the join key that lets
740
- * the AppleScript backend locate the *same* message and return its numeric
741
- * Mail.app id — needed because flag **colors** only apply on the AppleScript
742
- * numeric-id path (IMAP `\Flagged` is colorless). Returns the normalized
743
- * Message-ID (no angle brackets), or null if `id` isn't an imap: token, the
744
- * message/envelope can't be fetched, or it carries no Message-ID.
745
- */
746
- export async function imapFetchMessageId(id, deps = {}) {
747
- const ref = decodeImapId(id);
748
- if (!ref)
749
- return null;
750
- try {
751
- return await withMailbox(ref.path, { ...deps, account: deps.account ?? ref.account }, async (client) => {
752
- const msg = await client.fetchOne(String(ref.uid), { envelope: true }, { uid: true });
753
- const mid = msg && msg.envelope?.messageId;
754
- return mid ? normalizeMessageId(mid) : null;
755
- });
756
- }
757
- catch {
758
- return null;
759
- }
760
- }
761
- function flagOp(id, flag, add, deps) {
762
- const ref = decodeImapId(id);
763
- if (!ref)
764
- return Promise.resolve({ success: false, error: `Not an IMAP message id: "${id}".` });
765
- return withMailbox(ref.path, { ...deps, account: deps.account ?? ref.account }, async (client) => {
766
- try {
767
- const ok = add
768
- ? await client.messageFlagsAdd([ref.uid], [flag], { uid: true })
769
- : await client.messageFlagsRemove([ref.uid], [flag], { uid: true });
770
- if (!ok)
771
- return { success: false, error: `IMAP flag update returned false for UID ${ref.uid}.` };
772
- return { success: true };
773
- }
774
- catch (e) {
775
- return {
776
- success: false,
777
- error: `IMAP flag update failed for UID ${ref.uid}: ${errText(e)}`,
778
- };
779
- }
780
- });
781
- }
782
- export const imapMarkRead = (id, deps = {}) => flagOp(id, "\\Seen", true, deps);
783
- export const imapMarkUnread = (id, deps = {}) => flagOp(id, "\\Seen", false, deps);
784
- export const imapFlagMessage = (id, deps = {}) => flagOp(id, "\\Flagged", true, deps);
785
- export const imapUnflagMessage = (id, deps = {}) => flagOp(id, "\\Flagged", false, deps);
786
- export async function imapMoveMessageById(id, destMailbox, deps = {}) {
787
- const ref = decodeImapId(id);
788
- if (!ref)
789
- return { success: false, error: `Not an IMAP message id: "${id}".` };
790
- return withClient({ ...deps, account: deps.account ?? ref.account }, async (client) => {
791
- const destPath = (await findMailboxPath(client, destMailbox)) ?? resolveMailboxPath(destMailbox, "list");
792
- const lock = await client.getMailboxLock(ref.path);
793
- try {
794
- await client.messageMove([ref.uid], destPath, { uid: true });
795
- return { success: true, info: `Moved UID ${ref.uid} to "${destPath}" via IMAP.` };
796
- }
797
- catch (e) {
798
- return {
799
- success: false,
800
- error: `IMAP move failed for UID ${ref.uid} -> "${destPath}": ${errText(e)}`,
801
- };
802
- }
803
- finally {
804
- lock.release();
805
- }
806
- });
807
- }
808
- export async function imapDeleteMessageById(id, deps = {}) {
809
- const ref = decodeImapId(id);
810
- if (!ref)
811
- return { success: false, error: `Not an IMAP message id: "${id}".` };
812
- return withMailbox(ref.path, { ...deps, account: deps.account ?? ref.account }, async (client) => {
813
- try {
814
- const ok = await client.messageDelete([ref.uid], { uid: true });
815
- if (!ok)
816
- return { success: false, error: `IMAP delete returned false for UID ${ref.uid}.` };
817
- return { success: true, info: `Deleted UID ${ref.uid} from "${ref.path}" via IMAP.` };
818
- }
819
- catch (e) {
820
- return { success: false, error: `IMAP delete failed for UID ${ref.uid}: ${errText(e)}` };
821
- }
822
- });
823
- }
824
- /** Walk a BODYSTRUCTURE tree collecting attachment parts (disposition or filename). */
825
- function collectAttachments(node, out = []) {
826
- if (!node)
827
- return out;
828
- const filename = node.dispositionParameters?.filename || node.parameters?.name;
829
- const disposition = node.disposition?.toLowerCase();
830
- const isAttachment = !!node.part && (disposition === "attachment" || (!!filename && disposition !== "inline"));
831
- if (isAttachment) {
832
- out.push({
833
- part: node.part,
834
- filename: filename || `part-${node.part}`,
835
- mimeType: node.type || "application/octet-stream",
836
- size: node.size ?? 0,
837
- });
838
- }
839
- for (const child of node.childNodes ?? [])
840
- collectAttachments(child, out);
841
- return out;
842
- }
843
- async function streamToBuffer(content) {
844
- const chunks = [];
845
- for await (const chunk of content)
846
- chunks.push(Buffer.from(chunk));
847
- return Buffer.concat(chunks);
848
- }
849
- /** List a message's attachments via IMAP BODYSTRUCTURE (no full download). */
850
- export async function imapListAttachments(id, deps = {}) {
851
- const ref = decodeImapId(id);
852
- if (!ref)
853
- return { success: false, error: `Not an IMAP message id: "${id}".` };
854
- return withMailbox(ref.path, { ...deps, account: deps.account ?? ref.account }, async (client) => {
855
- const msg = await client.fetchOne(String(ref.uid), { bodyStructure: true }, { uid: true });
856
- if (!msg || !msg.bodyStructure) {
857
- return { success: false, error: `IMAP message UID ${ref.uid} not found in "${ref.path}".` };
858
- }
859
- const attachments = collectAttachments(msg.bodyStructure).map((a) => ({
860
- id: `${id}#${a.part}`,
861
- name: a.filename,
862
- mimeType: a.mimeType,
863
- size: a.size,
864
- }));
865
- return { success: true, attachments };
866
- });
867
- }
868
- /** Fetch one attachment's bytes (base64) via IMAP, matched by filename. */
869
- export async function imapFetchAttachment(id, attachmentName, deps = {}) {
870
- const ref = decodeImapId(id);
871
- if (!ref)
872
- return { success: false, error: `Not an IMAP message id: "${id}".` };
873
- return withMailbox(ref.path, { ...deps, account: deps.account ?? ref.account }, async (client) => {
874
- const msg = await client.fetchOne(String(ref.uid), { bodyStructure: true }, { uid: true });
875
- if (!msg || !msg.bodyStructure) {
876
- return { success: false, error: `IMAP message UID ${ref.uid} not found in "${ref.path}".` };
877
- }
878
- const atts = collectAttachments(msg.bodyStructure);
879
- const match = atts.find((a) => a.filename === attachmentName);
880
- if (!match) {
881
- const names = atts.map((a) => a.filename).join(", ") || "none";
882
- return {
883
- success: false,
884
- error: `Attachment "${attachmentName}" not found on UID ${ref.uid}. Available: ${names}.`,
885
- };
886
- }
887
- const dl = await client.download(String(ref.uid), match.part, { uid: true });
888
- const buf = await streamToBuffer(dl.content);
889
- return {
890
- success: true,
891
- base64: buf.toString("base64"),
892
- bytes: buf.length,
893
- mimeType: match.mimeType,
894
- };
895
- });
896
- }
897
- async function imapBatch(ids, deps, op) {
898
- const groups = new Map();
899
- const errors = [];
900
- let failed = 0;
901
- for (const id of ids) {
902
- const ref = decodeImapId(id);
903
- if (!ref) {
904
- failed++;
905
- errors.push(`Not an IMAP id: "${id}"`);
906
- continue;
907
- }
908
- const key = `${ref.account}\0${ref.path}`;
909
- const g = groups.get(key) ?? { account: ref.account, path: ref.path, uids: [] };
910
- g.uids.push(ref.uid);
911
- groups.set(key, g);
912
- }
913
- let success = 0;
914
- for (const g of groups.values()) {
915
- try {
916
- await useClient({ ...deps, account: deps.account ?? g.account }, async (client) => {
917
- const lock = await client.getMailboxLock(g.path);
918
- try {
919
- await op(client, g.uids, g.path);
920
- }
921
- finally {
922
- lock.release();
923
- }
924
- });
925
- success += g.uids.length;
926
- }
927
- catch (e) {
928
- failed += g.uids.length;
929
- errors.push(`${g.path}: ${errText(e)}`);
930
- }
931
- }
932
- return { success, failed, errors };
933
- }
934
- export const imapBatchMarkRead = (ids, deps = {}) => imapBatch(ids, deps, async (c, uids) => {
935
- await c.messageFlagsAdd(uids, ["\\Seen"], { uid: true });
936
- });
937
- export const imapBatchMarkUnread = (ids, deps = {}) => imapBatch(ids, deps, async (c, uids) => {
938
- await c.messageFlagsRemove(uids, ["\\Seen"], { uid: true });
939
- });
940
- export const imapBatchFlag = (ids, deps = {}) => imapBatch(ids, deps, async (c, uids) => {
941
- await c.messageFlagsAdd(uids, ["\\Flagged"], { uid: true });
942
- });
943
- export const imapBatchUnflag = (ids, deps = {}) => imapBatch(ids, deps, async (c, uids) => {
944
- await c.messageFlagsRemove(uids, ["\\Flagged"], { uid: true });
945
- });
946
- export const imapBatchDelete = (ids, deps = {}) => imapBatch(ids, deps, async (c, uids) => {
947
- await c.messageDelete(uids, { uid: true });
948
- });
949
- export function imapBatchMove(ids, destMailbox, deps = {}) {
950
- return imapBatch(ids, deps, async (c, uids) => {
951
- const dest = (await findMailboxPath(c, destMailbox)) ?? resolveMailboxPath(destMailbox, "list");
952
- await c.messageMove(uids, dest, { uid: true });
953
- });
954
- }
955
- function senderName(from) {
956
- const a = from?.[0];
957
- if (!a)
958
- return "(unknown)";
959
- return a.name ? `${a.name} <${a.address ?? ""}>` : (a.address ?? "(unknown)");
960
- }
961
- function dateMs(m) {
962
- return m.envelope?.date ? new Date(m.envelope.date).getTime() : 0;
963
- }
964
- export async function imapThread(id, deps = {}, limit = 50) {
965
- const ref = decodeImapId(id);
966
- if (!ref)
967
- return null;
968
- return useClient({ ...deps, account: deps.account ?? ref.account }, async (client) => {
969
- const lock = await client.getMailboxLock(ref.path);
970
- try {
971
- const seed = await client.fetchOne(String(ref.uid), { envelope: true, headers: ["references", "in-reply-to", "message-id"] }, { uid: true });
972
- if (!seed)
973
- return null;
974
- const seedMsgId = seed.envelope?.messageId;
975
- const refIds = new Set();
976
- const hdr = seed.headers ? seed.headers.toString() : "";
977
- for (const m of hdr.matchAll(/<[^>]+>/g))
978
- refIds.add(m[0]);
979
- if (seed.envelope?.inReplyTo)
980
- refIds.add(seed.envelope.inReplyTo);
981
- const uidSet = new Set([ref.uid]);
982
- const addFound = (found) => {
983
- if (Array.isArray(found))
984
- found.forEach((u) => uidSet.add(u));
985
- };
986
- // Descendants: anything referencing the seed.
987
- if (seedMsgId) {
988
- addFound(await client.search({ header: { references: seedMsgId } }, { uid: true }));
989
- addFound(await client.search({ header: { "in-reply-to": seedMsgId } }, { uid: true }));
990
- }
991
- // Ancestors: messages whose Message-ID is in the seed's References (bounded).
992
- for (const mid of [...refIds].slice(0, 20)) {
993
- addFound(await client.search({ header: { "message-id": mid } }, { uid: true }));
994
- }
995
- if (uidSet.size <= 1)
996
- return null; // only the seed → caller falls back to subject
997
- const uids = [...uidSet].slice(0, limit);
998
- const msgs = [];
999
- for await (const msg of client.fetch(uids.join(","), { envelope: true, flags: true }, { uid: true })) {
1000
- msgs.push(msg);
1001
- }
1002
- msgs.sort((a, b) => dateMs(a) - dateMs(b)); // oldest first
1003
- const subject = seed.envelope?.subject || "(no subject)";
1004
- const structured = {
1005
- subject,
1006
- count: msgs.length,
1007
- messages: msgs.map((m) => ({
1008
- id: encodeImapId(ref.account, ref.path, m.uid),
1009
- subject: m.envelope?.subject || "(no subject)",
1010
- sender: senderName(m.envelope?.from),
1011
- date: m.envelope?.date ? new Date(m.envelope.date).toISOString() : "",
1012
- isRead: m.flags?.has("\\Seen") ?? false,
1013
- })),
1014
- };
1015
- const text = `Thread "${subject}" — ${msgs.length} message(s) via IMAP (References-linked, oldest first):\n` +
1016
- msgs.map((m) => formatRow(m, ref.account, ref.path)).join("\n");
1017
- return { count: msgs.length, text, structured };
1018
- }
1019
- finally {
1020
- lock.release();
1021
- }
1022
- }, true);
1023
- }