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/README.md +35 -4
- package/build/index.js +430 -55
- package/build/services/appleMailManager.d.ts +37 -2
- package/build/services/appleMailManager.d.ts.map +1 -1
- package/build/services/appleMailManager.js +106 -10
- package/build/services/imapClient.d.ts +14 -0
- package/build/services/imapClient.d.ts.map +1 -1
- package/build/services/imapClient.js +22 -1
- package/build/services/imapMultiAccount.d.ts +124 -0
- package/build/services/imapMultiAccount.d.ts.map +1 -0
- package/build/services/imapMultiAccount.js +253 -0
- package/build/services/replyForward.d.ts +87 -0
- package/build/services/replyForward.d.ts.map +1 -0
- package/build/services/replyForward.js +150 -0
- package/build/services/smtpMailer.d.ts +43 -0
- package/build/services/smtpMailer.d.ts.map +1 -1
- package/build/services/smtpMailer.js +55 -0
- package/package.json +1 -1
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 {
|
|
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
|
|
193
|
-
//
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
|
372
|
-
//
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
|
|
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
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
})
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
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 =
|
|
984
|
-
return successResponse(`Found ${
|
|
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
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
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
|
-
|
|
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):
|
|
514
|
+
createMailbox(name: string, account?: string): {
|
|
515
|
+
success: boolean;
|
|
516
|
+
error?: string;
|
|
517
|
+
};
|
|
483
518
|
/**
|
|
484
519
|
* Delete a mailbox.
|
|
485
520
|
*/
|