apple-mail-mcp 2.17.7 → 2.17.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +14 -4
  2. package/build/index.js +275 -213
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -725,7 +725,7 @@ Return an attachment's bytes as base64 (the read counterpart to inline-base64 se
725
725
 
726
726
  #### `resolve-message-id`
727
727
 
728
- Map `imap:` message IDs to their numeric Mail.app IDs, via each message's RFC 5322 `Message-ID` (the join key both backends share). Needed only for the two tools that are numeric-ID-only `reply-to-message` and `forward-message`. Numeric IDs pass through unchanged.
728
+ Map `imap:` message IDs to their numeric Mail.app IDs, via each message's RFC 5322 `Message-ID` (the join key both backends share). Needed for the AppleScript reply/forward path; direct SMTP replies and forwards read `imap:` IDs without numeric conversion. Numeric IDs pass through unchanged.
729
729
 
730
730
  | Parameter | Type | Required | Description |
731
731
  |-----------|------|----------|-------------|
@@ -747,6 +747,7 @@ Reply to an existing message.
747
747
  | `body` | string | Yes | Reply body |
748
748
  | `replyAll` | boolean | No | Reply to all recipients (default: false) |
749
749
  | `send` | boolean | No | Send immediately (default: true, false = save as draft) |
750
+ | `transport` | string | No | `smtp` or `applescript`; omitted prefers configured SMTP when sending. Drafts use AppleScript. |
750
751
 
751
752
  **Example - Reply to sender only:**
752
753
  ```json
@@ -766,7 +767,13 @@ Reply to an existing message.
766
767
  }
767
768
  ```
768
769
 
769
- > **Transport (v2.5.0):** when SMTP is configured, `reply-to-message` sends via **clean SMTP**, threading the reply with proper RFC 5322 `In-Reply-To`/`References` headers (built from the original message) so it lands in the same conversation. When SMTP is not configured (or the original lacks the headers needed to thread), it falls back to Mail.app's AppleScript `reply … without opening window` same reliable-from-background-process path as before. See [SMTP transport](#smtp-transport).
770
+ **Delivery:** `imap:` IDs are read directly from their encoded account, mailbox, and UID. Numeric IDs are read through Mail.app. With SMTP configured, replies use clean MIME with the original `Message-ID` in `In-Reply-To` and the full `References` chain. Only the original plain-text body is quoted; the new text is not. Pass `transport: "smtp"` to require this path, or `transport: "applescript"` to use Mail.app explicitly.
771
+
772
+ **Failure behavior:** once SMTP is selected, a missing password, unreadable source, missing reply address or `Message-ID`, or SMTP failure returns an error. It does not silently switch to AppleScript or create an unthreaded new message. With SMTP unconfigured and transport omitted, AppleScript remains available. `send: false` saves a Mail.app draft; combining it with `transport: "smtp"` is rejected before composing.
773
+
774
+ The direct IMAP source read is bounded to 25 MiB, including MIME attachments. Its account login must match the SMTP login, configured From, or an explicitly configured `APPLE_MAIL_MCP_SMTP_ALLOWED_FROM` identity; otherwise the call fails rather than sending from an unrelated account. The SMTP configuration still represents a single sending identity, not a per-account SMTP registry.
775
+
776
+ Success includes `transport` and, for SMTP when returned by the server, the new `messageId`. SMTP submission does not add a local Sent copy; server-side Sent-folder behavior is unchanged. `send-email` is for **new conversations**: adding `Re:` to its subject does not add threading headers. Use this reply tool with the original id instead.
770
777
 
771
778
  **⚠️ Safety:** With the default `send: true`, sends real mail immediately and cannot be unsent. Confirm the recipients, subject, and body with the user before calling (or pass `send: false` to save a draft for review).
772
779
 
@@ -782,8 +789,11 @@ Forward a message to new recipients.
782
789
  | `to` | string[] | Yes | Recipients to forward to |
783
790
  | `body` | string | No | Message to prepend |
784
791
  | `send` | boolean | No | Send immediately (default: true, false = save as draft) |
792
+ | `transport` | string | No | `smtp` or `applescript`; omitted prefers configured SMTP when sending. Drafts use AppleScript. |
793
+
794
+ **Delivery:** uses the same source lookup, transport selection, 25 MiB source limit, account-identity check, and failure behavior as `reply-to-message`. A forward deliberately starts a new conversation, so it has no `In-Reply-To` or `References` headers. The existing plain-text forwarding behavior is unchanged: original attachments are not reattached. See [SMTP transport](#smtp-transport).
785
795
 
786
- > **Transport (v2.5.0):** when SMTP is configured, `forward-message` sends via **clean SMTP** (a fresh message with the original quoted, no threading headers a forward starts a new conversation). When SMTP is not configured it falls back to Mail.app's AppleScript `forward without opening window`. See [SMTP transport](#smtp-transport).
796
+ SMTP forwarding requires a readable plain-text original. HTML-only IMAP messages and failed Mail.app body reads return an error before sending instead of silently omitting the original content. Explicitly select `transport: "applescript"` to forward these with Mail.app; no automatic fallback occurs. An intentionally empty plain-text message is still valid.
787
797
 
788
798
  **⚠️ Safety:** With the default `send: true`, sends real mail immediately and cannot be unsent. Confirm the recipients, subject, and body with the user before calling (or pass `send: false` to save a draft for review).
789
799
 
@@ -1838,7 +1848,7 @@ Prior to v1.4.0, `reply-to-message` and `forward-message` would send messages wi
1838
1848
 
1839
1849
  **Fix:** Replaced `with opening window` with `without opening window` for both `reply` and `forward` commands. With this approach, `set content` works immediately and reliably from background processes. `In-Reply-To` and `References` headers are still set correctly by Mail.app, and no GUI compose window is opened.
1840
1850
 
1841
- **Update (v2.5.0):** when SMTP is configured, `reply-to-message` and `forward-message` now prefer **clean direct SMTP** instead of AppleScript — the same prefer-direct model as `send-email`. Replies are threaded with RFC 5322 `In-Reply-To`/`References` headers built from the original message; forwards start a new conversation. The AppleScript `without opening window` path above remains the fallback when SMTP is not configured (or, for replies, when the original message lacks the headers needed to thread).
1851
+ **Update (v2.5.0):** when SMTP is configured, `reply-to-message` and `forward-message` now prefer **clean direct SMTP** instead of AppleScript — the same prefer-direct model as `send-email`. Replies are threaded with RFC 5322 `In-Reply-To`/`References` headers built from the original message; forwards start a new conversation. With transport omitted, the AppleScript `without opening window` path above is used only when SMTP is not configured or a draft is requested. Since v2.17.8, a selected SMTP path returns source/configuration/threading errors instead of silently falling back; explicit `transport: "applescript"` remains available.
1842
1852
 
1843
1853
  See [#7](https://github.com/sweetrb/apple-mail-mcp/issues/7) for full details and the list of approaches that were tested.
1844
1854
 
package/build/index.js CHANGED
@@ -84179,123 +84179,6 @@ async function sendSerialViaSmtp(recipients, subject, body, config2, opts = {})
84179
84179
  return results;
84180
84180
  }
84181
84181
 
84182
- // src/services/replyForward.ts
84183
- function extractAddresses(headerValue) {
84184
- if (!headerValue.trim()) return [];
84185
- return headerValue.split(",").map((part) => {
84186
- const angle = part.match(/<([^>]+)>/);
84187
- return (angle ? angle[1] : part).trim();
84188
- }).filter((addr) => addr.includes("@"));
84189
- }
84190
- function parseOriginalHeaders(raw) {
84191
- const headerBlock = raw.split(/\r?\n\r?\n/)[0] ?? "";
84192
- const lines = [];
84193
- for (const line of headerBlock.split(/\r?\n/)) {
84194
- if (/^[ \t]/.test(line) && lines.length > 0) {
84195
- lines[lines.length - 1] += " " + line.trim();
84196
- } else {
84197
- lines.push(line);
84198
- }
84199
- }
84200
- const get = (name) => {
84201
- const prefix = `${name.toLowerCase()}:`;
84202
- const found = lines.find((l) => l.toLowerCase().startsWith(prefix));
84203
- return found ? found.slice(found.indexOf(":") + 1).trim() : void 0;
84204
- };
84205
- const rawMessageId = get("Message-ID");
84206
- const messageId = rawMessageId?.match(/<[^>]+>/)?.[0] ?? rawMessageId ?? void 0;
84207
- const references = `${get("References") ?? ""} ${get("In-Reply-To") ?? ""}`.match(/<[^>]+>/g) ?? [];
84208
- return {
84209
- messageId,
84210
- references,
84211
- from: extractAddresses(get("From") ?? ""),
84212
- replyTo: extractAddresses(get("Reply-To") ?? ""),
84213
- to: extractAddresses(get("To") ?? ""),
84214
- cc: extractAddresses(get("Cc") ?? ""),
84215
- subject: get("Subject") ?? "",
84216
- date: get("Date")
84217
- };
84218
- }
84219
- function dedupe(addrs) {
84220
- const seen = /* @__PURE__ */ new Set();
84221
- const out = [];
84222
- for (const a of addrs) {
84223
- const k = a.toLowerCase();
84224
- if (!seen.has(k)) {
84225
- seen.add(k);
84226
- out.push(a);
84227
- }
84228
- }
84229
- return out;
84230
- }
84231
- function withSubjectPrefix(subject, prefix) {
84232
- const s = subject.trim();
84233
- const present = prefix === "Re:" ? /^re:/i : /^(fwd?|fw):/i;
84234
- return present.test(s) ? s : `${prefix} ${s}`;
84235
- }
84236
- function quoteBody(plainText) {
84237
- return plainText.replace(/\s+$/, "").split(/\r?\n/).map((l) => l ? `> ${l}` : ">").join("\n");
84238
- }
84239
- function buildReplyOptions(args) {
84240
- const { original, originalPlainText, body, replyAll, self, from } = args;
84241
- const selfSet = new Set(self.filter(Boolean).map((s) => s.toLowerCase()));
84242
- const to = dedupe((original.replyTo.length ? original.replyTo : original.from).filter(Boolean));
84243
- const toSet = new Set(to.map((t) => t.toLowerCase()));
84244
- let cc;
84245
- if (replyAll) {
84246
- const extra = dedupe(
84247
- [...original.to, ...original.cc].filter(
84248
- (a) => !selfSet.has(a.toLowerCase()) && !toSet.has(a.toLowerCase())
84249
- )
84250
- );
84251
- cc = extra.length ? extra : void 0;
84252
- }
84253
- const attribution = buildAttribution(original);
84254
- const quoted = originalPlainText.trim() ? `
84255
-
84256
- ${attribution}${quoteBody(originalPlainText)}` : "";
84257
- const references = dedupe(
84258
- original.messageId ? [...original.references, original.messageId] : original.references
84259
- );
84260
- return {
84261
- to,
84262
- cc,
84263
- subject: withSubjectPrefix(original.subject, "Re:"),
84264
- body: `${body}${quoted}`,
84265
- inReplyTo: original.messageId,
84266
- references: references.length ? references : void 0,
84267
- from
84268
- };
84269
- }
84270
- function buildAttribution(original) {
84271
- const who = original.from[0] ?? original.replyTo[0] ?? "the sender";
84272
- return original.date ? `On ${original.date}, ${who} wrote:
84273
- ` : `${who} wrote:
84274
- `;
84275
- }
84276
- function buildForwardOptions(args) {
84277
- const { original, originalPlainText, to, body, from } = args;
84278
- const headerBlock = [
84279
- "---------- Forwarded message ----------",
84280
- original.from.length ? `From: ${original.from.join(", ")}` : "",
84281
- original.date ? `Date: ${original.date}` : "",
84282
- `Subject: ${original.subject}`,
84283
- original.to.length ? `To: ${original.to.join(", ")}` : "",
84284
- original.cc.length ? `Cc: ${original.cc.join(", ")}` : ""
84285
- ].filter(Boolean).join("\n");
84286
- const prefix = body?.trim() ? `${body}
84287
-
84288
- ` : "";
84289
- return {
84290
- to: dedupe(to),
84291
- subject: withSubjectPrefix(original.subject, "Fwd:"),
84292
- body: `${prefix}${headerBlock}
84293
-
84294
- ${originalPlainText}`,
84295
- from
84296
- };
84297
- }
84298
-
84299
84182
  // src/services/imapClient.ts
84300
84183
  var import_imapflow = __toESM(require_imap_flow(), 1);
84301
84184
  var IMAP_ENV = {
@@ -85058,6 +84941,34 @@ async function withMailbox(path, deps, fn) {
85058
84941
  }
85059
84942
  });
85060
84943
  }
84944
+ var MAX_COMPOSE_SOURCE_BYTES = 25 * 1024 * 1024;
84945
+ async function imapGetMessageSource(id, deps = {}) {
84946
+ const ref = decodeImapId(id);
84947
+ if (!ref) throw new Error(`Not an IMAP message id: "${id}".`);
84948
+ return withClient(depsForMessageRef(ref, deps), async (client, cfg) => {
84949
+ const lock = await client.getMailboxLock(ref.path);
84950
+ try {
84951
+ const msg = await client.fetchOne(
84952
+ String(ref.uid),
84953
+ {
84954
+ envelope: true,
84955
+ // One extra byte distinguishes an exact-limit source from truncation.
84956
+ source: { start: 0, maxLength: MAX_COMPOSE_SOURCE_BYTES + 1 }
84957
+ },
84958
+ { uid: true }
84959
+ );
84960
+ if (!msg) throw new Error(`IMAP message UID ${ref.uid} not found in "${ref.path}".`);
84961
+ if (!msg.source || !msg.source.length)
84962
+ throw new Error("IMAP returned no original message source.");
84963
+ if (Buffer.byteLength(msg.source) > MAX_COMPOSE_SOURCE_BYTES) {
84964
+ throw new Error("Original message source exceeds the 25 MiB reply/forward limit.");
84965
+ }
84966
+ return { raw: msg.source.toString(), subject: msg.envelope?.subject, accountUser: cfg.user };
84967
+ } finally {
84968
+ lock.release();
84969
+ }
84970
+ });
84971
+ }
85061
84972
  async function imapGetMessage(id, preferHtml, deps = {}) {
85062
84973
  const ref = decodeImapId(id);
85063
84974
  if (!ref) return { success: false, error: `Not an IMAP message id: "${id}".` };
@@ -85552,6 +85463,123 @@ async function imapThread(id, deps = {}, limit = 50) {
85552
85463
  );
85553
85464
  }
85554
85465
 
85466
+ // src/services/replyForward.ts
85467
+ function extractAddresses(headerValue) {
85468
+ if (!headerValue.trim()) return [];
85469
+ return headerValue.split(",").map((part) => {
85470
+ const angle = part.match(/<([^>]+)>/);
85471
+ return (angle ? angle[1] : part).trim();
85472
+ }).filter((addr) => addr.includes("@"));
85473
+ }
85474
+ function parseOriginalHeaders(raw) {
85475
+ const headerBlock = raw.split(/\r?\n\r?\n/)[0] ?? "";
85476
+ const lines = [];
85477
+ for (const line of headerBlock.split(/\r?\n/)) {
85478
+ if (/^[ \t]/.test(line) && lines.length > 0) {
85479
+ lines[lines.length - 1] += " " + line.trim();
85480
+ } else {
85481
+ lines.push(line);
85482
+ }
85483
+ }
85484
+ const get = (name) => {
85485
+ const prefix = `${name.toLowerCase()}:`;
85486
+ const found = lines.find((l) => l.toLowerCase().startsWith(prefix));
85487
+ return found ? found.slice(found.indexOf(":") + 1).trim() : void 0;
85488
+ };
85489
+ const rawMessageId = get("Message-ID");
85490
+ const messageId = rawMessageId?.match(/<[^>]+>/)?.[0] ?? rawMessageId ?? void 0;
85491
+ const references = `${get("References") ?? ""} ${get("In-Reply-To") ?? ""}`.match(/<[^>]+>/g) ?? [];
85492
+ return {
85493
+ messageId,
85494
+ references,
85495
+ from: extractAddresses(get("From") ?? ""),
85496
+ replyTo: extractAddresses(get("Reply-To") ?? ""),
85497
+ to: extractAddresses(get("To") ?? ""),
85498
+ cc: extractAddresses(get("Cc") ?? ""),
85499
+ subject: get("Subject") ?? "",
85500
+ date: get("Date")
85501
+ };
85502
+ }
85503
+ function dedupe(addrs) {
85504
+ const seen = /* @__PURE__ */ new Set();
85505
+ const out = [];
85506
+ for (const a of addrs) {
85507
+ const k = a.toLowerCase();
85508
+ if (!seen.has(k)) {
85509
+ seen.add(k);
85510
+ out.push(a);
85511
+ }
85512
+ }
85513
+ return out;
85514
+ }
85515
+ function withSubjectPrefix(subject, prefix) {
85516
+ const s = subject.trim();
85517
+ const present = prefix === "Re:" ? /^re:/i : /^(fwd?|fw):/i;
85518
+ return present.test(s) ? s : `${prefix} ${s}`;
85519
+ }
85520
+ function quoteBody(plainText) {
85521
+ return plainText.replace(/\s+$/, "").split(/\r?\n/).map((l) => l ? `> ${l}` : ">").join("\n");
85522
+ }
85523
+ function buildReplyOptions(args) {
85524
+ const { original, originalPlainText, body, replyAll, self, from } = args;
85525
+ const selfSet = new Set(self.filter(Boolean).map((s) => s.toLowerCase()));
85526
+ const to = dedupe((original.replyTo.length ? original.replyTo : original.from).filter(Boolean));
85527
+ const toSet = new Set(to.map((t) => t.toLowerCase()));
85528
+ let cc;
85529
+ if (replyAll) {
85530
+ const extra = dedupe(
85531
+ [...original.to, ...original.cc].filter(
85532
+ (a) => !selfSet.has(a.toLowerCase()) && !toSet.has(a.toLowerCase())
85533
+ )
85534
+ );
85535
+ cc = extra.length ? extra : void 0;
85536
+ }
85537
+ const attribution = buildAttribution(original);
85538
+ const quoted = originalPlainText.trim() ? `
85539
+
85540
+ ${attribution}${quoteBody(originalPlainText)}` : "";
85541
+ const references = dedupe(
85542
+ original.messageId ? [...original.references, original.messageId] : original.references
85543
+ );
85544
+ return {
85545
+ to,
85546
+ cc,
85547
+ subject: withSubjectPrefix(original.subject, "Re:"),
85548
+ body: `${body}${quoted}`,
85549
+ inReplyTo: original.messageId,
85550
+ references: references.length ? references : void 0,
85551
+ from
85552
+ };
85553
+ }
85554
+ function buildAttribution(original) {
85555
+ const who = original.from[0] ?? original.replyTo[0] ?? "the sender";
85556
+ return original.date ? `On ${original.date}, ${who} wrote:
85557
+ ` : `${who} wrote:
85558
+ `;
85559
+ }
85560
+ function buildForwardOptions(args) {
85561
+ const { original, originalPlainText, to, body, from } = args;
85562
+ const headerBlock = [
85563
+ "---------- Forwarded message ----------",
85564
+ original.from.length ? `From: ${original.from.join(", ")}` : "",
85565
+ original.date ? `Date: ${original.date}` : "",
85566
+ `Subject: ${original.subject}`,
85567
+ original.to.length ? `To: ${original.to.join(", ")}` : "",
85568
+ original.cc.length ? `Cc: ${original.cc.join(", ")}` : ""
85569
+ ].filter(Boolean).join("\n");
85570
+ const prefix = body?.trim() ? `${body}
85571
+
85572
+ ` : "";
85573
+ return {
85574
+ to: dedupe(to),
85575
+ subject: withSubjectPrefix(original.subject, "Fwd:"),
85576
+ body: `${prefix}${headerBlock}
85577
+
85578
+ ${originalPlainText}`,
85579
+ from
85580
+ };
85581
+ }
85582
+
85555
85583
  // src/tools/respond.ts
85556
85584
  import { AsyncLocalStorage } from "node:async_hooks";
85557
85585
 
@@ -85644,6 +85672,117 @@ function withErrorHandling(handler, errorPrefix) {
85644
85672
  };
85645
85673
  }
85646
85674
 
85675
+ // src/tools/compose.ts
85676
+ async function readOriginal(deps, id, cfg) {
85677
+ if (decodeImapId(id)) {
85678
+ const source = await deps.imapSource(id);
85679
+ const identities = [cfg.user, cfg.from, ...cfg.allowedFrom ?? []].map(
85680
+ (s) => s.trim().toLowerCase()
85681
+ );
85682
+ if (!identities.includes(source.accountUser.trim().toLowerCase())) {
85683
+ throw new Error(
85684
+ "The source IMAP account does not match the configured SMTP identity. Configure SMTP for that account or explicitly select transport=applescript."
85685
+ );
85686
+ }
85687
+ const original = parseOriginalHeaders(source.raw);
85688
+ if (source.subject !== void 0) original.subject = source.subject;
85689
+ return { original, plainText: extractTextBody(source.raw) };
85690
+ }
85691
+ const raw = deps.mail.getRawSource(id);
85692
+ if (!raw)
85693
+ throw new Error(
85694
+ "Cannot read the original message source. Re-list the intended mailbox and retry with its message id."
85695
+ );
85696
+ const content = deps.mail.getMessageContent(id);
85697
+ return { original: parseOriginalHeaders(raw), plainText: content?.plainText ?? null };
85698
+ }
85699
+ async function runCompose(deps, args) {
85700
+ const { id, send, transport: transport2 } = args;
85701
+ const verb = args.kind === "reply" ? "reply to" : "forward";
85702
+ if (!send && transport2 === "smtp") {
85703
+ return errorResponse(
85704
+ "SMTP cannot save a Mail.app draft. Omit transport or use transport=applescript with send=false."
85705
+ );
85706
+ }
85707
+ const smtp = send && transport2 !== "applescript" && (transport2 === "smtp" || deps.smtpConfigured());
85708
+ if (smtp) {
85709
+ try {
85710
+ const cfg = deps.smtpConfig();
85711
+ const { original, plainText } = await readOriginal(deps, id, cfg);
85712
+ if (args.kind === "forward" && plainText === null)
85713
+ throw new Error(
85714
+ "The original message has no readable plain-text body. SMTP forwarding would omit its content; explicitly select transport=applescript to forward it with Mail.app."
85715
+ );
85716
+ if (args.kind === "reply") {
85717
+ if (!original.messageId)
85718
+ throw new Error(
85719
+ "The original message has no Message-ID; a threaded SMTP reply cannot be constructed."
85720
+ );
85721
+ if (!original.replyTo.length && !original.from.length)
85722
+ throw new Error("The original message has no reply address.");
85723
+ }
85724
+ const opts = args.kind === "reply" ? buildReplyOptions({
85725
+ original,
85726
+ originalPlainText: plainText ?? "",
85727
+ body: args.body,
85728
+ replyAll: args.replyAll,
85729
+ self: [cfg.from, cfg.user, ...cfg.allowedFrom ?? []],
85730
+ from: cfg.from
85731
+ }) : buildForwardOptions({
85732
+ original,
85733
+ originalPlainText: plainText ?? "",
85734
+ to: args.to,
85735
+ body: args.body,
85736
+ from: cfg.from
85737
+ });
85738
+ const result = await deps.smtpSend(opts, cfg);
85739
+ if (!result.success)
85740
+ return errorResponse(
85741
+ `Failed to ${verb} message "${id}" via SMTP: ${result.error ?? "unknown SMTP error"}`
85742
+ );
85743
+ return successResponse(
85744
+ args.kind === "reply" ? "Reply sent via SMTP" : `Message forwarded via SMTP to ${args.to.join(", ")}`,
85745
+ {
85746
+ ok: true,
85747
+ sent: true,
85748
+ id,
85749
+ transport: "smtp",
85750
+ messageId: result.messageId,
85751
+ ...args.kind === "forward" ? { recipients: args.to } : {}
85752
+ }
85753
+ );
85754
+ } catch (error2) {
85755
+ return errorResponse(
85756
+ `Failed to ${verb} message "${id}" via SMTP: ${error2 instanceof Error ? error2.message : String(error2)} No AppleScript fallback was attempted.`
85757
+ );
85758
+ }
85759
+ }
85760
+ const resolved = await deps.numericId(id);
85761
+ if (!resolved.numericId)
85762
+ return errorResponse(
85763
+ `Failed to ${verb} message "${id}": ${resolved.error ?? "message not found"}`
85764
+ );
85765
+ const outcome = args.kind === "reply" ? deps.mail.replyToMessage(resolved.numericId, args.body, args.replyAll, send) : deps.mail.forwardMessage(resolved.numericId, args.to, args.body, send);
85766
+ if (!outcome.success)
85767
+ return errorResponse(
85768
+ `Failed to ${verb} message "${id}": ${outcome.error ?? "Mail.app compose failed"}`
85769
+ );
85770
+ const text = args.kind === "reply" ? send ? "Reply sent via AppleScript" : "Reply saved as draft" : send ? `Message forwarded to ${args.to.join(", ")}` : "Forward saved as draft";
85771
+ return successResponse(text, {
85772
+ ok: true,
85773
+ sent: send,
85774
+ id,
85775
+ transport: "applescript",
85776
+ ...args.kind === "forward" ? { recipients: args.to } : {}
85777
+ });
85778
+ }
85779
+ function runReply(deps, args) {
85780
+ return runCompose(deps, { ...args, kind: "reply" });
85781
+ }
85782
+ function runForward(deps, args) {
85783
+ return runCompose(deps, { ...args, kind: "forward" });
85784
+ }
85785
+
85647
85786
  // src/tools/mailboxListing.ts
85648
85787
  function unlistableStoreError(account, error2, knownAccounts) {
85649
85788
  const scope = account ? ` for "${account}"` : "";
@@ -87115,88 +87254,37 @@ async function toNumericMailId(id) {
87115
87254
  }
87116
87255
  return { numericId };
87117
87256
  }
87118
- async function sendReplyViaSmtp(id, body, replyAll) {
87119
- const cfg = resolveSmtpOrFallback();
87120
- if (!cfg) return { sent: false, fallback: true };
87121
- const raw = mailManager.getRawSource(id);
87122
- if (!raw) return { sent: false, fallback: true };
87123
- const original = parseOriginalHeaders(raw);
87124
- if (!original.messageId || original.from.length === 0) {
87125
- return { sent: false, fallback: true };
87126
- }
87127
- const content = mailManager.getMessageContent(id);
87128
- const opts = buildReplyOptions({
87129
- original,
87130
- originalPlainText: content?.plainText ?? "",
87131
- body,
87132
- replyAll,
87133
- self: [cfg.from, cfg.user],
87134
- from: cfg.from
87135
- });
87136
- const result = await sendViaSmtp(opts, cfg);
87137
- if (result.success) return { sent: true };
87138
- return { sent: false, fallback: false, error: result.error ?? "unknown SMTP error" };
87139
- }
87140
- async function sendForwardViaSmtp(id, to, body) {
87141
- const cfg = resolveSmtpOrFallback();
87142
- if (!cfg) return { sent: false, fallback: true };
87143
- const raw = mailManager.getRawSource(id);
87144
- if (!raw) return { sent: false, fallback: true };
87145
- const original = parseOriginalHeaders(raw);
87146
- const content = mailManager.getMessageContent(id);
87147
- const opts = buildForwardOptions({
87148
- original,
87149
- originalPlainText: content?.plainText ?? "",
87150
- to,
87151
- body,
87152
- from: cfg.from
87153
- });
87154
- const result = await sendViaSmtp(opts, cfg);
87155
- if (result.success) return { sent: true };
87156
- return { sent: false, fallback: false, error: result.error ?? "unknown SMTP error" };
87157
- }
87257
+ var composeDeps = {
87258
+ mail: mailManager,
87259
+ imapSource: imapGetMessageSource,
87260
+ numericId: toNumericMailId,
87261
+ smtpConfigured: isSmtpConfigured,
87262
+ smtpConfig: resolveSmtpConfig,
87263
+ smtpSend: sendViaSmtp
87264
+ };
87265
+ var COMPOSE_TRANSPORT_SCHEMA = external_exports.enum(["applescript", "smtp"]).optional().describe(
87266
+ "SMTP sends clean MIME and preserves reply threading; requires SMTP configuration. AppleScript uses Mail.app and may quote-wrap the new body on macOS 15+. Omit to prefer configured SMTP when sending; drafts use AppleScript. A selected SMTP transport never silently falls back. SMTP with send=false is rejected."
87267
+ );
87158
87268
  registerTool(
87159
87269
  "reply-to-message",
87160
87270
  {
87161
87271
  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 \u2014 require explicit user confirmation of the recipients and body, or pass send=false to let the user review.",
87162
87272
  inputSchema: {
87163
87273
  id: MESSAGE_ID_SCHEMA,
87274
+ transport: COMPOSE_TRANSPORT_SCHEMA,
87164
87275
  body: external_exports.string().min(1, "Reply body is required"),
87165
87276
  replyAll: external_exports.boolean().optional().default(false).describe("Reply to all recipients"),
87166
87277
  send: external_exports.boolean().optional().default(true).describe("Send immediately (false = save as draft)")
87167
87278
  },
87168
87279
  outputSchema: {
87280
+ transport: external_exports.enum(["smtp", "applescript"]).optional(),
87281
+ messageId: external_exports.string().optional(),
87169
87282
  ok: external_exports.boolean().optional(),
87170
87283
  sent: external_exports.boolean().optional(),
87171
87284
  id: external_exports.string().optional()
87172
87285
  }
87173
87286
  },
87174
- withErrorHandling(async ({ id, body, replyAll, send }) => {
87175
- if (send && isSmtpConfigured()) {
87176
- const outcome2 = await sendReplyViaSmtp(id, body, replyAll);
87177
- if (outcome2.sent) {
87178
- return successResponse("Reply sent", { ok: true, sent: true, id });
87179
- }
87180
- if (!outcome2.fallback) {
87181
- return errorResponse(`Failed to reply to message "${id}" via SMTP: ${outcome2.error}`);
87182
- }
87183
- }
87184
- const resolvedReply = await toNumericMailId(id);
87185
- if (!resolvedReply.numericId) {
87186
- return errorResponse(`Failed to reply to message "${id}": ${resolvedReply.error}`);
87187
- }
87188
- const outcome = mailManager.replyToMessage(resolvedReply.numericId, body, replyAll, send);
87189
- if (!outcome.success) {
87190
- return errorResponse(
87191
- outcome.error ? `Failed to reply to message "${id}": ${outcome.error}` : `Failed to reply to message "${id}"`
87192
- );
87193
- }
87194
- return successResponse(send ? "Reply sent" : "Reply saved as draft", {
87195
- ok: true,
87196
- sent: send,
87197
- id
87198
- });
87199
- }, "Error replying to message")
87287
+ withErrorHandling((args) => runReply(composeDeps, args), "Error replying to message")
87200
87288
  );
87201
87289
  registerTool(
87202
87290
  "forward-message",
@@ -87204,47 +87292,21 @@ registerTool(
87204
87292
  description: "Use when: forwarding an existing message (by id) to new recipients (to is an array), with an optional body to prepend. Set send=false to save as a draft.\nReturns: a confirmation that the message was forwarded or saved as a draft.\nDo not use when: replying to the sender/recipients (use reply-to-message) or composing a new message (use send-email / create-draft).\nSafety: with the default send=true this SENDS real email immediately and cannot be unsent \u2014 require explicit user confirmation of the recipients and any prepended body, or pass send=false to let the user review.",
87205
87293
  inputSchema: {
87206
87294
  id: MESSAGE_ID_SCHEMA,
87295
+ transport: COMPOSE_TRANSPORT_SCHEMA,
87207
87296
  to: external_exports.array(external_exports.string()).min(1, "At least one recipient is required"),
87208
87297
  body: external_exports.string().optional().describe("Optional message to prepend"),
87209
87298
  send: external_exports.boolean().optional().default(true).describe("Send immediately (false = save as draft)")
87210
87299
  },
87211
87300
  outputSchema: {
87301
+ transport: external_exports.enum(["smtp", "applescript"]).optional(),
87302
+ messageId: external_exports.string().optional(),
87212
87303
  ok: external_exports.boolean().optional(),
87213
87304
  sent: external_exports.boolean().optional(),
87214
87305
  recipients: external_exports.array(external_exports.string()).optional(),
87215
87306
  id: external_exports.string().optional()
87216
87307
  }
87217
87308
  },
87218
- withErrorHandling(async ({ id, to, body, send }) => {
87219
- if (send && isSmtpConfigured()) {
87220
- const outcome2 = await sendForwardViaSmtp(id, to, body);
87221
- if (outcome2.sent) {
87222
- return successResponse(`Message forwarded to ${to.join(", ")}`, {
87223
- ok: true,
87224
- sent: true,
87225
- recipients: to,
87226
- id
87227
- });
87228
- }
87229
- if (!outcome2.fallback) {
87230
- return errorResponse(`Failed to forward message "${id}" via SMTP: ${outcome2.error}`);
87231
- }
87232
- }
87233
- const resolvedFwd = await toNumericMailId(id);
87234
- if (!resolvedFwd.numericId) {
87235
- return errorResponse(`Failed to forward message "${id}": ${resolvedFwd.error}`);
87236
- }
87237
- const outcome = mailManager.forwardMessage(resolvedFwd.numericId, to, body, send);
87238
- if (!outcome.success) {
87239
- return errorResponse(
87240
- outcome.error ? `Failed to forward message "${id}": ${outcome.error}` : `Failed to forward message "${id}"`
87241
- );
87242
- }
87243
- return successResponse(
87244
- send ? `Message forwarded to ${to.join(", ")}` : "Forward saved as draft",
87245
- { ok: true, sent: send, recipients: to, id }
87246
- );
87247
- }, "Error forwarding message")
87309
+ withErrorHandling((args) => runForward(composeDeps, args), "Error forwarding message")
87248
87310
  );
87249
87311
  registerTool(
87250
87312
  "mark-as-read",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-mail-mcp",
3
- "version": "2.17.7",
3
+ "version": "2.17.8",
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",