apple-mail-mcp 2.19.22 → 2.20.1

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 (4) hide show
  1. package/README.md +109 -34
  2. package/build/cli.js +179 -27
  3. package/build/index.js +430 -85
  4. package/package.json +1 -1
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,27 @@ 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
+ pass the exact `accountLabel` or login from `list-accounts`/your `APPLE_MAIL_MCP_IMAP_*`
818
+ config, e.g. `"user@icloud.com"`; a generic provider name like `"iCloud"` is not a
819
+ recognized alias and is refused);
820
+ otherwise the IMAP account whose login is the SMTP identity, else the only configured
821
+ IMAP account. When none qualifies — IMAP unconfigured, an unknown `account`, or several
822
+ accounts and no way to choose — the call **fails and files nothing**; it never falls back
823
+ to an unthreaded Mail.app draft. The result names the Drafts `mailbox`, the `account` and
824
+ the draft's `messageId`. Without threading headers, `create-draft` is unchanged
825
+ (AppleScript).
790
826
 
791
827
  #### `get-thread`
792
828
 
@@ -832,13 +868,14 @@ Map `imap:` message IDs to their numeric Mail.app IDs, via each message's RFC 53
832
868
 
833
869
  Reply to an existing message.
834
870
 
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. |
871
+ | Parameter | Type | Required | Description |
872
+ | ------------- | --------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
873
+ | `id` | string | Yes | Message ID to reply to |
874
+ | `body` | string | Yes | Reply body (plain text; HTML tags such as `<br>` are not rendered) |
875
+ | `replyAll` | boolean | No | Reply to all recipients (default: false) |
876
+ | `send` | boolean | No | Send immediately (default: true, false = save as draft) |
877
+ | `transport` | string | No | `smtp` or `applescript`; omitted prefers configured SMTP when sending. Drafts use AppleScript. |
878
+ | `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
879
 
843
880
  **Example - Reply to sender only:**
844
881
 
@@ -860,6 +897,30 @@ Reply to an existing message.
860
897
  }
861
898
  ```
862
899
 
900
+ **Example - Reply all with an attachment (2.20.0):**
901
+
902
+ ```json
903
+ {
904
+ "id": "imap:eyJhIjoiV29yayIsInAiOiJJTkJPWCIsInUiOjQyfQ",
905
+ "body": "Here is the PDF you asked for.",
906
+ "replyAll": true,
907
+ "attachments": [
908
+ "/Users/me/Documents/quote.pdf",
909
+ { "filename": "notes.txt", "contentBase64": "SGVsbG8K" }
910
+ ]
911
+ }
912
+ ```
913
+
914
+ **Attachments (2.20.0, #267):** same schema, limits (20 items, 25 MiB each) and read
915
+ roots as `send-email`, validated before the original is fetched — an out-of-roots path
916
+ fails with exactly `send-email`'s error. Recipients, threading headers and the quote are
917
+ identical to the same call without attachments. Over SMTP the files ride the same
918
+ nodemailer path `send-email` uses, so the Sent-folder copy (`sentCopy`) carries them too.
919
+ On the AppleScript transport they are added with `make new attachment` **after** the
920
+ reply content is set and before `save`/`send` (inline items are written to `0600` temp
921
+ files and removed afterwards), so a `send: false` draft contains both the attachments and
922
+ the quoted original. Results report `attachmentCount`.
923
+
863
924
  **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
925
 
865
926
  **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 +929,7 @@ Reply to an existing message.
868
929
 
869
930
  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
931
 
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.
932
+ 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
933
 
873
934
  **⚠️ 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
935
 
@@ -878,15 +939,18 @@ Success includes `transport` and, for SMTP when returned by the server, the new
878
939
 
879
940
  Forward a message to new recipients.
880
941
 
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. |
942
+ | Parameter | Type | Required | Description |
943
+ | ------------- | --------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
944
+ | `id` | string | Yes | Message ID to forward |
945
+ | `to` | string[] | Yes | Recipients to forward to |
946
+ | `body` | string | No | Message to prepend (plain text) |
947
+ | `send` | boolean | No | Send immediately (default: true, false = save as draft) |
948
+ | `transport` | string | No | `smtp` or `applescript`; omitted prefers configured SMTP when sending. Drafts use AppleScript. |
949
+ | `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 |
950
+
951
+ **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
952
 
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.
953
+ **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
954
 
891
955
  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
956
 
@@ -963,7 +1027,18 @@ List attachments on a message.
963
1027
  | --------- | ------ | -------- | ----------- |
964
1028
  | `id` | string | Yes | Message ID |
965
1029
 
966
- **Returns:** List of attachments with name, MIME type, and size.
1030
+ **Returns:** List of attachments with name, MIME type, and `size` — the **decoded** byte count,
1031
+ i.e. what `fetch-attachment` returns.
1032
+
1033
+ **Sizes over IMAP.** `BODYSTRUCTURE` reports a part's size in its transfer encoding, so a
1034
+ base64 attachment's raw figure is ~4/3 of the file. `list-attachments` converts it: a
1035
+ `Content-Disposition` `size=` parameter wins when present; 7bit/8bit/binary parts are exact
1036
+ as reported; base64 parts are measured exactly from a short tail of each part (padding and
1037
+ line wrap), fetched in one batched `FETCH` for the whole message; quoted-printable parts up
1038
+ to 64 KiB are fetched and counted exactly. If a part can't be measured (the probe fails, the
1039
+ wrap is irregular, or a quoted-printable part is larger), the size is an estimate within a
1040
+ few bytes for base64 — an upper bound for quoted-printable — and the entry carries
1041
+ `sizeApproximate: true`. `fetch-attachment` always reports the exact `bytes`.
967
1042
 
968
1043
  ---
969
1044
 
package/build/cli.js CHANGED
@@ -57841,11 +57841,15 @@ __export(imapClient_exports, {
57841
57841
  MAX_UNSCOPED_SEARCH_DEPTH: () => MAX_UNSCOPED_SEARCH_DEPTH,
57842
57842
  __resetPool: () => __resetPool,
57843
57843
  __setPoolConnect: () => __setPoolConnect,
57844
+ base64DecodedLength: () => base64DecodedLength,
57845
+ base64DecodedSizeFromTail: () => base64DecodedSizeFromTail,
57844
57846
  bodyStructureHasAttachments: () => bodyStructureHasAttachments,
57845
57847
  buildImapConnectionOptions: () => buildImapConnectionOptions,
57846
57848
  decodeImapId: () => decodeImapId,
57849
+ decodedSizeWithoutBody: () => decodedSizeWithoutBody,
57847
57850
  dropAllPools: () => dropAllPools,
57848
57851
  encodeImapId: () => encodeImapId,
57852
+ imapAppendDraft: () => imapAppendDraft,
57849
57853
  imapAppendSentCopy: () => imapAppendSentCopy,
57850
57854
  imapBatchDelete: () => imapBatchDelete,
57851
57855
  imapBatchFlag: () => imapBatchFlag,
@@ -57883,6 +57887,8 @@ __export(imapClient_exports, {
57883
57887
  matchMailbox: () => matchMailbox,
57884
57888
  normalizeMessageId: () => normalizeMessageId,
57885
57889
  omittedNote: () => omittedNote,
57890
+ quotedPrintableDecodedLength: () => quotedPrintableDecodedLength,
57891
+ resolveDraftImapAccount: () => resolveDraftImapAccount,
57886
57892
  resolveImapConfig: () => resolveImapConfig,
57887
57893
  resolveImapConfigs: () => resolveImapConfigs,
57888
57894
  resolveMailboxPath: () => resolveMailboxPath,
@@ -58940,6 +58946,40 @@ async function imapAppendSentCopy(smtpUser, raw, deps = {}) {
58940
58946
  return { attempted: true, success: false, error: errText(e) };
58941
58947
  }
58942
58948
  }
58949
+ function resolveDraftImapAccount(account, smtpUser, env = process.env) {
58950
+ const specs = listImapAccountSpecs(env);
58951
+ 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";
58952
+ if (specs.length === 0) {
58953
+ throw new Error(
58954
+ `${why}, and IMAP is not configured. Configure IMAP, omit inReplyTo/references, or use reply-to-message with send=false. ${SETUP_HINT}`
58955
+ );
58956
+ }
58957
+ let spec;
58958
+ if (account) {
58959
+ spec = specs.find((s) => specMatchesSelector(s, account));
58960
+ if (!spec) {
58961
+ throw new Error(
58962
+ `${why}, and "${account}" is not a configured IMAP account. Configured: ${specs.map((s) => s.accountLabel).join(", ")}.`
58963
+ );
58964
+ }
58965
+ } else {
58966
+ spec = (smtpUser ? specs.find((s) => specMatchesSelector(s, smtpUser)) : void 0) ?? (specs.length === 1 ? specs[0] : void 0);
58967
+ if (!spec) {
58968
+ throw new Error(
58969
+ `${why}; several IMAP accounts are configured, so pass \`account\` to choose one: ${specs.map((s) => s.accountLabel).join(", ")}.`
58970
+ );
58971
+ }
58972
+ }
58973
+ return { label: spec.accountLabel, user: spec.user };
58974
+ }
58975
+ async function imapAppendDraft(account, raw, deps = {}) {
58976
+ return withClient({ ...deps, account: deps.account ?? account }, async (client, cfg) => {
58977
+ const path3 = await resolveMailboxPath(client, "drafts", "list");
58978
+ const res = await client.append(path3, raw, ["\\Draft", "\\Seen"]);
58979
+ if (!res) throw new Error(`server rejected the APPEND to "${path3}" (IMAP NO/BAD)`);
58980
+ return { mailbox: path3, account: cfg.accountLabel };
58981
+ });
58982
+ }
58943
58983
  async function withMailbox(path3, deps, fn) {
58944
58984
  return withClient(deps, async (client) => {
58945
58985
  const lock = await client.getMailboxLock(path3);
@@ -59308,6 +59348,56 @@ async function imapDeleteMessageById(id, deps = {}) {
59308
59348
  }
59309
59349
  });
59310
59350
  }
59351
+ function dispositionSize(params) {
59352
+ const raw = params?.size?.trim();
59353
+ return raw && /^\d+$/.test(raw) ? Number(raw) : void 0;
59354
+ }
59355
+ function base64DecodedLength(body) {
59356
+ const s = typeof body === "string" ? body : body.toString("latin1");
59357
+ const n = s.replace(/[^A-Za-z0-9+/]/g, "").length;
59358
+ return Math.floor(n * 3 / 4);
59359
+ }
59360
+ function base64DecodedSizeFromTail(total, tail) {
59361
+ if (tail.length >= total) return base64DecodedLength(tail);
59362
+ const s = tail.toString("latin1");
59363
+ const term = s.includes("\r\n") ? "\r\n" : "\n";
59364
+ const segs = s.split(term);
59365
+ if (segs.length < 3) return void 0;
59366
+ let trailing = 0;
59367
+ while (segs.length > 1 && segs[segs.length - 1] === "") {
59368
+ segs.pop();
59369
+ trailing++;
59370
+ }
59371
+ const last = segs[segs.length - 1];
59372
+ const full = segs.slice(1, -1);
59373
+ if (full.length === 0 || !last) return void 0;
59374
+ const L = full[0].length;
59375
+ const alphabet = /^[A-Za-z0-9+/]+$/;
59376
+ if (L === 0 || L % 4 !== 0 || !full.every((l) => l.length === L && alphabet.test(l))) {
59377
+ return void 0;
59378
+ }
59379
+ const m = /^([A-Za-z0-9+/]*)(=*)$/.exec(last);
59380
+ if (!m || m[2].length > 2 || last.length > L) return void 0;
59381
+ const before = total - trailing * term.length - last.length;
59382
+ if (before < 0 || before % (L + term.length) !== 0) return void 0;
59383
+ const chars = before / (L + term.length) * L + last.length;
59384
+ if (chars % 4 !== 0) return void 0;
59385
+ return chars / 4 * 3 - m[2].length;
59386
+ }
59387
+ function quotedPrintableDecodedLength(body) {
59388
+ const s = (typeof body === "string" ? body : body.toString("latin1")).replace(/=\r?\n/g, "");
59389
+ const escapes = s.match(/=[0-9A-Fa-f]{2}/g)?.length ?? 0;
59390
+ return s.length - escapes * 2;
59391
+ }
59392
+ function decodedSizeWithoutBody(a) {
59393
+ if (a.declaredSize !== void 0) return { size: a.declaredSize, exact: true };
59394
+ if (a.encoding === "base64") {
59395
+ const chars = a.encodedSize - 2 * Math.floor(a.encodedSize / 78);
59396
+ return { size: Math.floor(chars * 3 / 4), exact: false };
59397
+ }
59398
+ if (a.encoding === "quoted-printable") return { size: a.encodedSize, exact: false };
59399
+ return { size: a.encodedSize, exact: true };
59400
+ }
59311
59401
  function collectAttachments(node, out = []) {
59312
59402
  if (!node) return out;
59313
59403
  const filename = node.dispositionParameters?.filename || node.parameters?.name;
@@ -59319,7 +59409,9 @@ function collectAttachments(node, out = []) {
59319
59409
  part: node.part,
59320
59410
  filename: filename || `part-${node.part}`,
59321
59411
  mimeType: node.type || "application/octet-stream",
59322
- size: node.size ?? 0
59412
+ encodedSize: node.size ?? 0,
59413
+ encoding: (node.encoding || "").toLowerCase().trim(),
59414
+ declaredSize: dispositionSize(node.dispositionParameters)
59323
59415
  });
59324
59416
  }
59325
59417
  for (const child of node.childNodes ?? []) collectAttachments(child, out);
@@ -59340,6 +59432,52 @@ async function streamToBuffer(content, maxBytes) {
59340
59432
  }
59341
59433
  return Buffer.concat(chunks);
59342
59434
  }
59435
+ async function resolveDecodedSizes(client, uid, atts) {
59436
+ const out = atts.map(decodedSizeWithoutBody);
59437
+ const probes = [];
59438
+ atts.forEach((a, i) => {
59439
+ if (a.declaredSize !== void 0 || a.encodedSize <= 0) return;
59440
+ if (a.encoding === "base64") {
59441
+ probes.push({ i, start: Math.max(0, a.encodedSize - BASE64_TAIL_WINDOW), kind: "b64" });
59442
+ } else if (a.encoding === "quoted-printable" && a.encodedSize <= QP_EXACT_MAX_BYTES) {
59443
+ probes.push({ i, start: 0, kind: "qp" });
59444
+ }
59445
+ });
59446
+ if (probes.length === 0) return out;
59447
+ let parts;
59448
+ try {
59449
+ const msg = await client.fetchOne(
59450
+ String(uid),
59451
+ {
59452
+ uid: true,
59453
+ bodyParts: probes.map((p) => ({
59454
+ key: atts[p.i].part,
59455
+ start: p.start,
59456
+ maxLength: atts[p.i].encodedSize - p.start
59457
+ }))
59458
+ },
59459
+ { uid: true }
59460
+ );
59461
+ parts = msg ? msg.bodyParts : void 0;
59462
+ } catch {
59463
+ return out;
59464
+ }
59465
+ for (const p of probes) {
59466
+ const a = atts[p.i];
59467
+ const buf = parts?.get(a.part.toLowerCase());
59468
+ if (!buf) continue;
59469
+ let exact;
59470
+ if (p.kind === "qp") {
59471
+ exact = quotedPrintableDecodedLength(buf);
59472
+ } else {
59473
+ const requested = a.encodedSize - p.start;
59474
+ const total = buf.length < requested ? p.start + buf.length : a.encodedSize;
59475
+ exact = base64DecodedSizeFromTail(total, buf);
59476
+ }
59477
+ if (exact !== void 0) out[p.i] = { size: exact, exact: true };
59478
+ }
59479
+ return out;
59480
+ }
59343
59481
  async function imapListAttachments(id, deps = {}) {
59344
59482
  const ref = decodeImapId(id);
59345
59483
  if (!ref) return { success: false, error: `Not an IMAP message id: "${id}".` };
@@ -59348,12 +59486,18 @@ async function imapListAttachments(id, deps = {}) {
59348
59486
  if (!msg || !msg.bodyStructure) {
59349
59487
  return { success: false, error: `IMAP message UID ${ref.uid} not found in "${ref.path}".` };
59350
59488
  }
59351
- const attachments = collectAttachments(msg.bodyStructure).map((a) => ({
59352
- id: `${id}#${a.part}`,
59353
- name: a.filename,
59354
- mimeType: a.mimeType,
59355
- size: a.size
59356
- }));
59489
+ const atts = collectAttachments(msg.bodyStructure);
59490
+ const sizes = await resolveDecodedSizes(client, ref.uid, atts);
59491
+ const attachments = atts.map((a, i) => {
59492
+ const info = {
59493
+ id: `${id}#${a.part}`,
59494
+ name: a.filename,
59495
+ mimeType: a.mimeType,
59496
+ size: sizes[i].size
59497
+ };
59498
+ if (!sizes[i].exact) info.sizeApproximate = true;
59499
+ return info;
59500
+ });
59357
59501
  return { success: true, attachments };
59358
59502
  });
59359
59503
  }
@@ -59374,10 +59518,11 @@ async function imapFetchAttachment(id, attachmentName, deps = {}) {
59374
59518
  error: `Attachment "${attachmentName}" not found on UID ${ref.uid}. Available: ${names}.`
59375
59519
  };
59376
59520
  }
59377
- if (match.size > MAX_IMAP_ATTACHMENT_BYTES) {
59521
+ const expected = decodedSizeWithoutBody(match).size;
59522
+ if (expected > MAX_IMAP_ATTACHMENT_BYTES) {
59378
59523
  return {
59379
59524
  success: false,
59380
- error: `IMAP attachment "${attachmentName}" is ${match.size} bytes; the maximum is ${MAX_IMAP_ATTACHMENT_BYTES} bytes (25 MiB).`
59525
+ error: `IMAP attachment "${attachmentName}" is ${expected} bytes; the maximum is ${MAX_IMAP_ATTACHMENT_BYTES} bytes (25 MiB).`
59381
59526
  };
59382
59527
  }
59383
59528
  try {
@@ -59565,7 +59710,7 @@ async function imapThread(id, deps = {}, limit = 50) {
59565
59710
  true
59566
59711
  );
59567
59712
  }
59568
- var import_imapflow, IMAP_ENV, defaultConnect, SPECIAL_USE_ALIASES, NOT_DELETED, LARGE_MAILBOX_MESSAGES, FIRST_SEARCH_WINDOW, MAX_WINDOW, MAX_UNSCOPED_SEARCH_DEPTH, ROW_QUERY, ROW_QUERY_NO_STRUCTURE, ROW_QUERY_HEADERS_ONLY, DELETED_MARGIN, poolConnect, pools, connecting, MAX_COMPOSE_SOURCE_BYTES, MAX_RFC822_INLINE_BYTES, MAX_RFC822_FILE_BYTES, HEADER_WINDOW_BYTES, MAIL_FLAG_BITS, imapMarkRead, imapMarkUnread, FALLBACK_TRASH_PATH, imapBatchMarkRead, imapBatchMarkUnread, imapBatchFlag, imapBatchUnflag, imapBatchDelete;
59713
+ var import_imapflow, IMAP_ENV, defaultConnect, SPECIAL_USE_ALIASES, NOT_DELETED, LARGE_MAILBOX_MESSAGES, FIRST_SEARCH_WINDOW, MAX_WINDOW, MAX_UNSCOPED_SEARCH_DEPTH, ROW_QUERY, ROW_QUERY_NO_STRUCTURE, ROW_QUERY_HEADERS_ONLY, DELETED_MARGIN, poolConnect, pools, connecting, MAX_COMPOSE_SOURCE_BYTES, MAX_RFC822_INLINE_BYTES, MAX_RFC822_FILE_BYTES, HEADER_WINDOW_BYTES, MAIL_FLAG_BITS, imapMarkRead, imapMarkUnread, FALLBACK_TRASH_PATH, BASE64_TAIL_WINDOW, QP_EXACT_MAX_BYTES, imapBatchMarkRead, imapBatchMarkUnread, imapBatchFlag, imapBatchUnflag, imapBatchDelete;
59569
59714
  var init_imapClient = __esm({
59570
59715
  "src/services/imapClient.ts"() {
59571
59716
  "use strict";
@@ -59670,6 +59815,8 @@ var init_imapClient = __esm({
59670
59815
  imapMarkRead = (id, deps = {}) => flagOp(id, "\\Seen", true, deps);
59671
59816
  imapMarkUnread = (id, deps = {}) => flagOp(id, "\\Seen", false, deps);
59672
59817
  FALLBACK_TRASH_PATH = "Trash";
59818
+ BASE64_TAIL_WINDOW = 1024;
59819
+ QP_EXACT_MAX_BYTES = 64 * 1024;
59673
59820
  imapBatchMarkRead = (ids, deps = {}) => imapBatch(ids, deps, async (c, uids) => {
59674
59821
  assertMutated(
59675
59822
  await c.messageFlagsAdd(uids, ["\\Seen"], { uid: true }),
@@ -59795,6 +59942,27 @@ function buildAttachments(attachments) {
59795
59942
  return { filename: a.filename, content: decodeInlineAttachment(a.contentBase64) };
59796
59943
  });
59797
59944
  }
59945
+ function buildMailOptions(opts, from, attachments) {
59946
+ const html = opts.htmlBody?.trim() ? opts.htmlBody : void 0;
59947
+ return {
59948
+ from,
59949
+ to: opts.to,
59950
+ cc: opts.cc,
59951
+ bcc: opts.bcc,
59952
+ subject: opts.subject,
59953
+ text: opts.body,
59954
+ // When present, nodemailer emits multipart/alternative (text + html).
59955
+ html,
59956
+ attachments,
59957
+ // RFC 5322 threading for SMTP replies/forwards (2.5.0). nodemailer treats
59958
+ // In-Reply-To / References as structured msg-id headers and never
59959
+ // RFC 2047-encodes them, however long the id (pinned by a test, #267).
59960
+ inReplyTo: opts.inReplyTo?.trim() || void 0,
59961
+ references: opts.references?.length ? opts.references : void 0,
59962
+ // Reply-To header (2.18.0, issue #220): nodemailer already supports this.
59963
+ replyTo: opts.replyTo?.trim() || void 0
59964
+ };
59965
+ }
59798
59966
  async function sendViaSmtp(opts, config, createTransport2 = nodemailer_default.createTransport, appendSentCopy = defaultAppendSentCopy) {
59799
59967
  let cfg;
59800
59968
  try {
@@ -59833,23 +60001,7 @@ async function sendViaSmtp(opts, config, createTransport2 = nodemailer_default.c
59833
60001
  requireTLS,
59834
60002
  auth: { user: cfg.user, pass: cfg.pass }
59835
60003
  });
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
- };
60004
+ const mailOptions = buildMailOptions(opts, requestedFrom || cfg.from, attachments);
59853
60005
  try {
59854
60006
  const info = await transporter.sendMail(mailOptions);
59855
60007
  let copyFields = {};