apple-mail-mcp 2.17.0 → 2.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +14 -5
  2. package/build/index.js +141 -54
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -470,11 +470,20 @@ matching `account` is passed. There are three cases:
470
470
  sort newest-first; count tools (`get-unread-count`, `get-mail-stats`) count each
471
471
  account via exactly one backend so a coverage mismatch can never double- (or
472
472
  under-) count.
473
- - **Default mailbox is resolved per account.** When you don't pin a `mailbox`, a
474
- fan-out search scopes each account to its own default — Gmail/Workspace to
475
- `[Gmail]/All Mail`, every other IMAP host (iCloud, etc.) to `INBOX` (since
476
- `[Gmail]/All Mail` is Gmail-only and selecting it elsewhere would silently drop
477
- that account). Pin a `mailbox` to search a wider scope on non-Gmail accounts.
473
+ - **An omitted mailbox on `search-messages` searches the account's entire
474
+ store, not just one default folder** (v2.17.1, [#199](https://github.com/sweetrb/apple-mail-mcp/issues/199)).
475
+ Per account, the fan-out uses the server-advertised RFC 6154 `\All`
476
+ mailbox when one exists (Gmail/Workspace's `[Gmail]/All Mail`); otherwise
477
+ it searches every selectable mailbox the server lists (iCloud, generic
478
+ IMAP), merges the matches, de-duplicates by Message-ID, and sorts
479
+ newest-first before applying `limit`/`offset`. A mailbox that can't be
480
+ selected or searched is named in the result instead of silently dropping
481
+ coverage. Scanning every mailbox on a large, deeply-nested account costs
482
+ one `SEARCH` + a bounded `FETCH` per mailbox over the pooled IMAP
483
+ connection — pin a `mailbox` to skip the fan-out when you already know
484
+ where to look. `list-messages` (no query) still defaults an omitted
485
+ mailbox to `INBOX` on every provider — only unscoped *search* scans the
486
+ whole account.
478
487
 
479
488
  If IMAP is **not** configured at all, every read behaves exactly as before
480
489
  (pure AppleScript). The three mailbox-**write** ops (`create-mailbox`,
package/build/index.js CHANGED
@@ -84030,8 +84030,8 @@ var defaultConnect = async (cfg) => {
84030
84030
  }
84031
84031
  return client;
84032
84032
  };
84033
- function resolveMailboxPath(mailbox, mode) {
84034
- if (!mailbox) return mode === "search" ? "[Gmail]/All Mail" : "INBOX";
84033
+ function resolveMailboxPath(mailbox, _mode) {
84034
+ if (!mailbox) return "INBOX";
84035
84035
  const map = {
84036
84036
  "all mail": "[Gmail]/All Mail",
84037
84037
  "sent mail": "[Gmail]/Sent Mail",
@@ -84094,49 +84094,127 @@ function structuredRow(m, account, path) {
84094
84094
  ...env.messageId ? { messageId: env.messageId } : {}
84095
84095
  };
84096
84096
  }
84097
+ function hasMailboxFlag(mailbox, wanted) {
84098
+ const normalized = wanted.toLowerCase();
84099
+ return [...mailbox.flags ?? []].some((flag) => flag.toLowerCase() === normalized);
84100
+ }
84101
+ function messageDateEpoch(message) {
84102
+ if (!message.envelope?.date) return 0;
84103
+ const epoch = new Date(message.envelope.date).getTime();
84104
+ return Number.isNaN(epoch) ? 0 : epoch;
84105
+ }
84106
+ function messageIdentity(entry) {
84107
+ const raw = entry.message.envelope?.messageId?.trim() ?? "";
84108
+ const messageId = raw.replace(/^<+|>+$/g, "").trim().toLowerCase();
84109
+ return messageId ? `mid:${messageId}` : `${entry.path}\0${entry.message.uid}`;
84110
+ }
84111
+ async function fetchMailboxMatches(client, path, criteria, newestCount) {
84112
+ const lock = await client.getMailboxLock(path);
84113
+ try {
84114
+ const found = await client.search(criteria, { uid: true });
84115
+ const uids = Array.isArray(found) ? found : [];
84116
+ if (uids.length === 0 || newestCount === 0) return { messages: [], total: uids.length };
84117
+ const newest = uids.slice().reverse().slice(0, newestCount);
84118
+ const byUid = /* @__PURE__ */ new Map();
84119
+ for await (const msg of client.fetch(
84120
+ newest.join(","),
84121
+ // BODYSTRUCTURE rides along so `hasAttachments` is computed rather
84122
+ // than assumed. Measured on 50 real messages: ~390ms -> ~465ms for
84123
+ // the fetch (~17%), same single round trip, no extra request.
84124
+ { envelope: true, flags: true, bodyStructure: true },
84125
+ { uid: true }
84126
+ )) {
84127
+ byUid.set(msg.uid, msg);
84128
+ }
84129
+ return {
84130
+ messages: newest.map((uid) => byUid.get(uid)).filter((message) => message !== void 0),
84131
+ total: uids.length
84132
+ };
84133
+ } finally {
84134
+ lock.release();
84135
+ }
84136
+ }
84097
84137
  async function run(args, listMode, deps) {
84098
84138
  return useClient(
84099
84139
  { ...deps, account: deps.account ?? args.account },
84100
84140
  async (client, cfg) => {
84101
- const path = resolveMailboxPath(args.mailbox, listMode ? "list" : "search");
84102
- const lock = await client.getMailboxLock(path);
84103
- try {
84104
- const found = await client.search(buildCriteria(args, listMode), { uid: true });
84105
- const uids = Array.isArray(found) ? found : [];
84106
- if (uids.length === 0) {
84107
- return {
84108
- text: `No messages found via IMAP in "${path}" (account ${cfg.accountLabel}).`,
84109
- messages: [],
84110
- count: 0,
84111
- partial: false
84112
- };
84141
+ const unscopedSearch = !listMode && !args.mailbox;
84142
+ let paths;
84143
+ let allMailboxCount = 0;
84144
+ if (unscopedSearch) {
84145
+ const listed = await client.list();
84146
+ const selectable = listed.filter((mailbox) => !hasMailboxFlag(mailbox, "\\Noselect"));
84147
+ const allMailbox = selectable.find(
84148
+ (mailbox) => mailbox.specialUse?.toLowerCase() === "\\all"
84149
+ );
84150
+ paths = allMailbox ? [allMailbox.path] : selectable.map((mailbox) => mailbox.path);
84151
+ allMailboxCount = paths.length;
84152
+ if (paths.length === 0) {
84153
+ throw new Error(`No selectable IMAP mailboxes found for account ${cfg.accountLabel}.`);
84113
84154
  }
84114
- const limit = args.limit ?? 50;
84115
- const offset = args.offset ?? 0;
84116
- const newest = uids.slice().reverse().slice(offset, offset + limit);
84117
- const byUid = /* @__PURE__ */ new Map();
84118
- for await (const msg of client.fetch(
84119
- newest.join(","),
84120
- // BODYSTRUCTURE rides along so `hasAttachments` is computed rather
84121
- // than assumed. Measured on 50 real messages: ~390ms -> ~465ms for
84122
- // the fetch (~17%), same single round trip, no extra request.
84123
- { envelope: true, flags: true, bodyStructure: true },
84124
- { uid: true }
84125
- )) {
84126
- byUid.set(msg.uid, msg);
84155
+ } else {
84156
+ paths = [resolveMailboxPath(args.mailbox, listMode ? "list" : "search")];
84157
+ }
84158
+ const limit = args.limit ?? 50;
84159
+ const offset = args.offset ?? 0;
84160
+ const criteria = buildCriteria(args, listMode);
84161
+ const newestPerMailbox = offset + limit;
84162
+ const fetched = [];
84163
+ const failedMailboxes = [];
84164
+ let totalMatched = 0;
84165
+ for (const path of paths) {
84166
+ try {
84167
+ const result = await fetchMailboxMatches(client, path, criteria, newestPerMailbox);
84168
+ totalMatched += result.total;
84169
+ fetched.push(...result.messages.map((message) => ({ message, path })));
84170
+ } catch (error2) {
84171
+ failedMailboxes.push(path);
84172
+ console.error(
84173
+ `IMAP ${listMode ? "list" : "search"} failed for account "${cfg.accountLabel}", mailbox "${path}": ${String(error2)}`
84174
+ );
84127
84175
  }
84128
- const ordered = newest.map((u) => byUid.get(u)).filter((m) => m !== void 0);
84129
- const rows = ordered.map((m) => formatRow(m, cfg.accountLabel, path));
84130
- const messages = ordered.map((m) => structuredRow(m, cfg.accountLabel, path));
84131
- const verb = listMode ? "listed" : "matched";
84132
- const text = `Found ${rows.length} message(s) via IMAP (server-side, account ${cfg.accountLabel}, mailbox "${path}"; ${uids.length} total ${verb}):
84133
- ` + rows.join("\n") + `
84176
+ }
84177
+ if (failedMailboxes.length === paths.length) {
84178
+ throw new Error(
84179
+ `IMAP ${listMode ? "list" : "search"} failed in every requested mailbox for account ${cfg.accountLabel}: ${failedMailboxes.join(", ")}.`
84180
+ );
84181
+ }
84182
+ let ordered = fetched;
84183
+ if (unscopedSearch) {
84184
+ ordered = fetched.slice().sort((a, b) => messageDateEpoch(b.message) - messageDateEpoch(a.message));
84185
+ const unique = /* @__PURE__ */ new Map();
84186
+ for (const entry of ordered) {
84187
+ const key = messageIdentity(entry);
84188
+ if (!unique.has(key)) unique.set(key, entry);
84189
+ }
84190
+ ordered = [...unique.values()].slice(offset, offset + limit);
84191
+ } else {
84192
+ ordered = fetched.slice(offset, offset + limit);
84193
+ }
84194
+ const rows = ordered.map(({ message, path }) => formatRow(message, cfg.accountLabel, path));
84195
+ const messages = ordered.map(
84196
+ ({ message, path }) => structuredRow(message, cfg.accountLabel, path)
84197
+ );
84198
+ const partial2 = failedMailboxes.length > 0;
84199
+ const failureNote = partial2 ? `
84134
84200
 
84135
- Note: these IMAP IDs (imap:\u2026) work with get-message and the message mutations (mark/flag/move/delete-message), which route back to IMAP.`;
84136
- return { text, messages, count: messages.length, partial: false };
84137
- } finally {
84138
- lock.release();
84201
+ Partial result. Could not search mailbox(es): ${failedMailboxes.map((path) => `"${path}"`).join(", ")}.` : "";
84202
+ const verb = listMode ? "listed" : "matched";
84203
+ const scope = unscopedSearch ? allMailboxCount === 1 ? `mailbox "${paths[0]}"` : `${allMailboxCount} selectable mailboxes` : `mailbox "${paths[0]}"`;
84204
+ if (messages.length === 0) {
84205
+ return {
84206
+ text: `No messages found via IMAP in ${scope} (account ${cfg.accountLabel}).${failureNote}`,
84207
+ messages,
84208
+ count: 0,
84209
+ partial: partial2,
84210
+ failedMailboxes
84211
+ };
84139
84212
  }
84213
+ const text = `Found ${rows.length} message(s) via IMAP (server-side, account ${cfg.accountLabel}, ${scope}; ${totalMatched} total ${verb}):
84214
+ ` + rows.join("\n") + `
84215
+
84216
+ Note: these IMAP IDs (imap:\u2026) work with get-message and the message mutations (mark/flag/move/delete-message), which route back to IMAP.` + failureNote;
84217
+ return { text, messages, count: messages.length, partial: partial2, failedMailboxes };
84140
84218
  },
84141
84219
  true
84142
84220
  );
@@ -85240,26 +85318,26 @@ function mergeMessages(imapRows, appleRows, limit) {
85240
85318
  merged.sort((a, b) => dateEpoch(b) - dateEpoch(a));
85241
85319
  return limit >= 0 ? merged.slice(0, limit) : merged;
85242
85320
  }
85243
- function isGmailHost(host) {
85244
- return /(^|\.)gmail\.com$/i.test(host.trim());
85245
- }
85246
85321
  async function fanOutImapMessages(args, kind, deps = {}, configs = resolveImapConfigs()) {
85247
85322
  const rows = [];
85248
85323
  const accountsQueried = [];
85249
85324
  const accountsFailed = [];
85325
+ const failedMailboxes = [];
85250
85326
  for (const config2 of configs) {
85251
- const mailbox = args.mailbox ?? (isGmailHost(config2.host) ? void 0 : "INBOX");
85252
- const perAccountArgs = { ...args, account: void 0, mailbox };
85327
+ const perAccountArgs = { ...args, account: void 0 };
85253
85328
  try {
85254
85329
  const res = kind === "search" ? await imapSearchMessages(perAccountArgs, { ...deps, config: config2 }) : await imapListMessages(perAccountArgs, { ...deps, config: config2 });
85255
85330
  rows.push(...res.messages);
85256
85331
  accountsQueried.push(config2.accountLabel);
85332
+ failedMailboxes.push(
85333
+ ...res.failedMailboxes.map((mailbox) => `${config2.accountLabel} / ${mailbox}`)
85334
+ );
85257
85335
  } catch (e) {
85258
85336
  accountsFailed.push(config2.accountLabel);
85259
85337
  console.error(`IMAP fan-out failed for account "${config2.accountLabel}": ${String(e)}`);
85260
85338
  }
85261
85339
  }
85262
- return { rows, accountsQueried, accountsFailed };
85340
+ return { rows, accountsQueried, accountsFailed, failedMailboxes };
85263
85341
  }
85264
85342
  function configMatchesAccount(config2, account) {
85265
85343
  const name = account.name.trim().toLowerCase();
@@ -85847,7 +85925,8 @@ var LIST_OUTPUT_SCHEMA = {
85847
85925
  partial: external_exports.boolean().optional(),
85848
85926
  skippedLargeMailboxes: external_exports.array(external_exports.string()).optional(),
85849
85927
  notSearchedMailboxes: external_exports.array(external_exports.string()).optional(),
85850
- timedOutAccounts: external_exports.array(external_exports.string()).optional()
85928
+ timedOutAccounts: external_exports.array(external_exports.string()).optional(),
85929
+ failedMailboxes: external_exports.array(external_exports.string()).optional()
85851
85930
  };
85852
85931
  var BATCH_COUNT_OUTPUT_SCHEMA = {
85853
85932
  ok: external_exports.boolean().optional(),
@@ -85926,8 +86005,9 @@ function mergedMessageResponse(fan, apple, limit, verb) {
85926
86005
  const merged = mergeMessages(fan.rows, apple.rows, limit);
85927
86006
  const diagnostics = {
85928
86007
  ...apple.diagnostics,
85929
- partial: apple.diagnostics.partial || fan.accountsFailed.length > 0,
85930
- timedOutAccounts: [...apple.diagnostics.timedOutAccounts, ...fan.accountsFailed]
86008
+ partial: apple.diagnostics.partial || fan.accountsFailed.length > 0 || fan.failedMailboxes.length > 0,
86009
+ timedOutAccounts: [...apple.diagnostics.timedOutAccounts, ...fan.accountsFailed],
86010
+ notSearchedMailboxes: [...apple.diagnostics.notSearchedMailboxes, ...fan.failedMailboxes]
85931
86011
  };
85932
86012
  const structured = {
85933
86013
  messages: merged,
@@ -85935,7 +86015,8 @@ function mergedMessageResponse(fan, apple, limit, verb) {
85935
86015
  partial: diagnostics.partial,
85936
86016
  skippedLargeMailboxes: diagnostics.skippedLargeMailboxes,
85937
86017
  notSearchedMailboxes: diagnostics.notSearchedMailboxes,
85938
- timedOutAccounts: diagnostics.timedOutAccounts
86018
+ timedOutAccounts: diagnostics.timedOutAccounts,
86019
+ failedMailboxes: fan.failedMailboxes
85939
86020
  };
85940
86021
  const coverageBlock = partialCoverageBlock(diagnostics);
85941
86022
  if (merged.length === 0) {
@@ -86031,7 +86112,8 @@ registerTool(
86031
86112
  return successResponse(r.text, {
86032
86113
  messages: r.messages,
86033
86114
  count: r.count,
86034
- partial: r.partial
86115
+ partial: r.partial,
86116
+ failedMailboxes: r.failedMailboxes
86035
86117
  });
86036
86118
  }
86037
86119
  const fan = await fanOutImapMessages(imapArgs, "search");
@@ -86178,7 +86260,8 @@ registerTool(
86178
86260
  subject: external_exports.string().optional(),
86179
86261
  messages: external_exports.array(MESSAGE_ROW_SCHEMA).optional(),
86180
86262
  count: external_exports.number().optional(),
86181
- partial: external_exports.boolean().optional()
86263
+ partial: external_exports.boolean().optional(),
86264
+ failedMailboxes: external_exports.array(external_exports.string()).optional()
86182
86265
  }
86183
86266
  },
86184
86267
  withErrorHandling(async ({ id, account, mailbox, limit = 50 }) => {
@@ -86206,7 +86289,8 @@ ${r.text}`, {
86206
86289
  subject: base,
86207
86290
  messages: r.messages,
86208
86291
  count: r.count,
86209
- partial: r.partial
86292
+ partial: r.partial,
86293
+ failedMailboxes: r.failedMailboxes
86210
86294
  });
86211
86295
  }
86212
86296
  const fan = await fanOutImapMessages({ subject: base, mailbox, limit }, "search");
@@ -86231,17 +86315,19 @@ ${r.text}`, {
86231
86315
  const orderedRows = mergedNewestFirst.slice().reverse().sort(
86232
86316
  (a, b) => (a.dateReceived ? new Date(a.dateReceived).getTime() : 0) - (b.dateReceived ? new Date(b.dateReceived).getTime() : 0)
86233
86317
  );
86234
- const partial2 = apple.diagnostics.partial || fan.accountsFailed.length > 0;
86318
+ const partial2 = apple.diagnostics.partial || fan.accountsFailed.length > 0 || fan.failedMailboxes.length > 0;
86235
86319
  const coverage = partialCoverageBlock({
86236
86320
  ...apple.diagnostics,
86237
86321
  partial: partial2,
86238
- timedOutAccounts: [...apple.diagnostics.timedOutAccounts, ...fan.accountsFailed]
86322
+ timedOutAccounts: [...apple.diagnostics.timedOutAccounts, ...fan.accountsFailed],
86323
+ notSearchedMailboxes: [...apple.diagnostics.notSearchedMailboxes, ...fan.failedMailboxes]
86239
86324
  });
86240
86325
  const structured2 = {
86241
86326
  subject: base,
86242
86327
  messages: orderedRows,
86243
86328
  count: orderedRows.length,
86244
- partial: partial2
86329
+ partial: partial2,
86330
+ failedMailboxes: fan.failedMailboxes
86245
86331
  };
86246
86332
  if (orderedRows.length === 0) {
86247
86333
  return successResponse(`No messages found in thread "${base}".${coverage}`, structured2);
@@ -86304,7 +86390,8 @@ registerTool(
86304
86390
  return successResponse(r.text, {
86305
86391
  messages: r.messages,
86306
86392
  count: r.count,
86307
- partial: r.partial
86393
+ partial: r.partial,
86394
+ failedMailboxes: r.failedMailboxes
86308
86395
  });
86309
86396
  }
86310
86397
  const fan = await fanOutImapMessages({ mailbox, limit, offset, from, unreadOnly }, "list");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-mail-mcp",
3
- "version": "2.17.0",
3
+ "version": "2.17.1",
4
4
  "description": "MCP server for Apple Mail - read, search, send, and manage emails via Claude and other AI assistants",
5
5
  "type": "module",
6
6
  "main": "build/index.js",