apple-mail-mcp 2.4.2 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/index.js CHANGED
@@ -26,9 +26,11 @@ import { z } from "zod";
26
26
  import { AppleMailManager, isPathWithinAllowedRoots } from "./services/appleMailManager.js";
27
27
  import { writeFileSync } from "fs";
28
28
  import { resolve as resolvePath, join as joinPath } from "path";
29
- import { sendViaSmtp, shouldUseSmtp } from "./services/smtpMailer.js";
30
- import { isImapAccount, resolveImapConfigs, dropAllPools, imapSearchMessages, imapListMessages, imapUnreadCount, imapListMailboxes, imapMailStats, imapListAttachments, imapFetchAttachment, imapBatchMarkRead, imapBatchMarkUnread, imapBatchFlag, imapBatchUnflag, imapBatchDelete, imapBatchMove, imapThread, imapCreateMailbox, imapDeleteMailbox, imapRenameMailbox, imapGetMessage, imapMarkRead, imapMarkUnread, imapFlagMessage, imapUnflagMessage, imapMoveMessageById, imapDeleteMessageById, } from "./services/imapClient.js";
29
+ import { sendViaSmtp, sendSerialViaSmtp, shouldUseSmtp, isSmtpConfigured, resolveSmtpConfig, } from "./services/smtpMailer.js";
30
+ import { buildReplyOptions, buildForwardOptions, parseOriginalHeaders, } from "./services/replyForward.js";
31
+ import { isImapAccount, shouldUseImap, resolveImapConfigs, dropAllPools, imapSearchMessages, imapListMessages, imapUnreadCount, imapListMailboxes, imapMailStats, imapListAttachments, imapFetchAttachment, imapBatchMarkRead, imapBatchMarkUnread, imapBatchFlag, imapBatchUnflag, imapBatchDelete, imapBatchMove, imapThread, imapCreateMailbox, imapDeleteMailbox, imapRenameMailbox, imapGetMessage, imapMarkRead, imapMarkUnread, imapFlagMessage, imapUnflagMessage, imapMoveMessageById, imapDeleteMessageById, } from "./services/imapClient.js";
31
32
  import { successResponse, errorResponse, partialCoverageBlock, withErrorHandling, messageSummary, } from "./tools/respond.js";
33
+ import { fanOutImapMessages, mergeMessages, formatMergedRows, partitionAccountsForCounts, planCountSources, } from "./services/imapMultiAccount.js";
32
34
  import { routeMessage } from "./services/messageRouter.js";
33
35
  import { runDoctor, formatDoctorReport } from "./tools/doctor.js";
34
36
  import { registerResourcesAndPrompts } from "./tools/resourcesAndPrompts.js";
@@ -115,6 +117,83 @@ const CHECK_ITEM_SCHEMA = z.object({}).passthrough();
115
117
  // Read version from package.json to keep it in sync
116
118
  const require = createRequire(import.meta.url);
117
119
  const { version } = require("../package.json");
120
+ /** An empty SearchDiagnostics (complete coverage). */
121
+ function emptyDiagnostics() {
122
+ return {
123
+ partial: false,
124
+ timedOutAccounts: [],
125
+ skippedLargeMailboxes: [],
126
+ notSearchedMailboxes: [],
127
+ };
128
+ }
129
+ /** Merge two diagnostics objects (union the lists, OR the partial flag). */
130
+ function mergeDiagnostics(a, b) {
131
+ return {
132
+ partial: a.partial || b.partial,
133
+ timedOutAccounts: [...a.timedOutAccounts, ...b.timedOutAccounts],
134
+ skippedLargeMailboxes: [...a.skippedLargeMailboxes, ...b.skippedLargeMailboxes],
135
+ notSearchedMailboxes: [...a.notSearchedMailboxes, ...b.notSearchedMailboxes],
136
+ };
137
+ }
138
+ /**
139
+ * Run a per-account AppleScript scan over ONLY the given accounts (the ones no
140
+ * IMAP config covers), concatenating their rows and merging diagnostics. The IMAP
141
+ * fan-out already covers the IMAP accounts, so this avoids scanning them on the
142
+ * AppleScript side — which, for an all-IMAP user, means ZERO AppleScript work and
143
+ * therefore no reliance on the fragile composite dedup. `scan` runs one account.
144
+ */
145
+ function appleScanForAccounts(accounts, scan) {
146
+ const rows = [];
147
+ let diagnostics = emptyDiagnostics();
148
+ for (const acct of accounts) {
149
+ const res = scan(acct.name);
150
+ rows.push(...res.messages.map(messageSummary));
151
+ diagnostics = mergeDiagnostics(diagnostics, res.diagnostics);
152
+ }
153
+ return { rows, diagnostics };
154
+ }
155
+ /**
156
+ * Build the success response for a no-account, prefer-IMAP MERGED message list
157
+ * (search-messages / list-messages — v2.6.0). Concatenates the IMAP fan-out rows
158
+ * with the (already partitioned) AppleScript rows, de-dups (IMAP copy wins — a
159
+ * safety net for heuristic misses + IMAP-vs-IMAP dupes, no longer load-bearing
160
+ * for matched accounts), sorts newest-first, applies `limit`, and preserves the
161
+ * partial-coverage diagnostics (AppleScript scan + any failed IMAP fan-out).
162
+ *
163
+ * @param verb "matched" (search) or "listed" (list) — only affects empty-state text.
164
+ */
165
+ function mergedMessageResponse(fan, apple, limit, verb) {
166
+ const merged = mergeMessages(fan.rows, apple.rows, limit);
167
+ // Surface IMAP fan-out failures alongside the AppleScript diagnostics so a
168
+ // partial merge is never mistaken for a confirmed "no such mail".
169
+ const diagnostics = {
170
+ ...apple.diagnostics,
171
+ partial: apple.diagnostics.partial || fan.accountsFailed.length > 0,
172
+ timedOutAccounts: [...apple.diagnostics.timedOutAccounts, ...fan.accountsFailed],
173
+ };
174
+ const structured = {
175
+ messages: merged,
176
+ count: merged.length,
177
+ partial: diagnostics.partial,
178
+ skippedLargeMailboxes: diagnostics.skippedLargeMailboxes,
179
+ notSearchedMailboxes: diagnostics.notSearchedMailboxes,
180
+ timedOutAccounts: diagnostics.timedOutAccounts,
181
+ };
182
+ const coverageBlock = partialCoverageBlock(diagnostics);
183
+ if (merged.length === 0) {
184
+ const base = diagnostics.partial
185
+ ? `No messages found in the portions that were ${verb === "matched" ? "searched" : "listed"}.`
186
+ : "No messages found";
187
+ return successResponse(`${base}${coverageBlock}`, structured);
188
+ }
189
+ const parts = [];
190
+ if (fan.accountsQueried.length > 0)
191
+ parts.push(`IMAP account(s): ${fan.accountsQueried.join(", ")}`);
192
+ if (apple.rows.length > 0)
193
+ parts.push("AppleScript");
194
+ const accountsNote = parts.length > 0 ? ` (merged across ${parts.join(" + ")})` : "";
195
+ return successResponse(`Found ${merged.length} message(s)${accountsNote}:\n${formatMergedRows(merged)}${coverageBlock}`, structured);
196
+ }
118
197
  // =============================================================================
119
198
  // Server Initialization
120
199
  // =============================================================================
@@ -189,13 +268,17 @@ server.registerTool("search-messages", {
189
268
  },
190
269
  outputSchema: LIST_OUTPUT_SCHEMA,
191
270
  }, withErrorHandling(async ({ query, mailbox, account, limit = 50, dateFrom, dateTo, from, subject, isRead, isFlagged, }) => {
192
- // IMAP backend (issue #43): server-side search when this account is
193
- // explicitly configured for IMAP; otherwise fall through to AppleScript.
194
- if (isImapAccount(account)) {
195
- const r = await imapSearchMessages({
271
+ // IMAP backend: prefer direct IMAP whenever IMAP is configured (v2.6.0).
272
+ // - explicit IMAP account single-account IMAP (fast path);
273
+ // - no account + IMAP configured → MERGE: IMAP fans out over every
274
+ // configured account; AppleScript scans ONLY the accounts no IMAP
275
+ // config covers (partitioned — so an all-IMAP user runs ZERO
276
+ // AppleScript and never relies on the composite dedup);
277
+ // - explicit non-IMAP account (or IMAP unconfigured) → AppleScript below.
278
+ if (shouldUseImap(account)) {
279
+ const imapArgs = {
196
280
  query,
197
281
  mailbox,
198
- account,
199
282
  limit,
200
283
  dateFrom,
201
284
  dateTo,
@@ -203,12 +286,19 @@ server.registerTool("search-messages", {
203
286
  subject,
204
287
  isRead,
205
288
  isFlagged,
206
- });
207
- return successResponse(r.text, {
208
- messages: r.messages,
209
- count: r.count,
210
- partial: r.partial,
211
- });
289
+ };
290
+ if (account !== undefined) {
291
+ const r = await imapSearchMessages({ ...imapArgs, account });
292
+ return successResponse(r.text, {
293
+ messages: r.messages,
294
+ count: r.count,
295
+ partial: r.partial,
296
+ });
297
+ }
298
+ const fan = await fanOutImapMessages(imapArgs, "search");
299
+ const { appleScriptOnly } = partitionAccountsForCounts(mailManager.listAccounts(), resolveImapConfigs());
300
+ const apple = appleScanForAccounts(appleScriptOnly, (acctName) => mailManager.searchMessagesWithDiagnostics(query, mailbox, acctName, limit, dateFrom, dateTo, from, subject, isRead, isFlagged));
301
+ return mergedMessageResponse(fan, apple, limit, "matched");
212
302
  }
213
303
  const { messages, diagnostics } = mailManager.searchMessagesWithDiagnostics(query, mailbox, account, limit, dateFrom, dateTo, from, subject, isRead, isFlagged);
214
304
  const coverageBlock = partialCoverageBlock(diagnostics);
@@ -322,15 +412,48 @@ server.registerTool("get-thread", {
322
412
  if (!seedSubject)
323
413
  return errorResponse(`Could not determine the subject of message "${id}"`);
324
414
  const base = normalizeSubject(seedSubject);
325
- // IMAP backend: server-side subject search.
326
- if (isImapAccount(account)) {
327
- const r = await imapSearchMessages({ subject: base, mailbox, account, limit });
328
- return successResponse(`Thread "${base}":\n${r.text}`, {
329
- subject: base,
330
- messages: r.messages,
331
- count: r.count,
332
- partial: r.partial,
415
+ // IMAP backend: server-side subject search (prefer-IMAP, v2.6.0).
416
+ // - explicit IMAP account → single-account IMAP subject search;
417
+ // - no account + IMAP configured → MERGE: IMAP fan-out subject search over
418
+ // every configured account + AppleScript subject search over ONLY the
419
+ // accounts no IMAP config covers (partitioned), then re-order oldest-
420
+ // first for natural thread reading.
421
+ if (shouldUseImap(account)) {
422
+ if (account !== undefined) {
423
+ const r = await imapSearchMessages({ subject: base, mailbox, account, limit });
424
+ return successResponse(`Thread "${base}":\n${r.text}`, {
425
+ subject: base,
426
+ messages: r.messages,
427
+ count: r.count,
428
+ partial: r.partial,
429
+ });
430
+ }
431
+ const fan = await fanOutImapMessages({ subject: base, mailbox, limit }, "search");
432
+ const { appleScriptOnly } = partitionAccountsForCounts(mailManager.listAccounts(), resolveImapConfigs());
433
+ const apple = appleScanForAccounts(appleScriptOnly, (acctName) => mailManager.searchMessagesWithDiagnostics(undefined, mailbox, acctName, limit, undefined, undefined, undefined, base));
434
+ // Merge + de-dup (IMAP wins), then sort OLDEST-first for thread order.
435
+ const mergedNewestFirst = mergeMessages(fan.rows, apple.rows, limit);
436
+ const orderedRows = mergedNewestFirst
437
+ .slice()
438
+ .reverse() // mergeMessages returns newest-first; threads read oldest-first
439
+ .sort((a, b) => (a.dateReceived ? new Date(a.dateReceived).getTime() : 0) -
440
+ (b.dateReceived ? new Date(b.dateReceived).getTime() : 0));
441
+ const partial = apple.diagnostics.partial || fan.accountsFailed.length > 0;
442
+ const coverage = partialCoverageBlock({
443
+ ...apple.diagnostics,
444
+ partial,
445
+ timedOutAccounts: [...apple.diagnostics.timedOutAccounts, ...fan.accountsFailed],
333
446
  });
447
+ const structured = {
448
+ subject: base,
449
+ messages: orderedRows,
450
+ count: orderedRows.length,
451
+ partial,
452
+ };
453
+ if (orderedRows.length === 0) {
454
+ return successResponse(`No messages found in thread "${base}".${coverage}`, structured);
455
+ }
456
+ return successResponse(`Thread "${base}" — ${orderedRows.length} message(s), oldest first:\n${formatMergedRows(orderedRows)}${coverage}`, structured);
334
457
  }
335
458
  const { messages, diagnostics } = mailManager.searchMessagesWithDiagnostics(undefined, mailbox, account, limit, undefined, undefined, undefined, base);
336
459
  // Oldest-first is the natural reading order for a conversation.
@@ -368,15 +491,27 @@ server.registerTool("list-messages", {
368
491
  },
369
492
  outputSchema: LIST_OUTPUT_SCHEMA,
370
493
  }, withErrorHandling(async ({ mailbox, account, limit = 50, offset = 0, from, unreadOnly }) => {
371
- // IMAP backend (issue #43): server-side listing when this account is
372
- // explicitly configured for IMAP; otherwise fall through to AppleScript.
373
- if (isImapAccount(account)) {
374
- const r = await imapListMessages({ mailbox, account, limit, offset, from, unreadOnly });
375
- return successResponse(r.text, {
376
- messages: r.messages,
377
- count: r.count,
378
- partial: r.partial,
379
- });
494
+ // IMAP backend: prefer direct IMAP whenever IMAP is configured (v2.6.0).
495
+ // - explicit IMAP account single-account IMAP listing (fast path);
496
+ // - no account + IMAP configured → MERGE: IMAP fans out over every
497
+ // configured account; AppleScript lists ONLY the accounts no IMAP config
498
+ // covers (partitioned — all-IMAP user runs ZERO AppleScript). NOTE:
499
+ // pagination via `offset` is applied PER-BACKEND before the merge, so
500
+ // deep offsets in a merged multi-account list are approximate — recommend
501
+ // scoping with an `account` for exact pagination.
502
+ if (shouldUseImap(account)) {
503
+ if (account !== undefined) {
504
+ const r = await imapListMessages({ mailbox, account, limit, offset, from, unreadOnly });
505
+ return successResponse(r.text, {
506
+ messages: r.messages,
507
+ count: r.count,
508
+ partial: r.partial,
509
+ });
510
+ }
511
+ const fan = await fanOutImapMessages({ mailbox, limit, offset, from, unreadOnly }, "list");
512
+ const { appleScriptOnly } = partitionAccountsForCounts(mailManager.listAccounts(), resolveImapConfigs());
513
+ const apple = appleScanForAccounts(appleScriptOnly, (acctName) => mailManager.listMessagesWithDiagnostics(mailbox, acctName, limit, from, offset));
514
+ return mergedMessageResponse(fan, apple, limit, "listed");
380
515
  }
381
516
  const { messages, diagnostics } = mailManager.listMessagesWithDiagnostics(mailbox, account, limit, from, offset);
382
517
  const coverageBlock = partialCoverageBlock(diagnostics);
@@ -504,8 +639,13 @@ server.registerTool("send-serial-email", {
504
639
  .passthrough())
505
640
  .optional(),
506
641
  },
507
- }, withErrorHandling(({ recipients, subject, body, account, delayMs }) => {
508
- const results = mailManager.sendSerialEmail(recipients, subject, body, account, delayMs);
642
+ }, withErrorHandling(async ({ recipients, subject, body, account, delayMs }) => {
643
+ // 2.5.0: prefer direct SMTP for mail-merge when configured (and not targeting
644
+ // a bare Mail.app account label); Mail.app fallback when not configured.
645
+ const smtpCfg = shouldUseSmtp(undefined, account) ? resolveSmtpOrFallback() : null;
646
+ const results = smtpCfg
647
+ ? await sendSerialViaSmtp(recipients, subject, body, smtpCfg, { delayMs })
648
+ : mailManager.sendSerialEmail(recipients, subject, body, account, delayMs);
509
649
  const successCount = results.filter((r) => r.success).length;
510
650
  const failCount = results.length - successCount;
511
651
  const details = results
@@ -557,6 +697,64 @@ server.registerTool("create-draft", {
557
697
  attachmentCount,
558
698
  });
559
699
  }, "Error creating draft"));
700
+ /** Resolve SMTP config, falling back (host/user set but no password) to Mail.app. */
701
+ function resolveSmtpOrFallback() {
702
+ try {
703
+ return resolveSmtpConfig();
704
+ }
705
+ catch {
706
+ return null;
707
+ }
708
+ }
709
+ /** Reply to a message over direct SMTP with RFC 5322 threading headers. */
710
+ async function sendReplyViaSmtp(id, body, replyAll) {
711
+ const cfg = resolveSmtpOrFallback();
712
+ if (!cfg)
713
+ return { sent: false, fallback: true };
714
+ const raw = mailManager.getRawSource(id);
715
+ if (!raw)
716
+ return { sent: false, fallback: true };
717
+ const original = parseOriginalHeaders(raw);
718
+ // Without a Message-ID we can't thread; let Mail.app's reply handle it.
719
+ if (!original.messageId || original.from.length === 0) {
720
+ return { sent: false, fallback: true };
721
+ }
722
+ const content = mailManager.getMessageContent(id);
723
+ const opts = buildReplyOptions({
724
+ original,
725
+ originalPlainText: content?.plainText ?? "",
726
+ body,
727
+ replyAll,
728
+ self: [cfg.from, cfg.user],
729
+ from: cfg.from,
730
+ });
731
+ const result = await sendViaSmtp(opts, cfg);
732
+ if (result.success)
733
+ return { sent: true };
734
+ return { sent: false, fallback: false, error: result.error ?? "unknown SMTP error" };
735
+ }
736
+ /** Forward a message over direct SMTP (clean MIME, new thread). */
737
+ async function sendForwardViaSmtp(id, to, body) {
738
+ const cfg = resolveSmtpOrFallback();
739
+ if (!cfg)
740
+ return { sent: false, fallback: true };
741
+ const raw = mailManager.getRawSource(id);
742
+ if (!raw)
743
+ return { sent: false, fallback: true };
744
+ const original = parseOriginalHeaders(raw);
745
+ const content = mailManager.getMessageContent(id);
746
+ const opts = buildForwardOptions({
747
+ original,
748
+ originalPlainText: content?.plainText ?? "",
749
+ to,
750
+ body,
751
+ from: cfg.from,
752
+ });
753
+ const result = await sendViaSmtp(opts, cfg);
754
+ if (result.success)
755
+ return { sent: true };
756
+ return { sent: false, fallback: false, error: result.error ?? "unknown SMTP error" };
757
+ }
560
758
  // --- reply-to-message ---
561
759
  server.registerTool("reply-to-message", {
562
760
  description: "Use when: replying to an existing message by id, preserving its threading headers. Set replyAll for all recipients; set send=false to save as a draft instead of sending.\nReturns: a confirmation that the reply was sent or saved as a draft.\nDo not use when: composing a brand-new message (use send-email / create-draft) or forwarding to new recipients (use forward-message).\nSafety: with the default send=true this SENDS real email immediately and cannot be unsent — require explicit user confirmation of the recipients and body, or pass send=false to let the user review.",
@@ -575,7 +773,19 @@ server.registerTool("reply-to-message", {
575
773
  sent: z.boolean().optional(),
576
774
  id: z.string().optional(),
577
775
  },
578
- }, withErrorHandling(({ id, body, replyAll, send }) => {
776
+ }, withErrorHandling(async ({ id, body, replyAll, send }) => {
777
+ // 2.5.0: prefer direct SMTP (clean, correctly threaded MIME) when configured
778
+ // and actually sending. Drafts (send=false) and the not-configured /
779
+ // unthreadable cases fall through to the Mail.app AppleScript path.
780
+ if (send && isSmtpConfigured()) {
781
+ const outcome = await sendReplyViaSmtp(id, body, replyAll);
782
+ if (outcome.sent) {
783
+ return successResponse("Reply sent", { ok: true, sent: true, id });
784
+ }
785
+ if (!outcome.fallback) {
786
+ return errorResponse(`Failed to reply to message "${id}" via SMTP: ${outcome.error}`);
787
+ }
788
+ }
579
789
  const success = mailManager.replyToMessage(id, body, replyAll, send);
580
790
  if (!success) {
581
791
  return errorResponse(`Failed to reply to message "${id}"`);
@@ -605,7 +815,22 @@ server.registerTool("forward-message", {
605
815
  recipients: z.array(z.string()).optional(),
606
816
  id: z.string().optional(),
607
817
  },
608
- }, withErrorHandling(({ id, to, body, send }) => {
818
+ }, withErrorHandling(async ({ id, to, body, send }) => {
819
+ // 2.5.0: prefer direct SMTP (clean MIME) when configured and actually sending.
820
+ if (send && isSmtpConfigured()) {
821
+ const outcome = await sendForwardViaSmtp(id, to, body);
822
+ if (outcome.sent) {
823
+ return successResponse(`Message forwarded to ${to.join(", ")}`, {
824
+ ok: true,
825
+ sent: true,
826
+ recipients: to,
827
+ id,
828
+ });
829
+ }
830
+ if (!outcome.fallback) {
831
+ return errorResponse(`Failed to forward message "${id}" via SMTP: ${outcome.error}`);
832
+ }
833
+ }
609
834
  const success = mailManager.forwardMessage(id, to, body, send);
610
835
  if (!success) {
611
836
  return errorResponse(`Failed to forward message "${id}"`);
@@ -967,21 +1192,65 @@ server.registerTool("list-mailboxes", {
967
1192
  },
968
1193
  }, withErrorHandling(async ({ account }) => {
969
1194
  // IMAP (I6): LIST + per-mailbox STATUS — sees the true server hierarchy and
970
- // authoritative counts; falls back to AppleScript for non-IMAP accounts.
971
- if (isImapAccount(account)) {
972
- const boxes = await imapListMailboxes({ account });
973
- const structured = {
974
- mailboxes: boxes.map((b) => ({
975
- name: b.path,
976
- unreadCount: b.unseen,
977
- messageCount: b.messages,
978
- })),
979
- count: boxes.length,
980
- };
981
- if (boxes.length === 0)
1195
+ // authoritative counts. Prefer-IMAP (v2.6.0):
1196
+ // - explicit IMAP account → that account's IMAP mailboxes;
1197
+ // - no account + IMAP configured → concatenate every configured IMAP
1198
+ // account's mailboxes (each name prefixed with its account label to
1199
+ // disambiguate identical mailbox names across accounts) PLUS the
1200
+ // AppleScript mailboxes of every account NOT covered by IMAP.
1201
+ if (shouldUseImap(account)) {
1202
+ if (account !== undefined) {
1203
+ const boxes = await imapListMailboxes({ account });
1204
+ const structured = {
1205
+ mailboxes: boxes.map((b) => ({
1206
+ name: b.path,
1207
+ unreadCount: b.unseen,
1208
+ messageCount: b.messages,
1209
+ })),
1210
+ count: boxes.length,
1211
+ };
1212
+ if (boxes.length === 0)
1213
+ return successResponse("No mailboxes found", structured);
1214
+ const list = boxes.map((b) => ` - ${b.path} (${b.unseen} unread)`).join("\n");
1215
+ return successResponse(`Found ${boxes.length} mailbox(es):\n${list}`, structured);
1216
+ }
1217
+ const configs = resolveImapConfigs();
1218
+ const rows = [];
1219
+ for (const config of configs) {
1220
+ try {
1221
+ const boxes = await imapListMailboxes({ config });
1222
+ for (const b of boxes) {
1223
+ // Prefix with the account label so "INBOX" from two accounts is
1224
+ // distinguishable; keep the raw path available via the structured row.
1225
+ rows.push({
1226
+ name: `${config.accountLabel}/${b.path}`,
1227
+ account: config.accountLabel,
1228
+ unreadCount: b.unseen,
1229
+ messageCount: b.messages,
1230
+ });
1231
+ }
1232
+ }
1233
+ catch (e) {
1234
+ console.error(`IMAP list-mailboxes failed for "${config.accountLabel}": ${String(e)}`);
1235
+ }
1236
+ }
1237
+ // AppleScript for the accounts IMAP doesn't cover (no double-listing).
1238
+ const { appleScriptOnly } = partitionAccountsForCounts(mailManager.listAccounts(), configs);
1239
+ for (const acct of appleScriptOnly) {
1240
+ for (const mb of mailManager.listMailboxes(acct.name)) {
1241
+ rows.push({
1242
+ name: `${acct.name}/${mb.name}`,
1243
+ account: acct.name,
1244
+ unreadCount: mb.unreadCount,
1245
+ messageCount: mb.messageCount,
1246
+ });
1247
+ }
1248
+ }
1249
+ const structured = { mailboxes: rows, count: rows.length };
1250
+ if (rows.length === 0)
982
1251
  return successResponse("No mailboxes found", structured);
983
- const list = boxes.map((b) => ` - ${b.path} (${b.unseen} unread)`).join("\n");
984
- return successResponse(`Found ${boxes.length} mailbox(es):\n${list}`, structured);
1252
+ const list = rows.map((b) => ` - ${b.name} (${b.unreadCount} unread)`).join("\n");
1253
+ return successResponse(`Found ${rows.length} mailbox(es):\n${list}`, structured);
985
1254
  }
986
1255
  const mailboxes = mailManager.listMailboxes(account);
987
1256
  const structured = { mailboxes, count: mailboxes.length };
@@ -1005,10 +1274,40 @@ server.registerTool("get-unread-count", {
1005
1274
  },
1006
1275
  }, withErrorHandling(async ({ mailbox, account }) => {
1007
1276
  // IMAP (I4): STATUS (UNSEEN) is authoritative and fast even on huge
1008
- // mailboxes; falls back to AppleScript for non-IMAP accounts.
1009
- const count = isImapAccount(account)
1010
- ? await imapUnreadCount(mailbox, { account })
1011
- : mailManager.getUnreadCount(mailbox, account);
1277
+ // mailboxes. Prefer-IMAP (v2.6.0). Counts are ACCOUNT-CENTRIC so each account
1278
+ // is counted exactly once even if the coverage heuristic mis-matches:
1279
+ // - explicit IMAP account → IMAP UNSEEN for that account;
1280
+ // - no account + IMAP configured → planCountSources assigns each account
1281
+ // ONE source (its matching IMAP config, else AppleScript) and counts any
1282
+ // config that matched no account once via IMAP — no double-counting;
1283
+ // - explicit non-IMAP account (or IMAP unconfigured) → AppleScript.
1284
+ let count;
1285
+ if (shouldUseImap(account)) {
1286
+ if (account !== undefined) {
1287
+ count = await imapUnreadCount(mailbox, { account });
1288
+ }
1289
+ else {
1290
+ const sources = planCountSources(mailManager.listAccounts(), resolveImapConfigs());
1291
+ let total = 0;
1292
+ for (const src of sources) {
1293
+ if (src.kind === "imap") {
1294
+ try {
1295
+ total += await imapUnreadCount(mailbox, { config: src.config });
1296
+ }
1297
+ catch (e) {
1298
+ console.error(`IMAP unread-count failed for "${src.label}": ${String(e)}`);
1299
+ }
1300
+ }
1301
+ else {
1302
+ total += mailManager.getUnreadCount(mailbox, src.account.name);
1303
+ }
1304
+ }
1305
+ count = total;
1306
+ }
1307
+ }
1308
+ else {
1309
+ count = mailManager.getUnreadCount(mailbox, account);
1310
+ }
1012
1311
  const location = mailbox ? ` in "${mailbox}"` : "";
1013
1312
  return successResponse(`${count} unread message(s)${location}`, {
1014
1313
  unread: count,
@@ -1036,9 +1335,9 @@ server.registerTool("create-mailbox", {
1036
1335
  return errorResponse(r.error || `Failed to create mailbox "${name}"`);
1037
1336
  return successResponse(r.info || `Mailbox "${name}" created`, { ok: true, name });
1038
1337
  }
1039
- const success = mailManager.createMailbox(name, account);
1338
+ const { success, error } = mailManager.createMailbox(name, account);
1040
1339
  if (!success) {
1041
- return errorResponse(`Failed to create mailbox "${name}"`);
1340
+ return errorResponse(error || `Failed to create mailbox "${name}"`);
1042
1341
  }
1043
1342
  return successResponse(`Mailbox "${name}" created`, { ok: true, name });
1044
1343
  }, "Error creating mailbox"));
@@ -1447,7 +1746,12 @@ server.registerTool("get-mail-stats", {
1447
1746
  }, withErrorHandling(async ({ account }) => {
1448
1747
  // IMAP (I3): for a named IMAP account, STATUS gives authoritative counts and
1449
1748
  // SEARCH SINCE gives recent activity — fast even on huge mailboxes.
1450
- if (account && isImapAccount(account)) {
1749
+ // - explicit IMAP account IMAP STATUS for that account (today's path);
1750
+ // - explicit non-IMAP account → AppleScript all-accounts stats below;
1751
+ // - no account + IMAP configured → MERGE: sum IMAP STATUS over every
1752
+ // configured account + AppleScript per-account stats for the accounts
1753
+ // IMAP does NOT cover (partitioned so no account is double-counted).
1754
+ if (account !== undefined && isImapAccount(account)) {
1451
1755
  const s = await imapMailStats({ account });
1452
1756
  const lines = [
1453
1757
  `📊 Mail Statistics — ${account} (IMAP)`,
@@ -1462,6 +1766,77 @@ server.registerTool("get-mail-stats", {
1462
1766
  ];
1463
1767
  return successResponse(lines.join("\n"), { account, ...s });
1464
1768
  }
1769
+ if (account === undefined && shouldUseImap(account)) {
1770
+ let totalMessages = 0;
1771
+ let totalUnread = 0;
1772
+ const recent = { last24h: 0, last7d: 0, last30d: 0 };
1773
+ const perAccount = [];
1774
+ // ACCOUNT-CENTRIC: each account counted via exactly ONE source so a
1775
+ // heuristic mis-match can't double-count. IMAP sources use STATUS; the
1776
+ // AppleScript sources are built from listMailboxes (same source
1777
+ // getMailStats uses) — never getMailStats(), which is all-accounts and
1778
+ // would re-count the IMAP-covered ones. Recently-received from AppleScript
1779
+ // is INBOX-wide (not per-account), so it's omitted for AppleScript sources;
1780
+ // IMAP's per-account recent IS included.
1781
+ const sources = planCountSources(mailManager.listAccounts(), resolveImapConfigs());
1782
+ for (const src of sources) {
1783
+ if (src.kind === "imap") {
1784
+ try {
1785
+ const s = await imapMailStats({ config: src.config });
1786
+ totalMessages += s.totalMessages;
1787
+ totalUnread += s.totalUnread;
1788
+ recent.last24h += s.recent.last24h;
1789
+ recent.last7d += s.recent.last7d;
1790
+ recent.last30d += s.recent.last30d;
1791
+ perAccount.push({
1792
+ name: src.label,
1793
+ totalMessages: s.totalMessages,
1794
+ unreadMessages: s.totalUnread,
1795
+ backend: "imap",
1796
+ });
1797
+ }
1798
+ catch (e) {
1799
+ console.error(`IMAP mail-stats failed for "${src.label}": ${String(e)}`);
1800
+ }
1801
+ }
1802
+ else {
1803
+ let m = 0;
1804
+ let u = 0;
1805
+ for (const mb of mailManager.listMailboxes(src.account.name)) {
1806
+ m += mb.messageCount;
1807
+ u += mb.unreadCount;
1808
+ }
1809
+ totalMessages += m;
1810
+ totalUnread += u;
1811
+ perAccount.push({
1812
+ name: src.label,
1813
+ totalMessages: m,
1814
+ unreadMessages: u,
1815
+ backend: "applescript",
1816
+ });
1817
+ }
1818
+ }
1819
+ const lines = [
1820
+ `📊 Mail Statistics (merged: IMAP + AppleScript)`,
1821
+ `══════════════════`,
1822
+ `Total messages: ${totalMessages}`,
1823
+ `Unread messages: ${totalUnread}`,
1824
+ ``,
1825
+ `📥 Recently Received (IMAP INBOXes):`,
1826
+ ` Last 24 hours: ${recent.last24h}`,
1827
+ ` Last 7 days: ${recent.last7d}`,
1828
+ ` Last 30 days: ${recent.last30d}`,
1829
+ ``,
1830
+ `📁 By Account:`,
1831
+ ...perAccount.map((a) => ` ${a.name}: ${a.totalMessages} messages (${a.unreadMessages} unread) [${a.backend}]`),
1832
+ ];
1833
+ return successResponse(lines.join("\n"), {
1834
+ totalMessages,
1835
+ totalUnread,
1836
+ accounts: perAccount,
1837
+ recent,
1838
+ });
1839
+ }
1465
1840
  const stats = mailManager.getMailStats();
1466
1841
  const lines = [];
1467
1842
  lines.push(`📊 Mail Statistics`);
@@ -53,7 +53,7 @@ export declare function isPathWithinAllowedRoots(resolvedPath: string): boolean;
53
53
  *
54
54
  * Exported for unit testing.
55
55
  */
56
- export declare function describeMailboxOpError(op: "delete" | "rename", raw: string): string;
56
+ export declare function describeMailboxOpError(op: "create" | "delete" | "rename", raw: string): string;
57
57
  /** Env var to pin the default account (matched by account name or email). */
58
58
  export declare const DEFAULT_ACCOUNT_ENV = "APPLE_MAIL_MCP_DEFAULT_ACCOUNT";
59
59
  /**
@@ -153,6 +153,38 @@ export declare class AppleMailManager {
153
153
  * mailbox structure (create/delete/rename mailbox).
154
154
  */
155
155
  private invalidateCache;
156
+ /**
157
+ * Reads the live `enabled` flag for an account directly from Mail (bypassing
158
+ * the 60 s account cache) so a guard reflects an account that was enabled or
159
+ * disabled out-of-band. Returns true/false when known, or null when the probe
160
+ * is inconclusive — account not found, or the probe itself failed. Callers
161
+ * treat null as "can't tell, don't block".
162
+ */
163
+ private isAccountEnabled;
164
+ /**
165
+ * Guard for AppleScript-backed structural operations (create / delete / rename
166
+ * mailbox). When the target account is disabled in Mail, Mail holds no live
167
+ * server session for it, so the operation fails inside Mail with an opaque
168
+ * AppleEvent -10000 — and a multi-step op like rename can leave half-built
169
+ * state behind (an orphaned destination mailbox). Detect the disabled account
170
+ * up front and refuse with an actionable message instead of attempting the
171
+ * doomed op.
172
+ *
173
+ * Returns an error string when the account is known-disabled, else null —
174
+ * including when the state can't be determined. We fail open: an inconclusive
175
+ * probe never blocks an otherwise-valid operation.
176
+ *
177
+ * Applies only to the AppleScript backend. Direct-IMAP accounts talk to the
178
+ * server independent of Mail's enabled toggle and are routed before reaching
179
+ * the manager.
180
+ */
181
+ private disabledAccountGuard;
182
+ /**
183
+ * Best-effort rollback for a failed rename: delete a just-created destination
184
+ * mailbox, but ONLY if it is empty, so any messages that did move are never
185
+ * destroyed. Returns true if the empty orphan was removed.
186
+ */
187
+ private deleteMailboxIfEmpty;
156
188
  /**
157
189
  * Resolves the account to use for an operation when the caller omits one.
158
190
  *
@@ -479,7 +511,10 @@ export declare class AppleMailManager {
479
511
  /**
480
512
  * Create a new mailbox.
481
513
  */
482
- createMailbox(name: string, account?: string): boolean;
514
+ createMailbox(name: string, account?: string): {
515
+ success: boolean;
516
+ error?: string;
517
+ };
483
518
  /**
484
519
  * Delete a mailbox.
485
520
  */