apple-mail-mcp 2.8.9 → 2.8.11

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();
@@ -77397,7 +77455,38 @@ var AppleMailManager = class {
77397
77455
  let listCommand;
77398
77456
  if (mailbox) {
77399
77457
  const targetMailbox = this.resolveMailbox(mailbox, targetAccount);
77400
- listCommand = `
77458
+ const gmailInbox = isInboxScope(mailbox) ? gmailReceivingMailboxes(this.getCachedMailboxNames(targetAccount)) : null;
77459
+ if (gmailInbox) {
77460
+ const nameList = appleScriptLowerNameList(gmailInbox);
77461
+ listCommand = `
77462
+ set outputText to ""
77463
+ set _timedOut to false
77464
+ set _notSearched to ""
77465
+ set _wantNames to ${nameList}
77466
+ set msgCount to 0
77467
+ set skipped to 0
77468
+ set seenIds to {}
77469
+ repeat with mb in mailboxes
77470
+ if msgCount >= ${limit} then exit repeat
77471
+ set mbName to ""
77472
+ try
77473
+ set mbName to name of mb
77474
+ end try
77475
+ ignoring case
77476
+ if _wantNames contains mbName then
77477
+ try
77478
+ ${buildMessageRowLoop({ collection: `messages of mb ${fromFilter}`, limit, offset, dedup: true, withAttachments: true, trailing: ` & "${FIELD_SEP}" & mbName` })}
77479
+ on error _errMsg number _errNum
77480
+ set _timedOut to true
77481
+ set _notSearched to _notSearched & mbName & "${DIAG_ITEM_SEP}"
77482
+ end try
77483
+ end if
77484
+ end ignoring
77485
+ end repeat
77486
+ return outputText & "${DIAG_MARKER}timedOut=" & (_timedOut as string) & "${DIAG_FIELD_SEP}skipped=${DIAG_FIELD_SEP}notSearched=" & _notSearched
77487
+ `;
77488
+ } else {
77489
+ listCommand = `
77401
77490
  set outputText to ""
77402
77491
  set _timedOut to false
77403
77492
  set _notSearched to ""
@@ -77412,6 +77501,7 @@ var AppleMailManager = class {
77412
77501
  end try
77413
77502
  return outputText & "${DIAG_MARKER}timedOut=" & (_timedOut as string) & "${DIAG_FIELD_SEP}skipped=${DIAG_FIELD_SEP}notSearched=" & _notSearched
77414
77503
  `;
77504
+ }
77415
77505
  } else {
77416
77506
  const scanGuard = scanThreshold > 0 ? `mbCount > ${scanThreshold}` : "false";
77417
77507
  listCommand = `
@@ -77917,7 +78007,7 @@ var AppleMailManager = class {
77917
78007
  findNumericIdByMessageId(messageId, accountName) {
77918
78008
  const mid = messageId.trim().replace(/^<+/, "").replace(/>+$/, "").trim();
77919
78009
  if (!mid) return null;
77920
- const q = (s) => s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
78010
+ const q = (s) => escapeForAppleScript(s);
77921
78011
  const midLit = `"${q(mid)}"`;
77922
78012
  const bracketedLit = `"${q(`<${mid}>`)}"`;
77923
78013
  const matchClause = (mbVar) => `(messages of ${mbVar} whose message id is ${midLit} or message id is ${bracketedLit})`;
@@ -78304,13 +78394,15 @@ var AppleMailManager = class {
78304
78394
  console.error(`Invalid attachment name: "${attachmentName}"`);
78305
78395
  return false;
78306
78396
  }
78307
- const resolvedPath = resolve(savePath);
78308
- if (!isPathWithinAllowedRoots(resolvedPath)) {
78309
- console.error(`Save path "${savePath}" is outside allowed directories`);
78397
+ let target;
78398
+ try {
78399
+ target = resolveAttachmentSaveTarget(savePath, attachmentName);
78400
+ } catch (error2) {
78401
+ console.error(error2 instanceof Error ? error2.message : String(error2));
78310
78402
  return false;
78311
78403
  }
78312
78404
  const safeName = escapeForAppleScript(attachmentName);
78313
- const safePath = escapeForAppleScript(resolvedPath);
78405
+ const safePath = escapeForAppleScript(target.saveDirectory);
78314
78406
  const numericId = Number(id);
78315
78407
  const script = buildAppLevelScript(`
78316
78408
  try
@@ -78352,12 +78444,7 @@ var AppleMailManager = class {
78352
78444
  return false;
78353
78445
  }
78354
78446
  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);
78447
+ writeFileSync3(target.savedPath, attachment.data);
78361
78448
  return true;
78362
78449
  } catch (err) {
78363
78450
  console.error(`Failed to write attachment to disk: ${err}`);
@@ -78374,7 +78461,7 @@ var AppleMailManager = class {
78374
78461
  try {
78375
78462
  dir = mkdtempSync2("/private/tmp/amcp-fetch-");
78376
78463
  const dest = join4(dir, attachmentName.replace(/[/\\]/g, "_"));
78377
- const ok = this.saveAttachment(id, attachmentName, dest);
78464
+ const ok = this.saveAttachment(id, attachmentName, dir);
78378
78465
  if (!ok) {
78379
78466
  return {
78380
78467
  success: false,
@@ -79125,7 +79212,7 @@ ${actionStmts.join("\n")}
79125
79212
 
79126
79213
  // src/index.ts
79127
79214
  import { writeFileSync as writeFileSync4 } from "fs";
79128
- import { resolve as resolvePath, join as joinPath } from "path";
79215
+ import { join as joinPath } from "path";
79129
79216
 
79130
79217
  // src/services/smtpMailer.ts
79131
79218
  var import_nodemailer = __toESM(require_nodemailer(), 1);
@@ -79144,6 +79231,7 @@ var SMTP_ENV = {
79144
79231
  secure: "APPLE_MAIL_MCP_SMTP_SECURE",
79145
79232
  user: "APPLE_MAIL_MCP_SMTP_USER",
79146
79233
  from: "APPLE_MAIL_MCP_SMTP_FROM",
79234
+ allowedFrom: "APPLE_MAIL_MCP_SMTP_ALLOWED_FROM",
79147
79235
  password: "APPLE_MAIL_MCP_SMTP_PASSWORD",
79148
79236
  keychainService: "APPLE_MAIL_MCP_SMTP_KEYCHAIN_SERVICE",
79149
79237
  keychainAccount: "APPLE_MAIL_MCP_SMTP_KEYCHAIN_ACCOUNT"
@@ -79189,6 +79277,7 @@ function resolveSmtpConfig(env = process.env) {
79189
79277
  throw new Error(`Invalid ${SMTP_ENV.port}: "${env[SMTP_ENV.port]}" is not a valid port.`);
79190
79278
  }
79191
79279
  const from = env[SMTP_ENV.from]?.trim() || user;
79280
+ const allowedFrom = (env[SMTP_ENV.allowedFrom] ?? "").split(",").map((value) => value.trim()).filter(Boolean);
79192
79281
  let pass = env[SMTP_ENV.password];
79193
79282
  if (!pass) {
79194
79283
  const service = env[SMTP_ENV.keychainService]?.trim() || host;
@@ -79200,7 +79289,7 @@ function resolveSmtpConfig(env = process.env) {
79200
79289
  `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
79290
  );
79202
79291
  }
79203
- return { host, port, secure, user, pass, from };
79292
+ return { host, port, secure, user, pass, from, allowedFrom };
79204
79293
  }
79205
79294
  function buildAttachments(attachments) {
79206
79295
  if (!attachments || attachments.length === 0) return void 0;
@@ -79213,7 +79302,7 @@ function buildAttachments(attachments) {
79213
79302
  if (!a.filename || !a.contentBase64) {
79214
79303
  throw new Error("Inline attachment requires both filename and contentBase64.");
79215
79304
  }
79216
- return { filename: a.filename, content: Buffer.from(a.contentBase64, "base64") };
79305
+ return { filename: a.filename, content: decodeInlineAttachment(a.contentBase64) };
79217
79306
  });
79218
79307
  }
79219
79308
  async function sendViaSmtp(opts, config2, createTransport = import_nodemailer.default.createTransport) {
@@ -79223,6 +79312,16 @@ async function sendViaSmtp(opts, config2, createTransport = import_nodemailer.de
79223
79312
  } catch (error2) {
79224
79313
  return { success: false, error: error2 instanceof Error ? error2.message : String(error2) };
79225
79314
  }
79315
+ const requestedFrom = opts.from?.trim();
79316
+ const allowedFrom = new Set(
79317
+ [cfg.user, cfg.from, ...cfg.allowedFrom ?? []].map((value) => value.trim().toLowerCase())
79318
+ );
79319
+ if (requestedFrom && !allowedFrom.has(requestedFrom.toLowerCase())) {
79320
+ return {
79321
+ success: false,
79322
+ error: `SMTP From "${requestedFrom}" is not a configured sender identity.`
79323
+ };
79324
+ }
79226
79325
  let attachments;
79227
79326
  try {
79228
79327
  attachments = buildAttachments(opts.attachments);
@@ -79238,7 +79337,7 @@ async function sendViaSmtp(opts, config2, createTransport = import_nodemailer.de
79238
79337
  const html = opts.htmlBody?.trim() ? opts.htmlBody : void 0;
79239
79338
  try {
79240
79339
  const info = await transporter.sendMail({
79241
- from: opts.from?.trim() || cfg.from,
79340
+ from: requestedFrom || cfg.from,
79242
79341
  to: opts.to,
79243
79342
  cc: opts.cc,
79244
79343
  bcc: opts.bcc,
@@ -79448,6 +79547,27 @@ function decodeImapId(id) {
79448
79547
  return null;
79449
79548
  }
79450
79549
  }
79550
+ function sameImapAccount(left, right, deps) {
79551
+ if (left === right) return true;
79552
+ if (deps.config) {
79553
+ const aliases = /* @__PURE__ */ new Set([deps.config.accountLabel, deps.config.user]);
79554
+ if (aliases.has(left) && aliases.has(right)) return true;
79555
+ }
79556
+ const specs = listImapAccountSpecs();
79557
+ const matches = (selector, spec) => spec.accountLabel === selector || spec.user === selector;
79558
+ const leftSpec = specs.find((spec) => matches(left, spec));
79559
+ const rightSpec = specs.find((spec) => matches(right, spec));
79560
+ return leftSpec !== void 0 && leftSpec === rightSpec;
79561
+ }
79562
+ function depsForAccount(account, deps) {
79563
+ if (deps.account && !sameImapAccount(account, deps.account, deps)) {
79564
+ throw new Error(`IMAP message id belongs to account "${account}", not "${deps.account}".`);
79565
+ }
79566
+ return { ...deps, account };
79567
+ }
79568
+ function depsForMessageRef(ref, deps) {
79569
+ return depsForAccount(ref.account, deps);
79570
+ }
79451
79571
  function str(v) {
79452
79572
  return typeof v === "string" && v.trim() ? v.trim() : void 0;
79453
79573
  }
@@ -79955,25 +80075,21 @@ async function withMailbox(path, deps, fn) {
79955
80075
  async function imapGetMessage(id, preferHtml, deps = {}) {
79956
80076
  const ref = decodeImapId(id);
79957
80077
  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}
80078
+ return withMailbox(ref.path, depsForMessageRef(ref, deps), async (client) => {
80079
+ const msg = await client.fetchOne(
80080
+ String(ref.uid),
80081
+ { envelope: true, source: true },
80082
+ { uid: true }
80083
+ );
80084
+ if (!msg)
80085
+ return { success: false, error: `IMAP message UID ${ref.uid} not found in "${ref.path}".` };
80086
+ const subject = msg.envelope?.subject || "(no subject)";
80087
+ const src = msg.source ? msg.source.toString() : "";
80088
+ const body = (preferHtml ? extractHtmlBody(src) : extractTextBody(src)) ?? extractTextBody(src) ?? extractHtmlBody(src) ?? "(no readable body)";
80089
+ return { success: true, info: `Subject: ${subject}
79973
80090
 
79974
80091
  ${body}` };
79975
- }
79976
- );
80092
+ });
79977
80093
  }
79978
80094
  function normalizeMessageId(mid) {
79979
80095
  return mid.trim().replace(/^<+/, "").replace(/>+$/, "").trim();
@@ -79982,15 +80098,11 @@ async function imapFetchMessageId(id, deps = {}) {
79982
80098
  const ref = decodeImapId(id);
79983
80099
  if (!ref) return null;
79984
80100
  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
- );
80101
+ return await withMailbox(ref.path, depsForMessageRef(ref, deps), async (client) => {
80102
+ const msg = await client.fetchOne(String(ref.uid), { envelope: true }, { uid: true });
80103
+ const mid = msg && msg.envelope?.messageId;
80104
+ return mid ? normalizeMessageId(mid) : null;
80105
+ });
79994
80106
  } catch {
79995
80107
  return null;
79996
80108
  }
@@ -79998,23 +80110,19 @@ async function imapFetchMessageId(id, deps = {}) {
79998
80110
  function flagOp(id, flag, add, deps) {
79999
80111
  const ref = decodeImapId(id);
80000
80112
  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
- }
80113
+ return withMailbox(ref.path, depsForMessageRef(ref, deps), async (client) => {
80114
+ try {
80115
+ const ok = add ? await client.messageFlagsAdd([ref.uid], [flag], { uid: true }) : await client.messageFlagsRemove([ref.uid], [flag], { uid: true });
80116
+ if (!ok)
80117
+ return { success: false, error: `IMAP flag update returned false for UID ${ref.uid}.` };
80118
+ return { success: true };
80119
+ } catch (e) {
80120
+ return {
80121
+ success: false,
80122
+ error: `IMAP flag update failed for UID ${ref.uid}: ${errText(e)}`
80123
+ };
80016
80124
  }
80017
- );
80125
+ });
80018
80126
  }
80019
80127
  var imapMarkRead = (id, deps = {}) => flagOp(id, "\\Seen", true, deps);
80020
80128
  var imapMarkUnread = (id, deps = {}) => flagOp(id, "\\Seen", false, deps);
@@ -80023,7 +80131,7 @@ var imapUnflagMessage = (id, deps = {}) => flagOp(id, "\\Flagged", false, deps);
80023
80131
  async function imapMoveMessageById(id, destMailbox, deps = {}) {
80024
80132
  const ref = decodeImapId(id);
80025
80133
  if (!ref) return { success: false, error: `Not an IMAP message id: "${id}".` };
80026
- return withClient({ ...deps, account: deps.account ?? ref.account }, async (client) => {
80134
+ return withClient(depsForMessageRef(ref, deps), async (client) => {
80027
80135
  const destPath = await findMailboxPath(client, destMailbox) ?? resolveMailboxPath(destMailbox, "list");
80028
80136
  const lock = await client.getMailboxLock(ref.path);
80029
80137
  try {
@@ -80064,21 +80172,17 @@ async function trashUids(client, uids, srcPath) {
80064
80172
  async function imapDeleteMessageById(id, deps = {}) {
80065
80173
  const ref = decodeImapId(id);
80066
80174
  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
- }
80175
+ return withMailbox(ref.path, depsForMessageRef(ref, deps), async (client) => {
80176
+ try {
80177
+ const { dest, expunged } = await trashUids(client, [ref.uid], ref.path);
80178
+ return {
80179
+ success: true,
80180
+ info: expunged ? `Permanently deleted UID ${ref.uid} from Trash ("${ref.path}") via IMAP.` : `Moved UID ${ref.uid} to Trash ("${dest}") via IMAP.`
80181
+ };
80182
+ } catch (e) {
80183
+ return { success: false, error: `IMAP delete failed for UID ${ref.uid}: ${errText(e)}` };
80080
80184
  }
80081
- );
80185
+ });
80082
80186
  }
80083
80187
  function collectAttachments(node, out = []) {
80084
80188
  if (!node) return out;
@@ -80104,54 +80208,46 @@ async function streamToBuffer(content) {
80104
80208
  async function imapListAttachments(id, deps = {}) {
80105
80209
  const ref = decodeImapId(id);
80106
80210
  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
- );
80211
+ return withMailbox(ref.path, depsForMessageRef(ref, deps), async (client) => {
80212
+ const msg = await client.fetchOne(String(ref.uid), { bodyStructure: true }, { uid: true });
80213
+ if (!msg || !msg.bodyStructure) {
80214
+ return { success: false, error: `IMAP message UID ${ref.uid} not found in "${ref.path}".` };
80215
+ }
80216
+ const attachments = collectAttachments(msg.bodyStructure).map((a) => ({
80217
+ id: `${id}#${a.part}`,
80218
+ name: a.filename,
80219
+ mimeType: a.mimeType,
80220
+ size: a.size
80221
+ }));
80222
+ return { success: true, attachments };
80223
+ });
80124
80224
  }
80125
80225
  async function imapFetchAttachment(id, attachmentName, deps = {}) {
80126
80226
  const ref = decodeImapId(id);
80127
80227
  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);
80228
+ return withMailbox(ref.path, depsForMessageRef(ref, deps), async (client) => {
80229
+ const msg = await client.fetchOne(String(ref.uid), { bodyStructure: true }, { uid: true });
80230
+ if (!msg || !msg.bodyStructure) {
80231
+ return { success: false, error: `IMAP message UID ${ref.uid} not found in "${ref.path}".` };
80232
+ }
80233
+ const atts = collectAttachments(msg.bodyStructure);
80234
+ const match = atts.find((a) => a.filename === attachmentName);
80235
+ if (!match) {
80236
+ const names = atts.map((a) => a.filename).join(", ") || "none";
80147
80237
  return {
80148
- success: true,
80149
- base64: buf.toString("base64"),
80150
- bytes: buf.length,
80151
- mimeType: match.mimeType
80238
+ success: false,
80239
+ error: `Attachment "${attachmentName}" not found on UID ${ref.uid}. Available: ${names}.`
80152
80240
  };
80153
80241
  }
80154
- );
80242
+ const dl = await client.download(String(ref.uid), match.part, { uid: true });
80243
+ const buf = await streamToBuffer(dl.content);
80244
+ return {
80245
+ success: true,
80246
+ base64: buf.toString("base64"),
80247
+ bytes: buf.length,
80248
+ mimeType: match.mimeType
80249
+ };
80250
+ });
80155
80251
  }
80156
80252
  async function imapBatch(ids, deps, op) {
80157
80253
  const groups = /* @__PURE__ */ new Map();
@@ -80172,7 +80268,7 @@ async function imapBatch(ids, deps, op) {
80172
80268
  let success = 0;
80173
80269
  for (const g of groups.values()) {
80174
80270
  try {
80175
- await useClient({ ...deps, account: deps.account ?? g.account }, async (client) => {
80271
+ await useClient(depsForAccount(g.account, deps), async (client) => {
80176
80272
  const lock = await client.getMailboxLock(g.path);
80177
80273
  try {
80178
80274
  await op(client, g.uids, g.path);
@@ -80221,7 +80317,7 @@ async function imapThread(id, deps = {}, limit = 50) {
80221
80317
  const ref = decodeImapId(id);
80222
80318
  if (!ref) return null;
80223
80319
  return useClient(
80224
- { ...deps, account: deps.account ?? ref.account },
80320
+ depsForMessageRef(ref, deps),
80225
80321
  async (client) => {
80226
80322
  const lock = await client.getMailboxLock(ref.path);
80227
80323
  try {
@@ -80860,12 +80956,18 @@ var ATTACHMENTS_SCHEMA = external_exports.array(
80860
80956
  external_exports.union([
80861
80957
  external_exports.string().describe("Absolute path to an existing file"),
80862
80958
  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")
80959
+ filename: external_exports.string().min(1).max(255).describe("Filename to give the attachment"),
80960
+ contentBase64: external_exports.string().min(1).max(
80961
+ MAX_INLINE_ATTACHMENT_BASE64_INPUT_CHARS,
80962
+ "Inline attachment exceeds the 25 MiB decoded size limit"
80963
+ ).refine(
80964
+ isInlineAttachmentBase64WithinLimit,
80965
+ "Inline attachment exceeds the 25 MiB decoded size limit"
80966
+ ).describe("Base64-encoded file content (maximum 25 MiB decoded)")
80865
80967
  })
80866
80968
  ])
80867
80969
  ).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."
80970
+ "Files to attach: absolute paths (e.g. '/Users/me/report.pdf') and/or inline {filename, contentBase64} objects up to 25 MiB decoded each."
80869
80971
  );
80870
80972
  var MESSAGE_ROW_SCHEMA = external_exports.object({}).passthrough();
80871
80973
  var LIST_OUTPUT_SCHEMA = {
@@ -82009,20 +82111,21 @@ server.registerTool(
82009
82111
  if (/[/\\\0]/.test(attachmentName) || attachmentName.includes("..")) {
82010
82112
  return errorResponse(`Invalid attachment name: "${attachmentName}"`);
82011
82113
  }
82012
- const resolvedDir = resolvePath(savePath);
82013
- if (!isPathWithinAllowedRoots(resolvedDir)) {
82014
- return errorResponse(`Save path "${savePath}" is outside allowed directories`);
82114
+ let target;
82115
+ try {
82116
+ target = resolveAttachmentSaveTarget(savePath, attachmentName);
82117
+ } catch (error2) {
82118
+ return errorResponse(error2 instanceof Error ? error2.message : String(error2));
82015
82119
  }
82016
82120
  const r = await imapFetchAttachment(id, attachmentName);
82017
82121
  if (!r.success || !r.base64) {
82018
82122
  return errorResponse(r.error || `Failed to fetch attachment "${attachmentName}"`);
82019
82123
  }
82020
- const savedPath = joinPath(resolvedDir, attachmentName);
82021
- writeFileSync4(savedPath, Buffer.from(r.base64, "base64"));
82124
+ writeFileSync4(target.savedPath, Buffer.from(r.base64, "base64"));
82022
82125
  return successResponse(`Attachment "${attachmentName}" saved to ${savePath}`, {
82023
82126
  ok: true,
82024
82127
  attachmentName,
82025
- savedPath
82128
+ savedPath: target.savedPath
82026
82129
  });
82027
82130
  }
82028
82131
  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.11",
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",