apple-mail-mcp 2.8.8 → 2.8.10

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
@@ -291,8 +291,8 @@ Send a new email immediately.
291
291
  | `body` | string | Yes | Email body (plain text) |
292
292
  | `cc` | string[] | No | CC recipients |
293
293
  | `bcc` | string[] | No | BCC recipients |
294
- | `account` | string | No | Send from specific account (with `transport: "smtp"`, overrides the From address) |
295
- | `attachments` | (string \| {filename, contentBase64})[] | No | Up to 20 attachments: absolute file paths (e.g., `"/Users/me/report.pdf"`) and/or inline `{filename, contentBase64}` objects for content not on disk |
294
+ | `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` |
295
+ | `attachments` | (string \| {filename, contentBase64})[] | No | Up to 20 attachments: absolute file paths (e.g., `"/Users/me/report.pdf"`) and/or inline `{filename, contentBase64}` objects up to 25 MiB decoded each |
296
296
  | `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) |
297
297
 
298
298
  **Example:**
@@ -326,7 +326,11 @@ Two differences to know when SMTP is auto-preferred:
326
326
  is used as the From address only when it is an email address; a Mail.app
327
327
  account *label* (e.g. `"Work"`) can't select an account over SMTP, so a call
328
328
  that passes one is left on the AppleScript path automatically. To force
329
- account selection, pass `transport: "applescript"` explicitly.
329
+ account selection, pass `transport: "applescript"` explicitly. For sender
330
+ safety, an email-form override must match the SMTP login user, the configured
331
+ `APPLE_MAIL_MCP_SMTP_FROM`, or an address listed in the comma-separated
332
+ `APPLE_MAIL_MCP_SMTP_ALLOWED_FROM`; any other From address is rejected before
333
+ connecting.
330
334
 
331
335
  Both plain-text and HTML bodies are supported — over SMTP an HTML body (CLI
332
336
  `--html-body-file`) is sent as `multipart/alternative` with the plain-text
@@ -342,6 +346,7 @@ read from the macOS **Keychain** by default, so no secret goes in config:
342
346
  | `APPLE_MAIL_MCP_SMTP_PORT` | No | `465` if secure, else `587` | SMTP port |
343
347
  | `APPLE_MAIL_MCP_SMTP_SECURE` | No | `false` | `true` for implicit TLS (port 465); otherwise STARTTLS |
344
348
  | `APPLE_MAIL_MCP_SMTP_FROM` | No | = user | From address |
349
+ | `APPLE_MAIL_MCP_SMTP_ALLOWED_FROM` | No | — | Comma-separated sender aliases permitted as per-message From overrides |
345
350
  | `APPLE_MAIL_MCP_SMTP_PASSWORD` | No | — | Password (if set, used instead of the Keychain) |
346
351
  | `APPLE_MAIL_MCP_SMTP_KEYCHAIN_SERVICE` | No | = host | Keychain item service/server name |
347
352
  | `APPLE_MAIL_MCP_SMTP_KEYCHAIN_ACCOUNT` | No | = user | Keychain item account |
@@ -625,7 +630,7 @@ Save an email to Drafts without sending.
625
630
  | `cc` | string[] | No | CC recipients |
626
631
  | `bcc` | string[] | No | BCC recipients |
627
632
  | `account` | string | No | Account for draft |
628
- | `attachments` | (string \| {filename, contentBase64})[] | No | Up to 20 attachments: absolute file paths and/or inline `{filename, contentBase64}` objects |
633
+ | `attachments` | (string \| {filename, contentBase64})[] | No | Up to 20 attachments: absolute file paths and/or inline `{filename, contentBase64}` objects up to 25 MiB decoded each |
629
634
 
630
635
  **Returns:** Confirmation that draft was created.
631
636
 
package/build/cli.js CHANGED
@@ -11869,6 +11869,29 @@ import { execFileSync } from "child_process";
11869
11869
  import { isAbsolute } from "path";
11870
11870
  import { existsSync } from "fs";
11871
11871
 
11872
+ // src/utils/attachmentLimits.ts
11873
+ var MAX_INLINE_ATTACHMENT_BYTES = 25 * 1024 * 1024;
11874
+ var MAX_INLINE_ATTACHMENT_BASE64_CHARS = Math.ceil(MAX_INLINE_ATTACHMENT_BYTES / 3) * 4;
11875
+ var MAX_INLINE_ATTACHMENT_BASE64_INPUT_CHARS = MAX_INLINE_ATTACHMENT_BASE64_CHARS * 2;
11876
+ function isInlineAttachmentBase64WithinLimit(contentBase64) {
11877
+ if (contentBase64.length > MAX_INLINE_ATTACHMENT_BASE64_INPUT_CHARS) return false;
11878
+ let encodedChars = 0;
11879
+ for (const char of contentBase64) {
11880
+ if (!/\s/u.test(char) && ++encodedChars > MAX_INLINE_ATTACHMENT_BASE64_CHARS) return false;
11881
+ }
11882
+ return true;
11883
+ }
11884
+ function decodeInlineAttachment(contentBase64) {
11885
+ if (!isInlineAttachmentBase64WithinLimit(contentBase64)) {
11886
+ throw new Error("Inline attachment exceeds the 25 MiB decoded size limit.");
11887
+ }
11888
+ const content = Buffer.from(contentBase64, "base64");
11889
+ if (content.length > MAX_INLINE_ATTACHMENT_BYTES) {
11890
+ throw new Error("Inline attachment exceeds the 25 MiB decoded size limit.");
11891
+ }
11892
+ return content;
11893
+ }
11894
+
11872
11895
  // src/utils/docsUrls.ts
11873
11896
  var SETUP_GUIDE_URL = "https://github.com/sweetrb/apple-mail-mcp/blob/main/docs/IMAP-SETUP.md";
11874
11897
  var SETUP_HINT = `Setup guide: ${SETUP_GUIDE_URL} \u2014 run the "doctor" tool to check your setup.`;
@@ -11880,6 +11903,7 @@ var SMTP_ENV = {
11880
11903
  secure: "APPLE_MAIL_MCP_SMTP_SECURE",
11881
11904
  user: "APPLE_MAIL_MCP_SMTP_USER",
11882
11905
  from: "APPLE_MAIL_MCP_SMTP_FROM",
11906
+ allowedFrom: "APPLE_MAIL_MCP_SMTP_ALLOWED_FROM",
11883
11907
  password: "APPLE_MAIL_MCP_SMTP_PASSWORD",
11884
11908
  keychainService: "APPLE_MAIL_MCP_SMTP_KEYCHAIN_SERVICE",
11885
11909
  keychainAccount: "APPLE_MAIL_MCP_SMTP_KEYCHAIN_ACCOUNT"
@@ -11915,6 +11939,7 @@ function resolveSmtpConfig(env = process.env) {
11915
11939
  throw new Error(`Invalid ${SMTP_ENV.port}: "${env[SMTP_ENV.port]}" is not a valid port.`);
11916
11940
  }
11917
11941
  const from = env[SMTP_ENV.from]?.trim() || user;
11942
+ const allowedFrom = (env[SMTP_ENV.allowedFrom] ?? "").split(",").map((value) => value.trim()).filter(Boolean);
11918
11943
  let pass = env[SMTP_ENV.password];
11919
11944
  if (!pass) {
11920
11945
  const service = env[SMTP_ENV.keychainService]?.trim() || host;
@@ -11926,7 +11951,7 @@ function resolveSmtpConfig(env = process.env) {
11926
11951
  `No SMTP password found. Set ${SMTP_ENV.password}, or store an internet password in the Keychain for service "${env[SMTP_ENV.keychainService]?.trim() || host}" / account "${env[SMTP_ENV.keychainAccount]?.trim() || user}". ` + SETUP_HINT
11927
11952
  );
11928
11953
  }
11929
- return { host, port, secure, user, pass, from };
11954
+ return { host, port, secure, user, pass, from, allowedFrom };
11930
11955
  }
11931
11956
  function buildAttachments(attachments) {
11932
11957
  if (!attachments || attachments.length === 0) return void 0;
@@ -11939,7 +11964,7 @@ function buildAttachments(attachments) {
11939
11964
  if (!a.filename || !a.contentBase64) {
11940
11965
  throw new Error("Inline attachment requires both filename and contentBase64.");
11941
11966
  }
11942
- return { filename: a.filename, content: Buffer.from(a.contentBase64, "base64") };
11967
+ return { filename: a.filename, content: decodeInlineAttachment(a.contentBase64) };
11943
11968
  });
11944
11969
  }
11945
11970
  async function sendViaSmtp(opts, config, createTransport = import_nodemailer.default.createTransport) {
@@ -11949,6 +11974,16 @@ async function sendViaSmtp(opts, config, createTransport = import_nodemailer.def
11949
11974
  } catch (error) {
11950
11975
  return { success: false, error: error instanceof Error ? error.message : String(error) };
11951
11976
  }
11977
+ const requestedFrom = opts.from?.trim();
11978
+ const allowedFrom = new Set(
11979
+ [cfg.user, cfg.from, ...cfg.allowedFrom ?? []].map((value) => value.trim().toLowerCase())
11980
+ );
11981
+ if (requestedFrom && !allowedFrom.has(requestedFrom.toLowerCase())) {
11982
+ return {
11983
+ success: false,
11984
+ error: `SMTP From "${requestedFrom}" is not a configured sender identity.`
11985
+ };
11986
+ }
11952
11987
  let attachments;
11953
11988
  try {
11954
11989
  attachments = buildAttachments(opts.attachments);
@@ -11964,7 +11999,7 @@ async function sendViaSmtp(opts, config, createTransport = import_nodemailer.def
11964
11999
  const html = opts.htmlBody?.trim() ? opts.htmlBody : void 0;
11965
12000
  try {
11966
12001
  const info = await transporter.sendMail({
11967
- from: opts.from?.trim() || cfg.from,
12002
+ from: requestedFrom || cfg.from,
11968
12003
  to: opts.to,
11969
12004
  cc: opts.cc,
11970
12005
  bcc: opts.bcc,
@@ -11995,7 +12030,8 @@ var EX_CONFIG = 78;
11995
12030
  var USAGE = `apple-mail-send \u2014 send a clean email via SMTP (no Mail.app blockquote wrapping).
11996
12031
 
11997
12032
  Required:
11998
- --from <addr> Sender address (must be allowed by the SMTP server)
12033
+ --from <addr> Sender address (SMTP user/configured From, or an alias in
12034
+ ${SMTP_ENV.allowedFrom})
11999
12035
  --to <addr> Recipient (repeatable)
12000
12036
  --subject <text> Subject line
12001
12037
  --body-file <path> UTF-8 file with the plain-text body
package/build/index.js CHANGED
@@ -75678,7 +75678,15 @@ var StdioServerTransport = class {
75678
75678
 
75679
75679
  // src/services/appleMailManager.ts
75680
75680
  import { spawnSync as spawnSync2 } from "child_process";
75681
- import { existsSync as existsSync3, writeFileSync as writeFileSync3, readFileSync as readFileSync2, mkdtempSync as mkdtempSync2, rmSync as rmSync2 } from "fs";
75681
+ import {
75682
+ existsSync as existsSync3,
75683
+ writeFileSync as writeFileSync3,
75684
+ readFileSync as readFileSync2,
75685
+ mkdtempSync as mkdtempSync2,
75686
+ rmSync as rmSync2,
75687
+ realpathSync,
75688
+ lstatSync
75689
+ } from "fs";
75682
75690
  import { isAbsolute, resolve, sep, join as join4 } from "path";
75683
75691
  import { homedir as homedir3 } from "os";
75684
75692
 
@@ -76095,6 +76103,14 @@ function extractTextBody(source) {
76095
76103
  const encoding = getHeader(headers, "Content-Transfer-Encoding");
76096
76104
  return decodeBody(body, encoding).toString("utf8");
76097
76105
  }
76106
+ function extractRfcMessageIdFromSource(source) {
76107
+ if (!source || !source.trim()) return "";
76108
+ const blankLineIdx = source.search(/\r?\n\r?\n/);
76109
+ const headers = blankLineIdx === -1 ? source : source.substring(0, blankLineIdx);
76110
+ const raw = getHeader(headers, "Message-ID") ?? getHeader(headers, "Message-Id");
76111
+ if (!raw) return "";
76112
+ return raw.trim().replace(/^<+/, "").replace(/>+$/, "").trim();
76113
+ }
76098
76114
  function extractMimeAttachment(source, attachmentName) {
76099
76115
  if (!source || !source.trim()) return null;
76100
76116
  const boundary = extractBoundary(source);
@@ -76192,22 +76208,53 @@ var TemplateStore = class {
76192
76208
  import { writeFileSync as writeFileSync2, rmSync, mkdtempSync } from "fs";
76193
76209
  import { join as join2 } from "path";
76194
76210
  import { tmpdir } from "os";
76211
+
76212
+ // src/utils/attachmentLimits.ts
76213
+ var MAX_INLINE_ATTACHMENT_BYTES = 25 * 1024 * 1024;
76214
+ var MAX_INLINE_ATTACHMENT_BASE64_CHARS = Math.ceil(MAX_INLINE_ATTACHMENT_BYTES / 3) * 4;
76215
+ var MAX_INLINE_ATTACHMENT_BASE64_INPUT_CHARS = MAX_INLINE_ATTACHMENT_BASE64_CHARS * 2;
76216
+ function isInlineAttachmentBase64WithinLimit(contentBase64) {
76217
+ if (contentBase64.length > MAX_INLINE_ATTACHMENT_BASE64_INPUT_CHARS) return false;
76218
+ let encodedChars = 0;
76219
+ for (const char of contentBase64) {
76220
+ if (!/\s/u.test(char) && ++encodedChars > MAX_INLINE_ATTACHMENT_BASE64_CHARS) return false;
76221
+ }
76222
+ return true;
76223
+ }
76224
+ function decodeInlineAttachment(contentBase64) {
76225
+ if (!isInlineAttachmentBase64WithinLimit(contentBase64)) {
76226
+ throw new Error("Inline attachment exceeds the 25 MiB decoded size limit.");
76227
+ }
76228
+ const content = Buffer.from(contentBase64, "base64");
76229
+ if (content.length > MAX_INLINE_ATTACHMENT_BYTES) {
76230
+ throw new Error("Inline attachment exceeds the 25 MiB decoded size limit.");
76231
+ }
76232
+ return content;
76233
+ }
76234
+
76235
+ // src/utils/attachmentMaterialize.ts
76195
76236
  function materializeAttachments(attachments) {
76196
76237
  if (!attachments || attachments.length === 0) {
76197
76238
  return { paths: [], cleanup: () => void 0 };
76198
76239
  }
76199
76240
  let dir = null;
76200
- const paths = attachments.map((a) => {
76201
- if (typeof a === "string") return a;
76202
- if (!a.filename || !a.contentBase64) {
76203
- throw new Error("Inline attachment requires both filename and contentBase64.");
76204
- }
76205
- if (!dir) dir = mkdtempSync(join2(tmpdir(), "amcp-att-"));
76206
- const safeName = a.filename.replace(/[/\\]/g, "_");
76207
- const p = join2(dir, safeName);
76208
- writeFileSync2(p, Buffer.from(a.contentBase64, "base64"));
76209
- return p;
76210
- });
76241
+ let paths;
76242
+ try {
76243
+ paths = attachments.map((a) => {
76244
+ if (typeof a === "string") return a;
76245
+ if (!a.filename || !a.contentBase64) {
76246
+ throw new Error("Inline attachment requires both filename and contentBase64.");
76247
+ }
76248
+ if (!dir) dir = mkdtempSync(join2(tmpdir(), "amcp-att-"));
76249
+ const safeName = a.filename.replace(/[/\\]/g, "_");
76250
+ const p = join2(dir, safeName);
76251
+ writeFileSync2(p, decodeInlineAttachment(a.contentBase64));
76252
+ return p;
76253
+ });
76254
+ } catch (error2) {
76255
+ if (dir) rmSync(dir, { recursive: true, force: true });
76256
+ throw error2;
76257
+ }
76211
76258
  return {
76212
76259
  paths,
76213
76260
  cleanup: () => {
@@ -76341,8 +76388,12 @@ var DIAG_MARKER = "DIAG";
76341
76388
  var DIAG_FIELD_SEP = "F";
76342
76389
  var DIAG_ITEM_SEP = "M";
76343
76390
  var CONTENT_MARKER = "CONTENT";
76391
+ var MSGID_MARKER = "MSGID";
76344
76392
  var HTML_MARKER = "HTML";
76345
76393
  var BATCH_FATAL = "FATAL";
76394
+ function normalizeRfcMessageId(mid) {
76395
+ return (mid || "").trim().replace(/^<+/, "").replace(/>+$/, "").trim();
76396
+ }
76346
76397
  function mergeSearchDiagnostics(into, from) {
76347
76398
  into.timedOutAccounts.push(...from.timedOutAccounts);
76348
76399
  into.skippedLargeMailboxes.push(...from.skippedLargeMailboxes);
@@ -76386,6 +76437,25 @@ function isPathWithinAllowedRoots(resolvedPath) {
76386
76437
  return resolvedPath === base || resolvedPath.startsWith(base + sep);
76387
76438
  });
76388
76439
  }
76440
+ function resolveAttachmentSaveTarget(savePath, attachmentName) {
76441
+ let saveDirectory;
76442
+ try {
76443
+ saveDirectory = realpathSync(resolve(savePath));
76444
+ } catch {
76445
+ throw new Error(`Save directory "${savePath}" does not exist`);
76446
+ }
76447
+ if (!isPathWithinAllowedRoots(saveDirectory)) {
76448
+ throw new Error(`Save path "${savePath}" is outside allowed directories`);
76449
+ }
76450
+ const savedPath = resolve(saveDirectory, attachmentName);
76451
+ if (!isPathWithinAllowedRoots(savedPath)) {
76452
+ throw new Error(`Output path "${savedPath}" is outside allowed directories`);
76453
+ }
76454
+ if (existsSync3(savedPath) && lstatSync(savedPath).isSymbolicLink()) {
76455
+ throw new Error(`Refusing to overwrite symbolic link "${savedPath}"`);
76456
+ }
76457
+ return { saveDirectory, savedPath };
76458
+ }
76389
76459
  var UNSUPPORTED_APPLESCRIPT_OP = /AppleEvent handler failed|-10000/i;
76390
76460
  function describeMailboxOpError(op, raw) {
76391
76461
  const trimmed = (raw || "").trim();
@@ -76621,6 +76691,32 @@ var AppleMailManager = class {
76621
76691
  };
76622
76692
  /** Cache TTL in milliseconds (60 seconds). */
76623
76693
  CACHE_TTL_MS = 6e4;
76694
+ /**
76695
+ * Remembers where each message id was last seen: id → {account, mailbox}.
76696
+ *
76697
+ * Mail.app numeric message ids are unique *per mailbox*, and by-id fetches
76698
+ * (getMessageContent/getRawSource) otherwise have to linear-scan every mailbox
76699
+ * of every account probing `whose id is N`. On a real multi-account setup that
76700
+ * is 700+ mailboxes; a message in a late-iterated folder (e.g. a large "Sent
76701
+ * Items") isn't reached before the AppleScript timeout fires, so the fetch
76702
+ * returns a false "not found" (only INBOX ids, reached early, worked). Every
76703
+ * search/list/by-id result records its id→location here so a subsequent fetch
76704
+ * opens the one right mailbox directly. A stale entry (message moved) simply
76705
+ * misses and falls back to the full scan, so it can never wedge a lookup.
76706
+ */
76707
+ idLocationIndex = /* @__PURE__ */ new Map();
76708
+ /** Cap on the id→location index so a long-lived process can't grow unbounded. */
76709
+ ID_LOCATION_MAX = 5e3;
76710
+ /** Record (or refresh) where a message id lives, evicting oldest when full. */
76711
+ rememberLocation(id, account, mailbox) {
76712
+ if (!id || !account || !mailbox) return;
76713
+ if (this.idLocationIndex.has(id)) this.idLocationIndex.delete(id);
76714
+ this.idLocationIndex.set(id, { account, mailbox });
76715
+ if (this.idLocationIndex.size > this.ID_LOCATION_MAX) {
76716
+ const oldest = this.idLocationIndex.keys().next().value;
76717
+ if (oldest !== void 0) this.idLocationIndex.delete(oldest);
76718
+ }
76719
+ }
76624
76720
  /**
76625
76721
  * Returns cached accounts or fetches fresh data if cache is expired/empty.
76626
76722
  */
@@ -77123,6 +77219,7 @@ var AppleMailManager = class {
77123
77219
  }
77124
77220
  const parts = result.output.split(FIELD_SEP);
77125
77221
  if (parts.length < 9) return null;
77222
+ this.rememberLocation(id.toString(), parts[8], parts[7]);
77126
77223
  return {
77127
77224
  id: id.toString(),
77128
77225
  subject: parts[0],
@@ -77138,6 +77235,47 @@ var AppleMailManager = class {
77138
77235
  hasAttachments: parts.length > 9 ? parts[9] === "true" : false
77139
77236
  };
77140
77237
  }
77238
+ /**
77239
+ * Build an app-level AppleScript that opens exactly one account+mailbox, finds
77240
+ * the message with numeric `id` in it, and runs `innerAction` (which may assume
77241
+ * `msg` is bound). Used by the by-id fast paths (getMessageContent/getRawSource)
77242
+ * so a message in a late-iterated large folder resolves directly instead of via
77243
+ * the timeout-prone full-mailbox scan.
77244
+ *
77245
+ * The mailbox name is resolved through `resolveMailbox` (so an alias like
77246
+ * "Sent"→"Sent Items" or a casing mismatch like "INBOX"→"Inbox" still opens the
77247
+ * right folder), and matched case-insensitively by iterating the account's
77248
+ * mailboxes — `mailbox "INBOX" of account …` throws on accounts whose inbox is
77249
+ * actually named "Inbox", which would silently drop us back to the slow scan.
77250
+ * Returns "" (found nothing) on any error, so the caller falls back safely.
77251
+ */
77252
+ scopedByIdScript(account, mailbox, id, innerAction) {
77253
+ const resolved = this.resolveMailbox(mailbox, account);
77254
+ return buildAppLevelScript(`
77255
+ try
77256
+ set acct to (first account whose name is "${escapeForAppleScript(account)}")
77257
+ set targetMb to missing value
77258
+ ignoring case
77259
+ repeat with mb in mailboxes of acct
77260
+ if (name of mb) is "${escapeForAppleScript(resolved)}" then
77261
+ set targetMb to mb
77262
+ exit repeat
77263
+ end if
77264
+ end repeat
77265
+ end ignoring
77266
+ if targetMb is not missing value then
77267
+ set matchingMsgs to (messages of targetMb whose id is ${Number(id)})
77268
+ if (count of matchingMsgs) > 0 then
77269
+ set msg to item 1 of matchingMsgs
77270
+ ${innerAction}
77271
+ end if
77272
+ end if
77273
+ return ""
77274
+ on error errMsg
77275
+ return ""
77276
+ end try
77277
+ `);
77278
+ }
77141
77279
  /**
77142
77280
  * Get the content of a message.
77143
77281
  *
@@ -77148,11 +77286,30 @@ var AppleMailManager = class {
77148
77286
  * path doesn't need it; fetching it unconditionally was both slow and, worse,
77149
77287
  * returned the entire raw MIME blob mislabeled as HTML (#32).
77150
77288
  */
77151
- getMessageContent(id, includeHtml = false) {
77289
+ getMessageContent(id, includeHtml = false, hint) {
77152
77290
  const sourceFetch = includeHtml ? `set htmlSource to ""
77153
77291
  try
77154
77292
  set htmlSource to source of msg
77155
77293
  end try` : `set htmlSource to ""`;
77294
+ const innerFetch = `
77295
+ set msgSubject to subject of msg
77296
+ set msgRfcId to ""
77297
+ try
77298
+ set msgRfcId to message id of msg
77299
+ end try
77300
+ set msgContent to content of msg
77301
+ ${sourceFetch}
77302
+ return msgSubject & "${MSGID_MARKER}" & msgRfcId & "${CONTENT_MARKER}" & msgContent & "${HTML_MARKER}" & htmlSource`;
77303
+ const loc = hint?.account && hint?.mailbox ? { account: hint.account, mailbox: hint.mailbox } : this.idLocationIndex.get(id.toString());
77304
+ if (loc) {
77305
+ const scopedScript = this.scopedByIdScript(loc.account, loc.mailbox, id, innerFetch);
77306
+ const scoped = this.parseMessageContent(
77307
+ id,
77308
+ executeAppleScript(scopedScript, { timeoutMs: 6e4 }),
77309
+ includeHtml
77310
+ );
77311
+ if (scoped) return scoped;
77312
+ }
77156
77313
  const script = buildAppLevelScript(`
77157
77314
  try
77158
77315
  repeat with acct in accounts
@@ -77161,10 +77318,7 @@ var AppleMailManager = class {
77161
77318
  set matchingMsgs to (messages of mb whose id is ${Number(id)})
77162
77319
  if (count of matchingMsgs) > 0 then
77163
77320
  set msg to item 1 of matchingMsgs
77164
- set msgSubject to subject of msg
77165
- set msgContent to content of msg
77166
- ${sourceFetch}
77167
- return msgSubject & "${CONTENT_MARKER}" & msgContent & "${HTML_MARKER}" & htmlSource
77321
+ ${innerFetch}
77168
77322
  end if
77169
77323
  end try
77170
77324
  end repeat
@@ -77174,9 +77328,20 @@ var AppleMailManager = class {
77174
77328
  return ""
77175
77329
  end try
77176
77330
  `);
77177
- const result = executeAppleScript(script, { timeoutMs: 6e4 });
77331
+ return this.parseMessageContent(
77332
+ id,
77333
+ executeAppleScript(script, { timeoutMs: 6e4 }),
77334
+ includeHtml
77335
+ );
77336
+ }
77337
+ /**
77338
+ * Parse the marker-delimited output of a getMessageContent AppleScript into a
77339
+ * MessageContent, or null when nothing was found / the fetch failed. Shared by
77340
+ * the scoped fast path and the full-mailbox-scan fallback.
77341
+ */
77342
+ parseMessageContent(id, result, includeHtml) {
77178
77343
  if (!result.success || !result.output.trim()) {
77179
- console.error(`Failed to get message content: ${result.error}`);
77344
+ if (!result.success) console.error(`Failed to get message content: ${result.error}`);
77180
77345
  return null;
77181
77346
  }
77182
77347
  const htmlSplit = result.output.split(HTML_MARKER);
@@ -77184,12 +77349,16 @@ var AppleMailManager = class {
77184
77349
  const rawSource = htmlSplit.length > 1 ? htmlSplit[1] : "";
77185
77350
  const parts = contentPart.split(CONTENT_MARKER);
77186
77351
  if (parts.length < 2) return null;
77352
+ const subjParts = parts[0].split(MSGID_MARKER);
77353
+ const subject = subjParts[0];
77354
+ const rfcMessageId = normalizeRfcMessageId(subjParts.length > 1 ? subjParts[1] : "");
77187
77355
  const htmlContent = includeHtml && rawSource ? extractHtmlBody(rawSource) || void 0 : void 0;
77188
77356
  return {
77189
77357
  id: id.toString(),
77190
- subject: parts[0],
77358
+ subject,
77191
77359
  plainText: parts[1],
77192
- htmlContent
77360
+ htmlContent,
77361
+ rfcMessageId
77193
77362
  };
77194
77363
  }
77195
77364
  /**
@@ -77201,7 +77370,18 @@ var AppleMailManager = class {
77201
77370
  * the entire raw message including base64-encoded attachments —
77202
77371
  * a 20MB attachment can take several seconds over Exchange/IMAP.
77203
77372
  */
77204
- getRawSource(id) {
77373
+ getRawSource(id, hint) {
77374
+ const loc = hint?.account && hint?.mailbox ? { account: hint.account, mailbox: hint.mailbox } : this.idLocationIndex.get(id.toString());
77375
+ if (loc) {
77376
+ const scopedScript = this.scopedByIdScript(
77377
+ loc.account,
77378
+ loc.mailbox,
77379
+ id,
77380
+ "return source of msg"
77381
+ );
77382
+ const scoped = executeAppleScript(scopedScript, { timeoutMs: 12e4 });
77383
+ if (scoped.success && scoped.output.trim()) return scoped.output;
77384
+ }
77205
77385
  const script = buildAppLevelScript(`
77206
77386
  try
77207
77387
  repeat with acct in accounts
@@ -77372,8 +77552,9 @@ var AppleMailManager = class {
77372
77552
  } else if (parts.length === 7) {
77373
77553
  hasAttachments = parts[6] === "true";
77374
77554
  }
77555
+ const msgId = parts[0].trim();
77375
77556
  messages.push({
77376
- id: parts[0].trim(),
77557
+ id: msgId,
77377
77558
  subject: parts[1],
77378
77559
  sender: parts[2],
77379
77560
  recipients: [],
@@ -77386,6 +77567,7 @@ var AppleMailManager = class {
77386
77567
  account,
77387
77568
  hasAttachments
77388
77569
  });
77570
+ this.rememberLocation(msgId, account, msgMailbox);
77389
77571
  }
77390
77572
  return messages;
77391
77573
  }
@@ -77793,7 +77975,7 @@ var AppleMailManager = class {
77793
77975
  findNumericIdByMessageId(messageId, accountName) {
77794
77976
  const mid = messageId.trim().replace(/^<+/, "").replace(/>+$/, "").trim();
77795
77977
  if (!mid) return null;
77796
- const q = (s) => s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
77978
+ const q = (s) => escapeForAppleScript(s);
77797
77979
  const midLit = `"${q(mid)}"`;
77798
77980
  const bracketedLit = `"${q(`<${mid}>`)}"`;
77799
77981
  const matchClause = (mbVar) => `(messages of ${mbVar} whose message id is ${midLit} or message id is ${bracketedLit})`;
@@ -78180,13 +78362,15 @@ var AppleMailManager = class {
78180
78362
  console.error(`Invalid attachment name: "${attachmentName}"`);
78181
78363
  return false;
78182
78364
  }
78183
- const resolvedPath = resolve(savePath);
78184
- if (!isPathWithinAllowedRoots(resolvedPath)) {
78185
- console.error(`Save path "${savePath}" is outside allowed directories`);
78365
+ let target;
78366
+ try {
78367
+ target = resolveAttachmentSaveTarget(savePath, attachmentName);
78368
+ } catch (error2) {
78369
+ console.error(error2 instanceof Error ? error2.message : String(error2));
78186
78370
  return false;
78187
78371
  }
78188
78372
  const safeName = escapeForAppleScript(attachmentName);
78189
- const safePath = escapeForAppleScript(resolvedPath);
78373
+ const safePath = escapeForAppleScript(target.saveDirectory);
78190
78374
  const numericId = Number(id);
78191
78375
  const script = buildAppLevelScript(`
78192
78376
  try
@@ -78228,12 +78412,7 @@ var AppleMailManager = class {
78228
78412
  return false;
78229
78413
  }
78230
78414
  try {
78231
- const outPath = resolve(resolvedPath, attachmentName);
78232
- if (!isPathWithinAllowedRoots(outPath)) {
78233
- console.error(`Output path "${outPath}" is outside allowed directories`);
78234
- return false;
78235
- }
78236
- writeFileSync3(outPath, attachment.data);
78415
+ writeFileSync3(target.savedPath, attachment.data);
78237
78416
  return true;
78238
78417
  } catch (err) {
78239
78418
  console.error(`Failed to write attachment to disk: ${err}`);
@@ -78250,7 +78429,7 @@ var AppleMailManager = class {
78250
78429
  try {
78251
78430
  dir = mkdtempSync2("/private/tmp/amcp-fetch-");
78252
78431
  const dest = join4(dir, attachmentName.replace(/[/\\]/g, "_"));
78253
- const ok = this.saveAttachment(id, attachmentName, dest);
78432
+ const ok = this.saveAttachment(id, attachmentName, dir);
78254
78433
  if (!ok) {
78255
78434
  return {
78256
78435
  success: false,
@@ -79001,7 +79180,7 @@ ${actionStmts.join("\n")}
79001
79180
 
79002
79181
  // src/index.ts
79003
79182
  import { writeFileSync as writeFileSync4 } from "fs";
79004
- import { resolve as resolvePath, join as joinPath } from "path";
79183
+ import { join as joinPath } from "path";
79005
79184
 
79006
79185
  // src/services/smtpMailer.ts
79007
79186
  var import_nodemailer = __toESM(require_nodemailer(), 1);
@@ -79020,6 +79199,7 @@ var SMTP_ENV = {
79020
79199
  secure: "APPLE_MAIL_MCP_SMTP_SECURE",
79021
79200
  user: "APPLE_MAIL_MCP_SMTP_USER",
79022
79201
  from: "APPLE_MAIL_MCP_SMTP_FROM",
79202
+ allowedFrom: "APPLE_MAIL_MCP_SMTP_ALLOWED_FROM",
79023
79203
  password: "APPLE_MAIL_MCP_SMTP_PASSWORD",
79024
79204
  keychainService: "APPLE_MAIL_MCP_SMTP_KEYCHAIN_SERVICE",
79025
79205
  keychainAccount: "APPLE_MAIL_MCP_SMTP_KEYCHAIN_ACCOUNT"
@@ -79065,6 +79245,7 @@ function resolveSmtpConfig(env = process.env) {
79065
79245
  throw new Error(`Invalid ${SMTP_ENV.port}: "${env[SMTP_ENV.port]}" is not a valid port.`);
79066
79246
  }
79067
79247
  const from = env[SMTP_ENV.from]?.trim() || user;
79248
+ const allowedFrom = (env[SMTP_ENV.allowedFrom] ?? "").split(",").map((value) => value.trim()).filter(Boolean);
79068
79249
  let pass = env[SMTP_ENV.password];
79069
79250
  if (!pass) {
79070
79251
  const service = env[SMTP_ENV.keychainService]?.trim() || host;
@@ -79076,7 +79257,7 @@ function resolveSmtpConfig(env = process.env) {
79076
79257
  `No SMTP password found. Set ${SMTP_ENV.password}, or store an internet password in the Keychain for service "${env[SMTP_ENV.keychainService]?.trim() || host}" / account "${env[SMTP_ENV.keychainAccount]?.trim() || user}". ` + SETUP_HINT
79077
79258
  );
79078
79259
  }
79079
- return { host, port, secure, user, pass, from };
79260
+ return { host, port, secure, user, pass, from, allowedFrom };
79080
79261
  }
79081
79262
  function buildAttachments(attachments) {
79082
79263
  if (!attachments || attachments.length === 0) return void 0;
@@ -79089,7 +79270,7 @@ function buildAttachments(attachments) {
79089
79270
  if (!a.filename || !a.contentBase64) {
79090
79271
  throw new Error("Inline attachment requires both filename and contentBase64.");
79091
79272
  }
79092
- return { filename: a.filename, content: Buffer.from(a.contentBase64, "base64") };
79273
+ return { filename: a.filename, content: decodeInlineAttachment(a.contentBase64) };
79093
79274
  });
79094
79275
  }
79095
79276
  async function sendViaSmtp(opts, config2, createTransport = import_nodemailer.default.createTransport) {
@@ -79099,6 +79280,16 @@ async function sendViaSmtp(opts, config2, createTransport = import_nodemailer.de
79099
79280
  } catch (error2) {
79100
79281
  return { success: false, error: error2 instanceof Error ? error2.message : String(error2) };
79101
79282
  }
79283
+ const requestedFrom = opts.from?.trim();
79284
+ const allowedFrom = new Set(
79285
+ [cfg.user, cfg.from, ...cfg.allowedFrom ?? []].map((value) => value.trim().toLowerCase())
79286
+ );
79287
+ if (requestedFrom && !allowedFrom.has(requestedFrom.toLowerCase())) {
79288
+ return {
79289
+ success: false,
79290
+ error: `SMTP From "${requestedFrom}" is not a configured sender identity.`
79291
+ };
79292
+ }
79102
79293
  let attachments;
79103
79294
  try {
79104
79295
  attachments = buildAttachments(opts.attachments);
@@ -79114,7 +79305,7 @@ async function sendViaSmtp(opts, config2, createTransport = import_nodemailer.de
79114
79305
  const html = opts.htmlBody?.trim() ? opts.htmlBody : void 0;
79115
79306
  try {
79116
79307
  const info = await transporter.sendMail({
79117
- from: opts.from?.trim() || cfg.from,
79308
+ from: requestedFrom || cfg.from,
79118
79309
  to: opts.to,
79119
79310
  cc: opts.cc,
79120
79311
  bcc: opts.bcc,
@@ -79324,6 +79515,27 @@ function decodeImapId(id) {
79324
79515
  return null;
79325
79516
  }
79326
79517
  }
79518
+ function sameImapAccount(left, right, deps) {
79519
+ if (left === right) return true;
79520
+ if (deps.config) {
79521
+ const aliases = /* @__PURE__ */ new Set([deps.config.accountLabel, deps.config.user]);
79522
+ if (aliases.has(left) && aliases.has(right)) return true;
79523
+ }
79524
+ const specs = listImapAccountSpecs();
79525
+ const matches = (selector, spec) => spec.accountLabel === selector || spec.user === selector;
79526
+ const leftSpec = specs.find((spec) => matches(left, spec));
79527
+ const rightSpec = specs.find((spec) => matches(right, spec));
79528
+ return leftSpec !== void 0 && leftSpec === rightSpec;
79529
+ }
79530
+ function depsForAccount(account, deps) {
79531
+ if (deps.account && !sameImapAccount(account, deps.account, deps)) {
79532
+ throw new Error(`IMAP message id belongs to account "${account}", not "${deps.account}".`);
79533
+ }
79534
+ return { ...deps, account };
79535
+ }
79536
+ function depsForMessageRef(ref, deps) {
79537
+ return depsForAccount(ref.account, deps);
79538
+ }
79327
79539
  function str(v) {
79328
79540
  return typeof v === "string" && v.trim() ? v.trim() : void 0;
79329
79541
  }
@@ -79831,25 +80043,21 @@ async function withMailbox(path, deps, fn) {
79831
80043
  async function imapGetMessage(id, preferHtml, deps = {}) {
79832
80044
  const ref = decodeImapId(id);
79833
80045
  if (!ref) return { success: false, error: `Not an IMAP message id: "${id}".` };
79834
- return withMailbox(
79835
- ref.path,
79836
- { ...deps, account: deps.account ?? ref.account },
79837
- async (client) => {
79838
- const msg = await client.fetchOne(
79839
- String(ref.uid),
79840
- { envelope: true, source: true },
79841
- { uid: true }
79842
- );
79843
- if (!msg)
79844
- return { success: false, error: `IMAP message UID ${ref.uid} not found in "${ref.path}".` };
79845
- const subject = msg.envelope?.subject || "(no subject)";
79846
- const src = msg.source ? msg.source.toString() : "";
79847
- const body = (preferHtml ? extractHtmlBody(src) : extractTextBody(src)) ?? extractTextBody(src) ?? extractHtmlBody(src) ?? "(no readable body)";
79848
- return { success: true, info: `Subject: ${subject}
80046
+ return withMailbox(ref.path, depsForMessageRef(ref, deps), async (client) => {
80047
+ const msg = await client.fetchOne(
80048
+ String(ref.uid),
80049
+ { envelope: true, source: true },
80050
+ { uid: true }
80051
+ );
80052
+ if (!msg)
80053
+ return { success: false, error: `IMAP message UID ${ref.uid} not found in "${ref.path}".` };
80054
+ const subject = msg.envelope?.subject || "(no subject)";
80055
+ const src = msg.source ? msg.source.toString() : "";
80056
+ const body = (preferHtml ? extractHtmlBody(src) : extractTextBody(src)) ?? extractTextBody(src) ?? extractHtmlBody(src) ?? "(no readable body)";
80057
+ return { success: true, info: `Subject: ${subject}
79849
80058
 
79850
80059
  ${body}` };
79851
- }
79852
- );
80060
+ });
79853
80061
  }
79854
80062
  function normalizeMessageId(mid) {
79855
80063
  return mid.trim().replace(/^<+/, "").replace(/>+$/, "").trim();
@@ -79858,15 +80066,11 @@ async function imapFetchMessageId(id, deps = {}) {
79858
80066
  const ref = decodeImapId(id);
79859
80067
  if (!ref) return null;
79860
80068
  try {
79861
- return await withMailbox(
79862
- ref.path,
79863
- { ...deps, account: deps.account ?? ref.account },
79864
- async (client) => {
79865
- const msg = await client.fetchOne(String(ref.uid), { envelope: true }, { uid: true });
79866
- const mid = msg && msg.envelope?.messageId;
79867
- return mid ? normalizeMessageId(mid) : null;
79868
- }
79869
- );
80069
+ return await withMailbox(ref.path, depsForMessageRef(ref, deps), async (client) => {
80070
+ const msg = await client.fetchOne(String(ref.uid), { envelope: true }, { uid: true });
80071
+ const mid = msg && msg.envelope?.messageId;
80072
+ return mid ? normalizeMessageId(mid) : null;
80073
+ });
79870
80074
  } catch {
79871
80075
  return null;
79872
80076
  }
@@ -79874,23 +80078,19 @@ async function imapFetchMessageId(id, deps = {}) {
79874
80078
  function flagOp(id, flag, add, deps) {
79875
80079
  const ref = decodeImapId(id);
79876
80080
  if (!ref) return Promise.resolve({ success: false, error: `Not an IMAP message id: "${id}".` });
79877
- return withMailbox(
79878
- ref.path,
79879
- { ...deps, account: deps.account ?? ref.account },
79880
- async (client) => {
79881
- try {
79882
- const ok = add ? await client.messageFlagsAdd([ref.uid], [flag], { uid: true }) : await client.messageFlagsRemove([ref.uid], [flag], { uid: true });
79883
- if (!ok)
79884
- return { success: false, error: `IMAP flag update returned false for UID ${ref.uid}.` };
79885
- return { success: true };
79886
- } catch (e) {
79887
- return {
79888
- success: false,
79889
- error: `IMAP flag update failed for UID ${ref.uid}: ${errText(e)}`
79890
- };
79891
- }
80081
+ return withMailbox(ref.path, depsForMessageRef(ref, deps), async (client) => {
80082
+ try {
80083
+ const ok = add ? await client.messageFlagsAdd([ref.uid], [flag], { uid: true }) : await client.messageFlagsRemove([ref.uid], [flag], { uid: true });
80084
+ if (!ok)
80085
+ return { success: false, error: `IMAP flag update returned false for UID ${ref.uid}.` };
80086
+ return { success: true };
80087
+ } catch (e) {
80088
+ return {
80089
+ success: false,
80090
+ error: `IMAP flag update failed for UID ${ref.uid}: ${errText(e)}`
80091
+ };
79892
80092
  }
79893
- );
80093
+ });
79894
80094
  }
79895
80095
  var imapMarkRead = (id, deps = {}) => flagOp(id, "\\Seen", true, deps);
79896
80096
  var imapMarkUnread = (id, deps = {}) => flagOp(id, "\\Seen", false, deps);
@@ -79899,7 +80099,7 @@ var imapUnflagMessage = (id, deps = {}) => flagOp(id, "\\Flagged", false, deps);
79899
80099
  async function imapMoveMessageById(id, destMailbox, deps = {}) {
79900
80100
  const ref = decodeImapId(id);
79901
80101
  if (!ref) return { success: false, error: `Not an IMAP message id: "${id}".` };
79902
- return withClient({ ...deps, account: deps.account ?? ref.account }, async (client) => {
80102
+ return withClient(depsForMessageRef(ref, deps), async (client) => {
79903
80103
  const destPath = await findMailboxPath(client, destMailbox) ?? resolveMailboxPath(destMailbox, "list");
79904
80104
  const lock = await client.getMailboxLock(ref.path);
79905
80105
  try {
@@ -79940,21 +80140,17 @@ async function trashUids(client, uids, srcPath) {
79940
80140
  async function imapDeleteMessageById(id, deps = {}) {
79941
80141
  const ref = decodeImapId(id);
79942
80142
  if (!ref) return { success: false, error: `Not an IMAP message id: "${id}".` };
79943
- return withMailbox(
79944
- ref.path,
79945
- { ...deps, account: deps.account ?? ref.account },
79946
- async (client) => {
79947
- try {
79948
- const { dest, expunged } = await trashUids(client, [ref.uid], ref.path);
79949
- return {
79950
- success: true,
79951
- info: expunged ? `Permanently deleted UID ${ref.uid} from Trash ("${ref.path}") via IMAP.` : `Moved UID ${ref.uid} to Trash ("${dest}") via IMAP.`
79952
- };
79953
- } catch (e) {
79954
- return { success: false, error: `IMAP delete failed for UID ${ref.uid}: ${errText(e)}` };
79955
- }
80143
+ return withMailbox(ref.path, depsForMessageRef(ref, deps), async (client) => {
80144
+ try {
80145
+ const { dest, expunged } = await trashUids(client, [ref.uid], ref.path);
80146
+ return {
80147
+ success: true,
80148
+ info: expunged ? `Permanently deleted UID ${ref.uid} from Trash ("${ref.path}") via IMAP.` : `Moved UID ${ref.uid} to Trash ("${dest}") via IMAP.`
80149
+ };
80150
+ } catch (e) {
80151
+ return { success: false, error: `IMAP delete failed for UID ${ref.uid}: ${errText(e)}` };
79956
80152
  }
79957
- );
80153
+ });
79958
80154
  }
79959
80155
  function collectAttachments(node, out = []) {
79960
80156
  if (!node) return out;
@@ -79980,54 +80176,46 @@ async function streamToBuffer(content) {
79980
80176
  async function imapListAttachments(id, deps = {}) {
79981
80177
  const ref = decodeImapId(id);
79982
80178
  if (!ref) return { success: false, error: `Not an IMAP message id: "${id}".` };
79983
- return withMailbox(
79984
- ref.path,
79985
- { ...deps, account: deps.account ?? ref.account },
79986
- async (client) => {
79987
- const msg = await client.fetchOne(String(ref.uid), { bodyStructure: true }, { uid: true });
79988
- if (!msg || !msg.bodyStructure) {
79989
- return { success: false, error: `IMAP message UID ${ref.uid} not found in "${ref.path}".` };
79990
- }
79991
- const attachments = collectAttachments(msg.bodyStructure).map((a) => ({
79992
- id: `${id}#${a.part}`,
79993
- name: a.filename,
79994
- mimeType: a.mimeType,
79995
- size: a.size
79996
- }));
79997
- return { success: true, attachments };
79998
- }
79999
- );
80179
+ return withMailbox(ref.path, depsForMessageRef(ref, deps), async (client) => {
80180
+ const msg = await client.fetchOne(String(ref.uid), { bodyStructure: true }, { uid: true });
80181
+ if (!msg || !msg.bodyStructure) {
80182
+ return { success: false, error: `IMAP message UID ${ref.uid} not found in "${ref.path}".` };
80183
+ }
80184
+ const attachments = collectAttachments(msg.bodyStructure).map((a) => ({
80185
+ id: `${id}#${a.part}`,
80186
+ name: a.filename,
80187
+ mimeType: a.mimeType,
80188
+ size: a.size
80189
+ }));
80190
+ return { success: true, attachments };
80191
+ });
80000
80192
  }
80001
80193
  async function imapFetchAttachment(id, attachmentName, deps = {}) {
80002
80194
  const ref = decodeImapId(id);
80003
80195
  if (!ref) return { success: false, error: `Not an IMAP message id: "${id}".` };
80004
- return withMailbox(
80005
- ref.path,
80006
- { ...deps, account: deps.account ?? ref.account },
80007
- async (client) => {
80008
- const msg = await client.fetchOne(String(ref.uid), { bodyStructure: true }, { uid: true });
80009
- if (!msg || !msg.bodyStructure) {
80010
- return { success: false, error: `IMAP message UID ${ref.uid} not found in "${ref.path}".` };
80011
- }
80012
- const atts = collectAttachments(msg.bodyStructure);
80013
- const match = atts.find((a) => a.filename === attachmentName);
80014
- if (!match) {
80015
- const names = atts.map((a) => a.filename).join(", ") || "none";
80016
- return {
80017
- success: false,
80018
- error: `Attachment "${attachmentName}" not found on UID ${ref.uid}. Available: ${names}.`
80019
- };
80020
- }
80021
- const dl = await client.download(String(ref.uid), match.part, { uid: true });
80022
- const buf = await streamToBuffer(dl.content);
80196
+ return withMailbox(ref.path, depsForMessageRef(ref, deps), async (client) => {
80197
+ const msg = await client.fetchOne(String(ref.uid), { bodyStructure: true }, { uid: true });
80198
+ if (!msg || !msg.bodyStructure) {
80199
+ return { success: false, error: `IMAP message UID ${ref.uid} not found in "${ref.path}".` };
80200
+ }
80201
+ const atts = collectAttachments(msg.bodyStructure);
80202
+ const match = atts.find((a) => a.filename === attachmentName);
80203
+ if (!match) {
80204
+ const names = atts.map((a) => a.filename).join(", ") || "none";
80023
80205
  return {
80024
- success: true,
80025
- base64: buf.toString("base64"),
80026
- bytes: buf.length,
80027
- mimeType: match.mimeType
80206
+ success: false,
80207
+ error: `Attachment "${attachmentName}" not found on UID ${ref.uid}. Available: ${names}.`
80028
80208
  };
80029
80209
  }
80030
- );
80210
+ const dl = await client.download(String(ref.uid), match.part, { uid: true });
80211
+ const buf = await streamToBuffer(dl.content);
80212
+ return {
80213
+ success: true,
80214
+ base64: buf.toString("base64"),
80215
+ bytes: buf.length,
80216
+ mimeType: match.mimeType
80217
+ };
80218
+ });
80031
80219
  }
80032
80220
  async function imapBatch(ids, deps, op) {
80033
80221
  const groups = /* @__PURE__ */ new Map();
@@ -80048,7 +80236,7 @@ async function imapBatch(ids, deps, op) {
80048
80236
  let success = 0;
80049
80237
  for (const g of groups.values()) {
80050
80238
  try {
80051
- await useClient({ ...deps, account: deps.account ?? g.account }, async (client) => {
80239
+ await useClient(depsForAccount(g.account, deps), async (client) => {
80052
80240
  const lock = await client.getMailboxLock(g.path);
80053
80241
  try {
80054
80242
  await op(client, g.uids, g.path);
@@ -80097,7 +80285,7 @@ async function imapThread(id, deps = {}, limit = 50) {
80097
80285
  const ref = decodeImapId(id);
80098
80286
  if (!ref) return null;
80099
80287
  return useClient(
80100
- { ...deps, account: deps.account ?? ref.account },
80288
+ depsForMessageRef(ref, deps),
80101
80289
  async (client) => {
80102
80290
  const lock = await client.getMailboxLock(ref.path);
80103
80291
  try {
@@ -80736,12 +80924,18 @@ var ATTACHMENTS_SCHEMA = external_exports.array(
80736
80924
  external_exports.union([
80737
80925
  external_exports.string().describe("Absolute path to an existing file"),
80738
80926
  external_exports.object({
80739
- filename: external_exports.string().min(1).describe("Filename to give the attachment"),
80740
- contentBase64: external_exports.string().min(1).describe("Base64-encoded file content")
80927
+ filename: external_exports.string().min(1).max(255).describe("Filename to give the attachment"),
80928
+ contentBase64: external_exports.string().min(1).max(
80929
+ MAX_INLINE_ATTACHMENT_BASE64_INPUT_CHARS,
80930
+ "Inline attachment exceeds the 25 MiB decoded size limit"
80931
+ ).refine(
80932
+ isInlineAttachmentBase64WithinLimit,
80933
+ "Inline attachment exceeds the 25 MiB decoded size limit"
80934
+ ).describe("Base64-encoded file content (maximum 25 MiB decoded)")
80741
80935
  })
80742
80936
  ])
80743
80937
  ).max(20, "Cannot attach more than 20 files").optional().describe(
80744
- "Files to attach: absolute paths (e.g. '/Users/me/report.pdf') and/or inline {filename, contentBase64} objects for content not on disk."
80938
+ "Files to attach: absolute paths (e.g. '/Users/me/report.pdf') and/or inline {filename, contentBase64} objects up to 25 MiB decoded each."
80745
80939
  );
80746
80940
  var MESSAGE_ROW_SCHEMA = external_exports.object({}).passthrough();
80747
80941
  var LIST_OUTPUT_SCHEMA = {
@@ -80964,20 +81158,32 @@ ${messageList}${coverageBlock}`,
80964
81158
  server.registerTool(
80965
81159
  "get-message",
80966
81160
  {
80967
- description: "Use when: reading the full body of one message whose id you already have (numeric or imap:\u2026); set preferHtml to get the HTML body instead of plain text.\nReturns: the message subject and body (plain text by default, HTML when preferHtml is true).\nDo not use when: you don't yet have an id (use search-messages or list-messages first), or you want the whole conversation (use get-thread).",
81161
+ description: `Use when: reading the full body of one message whose id you already have (numeric or imap:\u2026); set preferHtml to get the HTML body instead of plain text.
81162
+ Returns: the message subject, body (plain text by default, HTML when preferHtml is true), and its stable RFC Message-ID (rfcMessageId) for dedup/threading.
81163
+ Tip: pass the mailbox+account you got the id from (e.g. from search-messages) to fetch it directly \u2014 required for reliable reads of large folders like "Sent Items", which otherwise time out.
81164
+ Do not use when: you don't yet have an id (use search-messages or list-messages first), or you want the whole conversation (use get-thread).`,
80968
81165
  inputSchema: {
80969
81166
  id: MESSAGE_ID_SCHEMA,
80970
- preferHtml: external_exports.boolean().optional().describe("Return the HTML body (extracted from the message source) instead of plain text")
81167
+ preferHtml: external_exports.boolean().optional().describe("Return the HTML body (extracted from the message source) instead of plain text"),
81168
+ mailbox: external_exports.string().optional().describe(
81169
+ 'Mailbox that holds the message (e.g. "Sent Items"). Numeric ids are unique per mailbox; supplying this (with account) opens that mailbox directly instead of scanning every mailbox, which is required to read large folders like Sent Items without timing out.'
81170
+ ),
81171
+ account: external_exports.string().optional().describe(
81172
+ "Account that holds the message. Pair with `mailbox` for a direct, scan-free fetch."
81173
+ )
80971
81174
  },
80972
81175
  outputSchema: {
80973
81176
  id: external_exports.string().optional(),
80974
81177
  subject: external_exports.string().optional(),
80975
81178
  body: external_exports.string().optional(),
80976
- isHtml: external_exports.boolean().optional()
81179
+ isHtml: external_exports.boolean().optional(),
81180
+ rfcMessageId: external_exports.string().optional().describe(
81181
+ "Stable RFC 5322 Message-ID (angle brackets stripped); empty when the message has none"
81182
+ )
80977
81183
  }
80978
81184
  },
80979
81185
  withErrorHandling(
80980
- ({ id, preferHtml }) => routeMessage(id, {
81186
+ ({ id, preferHtml, mailbox, account }) => routeMessage(id, {
80981
81187
  // IMAP id (imap:…) → fetch via IMAP (#43 Phase 3); else AppleScript.
80982
81188
  imap: () => imapGetMessage(id, preferHtml === true),
80983
81189
  // IMAP path: parse subject/body out of the returned source so the
@@ -80989,11 +81195,15 @@ server.registerTool(
80989
81195
  id,
80990
81196
  subject: subjectFromGetMessage(r.info),
80991
81197
  body: sep2 >= 0 ? r.info.slice(sep2 + 2) : r.info,
80992
- isHtml: preferHtml === true
81198
+ isHtml: preferHtml === true,
81199
+ rfcMessageId: extractRfcMessageIdFromSource(r.info)
80993
81200
  };
80994
81201
  },
80995
81202
  apple: () => {
80996
- const content = mailManager.getMessageContent(id, preferHtml === true);
81203
+ const content = mailManager.getMessageContent(id, preferHtml === true, {
81204
+ account,
81205
+ mailbox
81206
+ });
80997
81207
  if (!content) return errorResponse(`Message with ID "${id}" not found`);
80998
81208
  const isHtml = preferHtml === true && !!content.htmlContent;
80999
81209
  const body = isHtml ? content.htmlContent : content.plainText;
@@ -81003,7 +81213,8 @@ ${body}`, {
81003
81213
  id,
81004
81214
  subject: content.subject,
81005
81215
  body,
81006
- isHtml
81216
+ isHtml,
81217
+ rfcMessageId: content.rfcMessageId ?? ""
81007
81218
  });
81008
81219
  },
81009
81220
  ok: "",
@@ -81868,20 +82079,21 @@ server.registerTool(
81868
82079
  if (/[/\\\0]/.test(attachmentName) || attachmentName.includes("..")) {
81869
82080
  return errorResponse(`Invalid attachment name: "${attachmentName}"`);
81870
82081
  }
81871
- const resolvedDir = resolvePath(savePath);
81872
- if (!isPathWithinAllowedRoots(resolvedDir)) {
81873
- return errorResponse(`Save path "${savePath}" is outside allowed directories`);
82082
+ let target;
82083
+ try {
82084
+ target = resolveAttachmentSaveTarget(savePath, attachmentName);
82085
+ } catch (error2) {
82086
+ return errorResponse(error2 instanceof Error ? error2.message : String(error2));
81874
82087
  }
81875
82088
  const r = await imapFetchAttachment(id, attachmentName);
81876
82089
  if (!r.success || !r.base64) {
81877
82090
  return errorResponse(r.error || `Failed to fetch attachment "${attachmentName}"`);
81878
82091
  }
81879
- const savedPath = joinPath(resolvedDir, attachmentName);
81880
- writeFileSync4(savedPath, Buffer.from(r.base64, "base64"));
82092
+ writeFileSync4(target.savedPath, Buffer.from(r.base64, "base64"));
81881
82093
  return successResponse(`Attachment "${attachmentName}" saved to ${savePath}`, {
81882
82094
  ok: true,
81883
82095
  attachmentName,
81884
- savedPath
82096
+ savedPath: target.savedPath
81885
82097
  });
81886
82098
  }
81887
82099
  const success = mailManager.saveAttachment(id, attachmentName, savePath);
@@ -199,6 +199,7 @@ the macOS 15+ blockquote wrapping. SMTP is single-account (the default sender):
199
199
  "APPLE_MAIL_MCP_SMTP_PORT": "587",
200
200
  "APPLE_MAIL_MCP_SMTP_USER": "you@gmail.com",
201
201
  "APPLE_MAIL_MCP_SMTP_FROM": "you@gmail.com",
202
+ "APPLE_MAIL_MCP_SMTP_ALLOWED_FROM": "alias@example.com",
202
203
  "APPLE_MAIL_MCP_SMTP_KEYCHAIN_SERVICE": "imap.gmail.com",
203
204
  "APPLE_MAIL_MCP_SMTP_KEYCHAIN_ACCOUNT": "you@gmail.com"
204
205
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-mail-mcp",
3
- "version": "2.8.8",
3
+ "version": "2.8.10",
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",