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.
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Multi-account read merge (v2.6.0 — prefer-IMAP reads).
3
+ *
4
+ * v2.6.0 flips the read tools to PREFER direct IMAP whenever IMAP is configured
5
+ * (see `shouldUseImap` in imapClient.ts). When a read is issued with NO explicit
6
+ * account, results must still cover EVERY account the user has — so we:
7
+ *
8
+ * 1. fan the IMAP query out over every configured IMAP account
9
+ * (`resolveImapConfigs()`), and
10
+ * 2. ALSO run the existing AppleScript all-accounts path,
11
+ *
12
+ * then merge the two, de-duplicating any message that appears in both backends
13
+ * (e.g. a Gmail account that is both IMAP-configured AND visible to Mail.app's
14
+ * AppleScript scan) and preferring the IMAP copy (it carries the round-trippable
15
+ * `imap:` id that the mutation tools need).
16
+ *
17
+ * This module holds the backend-agnostic merge/dedup/sort + the per-account IMAP
18
+ * fan-out so the three message-list sites (search-messages, get-thread,
19
+ * list-messages) don't each re-implement it.
20
+ *
21
+ * @module services/imapMultiAccount
22
+ */
23
+ import { imapSearchMessages, imapListMessages, resolveImapConfigs, } from "../services/imapClient.js";
24
+ /**
25
+ * Normalize a Message-ID for comparison: strip surrounding angle brackets and
26
+ * whitespace, lowercase. Returns undefined when the row has no usable id.
27
+ */
28
+ function normalizeMessageId(row) {
29
+ const raw = typeof row.messageId === "string" ? row.messageId.trim() : "";
30
+ if (!raw)
31
+ return undefined;
32
+ const inner = raw.replace(/^<+|>+$/g, "").trim();
33
+ return inner ? inner.toLowerCase() : undefined;
34
+ }
35
+ /**
36
+ * Normalize a subject the same way the thread tool does for grouping: drop
37
+ * leading Re:/Fwd: prefixes, collapse whitespace, lowercase. Kept local (and
38
+ * deliberately simple) so the merge has no dependency cycle with the thread
39
+ * module; exactness isn't required — this only feeds the fallback dedup key.
40
+ */
41
+ function normalizeSubjectForKey(subject) {
42
+ const s = typeof subject === "string" ? subject : "";
43
+ return s
44
+ .replace(/^(\s*(re|fwd|fw|aw|sv|antw)\s*:\s*)+/i, "")
45
+ .replace(/\s+/g, " ")
46
+ .trim()
47
+ .toLowerCase();
48
+ }
49
+ /** Epoch ms of the row's dateReceived (ISO string or Date), or 0 when absent. */
50
+ function dateEpoch(row) {
51
+ const d = row.dateReceived;
52
+ if (d instanceof Date)
53
+ return d.getTime();
54
+ if (typeof d === "string" && d) {
55
+ const t = new Date(d).getTime();
56
+ return Number.isNaN(t) ? 0 : t;
57
+ }
58
+ return 0;
59
+ }
60
+ /**
61
+ * Cross-backend dedup key for a message row.
62
+ *
63
+ * - PREFER the normalized Message-ID when present (`mid:<id>`). It's the only
64
+ * globally-unique identity, so two IMAP accounts that both hold the very
65
+ * same message (rare, but possible with multi-delivery) collapse to one.
66
+ * - FALL BACK to `normalizedSubject|sender|dateReceivedEpoch` when no
67
+ * Message-ID is available. The AppleScript backend never exposes a
68
+ * Message-ID, so this composite is what actually dedups the common case: a
69
+ * Gmail account surfaced by BOTH the IMAP fan-out and the AppleScript
70
+ * all-accounts scan. The IMAP and AppleScript copies of one message share
71
+ * the same subject, sender, and received timestamp, so they collide here.
72
+ *
73
+ * Limitation (note for live testing): the composite key assumes the two backends
74
+ * report the SAME received timestamp to the second. If Mail.app and the IMAP
75
+ * server disagree on `dateReceived` (timezone/rounding), a message could escape
76
+ * dedup and appear twice. Message-ID dedup (IMAP-vs-IMAP) is exact; the
77
+ * composite is best-effort.
78
+ */
79
+ export function dedupKey(row) {
80
+ const mid = normalizeMessageId(row);
81
+ if (mid)
82
+ return `mid:${mid}`;
83
+ const subject = normalizeSubjectForKey(row.subject);
84
+ const sender = (typeof row.sender === "string" ? row.sender : "").trim().toLowerCase();
85
+ return `k:${subject}|${sender}|${dateEpoch(row)}`;
86
+ }
87
+ /**
88
+ * Merge IMAP rows with AppleScript rows: concatenate, de-dup by {@link dedupKey}
89
+ * preferring the IMAP copy (IMAP rows are passed first and win on collision),
90
+ * sort newest-first by dateReceived, then apply `limit`.
91
+ */
92
+ export function mergeMessages(imapRows, appleRows, limit) {
93
+ const byKey = new Map();
94
+ // IMAP first so its copy wins; AppleScript only fills keys IMAP didn't supply.
95
+ for (const r of imapRows) {
96
+ const k = dedupKey(r);
97
+ if (!byKey.has(k))
98
+ byKey.set(k, r);
99
+ }
100
+ for (const r of appleRows) {
101
+ const k = dedupKey(r);
102
+ if (!byKey.has(k))
103
+ byKey.set(k, r);
104
+ }
105
+ const merged = [...byKey.values()];
106
+ merged.sort((a, b) => dateEpoch(b) - dateEpoch(a)); // newest first
107
+ return limit >= 0 ? merged.slice(0, limit) : merged;
108
+ }
109
+ /** Gmail/Workspace IMAP host? Its `[Gmail]/All Mail` virtual mailbox is Gmail-only. */
110
+ export function isGmailHost(host) {
111
+ return /(^|\.)gmail\.com$/i.test(host.trim());
112
+ }
113
+ /**
114
+ * Fan an IMAP message query out over EVERY configured IMAP account and return
115
+ * the concatenated structured rows (not yet merged with AppleScript, not yet
116
+ * limited — the caller merges + limits). `kind` picks the underlying query so
117
+ * the mailbox-default and unreadOnly semantics match the single-account path.
118
+ *
119
+ * Per-account failures are swallowed (logged) so one unreachable account doesn't
120
+ * sink the whole read; the returned `accountsQueried`/`accountsFailed` let the
121
+ * caller surface partial-coverage diagnostics.
122
+ */
123
+ export async function fanOutImapMessages(args, kind, deps = {}, configs = resolveImapConfigs()) {
124
+ const rows = [];
125
+ const accountsQueried = [];
126
+ const accountsFailed = [];
127
+ for (const config of configs) {
128
+ // Default-mailbox resolution is PER-ACCOUNT: the single-account search default
129
+ // ("[Gmail]/All Mail") is Gmail-only, so fanning it out to a non-Gmail account
130
+ // (e.g. iCloud) makes that SELECT fail and the account silently drops from the
131
+ // merged results. When the caller pinned no mailbox, give Gmail hosts their
132
+ // All-Mail default (undefined → resolved downstream) and every other host a
133
+ // universal "INBOX". (limit is applied AFTER the cross-account merge so the
134
+ // global newest-N is correct even when one account dominates.)
135
+ const mailbox = args.mailbox ?? (isGmailHost(config.host) ? undefined : "INBOX");
136
+ const perAccountArgs = { ...args, account: undefined, mailbox };
137
+ try {
138
+ const res = kind === "search"
139
+ ? await imapSearchMessages(perAccountArgs, { ...deps, config })
140
+ : await imapListMessages(perAccountArgs, { ...deps, config });
141
+ rows.push(...res.messages);
142
+ accountsQueried.push(config.accountLabel);
143
+ }
144
+ catch (e) {
145
+ accountsFailed.push(config.accountLabel);
146
+ console.error(`IMAP fan-out failed for account "${config.accountLabel}": ${String(e)}`);
147
+ }
148
+ }
149
+ return { rows, accountsQueried, accountsFailed };
150
+ }
151
+ // ---------------------------------------------------------------------------
152
+ // Account coverage + count partitioning (reads, get-unread-count, get-mail-stats)
153
+ //
154
+ // Coverage is decided per (config, Mail-account) PAIR by configMatchesAccount.
155
+ // - Message-list reads use partitionAccountsForCounts to run AppleScript ONLY
156
+ // for accounts no IMAP config covers (IMAP fans out over the configs), so an
157
+ // all-IMAP user runs zero AppleScript and never depends on composite dedup.
158
+ // - Count tools use planCountSources, which assigns each account EXACTLY ONE
159
+ // source (its matching config, else AppleScript) and adds any config that
160
+ // matched no account once — so even a heuristic MISS can't double-count.
161
+ //
162
+ // Matching is case-insensitive on the config's accountLabel/user vs. the Mail
163
+ // account's name/email. accountLabel defaults to the login address and users
164
+ // typically set it to the Mail.app account NAME, so checking both against both
165
+ // catches the common setups (label=accountName, label=email, login=email).
166
+ // ---------------------------------------------------------------------------
167
+ /**
168
+ * Per-pair matcher: does this IMAP config correspond to this Mail.app account?
169
+ * Compared case-insensitively, the config's `accountLabel` AND `user` against the
170
+ * Mail account's `name` AND `email`. An empty email can't match (avoids
171
+ * `"" === ""` false positives). This is the single source of truth for coverage;
172
+ * everything else delegates here.
173
+ */
174
+ export function configMatchesAccount(config, account) {
175
+ const name = account.name.trim().toLowerCase();
176
+ const email = (account.email ?? "").trim().toLowerCase();
177
+ const label = config.accountLabel.trim().toLowerCase();
178
+ const user = config.user.trim().toLowerCase();
179
+ return (label === name || (!!email && label === email) || user === name || (!!email && user === email));
180
+ }
181
+ /** True when ANY config matches this Mail account (delegates to the per-pair matcher). */
182
+ export function isAccountCoveredByImap(account, configs) {
183
+ return configs.some((c) => configMatchesAccount(c, account));
184
+ }
185
+ /**
186
+ * Split Mail.app accounts into those covered by IMAP (counted via IMAP) and the
187
+ * rest (counted via AppleScript), so reads/counts skip the AppleScript scan for
188
+ * IMAP-covered accounts.
189
+ */
190
+ export function partitionAccountsForCounts(accounts, configs) {
191
+ const imapCovered = [];
192
+ const appleScriptOnly = [];
193
+ for (const a of accounts) {
194
+ if (isAccountCoveredByImap(a, configs))
195
+ imapCovered.push(a);
196
+ else
197
+ appleScriptOnly.push(a);
198
+ }
199
+ return { imapCovered, appleScriptOnly };
200
+ }
201
+ /**
202
+ * Plan the count sources so NO account is double-counted even when the coverage
203
+ * heuristic mis-matches (the failure mode the naive `Σimap(all configs) +
204
+ * Σapple(uncovered)` had: a config that fails to match its Mail account lands in
205
+ * BOTH sums).
206
+ *
207
+ * Account-centric: walk the Mail.app accounts; each is counted via its FIRST
208
+ * matching config (IMAP) or, if none matches, via AppleScript. Then any IMAP
209
+ * config that matched NO Mail account (configured-but-not-present-in-Mail.app) is
210
+ * added once as an IMAP source. A config is consumed by at most one Mail account,
211
+ * so two Mail accounts can't both claim the same config and inflate the total.
212
+ */
213
+ export function planCountSources(accounts, configs) {
214
+ const sources = [];
215
+ const usedConfigs = new Set();
216
+ for (const account of accounts) {
217
+ const match = configs.find((c) => !usedConfigs.has(c) && configMatchesAccount(c, account));
218
+ if (match) {
219
+ usedConfigs.add(match);
220
+ sources.push({ kind: "imap", config: match, label: account.name });
221
+ }
222
+ else {
223
+ sources.push({ kind: "applescript", account, label: account.name });
224
+ }
225
+ }
226
+ // IMAP accounts that exist in config but not in Mail.app — count them once.
227
+ for (const config of configs) {
228
+ if (!usedConfigs.has(config)) {
229
+ sources.push({ kind: "imap", config, label: config.accountLabel });
230
+ }
231
+ }
232
+ return sources;
233
+ }
234
+ /**
235
+ * Render the structured message rows into the human text block the read tools
236
+ * emit (` - ID: … | date | subject (from: sender) [read|unread]`). Shared so the
237
+ * merged path matches the single-backend formatting. `showReadState` mirrors
238
+ * search/list (which show [read]) vs. plain list rows.
239
+ */
240
+ export function formatMergedRows(rows, showReadState = true) {
241
+ return rows
242
+ .map((m) => {
243
+ const date = (() => {
244
+ const e = dateEpoch(m);
245
+ return e ? new Date(e).toLocaleDateString() : "";
246
+ })();
247
+ const subject = typeof m.subject === "string" ? m.subject : "(no subject)";
248
+ const sender = typeof m.sender === "string" ? m.sender : "(unknown)";
249
+ const state = showReadState ? ` [${m.isRead ? "read" : "unread"}]` : "";
250
+ return ` - ID: ${String(m.id)} | ${date} | ${subject} (from: ${sender})${state}`;
251
+ })
252
+ .join("\n");
253
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Pure helpers that turn an existing message (its raw RFC 5322 source plus the
3
+ * decoded plain-text body) into {@link SmtpSendOptions} for a **threaded reply**
4
+ * or a **forward** sent over SMTP.
5
+ *
6
+ * This is the 2.5.0 "prefer-direct" path: when SMTP is configured we send
7
+ * replies/forwards ourselves with correct `In-Reply-To`/`References` headers and
8
+ * a clean MIME body, instead of driving Mail.app's `reply`/`forward` AppleScript
9
+ * commands (which thread correctly but wrap the injected body in a `blockquote`
10
+ * on macOS 15+). Kept separate from {@link sendViaSmtp} so the addressing,
11
+ * subject-prefix, and quoting rules are unit-testable without a live SMTP server.
12
+ */
13
+ import type { SmtpSendOptions } from "../services/smtpMailer.js";
14
+ /** The subset of an original message's headers we need to reply/forward. */
15
+ export interface OriginalHeaders {
16
+ /** Raw `Message-ID` including angle brackets, e.g. `<abc@host>`. */
17
+ messageId?: string;
18
+ /** Thread chain: every `<id>` from `References` + `In-Reply-To`, in order. */
19
+ references: string[];
20
+ /** `From` address(es), bare (no display name). */
21
+ from: string[];
22
+ /** `Reply-To` address(es), bare. Preferred over {@link from} for replies. */
23
+ replyTo: string[];
24
+ /** `To` address(es), bare. */
25
+ to: string[];
26
+ /** `Cc` address(es), bare. */
27
+ cc: string[];
28
+ /** `Subject`, unfolded and trimmed (no `Re:`/`Fwd:` normalization). */
29
+ subject: string;
30
+ /** `Date` header verbatim, for the reply attribution line. */
31
+ date?: string;
32
+ }
33
+ /**
34
+ * Pull bare email addresses out of a header value such as
35
+ * `"Alice <a@x.com>, bob@y.com"` → `["a@x.com", "bob@y.com"]`. Splits on commas
36
+ * (address lists are comma-separated) and unwraps `<...>` when present. Anything
37
+ * without an `@` is dropped, so group syntax / junk is ignored.
38
+ */
39
+ export declare function extractAddresses(headerValue: string): string[];
40
+ /**
41
+ * Parse the header block (everything before the first blank line) of a raw
42
+ * message. Continuation lines (folded headers, leading WSP) are unfolded, and
43
+ * header names are matched case-insensitively. Only the headers in
44
+ * {@link OriginalHeaders} are extracted.
45
+ */
46
+ export declare function parseOriginalHeaders(raw: string): OriginalHeaders;
47
+ /**
48
+ * Ensure `subject` carries `prefix` exactly once. Existing `Re:`/`Fwd:`/`Fw:`
49
+ * prefixes (any case) are treated as already-present so we never stack them.
50
+ */
51
+ export declare function withSubjectPrefix(subject: string, prefix: "Re:" | "Fwd:"): string;
52
+ /** Prefix every line of the original body with `> ` for the quoted reply block. */
53
+ export declare function quoteBody(plainText: string): string;
54
+ /**
55
+ * Build {@link SmtpSendOptions} for a threaded reply.
56
+ *
57
+ * - recipients: `Reply-To` if the original had one, else `From`; with
58
+ * `replyAll`, the original `To`+`Cc` (minus our own addresses and the primary
59
+ * recipients) become `Cc`.
60
+ * - subject: `Re: ` prepended unless already present.
61
+ * - threading: `In-Reply-To` = original `Message-ID`; `References` = original
62
+ * chain + that `Message-ID`.
63
+ * - body: the new text, then an attribution line and the quoted original.
64
+ */
65
+ export declare function buildReplyOptions(args: {
66
+ original: OriginalHeaders;
67
+ originalPlainText: string;
68
+ body: string;
69
+ replyAll: boolean;
70
+ /** Our own addresses, excluded from a reply-all recipient set. */
71
+ self: string[];
72
+ /** From override (defaults to the SMTP identity at send time when omitted). */
73
+ from?: string;
74
+ }): SmtpSendOptions;
75
+ /**
76
+ * Build {@link SmtpSendOptions} for a forward to new recipients. A forward
77
+ * starts a new thread, so no `In-Reply-To`/`References` are set — the win over
78
+ * the AppleScript path is a clean, un-wrapped MIME body.
79
+ */
80
+ export declare function buildForwardOptions(args: {
81
+ original: OriginalHeaders;
82
+ originalPlainText: string;
83
+ to: string[];
84
+ body?: string;
85
+ from?: string;
86
+ }): SmtpSendOptions;
87
+ //# sourceMappingURL=replyForward.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"replyForward.d.ts","sourceRoot":"","sources":["../../src/services/replyForward.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,0BAA0B,CAAC;AAEhE,4EAA4E;AAC5E,MAAM,WAAW,eAAe;IAC9B,oEAAoE;IACpE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,8EAA8E;IAC9E,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,kDAAkD;IAClD,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,6EAA6E;IAC7E,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,8BAA8B;IAC9B,EAAE,EAAE,MAAM,EAAE,CAAC;IACb,8BAA8B;IAC9B,EAAE,EAAE,MAAM,EAAE,CAAC;IACb,uEAAuE;IACvE,OAAO,EAAE,MAAM,CAAC;IAChB,8DAA8D;IAC9D,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,EAAE,CAS9D;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,MAAM,GAAG,eAAe,CAkCjE;AAgBD;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,GAAG,MAAM,GAAG,MAAM,CAIjF;AAED,mFAAmF;AACnF,wBAAgB,SAAS,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAMnD;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE;IACtC,QAAQ,EAAE,eAAe,CAAC;IAC1B,iBAAiB,EAAE,MAAM,CAAC;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,OAAO,CAAC;IAClB,kEAAkE;IAClE,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,+EAA+E;IAC/E,IAAI,CAAC,EAAE,MAAM,CAAC;CACf,GAAG,eAAe,CAmClB;AAQD;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE;IACxC,QAAQ,EAAE,eAAe,CAAC;IAC1B,iBAAiB,EAAE,MAAM,CAAC;IAC1B,EAAE,EAAE,MAAM,EAAE,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;CACf,GAAG,eAAe,CAsBlB"}
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Pull bare email addresses out of a header value such as
3
+ * `"Alice <a@x.com>, bob@y.com"` → `["a@x.com", "bob@y.com"]`. Splits on commas
4
+ * (address lists are comma-separated) and unwraps `<...>` when present. Anything
5
+ * without an `@` is dropped, so group syntax / junk is ignored.
6
+ */
7
+ export function extractAddresses(headerValue) {
8
+ if (!headerValue.trim())
9
+ return [];
10
+ return headerValue
11
+ .split(",")
12
+ .map((part) => {
13
+ const angle = part.match(/<([^>]+)>/);
14
+ return (angle ? angle[1] : part).trim();
15
+ })
16
+ .filter((addr) => addr.includes("@"));
17
+ }
18
+ /**
19
+ * Parse the header block (everything before the first blank line) of a raw
20
+ * message. Continuation lines (folded headers, leading WSP) are unfolded, and
21
+ * header names are matched case-insensitively. Only the headers in
22
+ * {@link OriginalHeaders} are extracted.
23
+ */
24
+ export function parseOriginalHeaders(raw) {
25
+ const headerBlock = raw.split(/\r?\n\r?\n/)[0] ?? "";
26
+ // Unfold: any line starting with a space/tab continues the previous header.
27
+ const lines = [];
28
+ for (const line of headerBlock.split(/\r?\n/)) {
29
+ if (/^[ \t]/.test(line) && lines.length > 0) {
30
+ lines[lines.length - 1] += " " + line.trim();
31
+ }
32
+ else {
33
+ lines.push(line);
34
+ }
35
+ }
36
+ const get = (name) => {
37
+ const prefix = `${name.toLowerCase()}:`;
38
+ const found = lines.find((l) => l.toLowerCase().startsWith(prefix));
39
+ return found ? found.slice(found.indexOf(":") + 1).trim() : undefined;
40
+ };
41
+ const rawMessageId = get("Message-ID");
42
+ const messageId = rawMessageId?.match(/<[^>]+>/)?.[0] ?? rawMessageId ?? undefined;
43
+ const references = `${get("References") ?? ""} ${get("In-Reply-To") ?? ""}`.match(/<[^>]+>/g) ?? [];
44
+ return {
45
+ messageId,
46
+ references,
47
+ from: extractAddresses(get("From") ?? ""),
48
+ replyTo: extractAddresses(get("Reply-To") ?? ""),
49
+ to: extractAddresses(get("To") ?? ""),
50
+ cc: extractAddresses(get("Cc") ?? ""),
51
+ subject: get("Subject") ?? "",
52
+ date: get("Date"),
53
+ };
54
+ }
55
+ /** Case-insensitive de-dupe that preserves first-seen order and casing. */
56
+ function dedupe(addrs) {
57
+ const seen = new Set();
58
+ const out = [];
59
+ for (const a of addrs) {
60
+ const k = a.toLowerCase();
61
+ if (!seen.has(k)) {
62
+ seen.add(k);
63
+ out.push(a);
64
+ }
65
+ }
66
+ return out;
67
+ }
68
+ /**
69
+ * Ensure `subject` carries `prefix` exactly once. Existing `Re:`/`Fwd:`/`Fw:`
70
+ * prefixes (any case) are treated as already-present so we never stack them.
71
+ */
72
+ export function withSubjectPrefix(subject, prefix) {
73
+ const s = subject.trim();
74
+ const present = prefix === "Re:" ? /^re:/i : /^(fwd?|fw):/i;
75
+ return present.test(s) ? s : `${prefix} ${s}`;
76
+ }
77
+ /** Prefix every line of the original body with `> ` for the quoted reply block. */
78
+ export function quoteBody(plainText) {
79
+ return plainText
80
+ .replace(/\s+$/, "")
81
+ .split(/\r?\n/)
82
+ .map((l) => (l ? `> ${l}` : ">"))
83
+ .join("\n");
84
+ }
85
+ /**
86
+ * Build {@link SmtpSendOptions} for a threaded reply.
87
+ *
88
+ * - recipients: `Reply-To` if the original had one, else `From`; with
89
+ * `replyAll`, the original `To`+`Cc` (minus our own addresses and the primary
90
+ * recipients) become `Cc`.
91
+ * - subject: `Re: ` prepended unless already present.
92
+ * - threading: `In-Reply-To` = original `Message-ID`; `References` = original
93
+ * chain + that `Message-ID`.
94
+ * - body: the new text, then an attribution line and the quoted original.
95
+ */
96
+ export function buildReplyOptions(args) {
97
+ const { original, originalPlainText, body, replyAll, self, from } = args;
98
+ const selfSet = new Set(self.filter(Boolean).map((s) => s.toLowerCase()));
99
+ const to = dedupe((original.replyTo.length ? original.replyTo : original.from).filter(Boolean));
100
+ const toSet = new Set(to.map((t) => t.toLowerCase()));
101
+ let cc;
102
+ if (replyAll) {
103
+ const extra = dedupe([...original.to, ...original.cc].filter((a) => !selfSet.has(a.toLowerCase()) && !toSet.has(a.toLowerCase())));
104
+ cc = extra.length ? extra : undefined;
105
+ }
106
+ const attribution = buildAttribution(original);
107
+ const quoted = originalPlainText.trim()
108
+ ? `\n\n${attribution}${quoteBody(originalPlainText)}`
109
+ : "";
110
+ const references = dedupe(original.messageId ? [...original.references, original.messageId] : original.references);
111
+ return {
112
+ to,
113
+ cc,
114
+ subject: withSubjectPrefix(original.subject, "Re:"),
115
+ body: `${body}${quoted}`,
116
+ inReplyTo: original.messageId,
117
+ references: references.length ? references : undefined,
118
+ from,
119
+ };
120
+ }
121
+ /** "On <date>, <sender> wrote:\n" — omits the date clause when unknown. */
122
+ function buildAttribution(original) {
123
+ const who = original.from[0] ?? original.replyTo[0] ?? "the sender";
124
+ return original.date ? `On ${original.date}, ${who} wrote:\n` : `${who} wrote:\n`;
125
+ }
126
+ /**
127
+ * Build {@link SmtpSendOptions} for a forward to new recipients. A forward
128
+ * starts a new thread, so no `In-Reply-To`/`References` are set — the win over
129
+ * the AppleScript path is a clean, un-wrapped MIME body.
130
+ */
131
+ export function buildForwardOptions(args) {
132
+ const { original, originalPlainText, to, body, from } = args;
133
+ const headerBlock = [
134
+ "---------- Forwarded message ----------",
135
+ original.from.length ? `From: ${original.from.join(", ")}` : "",
136
+ original.date ? `Date: ${original.date}` : "",
137
+ `Subject: ${original.subject}`,
138
+ original.to.length ? `To: ${original.to.join(", ")}` : "",
139
+ original.cc.length ? `Cc: ${original.cc.join(", ")}` : "",
140
+ ]
141
+ .filter(Boolean)
142
+ .join("\n");
143
+ const prefix = body?.trim() ? `${body}\n\n` : "";
144
+ return {
145
+ to: dedupe(to),
146
+ subject: withSubjectPrefix(original.subject, "Fwd:"),
147
+ body: `${prefix}${headerBlock}\n\n${originalPlainText}`,
148
+ from,
149
+ };
150
+ }
@@ -36,6 +36,17 @@ export interface SmtpSendOptions {
36
36
  * plain-text clients still get a clean fallback.
37
37
  */
38
38
  htmlBody?: string;
39
+ /**
40
+ * RFC 5322 threading (2.5.0): the message this is replying to — emitted as the
41
+ * `In-Reply-To` header so SMTP replies/forwards thread correctly in Gmail and
42
+ * other clients. Pass the original message's `Message-ID`.
43
+ */
44
+ inReplyTo?: string;
45
+ /**
46
+ * RFC 5322 threading (2.5.0): the `References` chain — the original message's
47
+ * existing `References` plus its `Message-ID`. nodemailer accepts an array.
48
+ */
49
+ references?: string[];
39
50
  }
40
51
  /** Resolved SMTP connection configuration. */
41
52
  export interface SmtpConfig {
@@ -114,4 +125,36 @@ export declare function resolveSmtpConfig(env?: NodeJS.ProcessEnv): SmtpConfig;
114
125
  * a transporter factory only in tests.
115
126
  */
116
127
  export declare function sendViaSmtp(opts: SmtpSendOptions, config?: SmtpConfig, createTransport?: typeof nodemailer.createTransport): Promise<SmtpSendResult>;
128
+ /** One recipient of a mail-merge batch (mirrors the AppleScript serial path). */
129
+ export interface SerialSmtpRecipient {
130
+ email: string;
131
+ variables: Record<string, string>;
132
+ }
133
+ /** Per-recipient outcome of a serial SMTP send. */
134
+ export interface SerialSmtpResult {
135
+ email: string;
136
+ success: boolean;
137
+ error?: string;
138
+ }
139
+ /**
140
+ * Replace every `{{Key}}` token in `template` with the matching value from
141
+ * `variables`. Keys are escaped so regex metacharacters in a key are literal.
142
+ * Mirrors the substitution in {@link AppleMailManager.sendSerialEmail} so the
143
+ * two transports personalize identically.
144
+ */
145
+ export declare function applyPlaceholders(template: string, variables: Record<string, string>): string;
146
+ /**
147
+ * Send a personalized mail-merge batch over SMTP — one individual message per
148
+ * recipient (recipients never see each other), with `{{Key}}` placeholders in
149
+ * the subject/body replaced per recipient. Returns a per-recipient result list;
150
+ * a single recipient's failure does not abort the batch.
151
+ *
152
+ * `opts.send` and `opts.sleep` are injectable for tests (no real SMTP / no real
153
+ * delay). The default `sleep` waits `delayMs` (clamped 0–10000) between sends.
154
+ */
155
+ export declare function sendSerialViaSmtp(recipients: SerialSmtpRecipient[], subject: string, body: string, config: SmtpConfig, opts?: {
156
+ delayMs?: number;
157
+ send?: typeof sendViaSmtp;
158
+ sleep?: (ms: number) => Promise<void>;
159
+ }): Promise<SerialSmtpResult[]>;
117
160
  //# sourceMappingURL=smtpMailer.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"smtpMailer.d.ts","sourceRoot":"","sources":["../../src/services/smtpMailer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,UAAU,MAAM,YAAY,CAAC;AAIpC,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAElD,8EAA8E;AAC9E,MAAM,WAAW,eAAe;IAC9B,EAAE,EAAE,MAAM,EAAE,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,2DAA2D;IAC3D,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC;IACd,GAAG,CAAC,EAAE,MAAM,EAAE,CAAC;IACf,kFAAkF;IAClF,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,yEAAyE;IACzE,WAAW,CAAC,EAAE,eAAe,EAAE,CAAC;IAChC;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,8CAA8C;AAC9C,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,OAAO,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;CACd;AAED,8BAA8B;AAC9B,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,OAAO,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED;;;GAGG;AACH,eAAO,MAAM,QAAQ;;;;;;;;;CASX,CAAC;AAEX;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,OAAO,CAE9E;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,aAAa,CAC3B,SAAS,EAAE,aAAa,GAAG,MAAM,GAAG,SAAS,EAC7C,OAAO,EAAE,MAAM,GAAG,SAAS,EAC3B,UAAU,GAAE,OAA4B,GACvC,OAAO,CAMT;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAcpF;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,UAAU,CA8ClF;AAqBD;;;;;;GAMG;AACH,wBAAsB,WAAW,CAC/B,IAAI,EAAE,eAAe,EACrB,MAAM,CAAC,EAAE,UAAU,EACnB,eAAe,GAAE,OAAO,UAAU,CAAC,eAA4C,GAC9E,OAAO,CAAC,cAAc,CAAC,CA6CzB"}
1
+ {"version":3,"file":"smtpMailer.d.ts","sourceRoot":"","sources":["../../src/services/smtpMailer.ts"],"names":[],"mappings":"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"}
@@ -198,6 +198,9 @@ export async function sendViaSmtp(opts, config, createTransport = nodemailer.cre
198
198
  // When present, nodemailer emits multipart/alternative (text + html).
199
199
  html,
200
200
  attachments,
201
+ // RFC 5322 threading for SMTP replies/forwards (2.5.0).
202
+ inReplyTo: opts.inReplyTo?.trim() || undefined,
203
+ references: opts.references?.length ? opts.references : undefined,
201
204
  });
202
205
  return { success: true, messageId: info.messageId };
203
206
  }
@@ -211,3 +214,55 @@ export async function sendViaSmtp(opts, config, createTransport = nodemailer.cre
211
214
  transporter.close();
212
215
  }
213
216
  }
217
+ /**
218
+ * Replace every `{{Key}}` token in `template` with the matching value from
219
+ * `variables`. Keys are escaped so regex metacharacters in a key are literal.
220
+ * Mirrors the substitution in {@link AppleMailManager.sendSerialEmail} so the
221
+ * two transports personalize identically.
222
+ */
223
+ export function applyPlaceholders(template, variables) {
224
+ let out = template;
225
+ for (const [key, value] of Object.entries(variables)) {
226
+ const safeKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
227
+ out = out.replace(new RegExp(`\\{\\{${safeKey}\\}\\}`, "g"), value);
228
+ }
229
+ return out;
230
+ }
231
+ /**
232
+ * Send a personalized mail-merge batch over SMTP — one individual message per
233
+ * recipient (recipients never see each other), with `{{Key}}` placeholders in
234
+ * the subject/body replaced per recipient. Returns a per-recipient result list;
235
+ * a single recipient's failure does not abort the batch.
236
+ *
237
+ * `opts.send` and `opts.sleep` are injectable for tests (no real SMTP / no real
238
+ * delay). The default `sleep` waits `delayMs` (clamped 0–10000) between sends.
239
+ */
240
+ export async function sendSerialViaSmtp(recipients, subject, body, config, opts = {}) {
241
+ const send = opts.send ?? sendViaSmtp;
242
+ const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
243
+ const delay = Math.min(Math.max(opts.delayMs ?? 500, 0), 10000);
244
+ const results = [];
245
+ for (let i = 0; i < recipients.length; i++) {
246
+ const r = recipients[i];
247
+ try {
248
+ const res = await send({
249
+ to: [r.email],
250
+ subject: applyPlaceholders(subject, r.variables),
251
+ body: applyPlaceholders(body, r.variables),
252
+ from: config.from,
253
+ }, config);
254
+ results.push({ email: r.email, success: res.success, error: res.error });
255
+ }
256
+ catch (error) {
257
+ results.push({
258
+ email: r.email,
259
+ success: false,
260
+ error: error instanceof Error ? error.message : String(error),
261
+ });
262
+ }
263
+ if (delay > 0 && i < recipients.length - 1) {
264
+ await sleep(delay);
265
+ }
266
+ }
267
+ return results;
268
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-mail-mcp",
3
- "version": "2.4.2",
3
+ "version": "2.6.0",
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",