apple-mail-mcp 2.8.9 → 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
 
@@ -76200,22 +76208,53 @@ var TemplateStore = class {
76200
76208
  import { writeFileSync as writeFileSync2, rmSync, mkdtempSync } from "fs";
76201
76209
  import { join as join2 } from "path";
76202
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
76203
76236
  function materializeAttachments(attachments) {
76204
76237
  if (!attachments || attachments.length === 0) {
76205
76238
  return { paths: [], cleanup: () => void 0 };
76206
76239
  }
76207
76240
  let dir = null;
76208
- const paths = attachments.map((a) => {
76209
- if (typeof a === "string") return a;
76210
- if (!a.filename || !a.contentBase64) {
76211
- throw new Error("Inline attachment requires both filename and contentBase64.");
76212
- }
76213
- if (!dir) dir = mkdtempSync(join2(tmpdir(), "amcp-att-"));
76214
- const safeName = a.filename.replace(/[/\\]/g, "_");
76215
- const p = join2(dir, safeName);
76216
- writeFileSync2(p, Buffer.from(a.contentBase64, "base64"));
76217
- return p;
76218
- });
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
+ }
76219
76258
  return {
76220
76259
  paths,
76221
76260
  cleanup: () => {
@@ -76398,6 +76437,25 @@ function isPathWithinAllowedRoots(resolvedPath) {
76398
76437
  return resolvedPath === base || resolvedPath.startsWith(base + sep);
76399
76438
  });
76400
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
+ }
76401
76459
  var UNSUPPORTED_APPLESCRIPT_OP = /AppleEvent handler failed|-10000/i;
76402
76460
  function describeMailboxOpError(op, raw) {
76403
76461
  const trimmed = (raw || "").trim();
@@ -77917,7 +77975,7 @@ var AppleMailManager = class {
77917
77975
  findNumericIdByMessageId(messageId, accountName) {
77918
77976
  const mid = messageId.trim().replace(/^<+/, "").replace(/>+$/, "").trim();
77919
77977
  if (!mid) return null;
77920
- const q = (s) => s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
77978
+ const q = (s) => escapeForAppleScript(s);
77921
77979
  const midLit = `"${q(mid)}"`;
77922
77980
  const bracketedLit = `"${q(`<${mid}>`)}"`;
77923
77981
  const matchClause = (mbVar) => `(messages of ${mbVar} whose message id is ${midLit} or message id is ${bracketedLit})`;
@@ -78304,13 +78362,15 @@ var AppleMailManager = class {
78304
78362
  console.error(`Invalid attachment name: "${attachmentName}"`);
78305
78363
  return false;
78306
78364
  }
78307
- const resolvedPath = resolve(savePath);
78308
- if (!isPathWithinAllowedRoots(resolvedPath)) {
78309
- 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));
78310
78370
  return false;
78311
78371
  }
78312
78372
  const safeName = escapeForAppleScript(attachmentName);
78313
- const safePath = escapeForAppleScript(resolvedPath);
78373
+ const safePath = escapeForAppleScript(target.saveDirectory);
78314
78374
  const numericId = Number(id);
78315
78375
  const script = buildAppLevelScript(`
78316
78376
  try
@@ -78352,12 +78412,7 @@ var AppleMailManager = class {
78352
78412
  return false;
78353
78413
  }
78354
78414
  try {
78355
- const outPath = resolve(resolvedPath, attachmentName);
78356
- if (!isPathWithinAllowedRoots(outPath)) {
78357
- console.error(`Output path "${outPath}" is outside allowed directories`);
78358
- return false;
78359
- }
78360
- writeFileSync3(outPath, attachment.data);
78415
+ writeFileSync3(target.savedPath, attachment.data);
78361
78416
  return true;
78362
78417
  } catch (err) {
78363
78418
  console.error(`Failed to write attachment to disk: ${err}`);
@@ -78374,7 +78429,7 @@ var AppleMailManager = class {
78374
78429
  try {
78375
78430
  dir = mkdtempSync2("/private/tmp/amcp-fetch-");
78376
78431
  const dest = join4(dir, attachmentName.replace(/[/\\]/g, "_"));
78377
- const ok = this.saveAttachment(id, attachmentName, dest);
78432
+ const ok = this.saveAttachment(id, attachmentName, dir);
78378
78433
  if (!ok) {
78379
78434
  return {
78380
78435
  success: false,
@@ -79125,7 +79180,7 @@ ${actionStmts.join("\n")}
79125
79180
 
79126
79181
  // src/index.ts
79127
79182
  import { writeFileSync as writeFileSync4 } from "fs";
79128
- import { resolve as resolvePath, join as joinPath } from "path";
79183
+ import { join as joinPath } from "path";
79129
79184
 
79130
79185
  // src/services/smtpMailer.ts
79131
79186
  var import_nodemailer = __toESM(require_nodemailer(), 1);
@@ -79144,6 +79199,7 @@ var SMTP_ENV = {
79144
79199
  secure: "APPLE_MAIL_MCP_SMTP_SECURE",
79145
79200
  user: "APPLE_MAIL_MCP_SMTP_USER",
79146
79201
  from: "APPLE_MAIL_MCP_SMTP_FROM",
79202
+ allowedFrom: "APPLE_MAIL_MCP_SMTP_ALLOWED_FROM",
79147
79203
  password: "APPLE_MAIL_MCP_SMTP_PASSWORD",
79148
79204
  keychainService: "APPLE_MAIL_MCP_SMTP_KEYCHAIN_SERVICE",
79149
79205
  keychainAccount: "APPLE_MAIL_MCP_SMTP_KEYCHAIN_ACCOUNT"
@@ -79189,6 +79245,7 @@ function resolveSmtpConfig(env = process.env) {
79189
79245
  throw new Error(`Invalid ${SMTP_ENV.port}: "${env[SMTP_ENV.port]}" is not a valid port.`);
79190
79246
  }
79191
79247
  const from = env[SMTP_ENV.from]?.trim() || user;
79248
+ const allowedFrom = (env[SMTP_ENV.allowedFrom] ?? "").split(",").map((value) => value.trim()).filter(Boolean);
79192
79249
  let pass = env[SMTP_ENV.password];
79193
79250
  if (!pass) {
79194
79251
  const service = env[SMTP_ENV.keychainService]?.trim() || host;
@@ -79200,7 +79257,7 @@ function resolveSmtpConfig(env = process.env) {
79200
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
79201
79258
  );
79202
79259
  }
79203
- return { host, port, secure, user, pass, from };
79260
+ return { host, port, secure, user, pass, from, allowedFrom };
79204
79261
  }
79205
79262
  function buildAttachments(attachments) {
79206
79263
  if (!attachments || attachments.length === 0) return void 0;
@@ -79213,7 +79270,7 @@ function buildAttachments(attachments) {
79213
79270
  if (!a.filename || !a.contentBase64) {
79214
79271
  throw new Error("Inline attachment requires both filename and contentBase64.");
79215
79272
  }
79216
- return { filename: a.filename, content: Buffer.from(a.contentBase64, "base64") };
79273
+ return { filename: a.filename, content: decodeInlineAttachment(a.contentBase64) };
79217
79274
  });
79218
79275
  }
79219
79276
  async function sendViaSmtp(opts, config2, createTransport = import_nodemailer.default.createTransport) {
@@ -79223,6 +79280,16 @@ async function sendViaSmtp(opts, config2, createTransport = import_nodemailer.de
79223
79280
  } catch (error2) {
79224
79281
  return { success: false, error: error2 instanceof Error ? error2.message : String(error2) };
79225
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
+ }
79226
79293
  let attachments;
79227
79294
  try {
79228
79295
  attachments = buildAttachments(opts.attachments);
@@ -79238,7 +79305,7 @@ async function sendViaSmtp(opts, config2, createTransport = import_nodemailer.de
79238
79305
  const html = opts.htmlBody?.trim() ? opts.htmlBody : void 0;
79239
79306
  try {
79240
79307
  const info = await transporter.sendMail({
79241
- from: opts.from?.trim() || cfg.from,
79308
+ from: requestedFrom || cfg.from,
79242
79309
  to: opts.to,
79243
79310
  cc: opts.cc,
79244
79311
  bcc: opts.bcc,
@@ -79448,6 +79515,27 @@ function decodeImapId(id) {
79448
79515
  return null;
79449
79516
  }
79450
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
+ }
79451
79539
  function str(v) {
79452
79540
  return typeof v === "string" && v.trim() ? v.trim() : void 0;
79453
79541
  }
@@ -79955,25 +80043,21 @@ async function withMailbox(path, deps, fn) {
79955
80043
  async function imapGetMessage(id, preferHtml, deps = {}) {
79956
80044
  const ref = decodeImapId(id);
79957
80045
  if (!ref) return { success: false, error: `Not an IMAP message id: "${id}".` };
79958
- return withMailbox(
79959
- ref.path,
79960
- { ...deps, account: deps.account ?? ref.account },
79961
- async (client) => {
79962
- const msg = await client.fetchOne(
79963
- String(ref.uid),
79964
- { envelope: true, source: true },
79965
- { uid: true }
79966
- );
79967
- if (!msg)
79968
- return { success: false, error: `IMAP message UID ${ref.uid} not found in "${ref.path}".` };
79969
- const subject = msg.envelope?.subject || "(no subject)";
79970
- const src = msg.source ? msg.source.toString() : "";
79971
- const body = (preferHtml ? extractHtmlBody(src) : extractTextBody(src)) ?? extractTextBody(src) ?? extractHtmlBody(src) ?? "(no readable body)";
79972
- 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}
79973
80058
 
79974
80059
  ${body}` };
79975
- }
79976
- );
80060
+ });
79977
80061
  }
79978
80062
  function normalizeMessageId(mid) {
79979
80063
  return mid.trim().replace(/^<+/, "").replace(/>+$/, "").trim();
@@ -79982,15 +80066,11 @@ async function imapFetchMessageId(id, deps = {}) {
79982
80066
  const ref = decodeImapId(id);
79983
80067
  if (!ref) return null;
79984
80068
  try {
79985
- return await withMailbox(
79986
- ref.path,
79987
- { ...deps, account: deps.account ?? ref.account },
79988
- async (client) => {
79989
- const msg = await client.fetchOne(String(ref.uid), { envelope: true }, { uid: true });
79990
- const mid = msg && msg.envelope?.messageId;
79991
- return mid ? normalizeMessageId(mid) : null;
79992
- }
79993
- );
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
+ });
79994
80074
  } catch {
79995
80075
  return null;
79996
80076
  }
@@ -79998,23 +80078,19 @@ async function imapFetchMessageId(id, deps = {}) {
79998
80078
  function flagOp(id, flag, add, deps) {
79999
80079
  const ref = decodeImapId(id);
80000
80080
  if (!ref) return Promise.resolve({ success: false, error: `Not an IMAP message id: "${id}".` });
80001
- return withMailbox(
80002
- ref.path,
80003
- { ...deps, account: deps.account ?? ref.account },
80004
- async (client) => {
80005
- try {
80006
- const ok = add ? await client.messageFlagsAdd([ref.uid], [flag], { uid: true }) : await client.messageFlagsRemove([ref.uid], [flag], { uid: true });
80007
- if (!ok)
80008
- return { success: false, error: `IMAP flag update returned false for UID ${ref.uid}.` };
80009
- return { success: true };
80010
- } catch (e) {
80011
- return {
80012
- success: false,
80013
- error: `IMAP flag update failed for UID ${ref.uid}: ${errText(e)}`
80014
- };
80015
- }
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
+ };
80016
80092
  }
80017
- );
80093
+ });
80018
80094
  }
80019
80095
  var imapMarkRead = (id, deps = {}) => flagOp(id, "\\Seen", true, deps);
80020
80096
  var imapMarkUnread = (id, deps = {}) => flagOp(id, "\\Seen", false, deps);
@@ -80023,7 +80099,7 @@ var imapUnflagMessage = (id, deps = {}) => flagOp(id, "\\Flagged", false, deps);
80023
80099
  async function imapMoveMessageById(id, destMailbox, deps = {}) {
80024
80100
  const ref = decodeImapId(id);
80025
80101
  if (!ref) return { success: false, error: `Not an IMAP message id: "${id}".` };
80026
- return withClient({ ...deps, account: deps.account ?? ref.account }, async (client) => {
80102
+ return withClient(depsForMessageRef(ref, deps), async (client) => {
80027
80103
  const destPath = await findMailboxPath(client, destMailbox) ?? resolveMailboxPath(destMailbox, "list");
80028
80104
  const lock = await client.getMailboxLock(ref.path);
80029
80105
  try {
@@ -80064,21 +80140,17 @@ async function trashUids(client, uids, srcPath) {
80064
80140
  async function imapDeleteMessageById(id, deps = {}) {
80065
80141
  const ref = decodeImapId(id);
80066
80142
  if (!ref) return { success: false, error: `Not an IMAP message id: "${id}".` };
80067
- return withMailbox(
80068
- ref.path,
80069
- { ...deps, account: deps.account ?? ref.account },
80070
- async (client) => {
80071
- try {
80072
- const { dest, expunged } = await trashUids(client, [ref.uid], ref.path);
80073
- return {
80074
- success: true,
80075
- info: expunged ? `Permanently deleted UID ${ref.uid} from Trash ("${ref.path}") via IMAP.` : `Moved UID ${ref.uid} to Trash ("${dest}") via IMAP.`
80076
- };
80077
- } catch (e) {
80078
- return { success: false, error: `IMAP delete failed for UID ${ref.uid}: ${errText(e)}` };
80079
- }
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)}` };
80080
80152
  }
80081
- );
80153
+ });
80082
80154
  }
80083
80155
  function collectAttachments(node, out = []) {
80084
80156
  if (!node) return out;
@@ -80104,54 +80176,46 @@ async function streamToBuffer(content) {
80104
80176
  async function imapListAttachments(id, deps = {}) {
80105
80177
  const ref = decodeImapId(id);
80106
80178
  if (!ref) return { success: false, error: `Not an IMAP message id: "${id}".` };
80107
- return withMailbox(
80108
- ref.path,
80109
- { ...deps, account: deps.account ?? ref.account },
80110
- async (client) => {
80111
- const msg = await client.fetchOne(String(ref.uid), { bodyStructure: true }, { uid: true });
80112
- if (!msg || !msg.bodyStructure) {
80113
- return { success: false, error: `IMAP message UID ${ref.uid} not found in "${ref.path}".` };
80114
- }
80115
- const attachments = collectAttachments(msg.bodyStructure).map((a) => ({
80116
- id: `${id}#${a.part}`,
80117
- name: a.filename,
80118
- mimeType: a.mimeType,
80119
- size: a.size
80120
- }));
80121
- return { success: true, attachments };
80122
- }
80123
- );
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
+ });
80124
80192
  }
80125
80193
  async function imapFetchAttachment(id, attachmentName, deps = {}) {
80126
80194
  const ref = decodeImapId(id);
80127
80195
  if (!ref) return { success: false, error: `Not an IMAP message id: "${id}".` };
80128
- return withMailbox(
80129
- ref.path,
80130
- { ...deps, account: deps.account ?? ref.account },
80131
- async (client) => {
80132
- const msg = await client.fetchOne(String(ref.uid), { bodyStructure: true }, { uid: true });
80133
- if (!msg || !msg.bodyStructure) {
80134
- return { success: false, error: `IMAP message UID ${ref.uid} not found in "${ref.path}".` };
80135
- }
80136
- const atts = collectAttachments(msg.bodyStructure);
80137
- const match = atts.find((a) => a.filename === attachmentName);
80138
- if (!match) {
80139
- const names = atts.map((a) => a.filename).join(", ") || "none";
80140
- return {
80141
- success: false,
80142
- error: `Attachment "${attachmentName}" not found on UID ${ref.uid}. Available: ${names}.`
80143
- };
80144
- }
80145
- const dl = await client.download(String(ref.uid), match.part, { uid: true });
80146
- 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";
80147
80205
  return {
80148
- success: true,
80149
- base64: buf.toString("base64"),
80150
- bytes: buf.length,
80151
- mimeType: match.mimeType
80206
+ success: false,
80207
+ error: `Attachment "${attachmentName}" not found on UID ${ref.uid}. Available: ${names}.`
80152
80208
  };
80153
80209
  }
80154
- );
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
+ });
80155
80219
  }
80156
80220
  async function imapBatch(ids, deps, op) {
80157
80221
  const groups = /* @__PURE__ */ new Map();
@@ -80172,7 +80236,7 @@ async function imapBatch(ids, deps, op) {
80172
80236
  let success = 0;
80173
80237
  for (const g of groups.values()) {
80174
80238
  try {
80175
- await useClient({ ...deps, account: deps.account ?? g.account }, async (client) => {
80239
+ await useClient(depsForAccount(g.account, deps), async (client) => {
80176
80240
  const lock = await client.getMailboxLock(g.path);
80177
80241
  try {
80178
80242
  await op(client, g.uids, g.path);
@@ -80221,7 +80285,7 @@ async function imapThread(id, deps = {}, limit = 50) {
80221
80285
  const ref = decodeImapId(id);
80222
80286
  if (!ref) return null;
80223
80287
  return useClient(
80224
- { ...deps, account: deps.account ?? ref.account },
80288
+ depsForMessageRef(ref, deps),
80225
80289
  async (client) => {
80226
80290
  const lock = await client.getMailboxLock(ref.path);
80227
80291
  try {
@@ -80860,12 +80924,18 @@ var ATTACHMENTS_SCHEMA = external_exports.array(
80860
80924
  external_exports.union([
80861
80925
  external_exports.string().describe("Absolute path to an existing file"),
80862
80926
  external_exports.object({
80863
- filename: external_exports.string().min(1).describe("Filename to give the attachment"),
80864
- 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)")
80865
80935
  })
80866
80936
  ])
80867
80937
  ).max(20, "Cannot attach more than 20 files").optional().describe(
80868
- "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."
80869
80939
  );
80870
80940
  var MESSAGE_ROW_SCHEMA = external_exports.object({}).passthrough();
80871
80941
  var LIST_OUTPUT_SCHEMA = {
@@ -82009,20 +82079,21 @@ server.registerTool(
82009
82079
  if (/[/\\\0]/.test(attachmentName) || attachmentName.includes("..")) {
82010
82080
  return errorResponse(`Invalid attachment name: "${attachmentName}"`);
82011
82081
  }
82012
- const resolvedDir = resolvePath(savePath);
82013
- if (!isPathWithinAllowedRoots(resolvedDir)) {
82014
- 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));
82015
82087
  }
82016
82088
  const r = await imapFetchAttachment(id, attachmentName);
82017
82089
  if (!r.success || !r.base64) {
82018
82090
  return errorResponse(r.error || `Failed to fetch attachment "${attachmentName}"`);
82019
82091
  }
82020
- const savedPath = joinPath(resolvedDir, attachmentName);
82021
- writeFileSync4(savedPath, Buffer.from(r.base64, "base64"));
82092
+ writeFileSync4(target.savedPath, Buffer.from(r.base64, "base64"));
82022
82093
  return successResponse(`Attachment "${attachmentName}" saved to ${savePath}`, {
82023
82094
  ok: true,
82024
82095
  attachmentName,
82025
- savedPath
82096
+ savedPath: target.savedPath
82026
82097
  });
82027
82098
  }
82028
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.9",
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",