apple-mail-mcp 2.19.22 → 2.20.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 CHANGED
@@ -164,11 +164,11 @@ tool, and troubleshooting. Verify any time by running the **`doctor`** tool.
164
164
  | **Search Messages** | Search by sender, subject, content, date range, read/flagged status — across all accounts |
165
165
  | **Read Messages** | Get full email content (plain text or HTML) |
166
166
  | **Read Headers** | Get a message's raw RFC 5322 headers — the author's `Date:`, Message-ID, threading ids, `Received:` trace — without downloading the body |
167
- | **Send Email** | Compose and send new emails (attach by file path or inline base64 content) |
167
+ | **Send Email** | Compose and send new emails (attach by file path or inline base64 content; optional `inReplyTo`/`references` threading over SMTP) |
168
168
  | **Send Serial Email** | Mail merge — send personalized emails to a list of recipients with {{placeholder}} support |
169
- | **Create Draft** | Save emails to Drafts folder (attach by file path or inline base64 content) |
170
- | **Reply** | Reply to messages (with reply-all support) |
171
- | **Forward** | Forward messages to new recipients |
169
+ | **Create Draft** | Save emails to Drafts folder (attach by file path or inline base64 content; threaded drafts filed over IMAP) |
170
+ | **Reply** | Reply to messages (with reply-all support and attachments) |
171
+ | **Forward** | Forward messages to new recipients (with extra attachments) |
172
172
  | **Get Thread** | Group a conversation by normalized subject (across AppleScript or IMAP) |
173
173
  | **Mark Read/Unread** | Change read status (single or batch) |
174
174
  | **Flag/Unflag** | Flag or unflag messages (single or batch) |
@@ -402,16 +402,19 @@ Send a new email immediately.
402
402
 
403
403
  **⚠️ Safety:** Sends real mail immediately and cannot be unsent. Confirm the recipients, subject, and body with the user before calling.
404
404
 
405
- | Parameter | Type | Required | Description |
406
- | ------------- | --------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
407
- | `to` | string[] | Yes | Recipient addresses |
408
- | `subject` | string | Yes | Email subject |
409
- | `body` | string | Yes | Email body (plain text) |
410
- | `cc` | string[] | No | CC recipients |
411
- | `bcc` | string[] | No | BCC recipients |
412
- | `account` | string | No | Mail.app account label, or an email-form SMTP From override. An SMTP override must match `APPLE_MAIL_MCP_SMTP_USER`, `APPLE_MAIL_MCP_SMTP_FROM`, or an address in `APPLE_MAIL_MCP_SMTP_ALLOWED_FROM` |
413
- | `attachments` | (string \| {filename, contentBase64})[] | No | Up to 20 attachments: absolute file paths inside the configured read roots (e.g., `"/Users/me/Documents/report.pdf"`) and/or inline `{filename, contentBase64}` objects up to 25 MiB decoded each |
414
- | `transport` | `"applescript"` \| `"smtp"` | No | Send transport. If omitted, **SMTP is used automatically when configured** (otherwise AppleScript). Pass `"smtp"` to require clean MIME, or `"applescript"` to force the Mail.app path — see [SMTP transport](#smtp-transport) |
405
+ | Parameter | Type | Required | Description |
406
+ | ------------- | --------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
407
+ | `to` | string[] | Yes | Recipient addresses |
408
+ | `subject` | string | Yes | Email subject |
409
+ | `body` | string | Yes | Email body (plain text) |
410
+ | `cc` | string[] | No | CC recipients |
411
+ | `bcc` | string[] | No | BCC recipients |
412
+ | `account` | string | No | Mail.app account label, or an email-form SMTP From override. An SMTP override must match `APPLE_MAIL_MCP_SMTP_USER`, `APPLE_MAIL_MCP_SMTP_FROM`, or an address in `APPLE_MAIL_MCP_SMTP_ALLOWED_FROM` |
413
+ | `attachments` | (string \| {filename, contentBase64})[] | No | Up to 20 attachments: absolute file paths inside the configured read roots (e.g., `"/Users/me/Documents/report.pdf"`) and/or inline `{filename, contentBase64}` objects up to 25 MiB decoded each |
414
+ | `transport` | `"applescript"` \| `"smtp"` | No | Send transport. If omitted, **SMTP is used automatically when configured** (otherwise AppleScript). Pass `"smtp"` to require clean MIME, or `"applescript"` to force the Mail.app path — see [SMTP transport](#smtp-transport) |
415
+ | `replyTo` | string | No | SMTP only: `Reply-To` header, when replies should go somewhere other than the From address. Ignored on the AppleScript transport |
416
+ | `inReplyTo` | string | No | SMTP only (2.20.0): thread the message as a reply — the parent's `Message-ID` token with angle brackets, e.g. `"<abc@mail.example.com>"`. Emitted verbatim, never RFC 2047-encoded. See [Threading headers](#threading-headers-inreplyto--references) |
417
+ | `references` | string[] | No | SMTP only (2.20.0): the `References` chain, oldest first (the parent's own `References` + its `Message-ID`). Defaults to `[inReplyTo]` when omitted |
415
418
 
416
419
  **Example:**
417
420
 
@@ -425,6 +428,22 @@ Send a new email immediately.
425
428
  }
426
429
  ```
427
430
 
431
+ ##### Threading headers (`inReplyTo` / `references`)
432
+
433
+ `send-email` and `create-draft` accept caller-built threading headers (2.20.0, #267) for
434
+ callers that compose the recipients and quoted text themselves. **Prefer
435
+ `reply-to-message`** — it derives recipients, `In-Reply-To`/`References` and the quote
436
+ from the original, and (since 2.20.0) takes attachments too.
437
+
438
+ - Each value must be a single RFC 5322 msg-id token **with** angle brackets
439
+ (`<left@right>`, no whitespace); anything else is rejected by validation.
440
+ - The headers are always emitted as plain `<id>` tokens — never RFC 2047-encoded, even
441
+ for Outlook/Exchange IDs longer than 76 characters (a long header is folded at
442
+ whitespace between tokens, which is legal and does not alter the id).
443
+ - Mail.app's AppleScript cannot set these headers, so they are **never silently
444
+ dropped**: `send-email` refuses them unless the SMTP transport is in use, and
445
+ `create-draft` files a threaded draft over IMAP instead of through Mail.app (below).
446
+
428
447
  ##### SMTP transport
429
448
 
430
449
  On macOS 15+ (Sequoia/Tahoe), Mail.app wraps any AppleScript-injected body in
@@ -783,10 +802,24 @@ Save an email to Drafts without sending.
783
802
  | `body` | string | Yes | Email body (plain text) |
784
803
  | `cc` | string[] | No | CC recipients |
785
804
  | `bcc` | string[] | No | BCC recipients |
786
- | `account` | string | No | Account for draft |
805
+ | `account` | string | No | Account for draft. With `inReplyTo`/`references` it must name a configured IMAP account (label or login) |
787
806
  | `attachments` | (string \| {filename, contentBase64})[] | No | Up to 20 attachments: absolute file paths inside the configured read roots and/or inline `{filename, contentBase64}` objects up to 25 MiB decoded each |
788
-
789
- **Returns:** Confirmation that draft was created.
807
+ | `inReplyTo` | string | No | (2.20.0) Parent `Message-ID` token, e.g. `"<abc@mail.example.com>"` — makes this a **threaded draft** (see below) |
808
+ | `references` | string[] | No | (2.20.0) `References` chain, oldest first; defaults to `[inReplyTo]` |
809
+
810
+ **Returns:** Confirmation that draft was created, with `transport` (`applescript`, or `imap` for a threaded draft).
811
+
812
+ **Threaded drafts (2.20.0, #267):** Mail.app's `outgoing message` has no header property,
813
+ so a draft with `inReplyTo`/`references` cannot go through AppleScript. Instead the
814
+ server composes it with the same MIME builder as the SMTP send (attachments included) and
815
+ files it over IMAP into the account's Drafts mailbox (SPECIAL-USE `\Drafts`), flagged
816
+ `\Draft`. The account is `account` when given (it must be a configured IMAP account);
817
+ otherwise the IMAP account whose login is the SMTP identity, else the only configured
818
+ IMAP account. When none qualifies — IMAP unconfigured, an unknown `account`, or several
819
+ accounts and no way to choose — the call **fails and files nothing**; it never falls back
820
+ to an unthreaded Mail.app draft. The result names the Drafts `mailbox`, the `account` and
821
+ the draft's `messageId`. Without threading headers, `create-draft` is unchanged
822
+ (AppleScript).
790
823
 
791
824
  #### `get-thread`
792
825
 
@@ -832,13 +865,14 @@ Map `imap:` message IDs to their numeric Mail.app IDs, via each message's RFC 53
832
865
 
833
866
  Reply to an existing message.
834
867
 
835
- | Parameter | Type | Required | Description |
836
- | ----------- | ------- | -------- | ---------------------------------------------------------------------------------------------- |
837
- | `id` | string | Yes | Message ID to reply to |
838
- | `body` | string | Yes | Reply body (plain text; HTML tags such as `<br>` are not rendered) |
839
- | `replyAll` | boolean | No | Reply to all recipients (default: false) |
840
- | `send` | boolean | No | Send immediately (default: true, false = save as draft) |
841
- | `transport` | string | No | `smtp` or `applescript`; omitted prefers configured SMTP when sending. Drafts use AppleScript. |
868
+ | Parameter | Type | Required | Description |
869
+ | ------------- | --------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
870
+ | `id` | string | Yes | Message ID to reply to |
871
+ | `body` | string | Yes | Reply body (plain text; HTML tags such as `<br>` are not rendered) |
872
+ | `replyAll` | boolean | No | Reply to all recipients (default: false) |
873
+ | `send` | boolean | No | Send immediately (default: true, false = save as draft) |
874
+ | `transport` | string | No | `smtp` or `applescript`; omitted prefers configured SMTP when sending. Drafts use AppleScript. |
875
+ | `attachments` | (string \| {filename, contentBase64})[] | No | Up to 20 attachments, same rules as `send-email`: absolute file paths inside the configured read roots and/or inline `{filename, contentBase64}` objects up to 25 MiB decoded each |
842
876
 
843
877
  **Example - Reply to sender only:**
844
878
 
@@ -860,6 +894,30 @@ Reply to an existing message.
860
894
  }
861
895
  ```
862
896
 
897
+ **Example - Reply all with an attachment (2.20.0):**
898
+
899
+ ```json
900
+ {
901
+ "id": "imap:eyJhIjoiV29yayIsInAiOiJJTkJPWCIsInUiOjQyfQ",
902
+ "body": "Here is the PDF you asked for.",
903
+ "replyAll": true,
904
+ "attachments": [
905
+ "/Users/me/Documents/quote.pdf",
906
+ { "filename": "notes.txt", "contentBase64": "SGVsbG8K" }
907
+ ]
908
+ }
909
+ ```
910
+
911
+ **Attachments (2.20.0, #267):** same schema, limits (20 items, 25 MiB each) and read
912
+ roots as `send-email`, validated before the original is fetched — an out-of-roots path
913
+ fails with exactly `send-email`'s error. Recipients, threading headers and the quote are
914
+ identical to the same call without attachments. Over SMTP the files ride the same
915
+ nodemailer path `send-email` uses, so the Sent-folder copy (`sentCopy`) carries them too.
916
+ On the AppleScript transport they are added with `make new attachment` **after** the
917
+ reply content is set and before `save`/`send` (inline items are written to `0600` temp
918
+ files and removed afterwards), so a `send: false` draft contains both the attachments and
919
+ the quoted original. Results report `attachmentCount`.
920
+
863
921
  **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.
864
922
 
865
923
  **AppleScript transport quoting (v2.19.12):** every draft (`send: false`) and any send that falls back to AppleScript (SMTP not configured) also gets the original quoted — an "On \<date\>, \<sender\> wrote:" attribution line followed by the `> `-prefixed original body, built the same way as the SMTP path. Mail's own `reply`/`forward` AppleScript commands generate this quote internally but expose no way to read it back, so it's built here instead of relying on Mail's (unreadable) copy. Unlike SMTP, this path has no sending-identity restriction — it's read-only source lookup, not sending.
@@ -868,7 +926,7 @@ Reply to an existing message.
868
926
 
869
927
  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.
870
928
 
871
- 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.
929
+ Success includes `transport`, `attachmentCount` and, for SMTP when returned by the server, the new `messageId`. Over SMTP a best-effort Sent-folder copy is filed over IMAP exactly as for `send-email` (`sentCopy` / `sentCopyError`, see [SMTP transport](#smtp-transport)). `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.
872
930
 
873
931
  **⚠️ 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).
874
932
 
@@ -878,15 +936,18 @@ Success includes `transport` and, for SMTP when returned by the server, the new
878
936
 
879
937
  Forward a message to new recipients.
880
938
 
881
- | Parameter | Type | Required | Description |
882
- | ----------- | -------- | -------- | ---------------------------------------------------------------------------------------------- |
883
- | `id` | string | Yes | Message ID to forward |
884
- | `to` | string[] | Yes | Recipients to forward to |
885
- | `body` | string | No | Message to prepend (plain text) |
886
- | `send` | boolean | No | Send immediately (default: true, false = save as draft) |
887
- | `transport` | string | No | `smtp` or `applescript`; omitted prefers configured SMTP when sending. Drafts use AppleScript. |
939
+ | Parameter | Type | Required | Description |
940
+ | ------------- | --------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
941
+ | `id` | string | Yes | Message ID to forward |
942
+ | `to` | string[] | Yes | Recipients to forward to |
943
+ | `body` | string | No | Message to prepend (plain text) |
944
+ | `send` | boolean | No | Send immediately (default: true, false = save as draft) |
945
+ | `transport` | string | No | `smtp` or `applescript`; omitted prefers configured SMTP when sending. Drafts use AppleScript. |
946
+ | `attachments` | (string \| {filename, contentBase64})[] | No | Up to 20 additional attachments, same rules as `send-email`: absolute file paths inside the configured read roots and/or inline `{filename, contentBase64}` objects up to 25 MiB decoded each |
947
+
948
+ **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). On the AppleScript transport, a `body` is merged with the forwarded-message header block and original text the same way the SMTP path builds it (see `reply-to-message`'s AppleScript transport quoting note); with no `body`, Mail's own forward content is left untouched — unless `attachments` are given, in which case the forwarded-message block is built the same way so Mail has a paragraph to anchor the new files after.
888
949
 
889
- **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). On the AppleScript transport, a `body` is merged with the forwarded-message header block and original text the same way the SMTP path builds it (see `reply-to-message`'s AppleScript transport quoting note); with no `body`, Mail's own forward content is left untouched.
950
+ **Attachments (2.20.0, #267):** `attachments` adds new files to the forward, with or without a prepended `body`, under the same rules, validation and per-transport mechanics as `reply-to-message`'s attachments. They are independent of the original's attachments (which the SMTP path still does not reattach).
890
951
 
891
952
  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.
892
953
 
package/build/cli.js CHANGED
@@ -57846,6 +57846,7 @@ __export(imapClient_exports, {
57846
57846
  decodeImapId: () => decodeImapId,
57847
57847
  dropAllPools: () => dropAllPools,
57848
57848
  encodeImapId: () => encodeImapId,
57849
+ imapAppendDraft: () => imapAppendDraft,
57849
57850
  imapAppendSentCopy: () => imapAppendSentCopy,
57850
57851
  imapBatchDelete: () => imapBatchDelete,
57851
57852
  imapBatchFlag: () => imapBatchFlag,
@@ -57883,6 +57884,7 @@ __export(imapClient_exports, {
57883
57884
  matchMailbox: () => matchMailbox,
57884
57885
  normalizeMessageId: () => normalizeMessageId,
57885
57886
  omittedNote: () => omittedNote,
57887
+ resolveDraftImapAccount: () => resolveDraftImapAccount,
57886
57888
  resolveImapConfig: () => resolveImapConfig,
57887
57889
  resolveImapConfigs: () => resolveImapConfigs,
57888
57890
  resolveMailboxPath: () => resolveMailboxPath,
@@ -58940,6 +58942,40 @@ async function imapAppendSentCopy(smtpUser, raw, deps = {}) {
58940
58942
  return { attempted: true, success: false, error: errText(e) };
58941
58943
  }
58942
58944
  }
58945
+ function resolveDraftImapAccount(account, smtpUser, env = process.env) {
58946
+ const specs = listImapAccountSpecs(env);
58947
+ const why = "Mail.app's AppleScript cannot set In-Reply-To/References on a draft, so a threaded draft is filed over IMAP into the account's Drafts mailbox";
58948
+ if (specs.length === 0) {
58949
+ throw new Error(
58950
+ `${why}, and IMAP is not configured. Configure IMAP, omit inReplyTo/references, or use reply-to-message with send=false. ${SETUP_HINT}`
58951
+ );
58952
+ }
58953
+ let spec;
58954
+ if (account) {
58955
+ spec = specs.find((s) => specMatchesSelector(s, account));
58956
+ if (!spec) {
58957
+ throw new Error(
58958
+ `${why}, and "${account}" is not a configured IMAP account. Configured: ${specs.map((s) => s.accountLabel).join(", ")}.`
58959
+ );
58960
+ }
58961
+ } else {
58962
+ spec = (smtpUser ? specs.find((s) => specMatchesSelector(s, smtpUser)) : void 0) ?? (specs.length === 1 ? specs[0] : void 0);
58963
+ if (!spec) {
58964
+ throw new Error(
58965
+ `${why}; several IMAP accounts are configured, so pass \`account\` to choose one: ${specs.map((s) => s.accountLabel).join(", ")}.`
58966
+ );
58967
+ }
58968
+ }
58969
+ return { label: spec.accountLabel, user: spec.user };
58970
+ }
58971
+ async function imapAppendDraft(account, raw, deps = {}) {
58972
+ return withClient({ ...deps, account: deps.account ?? account }, async (client, cfg) => {
58973
+ const path3 = await resolveMailboxPath(client, "drafts", "list");
58974
+ const res = await client.append(path3, raw, ["\\Draft", "\\Seen"]);
58975
+ if (!res) throw new Error(`server rejected the APPEND to "${path3}" (IMAP NO/BAD)`);
58976
+ return { mailbox: path3, account: cfg.accountLabel };
58977
+ });
58978
+ }
58943
58979
  async function withMailbox(path3, deps, fn) {
58944
58980
  return withClient(deps, async (client) => {
58945
58981
  const lock = await client.getMailboxLock(path3);
@@ -59795,6 +59831,27 @@ function buildAttachments(attachments) {
59795
59831
  return { filename: a.filename, content: decodeInlineAttachment(a.contentBase64) };
59796
59832
  });
59797
59833
  }
59834
+ function buildMailOptions(opts, from, attachments) {
59835
+ const html = opts.htmlBody?.trim() ? opts.htmlBody : void 0;
59836
+ return {
59837
+ from,
59838
+ to: opts.to,
59839
+ cc: opts.cc,
59840
+ bcc: opts.bcc,
59841
+ subject: opts.subject,
59842
+ text: opts.body,
59843
+ // When present, nodemailer emits multipart/alternative (text + html).
59844
+ html,
59845
+ attachments,
59846
+ // RFC 5322 threading for SMTP replies/forwards (2.5.0). nodemailer treats
59847
+ // In-Reply-To / References as structured msg-id headers and never
59848
+ // RFC 2047-encodes them, however long the id (pinned by a test, #267).
59849
+ inReplyTo: opts.inReplyTo?.trim() || void 0,
59850
+ references: opts.references?.length ? opts.references : void 0,
59851
+ // Reply-To header (2.18.0, issue #220): nodemailer already supports this.
59852
+ replyTo: opts.replyTo?.trim() || void 0
59853
+ };
59854
+ }
59798
59855
  async function sendViaSmtp(opts, config, createTransport2 = nodemailer_default.createTransport, appendSentCopy = defaultAppendSentCopy) {
59799
59856
  let cfg;
59800
59857
  try {
@@ -59833,23 +59890,7 @@ async function sendViaSmtp(opts, config, createTransport2 = nodemailer_default.c
59833
59890
  requireTLS,
59834
59891
  auth: { user: cfg.user, pass: cfg.pass }
59835
59892
  });
59836
- const html = opts.htmlBody?.trim() ? opts.htmlBody : void 0;
59837
- const mailOptions = {
59838
- from: requestedFrom || cfg.from,
59839
- to: opts.to,
59840
- cc: opts.cc,
59841
- bcc: opts.bcc,
59842
- subject: opts.subject,
59843
- text: opts.body,
59844
- // When present, nodemailer emits multipart/alternative (text + html).
59845
- html,
59846
- attachments,
59847
- // RFC 5322 threading for SMTP replies/forwards (2.5.0).
59848
- inReplyTo: opts.inReplyTo?.trim() || void 0,
59849
- references: opts.references?.length ? opts.references : void 0,
59850
- // Reply-To header (2.18.0, issue #220): nodemailer already supports this.
59851
- replyTo: opts.replyTo?.trim() || void 0
59852
- };
59893
+ const mailOptions = buildMailOptions(opts, requestedFrom || cfg.from, attachments);
59853
59894
  try {
59854
59895
  const info = await transporter.sendMail(mailOptions);
59855
59896
  let copyFields = {};
package/build/index.js CHANGED
@@ -65187,6 +65187,7 @@ __export(imapClient_exports, {
65187
65187
  decodeImapId: () => decodeImapId,
65188
65188
  dropAllPools: () => dropAllPools,
65189
65189
  encodeImapId: () => encodeImapId,
65190
+ imapAppendDraft: () => imapAppendDraft,
65190
65191
  imapAppendSentCopy: () => imapAppendSentCopy,
65191
65192
  imapBatchDelete: () => imapBatchDelete,
65192
65193
  imapBatchFlag: () => imapBatchFlag,
@@ -65224,6 +65225,7 @@ __export(imapClient_exports, {
65224
65225
  matchMailbox: () => matchMailbox,
65225
65226
  normalizeMessageId: () => normalizeMessageId,
65226
65227
  omittedNote: () => omittedNote,
65228
+ resolveDraftImapAccount: () => resolveDraftImapAccount,
65227
65229
  resolveImapConfig: () => resolveImapConfig,
65228
65230
  resolveImapConfigs: () => resolveImapConfigs,
65229
65231
  resolveMailboxPath: () => resolveMailboxPath,
@@ -66281,6 +66283,40 @@ async function imapAppendSentCopy(smtpUser, raw, deps = {}) {
66281
66283
  return { attempted: true, success: false, error: errText(e) };
66282
66284
  }
66283
66285
  }
66286
+ function resolveDraftImapAccount(account, smtpUser, env = process.env) {
66287
+ const specs = listImapAccountSpecs(env);
66288
+ const why = "Mail.app's AppleScript cannot set In-Reply-To/References on a draft, so a threaded draft is filed over IMAP into the account's Drafts mailbox";
66289
+ if (specs.length === 0) {
66290
+ throw new Error(
66291
+ `${why}, and IMAP is not configured. Configure IMAP, omit inReplyTo/references, or use reply-to-message with send=false. ${SETUP_HINT}`
66292
+ );
66293
+ }
66294
+ let spec;
66295
+ if (account) {
66296
+ spec = specs.find((s) => specMatchesSelector(s, account));
66297
+ if (!spec) {
66298
+ throw new Error(
66299
+ `${why}, and "${account}" is not a configured IMAP account. Configured: ${specs.map((s) => s.accountLabel).join(", ")}.`
66300
+ );
66301
+ }
66302
+ } else {
66303
+ spec = (smtpUser ? specs.find((s) => specMatchesSelector(s, smtpUser)) : void 0) ?? (specs.length === 1 ? specs[0] : void 0);
66304
+ if (!spec) {
66305
+ throw new Error(
66306
+ `${why}; several IMAP accounts are configured, so pass \`account\` to choose one: ${specs.map((s) => s.accountLabel).join(", ")}.`
66307
+ );
66308
+ }
66309
+ }
66310
+ return { label: spec.accountLabel, user: spec.user };
66311
+ }
66312
+ async function imapAppendDraft(account, raw, deps = {}) {
66313
+ return withClient({ ...deps, account: deps.account ?? account }, async (client, cfg) => {
66314
+ const path3 = await resolveMailboxPath(client, "drafts", "list");
66315
+ const res = await client.append(path3, raw, ["\\Draft", "\\Seen"]);
66316
+ if (!res) throw new Error(`server rejected the APPEND to "${path3}" (IMAP NO/BAD)`);
66317
+ return { mailbox: path3, account: cfg.accountLabel };
66318
+ });
66319
+ }
66284
66320
  async function withMailbox(path3, deps, fn) {
66285
66321
  return withClient(deps, async (client) => {
66286
66322
  const lock = await client.getMailboxLock(path3);
@@ -67146,6 +67182,34 @@ function buildAttachments(attachments) {
67146
67182
  return { filename: a.filename, content: decodeInlineAttachment(a.contentBase64) };
67147
67183
  });
67148
67184
  }
67185
+ function buildMailOptions(opts, from, attachments) {
67186
+ const html = opts.htmlBody?.trim() ? opts.htmlBody : void 0;
67187
+ return {
67188
+ from,
67189
+ to: opts.to,
67190
+ cc: opts.cc,
67191
+ bcc: opts.bcc,
67192
+ subject: opts.subject,
67193
+ text: opts.body,
67194
+ // When present, nodemailer emits multipart/alternative (text + html).
67195
+ html,
67196
+ attachments,
67197
+ // RFC 5322 threading for SMTP replies/forwards (2.5.0). nodemailer treats
67198
+ // In-Reply-To / References as structured msg-id headers and never
67199
+ // RFC 2047-encodes them, however long the id (pinned by a test, #267).
67200
+ inReplyTo: opts.inReplyTo?.trim() || void 0,
67201
+ references: opts.references?.length ? opts.references : void 0,
67202
+ // Reply-To header (2.18.0, issue #220): nodemailer already supports this.
67203
+ replyTo: opts.replyTo?.trim() || void 0
67204
+ };
67205
+ }
67206
+ function composeRawMime(opts, from) {
67207
+ return buildRawMime({
67208
+ ...buildMailOptions(opts, from, buildAttachments(opts.attachments)),
67209
+ keepBcc: true,
67210
+ date: /* @__PURE__ */ new Date()
67211
+ });
67212
+ }
67149
67213
  async function sendViaSmtp(opts, config2, createTransport2 = nodemailer_default.createTransport, appendSentCopy = defaultAppendSentCopy) {
67150
67214
  let cfg;
67151
67215
  try {
@@ -67184,23 +67248,7 @@ async function sendViaSmtp(opts, config2, createTransport2 = nodemailer_default.
67184
67248
  requireTLS,
67185
67249
  auth: { user: cfg.user, pass: cfg.pass }
67186
67250
  });
67187
- const html = opts.htmlBody?.trim() ? opts.htmlBody : void 0;
67188
- const mailOptions = {
67189
- from: requestedFrom || cfg.from,
67190
- to: opts.to,
67191
- cc: opts.cc,
67192
- bcc: opts.bcc,
67193
- subject: opts.subject,
67194
- text: opts.body,
67195
- // When present, nodemailer emits multipart/alternative (text + html).
67196
- html,
67197
- attachments,
67198
- // RFC 5322 threading for SMTP replies/forwards (2.5.0).
67199
- inReplyTo: opts.inReplyTo?.trim() || void 0,
67200
- references: opts.references?.length ? opts.references : void 0,
67201
- // Reply-To header (2.18.0, issue #220): nodemailer already supports this.
67202
- replyTo: opts.replyTo?.trim() || void 0
67203
- };
67251
+ const mailOptions = buildMailOptions(opts, requestedFrom || cfg.from, attachments);
67204
67252
  try {
67205
67253
  const info = await transporter.sendMail(mailOptions);
67206
67254
  let copyFields = {};
@@ -82144,7 +82192,7 @@ function materializeAttachments(attachments) {
82144
82192
  if (!dir) dir = mkdtempSync(join3(tmpdir2(), "amcp-att-"));
82145
82193
  const safeName = a.filename.replace(/[/\\]/g, "_");
82146
82194
  const p = join3(dir, safeName);
82147
- writeFileSync2(p, decodeInlineAttachment(a.contentBase64));
82195
+ writeFileSync2(p, decodeInlineAttachment(a.contentBase64), { mode: 384 });
82148
82196
  return p;
82149
82197
  });
82150
82198
  } catch (error3) {
@@ -82158,6 +82206,18 @@ function materializeAttachments(attachments) {
82158
82206
  }
82159
82207
  };
82160
82208
  }
82209
+ function preflightAttachments(attachments) {
82210
+ for (const a of attachments ?? []) {
82211
+ if (typeof a === "string") {
82212
+ resolveAttachmentReadPath(a);
82213
+ continue;
82214
+ }
82215
+ if (!a.filename || !a.contentBase64) {
82216
+ throw new Error("Inline attachment requires both filename and contentBase64.");
82217
+ }
82218
+ decodeInlineAttachment(a.contentBase64);
82219
+ }
82220
+ }
82161
82221
 
82162
82222
  // src/services/appleMailManager.ts
82163
82223
  init_attachmentReadPolicy();
@@ -82421,6 +82481,11 @@ function buildAttachmentCommands(attachments) {
82421
82481
  }
82422
82482
  return commands;
82423
82483
  }
82484
+ function composeAttachmentBlock(msgVar, paths) {
82485
+ const commands = buildAttachmentCommands(paths);
82486
+ return commands ? `tell ${msgVar}
82487
+ ${commands}end tell` : "";
82488
+ }
82424
82489
  function asDateToString(varName) {
82425
82490
  return `((year of ${varName}) as string) & "-" & ((month of ${varName} as integer) as string) & "-" & ((day of ${varName}) as string) & "-" & ((hours of ${varName}) as string) & "-" & ((minutes of ${varName}) as string) & "-" & ((seconds of ${varName}) as string)`;
82426
82491
  }
@@ -84670,20 +84735,28 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
84670
84735
  * @param body - Reply body
84671
84736
  * @param replyAll - If true, reply to all recipients
84672
84737
  * @param send - If true, send immediately; if false, save as draft
84738
+ * @param attachments - Files to attach (#267): allowlisted paths and/or inline
84739
+ * base64 items (materialized to 0600 temp files, removed afterwards)
84673
84740
  * @returns true if reply created/sent successfully
84674
84741
  */
84675
- replyToMessage(id, body, replyAll = false, send = true) {
84742
+ replyToMessage(id, body, replyAll = false, send = true, attachments) {
84676
84743
  const safeBody = escapeForAppleScriptBody(body);
84677
84744
  const replyAllClause = replyAll ? " with reply to all" : "";
84678
84745
  const finalAction = send ? "send theReply" : "save theReply\n close theReply saving yes";
84679
- const script = this.findMessageScript(
84680
- id,
84681
- `
84746
+ const mat = materializeAttachments(attachments);
84747
+ try {
84748
+ const script = this.findMessageScript(
84749
+ id,
84750
+ `
84682
84751
  set theReply to reply msg without opening window${replyAllClause}
84683
84752
  set content of theReply to "${safeBody}"
84753
+ ${composeAttachmentBlock("theReply", mat.paths)}
84684
84754
  ${finalAction}`
84685
- );
84686
- return this.runComposeScript(script, "reply to");
84755
+ );
84756
+ return this.runComposeScript(script, "reply to");
84757
+ } finally {
84758
+ mat.cleanup();
84759
+ }
84687
84760
  }
84688
84761
  /**
84689
84762
  * Forward a message.
@@ -84692,9 +84765,10 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
84692
84765
  * @param to - Recipients to forward to
84693
84766
  * @param body - Optional body to prepend
84694
84767
  * @param send - If true, send immediately; if false, save as draft
84768
+ * @param attachments - Additional files to attach (#267), as for replyToMessage
84695
84769
  * @returns true if forward created/sent successfully
84696
84770
  */
84697
- forwardMessage(id, to, body, send = true) {
84771
+ forwardMessage(id, to, body, send = true, attachments) {
84698
84772
  const safeBody = body ? escapeForAppleScriptBody(body) : "";
84699
84773
  const finalAction = send ? "send theForward" : "save theForward\n close theForward saving yes";
84700
84774
  let recipientCommands = "";
@@ -84702,15 +84776,21 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
84702
84776
  recipientCommands += `make new to recipient at end of to recipients of theForward with properties {address:"${escapeForAppleScript(addr)}"}
84703
84777
  `;
84704
84778
  }
84705
- const script = this.findMessageScript(
84706
- id,
84707
- `
84779
+ const mat = materializeAttachments(attachments);
84780
+ try {
84781
+ const script = this.findMessageScript(
84782
+ id,
84783
+ `
84708
84784
  set theForward to forward msg without opening window
84709
84785
  ${recipientCommands}
84710
84786
  ${safeBody ? `set content of theForward to "${safeBody}"` : ""}
84787
+ ${composeAttachmentBlock("theForward", mat.paths)}
84711
84788
  ${finalAction}`
84712
- );
84713
- return this.runComposeScript(script, "forward");
84789
+ );
84790
+ return this.runComposeScript(script, "forward");
84791
+ } finally {
84792
+ mat.cleanup();
84793
+ }
84714
84794
  }
84715
84795
  /**
84716
84796
  * Helper to find and operate on a message by ID, scoped to the mailbox the id
@@ -84745,7 +84825,7 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
84745
84825
  * behind one indistinguishable message.
84746
84826
  */
84747
84827
  runComposeScript(script, verb) {
84748
- const result = executeAppleScript(script, { timeoutMs: 6e4 });
84828
+ const result = executeAppleScript(script, { timeoutMs: 6e4, maxRetries: 1 });
84749
84829
  if (!result.success || result.output.startsWith("error:")) {
84750
84830
  const raw = result.error || result.output;
84751
84831
  const error3 = raw.startsWith("error:") ? raw.slice("error:".length) : raw;
@@ -84935,13 +85015,13 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
84935
85015
  try
84936
85016
  set inMb to mailbox "INBOX" of acct
84937
85017
  set inHits to ${matchClause("inMb")}
84938
- if (count of inHits) > 0 then return (id of (item 1 of inHits)) as string
85018
+ if (count of inHits) > 0 then return ((id of (item 1 of inHits)) as string) & tab & (name of inMb) & tab & (name of acct)
84939
85019
  end try
84940
85020
  repeat with mb in mailboxes of acct
84941
85021
  try
84942
85022
  set matchingMsgs to ${matchClause("mb")}
84943
85023
  if (count of matchingMsgs) > 0 then
84944
- return (id of (item 1 of matchingMsgs)) as string
85024
+ return ((id of (item 1 of matchingMsgs)) as string) & tab & (name of mb) & tab & (name of acct)
84945
85025
  end if
84946
85026
  end try
84947
85027
  end repeat
@@ -84953,8 +85033,10 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
84953
85033
  `);
84954
85034
  const result = executeAppleScript(script, { timeoutMs: 6e4 });
84955
85035
  if (!result.success || result.output.startsWith("error:")) return null;
84956
- const out = result.output.trim();
84957
- return /^\d+$/.test(out) ? out : null;
85036
+ const [numericId, mailbox, account] = result.output.trim().split(" ");
85037
+ if (!/^\d+$/.test(numericId ?? "")) return null;
85038
+ if (mailbox && account) this.rememberLocation(numericId, account, mailbox);
85039
+ return numericId;
84958
85040
  }
84959
85041
  /**
84960
85042
  * Unflag a message.
@@ -87195,19 +87277,28 @@ async function replyComposeBody(deps, id, body) {
87195
87277
  const source = await readOriginalForQuote(deps, id);
87196
87278
  return source ? buildReplyBody(body, source.original, source.plainText) : body;
87197
87279
  }
87198
- async function forwardComposeBody(deps, id, body) {
87199
- if (!body) return body;
87280
+ async function forwardComposeBody(deps, id, body, hasAttachments = false) {
87281
+ if (!body && !hasAttachments) return body;
87200
87282
  const source = await readOriginalForQuote(deps, id);
87201
87283
  return source ? buildForwardBody(source.original, source.plainText, body) : body;
87202
87284
  }
87203
87285
  async function runCompose(deps, args) {
87204
- const { id, send, transport: transport2 } = args;
87286
+ const { id, send, transport: transport2, attachments } = args;
87205
87287
  const verb = args.kind === "reply" ? "reply to" : "forward";
87206
87288
  if (!send && transport2 === "smtp") {
87207
87289
  return errorResponse(
87208
87290
  "SMTP cannot save a Mail.app draft. Omit transport or use transport=applescript with send=false."
87209
87291
  );
87210
87292
  }
87293
+ try {
87294
+ preflightAttachments(attachments);
87295
+ } catch (error3) {
87296
+ return errorResponse(
87297
+ `Failed to ${verb} message "${id}": ${error3 instanceof Error ? error3.message : String(error3)}`
87298
+ );
87299
+ }
87300
+ const attachmentCount = attachments?.length ?? 0;
87301
+ const withFiles = attachmentCount ? ` with ${attachmentCount} attachment(s)` : "";
87211
87302
  const smtp = send && transport2 !== "applescript" && (transport2 === "smtp" || deps.smtpConfigured());
87212
87303
  if (smtp) {
87213
87304
  try {
@@ -87239,19 +87330,21 @@ async function runCompose(deps, args) {
87239
87330
  body: args.body,
87240
87331
  from: cfg.from
87241
87332
  });
87333
+ if (attachmentCount) opts.attachments = attachments;
87242
87334
  const result = await deps.smtpSend(opts, cfg);
87243
87335
  if (!result.success)
87244
87336
  return errorResponse(
87245
87337
  `Failed to ${verb} message "${id}" via SMTP: ${result.error ?? "unknown SMTP error"}`
87246
87338
  );
87247
87339
  return successResponse(
87248
- args.kind === "reply" ? "Reply sent via SMTP" : `Message forwarded via SMTP to ${args.to.join(", ")}`,
87340
+ args.kind === "reply" ? `Reply sent via SMTP${withFiles}` : `Message forwarded via SMTP to ${args.to.join(", ")}${withFiles}`,
87249
87341
  {
87250
87342
  ok: true,
87251
87343
  sent: true,
87252
87344
  id,
87253
87345
  transport: "smtp",
87254
87346
  messageId: result.messageId,
87347
+ attachmentCount,
87255
87348
  ...args.kind === "forward" ? { recipients: args.to } : {},
87256
87349
  // Best-effort Sent-folder copy (issue #220) — same field shape as
87257
87350
  // send-email, since it's the same sendViaSmtp underneath.
@@ -87274,23 +87367,26 @@ async function runCompose(deps, args) {
87274
87367
  resolved.numericId,
87275
87368
  await replyComposeBody(deps, id, args.body),
87276
87369
  args.replyAll,
87277
- send
87370
+ send,
87371
+ attachmentCount ? attachments : void 0
87278
87372
  ) : deps.mail.forwardMessage(
87279
87373
  resolved.numericId,
87280
87374
  args.to,
87281
- await forwardComposeBody(deps, id, args.body),
87282
- send
87375
+ await forwardComposeBody(deps, id, args.body, attachmentCount > 0),
87376
+ send,
87377
+ attachmentCount ? attachments : void 0
87283
87378
  );
87284
87379
  if (!outcome.success)
87285
87380
  return errorResponse(
87286
87381
  `Failed to ${verb} message "${id}": ${outcome.error ?? "Mail.app compose failed"}`
87287
87382
  );
87288
- 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";
87383
+ 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") + withFiles;
87289
87384
  return successResponse(text, {
87290
87385
  ok: true,
87291
87386
  sent: send,
87292
87387
  id,
87293
87388
  transport: "applescript",
87389
+ attachmentCount,
87294
87390
  ...args.kind === "forward" ? { recipients: args.to } : {}
87295
87391
  });
87296
87392
  }
@@ -87301,6 +87397,70 @@ function runForward(deps, args) {
87301
87397
  return runCompose(deps, { ...args, kind: "forward" });
87302
87398
  }
87303
87399
 
87400
+ // src/tools/threading.ts
87401
+ function threadingHeaders(args) {
87402
+ const inReplyTo = args.inReplyTo?.trim() || void 0;
87403
+ const references = args.references?.length ? args.references : void 0;
87404
+ if (!inReplyTo && !references) return void 0;
87405
+ return { inReplyTo, references: references ?? (inReplyTo ? [inReplyTo] : void 0) };
87406
+ }
87407
+ var SEND_THREADING_NEEDS_SMTP = "inReplyTo/references require the SMTP transport: Mail.app's AppleScript cannot set In-Reply-To or References, so the message would go out unthreaded. Configure SMTP (and do not pass transport=applescript or a Mail.app account label), or use reply-to-message, which threads on both transports.";
87408
+ function sendEmailThreading(args, useSmtp) {
87409
+ const headers = threadingHeaders(args);
87410
+ if (headers && !useSmtp) return { ok: false, error: SEND_THREADING_NEEDS_SMTP };
87411
+ return { ok: true, headers };
87412
+ }
87413
+ function messageIdOf(raw) {
87414
+ const head = raw.toString("utf8").split(/\r?\n\r?\n/)[0] ?? "";
87415
+ return head.replace(/\r?\n[ \t]+/g, " ").match(/^Message-ID:\s*(<[^>\s]+>)/im)?.[1];
87416
+ }
87417
+ async function runThreadedDraft(deps, args) {
87418
+ const threading = threadingHeaders(args);
87419
+ if (!threading) throw new Error("runThreadedDraft called without threading headers");
87420
+ const fail = (e) => errorResponse(
87421
+ `Failed to create threaded draft: ${e instanceof Error ? e.message : String(e)} Nothing was filed; no Mail.app fallback was attempted (it would drop the threading headers).`
87422
+ );
87423
+ try {
87424
+ preflightAttachments(args.attachments);
87425
+ const smtp = deps.smtpIdentity();
87426
+ const acct = deps.resolveAccount(args.account, smtp.user);
87427
+ const from = smtp.user && smtp.user.toLowerCase() === acct.user.toLowerCase() && smtp.from ? smtp.from : acct.user;
87428
+ const raw = await deps.compose(
87429
+ {
87430
+ to: args.to,
87431
+ cc: args.cc,
87432
+ bcc: args.bcc,
87433
+ subject: args.subject,
87434
+ body: args.body,
87435
+ attachments: args.attachments,
87436
+ inReplyTo: threading.inReplyTo,
87437
+ references: threading.references
87438
+ },
87439
+ from
87440
+ );
87441
+ const filed = await deps.append(acct.label, raw);
87442
+ const attachmentCount = args.attachments?.length ?? 0;
87443
+ const attachInfo = attachmentCount ? ` with ${attachmentCount} attachment(s)` : "";
87444
+ const messageId = messageIdOf(raw);
87445
+ return successResponse(
87446
+ `Threaded draft for ${args.to.join(", ")}${attachInfo} filed over IMAP in "${filed.mailbox}" (account ${filed.account}). Review and send it from Mail.app.`,
87447
+ {
87448
+ ok: true,
87449
+ recipients: args.to,
87450
+ attachmentCount,
87451
+ transport: "imap",
87452
+ account: filed.account,
87453
+ mailbox: filed.mailbox,
87454
+ ...messageId ? { messageId } : {},
87455
+ ...threading.inReplyTo ? { inReplyTo: threading.inReplyTo } : {},
87456
+ references: threading.references
87457
+ }
87458
+ );
87459
+ } catch (e) {
87460
+ return fail(e);
87461
+ }
87462
+ }
87463
+
87304
87464
  // src/index.ts
87305
87465
  init_imapClient();
87306
87466
 
@@ -87812,6 +87972,17 @@ var ATTACHMENTS_SCHEMA = external_exports.array(
87812
87972
  ).max(20, "Cannot attach more than 20 files").optional().describe(
87813
87973
  "Files to attach: absolute paths in the configured attachment read roots (e.g. '/Users/me/Documents/report.pdf') and/or inline {filename, contentBase64} objects up to 25 MiB decoded each."
87814
87974
  );
87975
+ var RFC_MESSAGE_ID_TOKEN = /^<[^<>\s@]+@[^<>\s@]+>$/;
87976
+ var MESSAGE_ID_TOKEN_SCHEMA = external_exports.string().max(998, "Message-ID is longer than an RFC 5322 header line (998 chars)").regex(
87977
+ RFC_MESSAGE_ID_TOKEN,
87978
+ "Must be an RFC 5322 Message-ID token including angle brackets, e.g. <abc123@mail.example.com>"
87979
+ );
87980
+ var IN_REPLY_TO_SCHEMA = MESSAGE_ID_TOKEN_SCHEMA.optional().describe(
87981
+ "Thread this message as a reply: the parent's Message-ID token, angle brackets included (e.g. '<abc123@mail.example.com>'). Emitted verbatim as In-Reply-To, never RFC 2047-encoded. When `references` is omitted, References defaults to [inReplyTo]. Prefer reply-to-message, which derives recipients, threading and the quote from the original for you."
87982
+ );
87983
+ var REFERENCES_SCHEMA = external_exports.array(MESSAGE_ID_TOKEN_SCHEMA).min(1, "references must contain at least one Message-ID").max(100, "Cannot list more than 100 Message-IDs in references").optional().describe(
87984
+ "Thread chain, oldest first: the parent's own References followed by the parent's Message-ID (each an angle-bracketed token). Emitted verbatim, never RFC 2047-encoded."
87985
+ );
87815
87986
 
87816
87987
  // src/tools/thread.ts
87817
87988
  function normalizeSubject(subject) {
@@ -88896,7 +89067,7 @@ var SENT_COPY_ERROR_SCHEMA = external_exports.string().optional().describe("Pres
88896
89067
  registerTool(
88897
89068
  "send-email",
88898
89069
  {
88899
- description: "Use when: the user has explicitly confirmed they want to send a single email now to the given recipients (to/cc/bcc are arrays), optionally with attachments and a chosen transport.\nReturns: a confirmation naming the recipients and attachment count; over SMTP, also whether a Sent-folder copy was filed (sentCopy).\nDo not use when: the user wants to review first (use create-draft), is replying to or forwarding an existing message (use reply-to-message / forward-message), or wants per-recipient personalized copies (use send-serial-email).\nSafety: this SENDS real email immediately and it cannot be unsent \u2014 require explicit user confirmation of the exact recipients, subject, and body before calling. Prefer create-draft when there is any doubt.",
89070
+ description: "Use when: the user has explicitly confirmed they want to send a single email now to the given recipients (to/cc/bcc are arrays), optionally with attachments, a chosen transport, and caller-built threading headers (inReplyTo/references, SMTP only).\nReturns: a confirmation naming the recipients and attachment count; over SMTP, also whether a Sent-folder copy was filed (sentCopy).\nDo not use when: the user wants to review first (use create-draft), is replying to or forwarding an existing message (use reply-to-message / forward-message \u2014 they derive recipients, threading and the quote, and also take attachments), or wants per-recipient personalized copies (use send-serial-email).\nSafety: this SENDS real email immediately and it cannot be unsent \u2014 require explicit user confirmation of the exact recipients, subject, and body before calling. Prefer create-draft when there is any doubt.",
88900
89071
  inputSchema: {
88901
89072
  to: external_exports.array(external_exports.string()).min(1, "At least one recipient is required"),
88902
89073
  subject: external_exports.string().min(1, "Subject is required"),
@@ -88905,6 +89076,8 @@ registerTool(
88905
89076
  bcc: external_exports.array(external_exports.string()).optional().describe("BCC recipients"),
88906
89077
  account: external_exports.string().optional().describe("Account to send from"),
88907
89078
  attachments: ATTACHMENTS_SCHEMA,
89079
+ inReplyTo: IN_REPLY_TO_SCHEMA,
89080
+ references: REFERENCES_SCHEMA,
88908
89081
  replyTo: external_exports.string().optional().describe(
88909
89082
  "SMTP only (issue #220): sets the Reply-To header when replies should go somewhere other than the From/login address, e.g. a domain-alias setup where APPLE_MAIL_MCP_SMTP_FROM differs from APPLE_MAIL_MCP_SMTP_USER. Ignored on the AppleScript transport."
88910
89083
  ),
@@ -88917,15 +89090,31 @@ registerTool(
88917
89090
  recipients: external_exports.array(external_exports.string()).optional(),
88918
89091
  attachmentCount: external_exports.number().optional(),
88919
89092
  transport: external_exports.string().optional(),
89093
+ messageId: external_exports.string().optional(),
88920
89094
  sentCopy: SENT_COPY_SCHEMA,
88921
89095
  sentCopyError: SENT_COPY_ERROR_SCHEMA
88922
89096
  }
88923
89097
  },
88924
89098
  withErrorHandling(
88925
- async ({ to, subject, body, cc, bcc, account, attachments, transport: transport2, replyTo }) => {
89099
+ async ({
89100
+ to,
89101
+ subject,
89102
+ body,
89103
+ cc,
89104
+ bcc,
89105
+ account,
89106
+ attachments,
89107
+ transport: transport2,
89108
+ replyTo,
89109
+ inReplyTo,
89110
+ references
89111
+ }) => {
88926
89112
  const attachInfo = attachments?.length ? ` with ${attachments.length} attachment(s)` : "";
88927
89113
  const attachmentCount = attachments?.length ?? 0;
88928
- if (shouldUseSmtp(transport2, account)) {
89114
+ const useSmtp = shouldUseSmtp(transport2, account);
89115
+ const threading = sendEmailThreading({ inReplyTo, references }, useSmtp);
89116
+ if (!threading.ok) return errorResponse(threading.error);
89117
+ if (useSmtp) {
88929
89118
  const smtpFrom = account?.includes("@") ? account : void 0;
88930
89119
  const result = await sendViaSmtp({
88931
89120
  to,
@@ -88935,7 +89124,8 @@ registerTool(
88935
89124
  bcc,
88936
89125
  from: smtpFrom,
88937
89126
  attachments,
88938
- replyTo
89127
+ replyTo,
89128
+ ...threading.headers
88939
89129
  });
88940
89130
  if (!result.success) {
88941
89131
  return errorResponse(result.error ?? "Failed to send email via SMTP.");
@@ -88946,6 +89136,7 @@ registerTool(
88946
89136
  recipients: to,
88947
89137
  attachmentCount,
88948
89138
  transport: "smtp",
89139
+ ...result.messageId ? { messageId: result.messageId } : {},
88949
89140
  ...result.sentCopy !== void 0 ? { sentCopy: result.sentCopy } : {},
88950
89141
  ...result.sentCopyError !== void 0 ? { sentCopyError: result.sentCopyError } : {}
88951
89142
  });
@@ -89023,36 +89214,73 @@ ${details}`,
89023
89214
  registerTool(
89024
89215
  "create-draft",
89025
89216
  {
89026
- description: "Use when: composing an email the user should review in Mail.app before sending \u2014 the safe default for any new message (to/cc/bcc are arrays, optional attachments).\nReturns: a confirmation that the draft was created, with recipients and attachment count.\nDo not use when: the user has already confirmed they want it sent now (use send-email).\nSafety: low risk \u2014 creates a draft only and sends nothing; the user must open Mail.app and send it themselves.",
89217
+ description: "Use when: composing an email the user should review in Mail.app before sending \u2014 the safe default for any new message (to/cc/bcc are arrays, optional attachments). With inReplyTo/references the draft is threaded: it is composed by this server and filed over IMAP into the account's Drafts mailbox (Mail.app's AppleScript cannot set those headers), so an IMAP account is required.\nReturns: a confirmation that the draft was created, with recipients and attachment count; for a threaded draft also its Drafts mailbox, account and Message-ID (transport: imap).\nDo not use when: the user has already confirmed they want it sent now (use send-email), or is replying to an existing message (use reply-to-message with send=false \u2014 it derives recipients, threading and the quote and takes attachments).\nSafety: low risk \u2014 creates a draft only and sends nothing; the user must open Mail.app and send it themselves.",
89027
89218
  inputSchema: {
89028
89219
  to: external_exports.array(external_exports.string()).min(1, "At least one recipient is required"),
89029
89220
  subject: external_exports.string().min(1, "Subject is required"),
89030
89221
  body: external_exports.string().min(1, "Body is required"),
89031
89222
  cc: external_exports.array(external_exports.string()).optional().describe("CC recipients"),
89032
89223
  bcc: external_exports.array(external_exports.string()).optional().describe("BCC recipients"),
89033
- account: external_exports.string().optional().describe("Account to create draft in"),
89034
- attachments: ATTACHMENTS_SCHEMA
89224
+ account: external_exports.string().optional().describe(
89225
+ "Account to create draft in. With inReplyTo/references this must name a configured IMAP account (label or login); when omitted, the IMAP account matching the SMTP identity, else the only IMAP account, is used."
89226
+ ),
89227
+ attachments: ATTACHMENTS_SCHEMA,
89228
+ inReplyTo: IN_REPLY_TO_SCHEMA,
89229
+ references: REFERENCES_SCHEMA
89035
89230
  },
89036
89231
  outputSchema: {
89037
89232
  ok: external_exports.boolean().optional(),
89038
89233
  recipients: external_exports.array(external_exports.string()).optional(),
89039
- attachmentCount: external_exports.number().optional()
89234
+ attachmentCount: external_exports.number().optional(),
89235
+ transport: external_exports.enum(["applescript", "imap"]).optional(),
89236
+ account: external_exports.string().optional().describe("Threaded draft only: IMAP account it was filed in"),
89237
+ mailbox: external_exports.string().optional().describe("Threaded draft only: Drafts mailbox path"),
89238
+ messageId: external_exports.string().optional().describe("Threaded draft only: the draft's Message-ID"),
89239
+ inReplyTo: external_exports.string().optional(),
89240
+ references: external_exports.array(external_exports.string()).optional()
89040
89241
  }
89041
89242
  },
89042
- withErrorHandling(({ to, subject, body, cc, bcc, account, attachments }) => {
89043
- const success = mailManager.createDraft(to, subject, body, cc, bcc, account, attachments);
89044
- if (!success) {
89045
- return errorResponse("Failed to create draft. Check Mail.app configuration.");
89046
- }
89047
- const attachmentCount = attachments?.length ?? 0;
89048
- const attachInfo = attachmentCount ? ` with ${attachmentCount} attachment(s)` : "";
89049
- return successResponse(`Draft created for ${to.join(", ")}${attachInfo}`, {
89050
- ok: true,
89051
- recipients: to,
89052
- attachmentCount
89053
- });
89054
- }, "Error creating draft")
89243
+ withErrorHandling(
89244
+ async ({ to, subject, body, cc, bcc, account, attachments, inReplyTo, references }) => {
89245
+ if (threadingHeaders({ inReplyTo, references })) {
89246
+ return runThreadedDraft(threadedDraftDeps, {
89247
+ to,
89248
+ subject,
89249
+ body,
89250
+ cc,
89251
+ bcc,
89252
+ account,
89253
+ attachments,
89254
+ inReplyTo,
89255
+ references
89256
+ });
89257
+ }
89258
+ const success = mailManager.createDraft(to, subject, body, cc, bcc, account, attachments);
89259
+ if (!success) {
89260
+ return errorResponse("Failed to create draft. Check Mail.app configuration.");
89261
+ }
89262
+ const attachmentCount = attachments?.length ?? 0;
89263
+ const attachInfo = attachmentCount ? ` with ${attachmentCount} attachment(s)` : "";
89264
+ return successResponse(`Draft created for ${to.join(", ")}${attachInfo}`, {
89265
+ ok: true,
89266
+ recipients: to,
89267
+ attachmentCount,
89268
+ transport: "applescript"
89269
+ });
89270
+ },
89271
+ "Error creating draft"
89272
+ )
89055
89273
  );
89274
+ var threadedDraftDeps = {
89275
+ resolveAccount: (account, smtpUser) => resolveDraftImapAccount(account, smtpUser),
89276
+ smtpIdentity: () => {
89277
+ if (!isSmtpConfigured()) return {};
89278
+ const user = process.env[SMTP_ENV.user]?.trim();
89279
+ return { user, from: process.env[SMTP_ENV.from]?.trim() || user };
89280
+ },
89281
+ compose: composeRawMime,
89282
+ append: (account, raw) => imapAppendDraft(account, raw)
89283
+ };
89056
89284
  function resolveSmtpOrFallback() {
89057
89285
  try {
89058
89286
  return resolveSmtpConfig();
@@ -89091,13 +89319,14 @@ var COMPOSE_TRANSPORT_SCHEMA = external_exports.enum(["applescript", "smtp"]).op
89091
89319
  registerTool(
89092
89320
  "reply-to-message",
89093
89321
  {
89094
- 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; over SMTP, also whether a Sent-folder copy was filed (sentCopy).\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.",
89322
+ description: "Use when: replying to an existing message by id, preserving its threading headers and quoting the original, optionally with attachments (same rules as send-email). 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, with the attachment count; over SMTP, also whether a Sent-folder copy was filed (sentCopy).\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.",
89095
89323
  inputSchema: {
89096
89324
  id: MESSAGE_ID_SCHEMA,
89097
89325
  transport: COMPOSE_TRANSPORT_SCHEMA,
89098
89326
  body: external_exports.string().min(1, "Reply body is required").describe("Reply body (plain text; HTML tags such as <br> are not rendered)"),
89099
89327
  replyAll: external_exports.boolean().optional().default(false).describe("Reply to all recipients"),
89100
- send: external_exports.boolean().optional().default(true).describe("Send immediately (false = save as draft)")
89328
+ send: external_exports.boolean().optional().default(true).describe("Send immediately (false = save as draft)"),
89329
+ attachments: ATTACHMENTS_SCHEMA
89101
89330
  },
89102
89331
  outputSchema: {
89103
89332
  transport: external_exports.enum(["smtp", "applescript"]).optional(),
@@ -89105,6 +89334,7 @@ registerTool(
89105
89334
  ok: external_exports.boolean().optional(),
89106
89335
  sent: external_exports.boolean().optional(),
89107
89336
  id: external_exports.string().optional(),
89337
+ attachmentCount: external_exports.number().optional(),
89108
89338
  sentCopy: SENT_COPY_SCHEMA,
89109
89339
  sentCopyError: SENT_COPY_ERROR_SCHEMA
89110
89340
  }
@@ -89114,13 +89344,14 @@ registerTool(
89114
89344
  registerTool(
89115
89345
  "forward-message",
89116
89346
  {
89117
- 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; over SMTP, also whether a Sent-folder copy was filed (sentCopy).\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.",
89347
+ description: "Use when: forwarding an existing message (by id) to new recipients (to is an array), with an optional body to prepend and optional extra attachments (same rules as send-email). Set send=false to save as a draft.\nReturns: a confirmation that the message was forwarded or saved as a draft, with the attachment count; over SMTP, also whether a Sent-folder copy was filed (sentCopy).\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.",
89118
89348
  inputSchema: {
89119
89349
  id: MESSAGE_ID_SCHEMA,
89120
89350
  transport: COMPOSE_TRANSPORT_SCHEMA,
89121
89351
  to: external_exports.array(external_exports.string()).min(1, "At least one recipient is required"),
89122
89352
  body: external_exports.string().optional().describe("Optional message to prepend (plain text)"),
89123
- send: external_exports.boolean().optional().default(true).describe("Send immediately (false = save as draft)")
89353
+ send: external_exports.boolean().optional().default(true).describe("Send immediately (false = save as draft)"),
89354
+ attachments: ATTACHMENTS_SCHEMA
89124
89355
  },
89125
89356
  outputSchema: {
89126
89357
  transport: external_exports.enum(["smtp", "applescript"]).optional(),
@@ -89129,6 +89360,7 @@ registerTool(
89129
89360
  sent: external_exports.boolean().optional(),
89130
89361
  recipients: external_exports.array(external_exports.string()).optional(),
89131
89362
  id: external_exports.string().optional(),
89363
+ attachmentCount: external_exports.number().optional(),
89132
89364
  sentCopy: SENT_COPY_SCHEMA,
89133
89365
  sentCopyError: SENT_COPY_ERROR_SCHEMA
89134
89366
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-mail-mcp",
3
- "version": "2.19.22",
3
+ "version": "2.20.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",