apple-mail-mcp 2.2.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,6 +6,7 @@ A [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server that e
6
6
  [![npm downloads](https://img.shields.io/npm/dm/apple-mail-mcp)](https://www.npmjs.com/package/apple-mail-mcp)
7
7
  [![node](https://img.shields.io/node/v/apple-mail-mcp)](https://www.npmjs.com/package/apple-mail-mcp)
8
8
  [![CI](https://github.com/sweetrb/apple-mail-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/sweetrb/apple-mail-mcp/actions/workflows/ci.yml)
9
+ [![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/sweetrb/apple-mail-mcp/badge)](https://scorecard.dev/viewer/?uri=github.com/sweetrb/apple-mail-mcp)
9
10
  [![platform: macOS](https://img.shields.io/badge/platform-macOS-111?logo=apple&logoColor=white)](https://www.apple.com/macos/)
10
11
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
11
12
  [![MCP](https://img.shields.io/badge/MCP-server-blue)](https://modelcontextprotocol.io)
@@ -255,7 +256,7 @@ Send a new email immediately.
255
256
  | `bcc` | string[] | No | BCC recipients |
256
257
  | `account` | string | No | Send from specific account (with `transport: "smtp"`, overrides the From address) |
257
258
  | `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 |
258
- | `transport` | `"applescript"` \| `"smtp"` | No | Send transport (default `"applescript"`). Use `"smtp"` to send clean MIME directly, avoiding the macOS 15+ Mail.app `<blockquote>` wrapping — see [SMTP transport](#smtp-transport) |
259
+ | `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) |
259
260
 
260
261
  **Example:**
261
262
  ```json
@@ -274,8 +275,25 @@ On macOS 15+ (Sequoia/Tahoe), Mail.app wraps any AppleScript-injected body in
274
275
  `<blockquote type="cite">` under the `Apple-Mail-URLShareWrapperClass` template,
275
276
  so emails sent through the default `applescript` transport render to recipients
276
277
  as if they were quoted/forwarded (Apple radar **FB11734014**, open since
277
- Ventura). Passing `transport: "smtp"` bypasses Mail.app entirely and submits
278
- clean MIME via SMTP.
278
+ Ventura). The SMTP transport bypasses Mail.app entirely and submits clean MIME
279
+ directly. **Once SMTP is configured, `send-email` uses it automatically** (no
280
+ need to pass `transport` per call); pass `transport: "applescript"` to force the
281
+ Mail.app path.
282
+
283
+ Two differences to know when SMTP is auto-preferred:
284
+
285
+ - **No Sent-folder copy.** SMTP submission does not file the message in Mail.app's
286
+ Sent mailbox (the server's own "save to Sent" may, depending on provider). Use
287
+ `transport: "applescript"` if you need the local Sent copy.
288
+ - **`account` is a From override, not account selection.** Over SMTP, `account`
289
+ is used as the From address only when it is an email address; a Mail.app
290
+ account *label* (e.g. `"Work"`) can't select an account over SMTP, so a call
291
+ that passes one is left on the AppleScript path automatically. To force
292
+ account selection, pass `transport: "applescript"` explicitly.
293
+
294
+ Both plain-text and HTML bodies are supported — over SMTP an HTML body (CLI
295
+ `--html-body-file`) is sent as `multipart/alternative` with the plain-text
296
+ fallback.
279
297
 
280
298
  Configure SMTP via environment variables on the MCP server. The password is
281
299
  read from the macOS **Keychain** by default, so no secret goes in config:
@@ -292,23 +310,48 @@ read from the macOS **Keychain** by default, so no secret goes in config:
292
310
  | `APPLE_MAIL_MCP_SMTP_KEYCHAIN_ACCOUNT` | No | = user | Keychain item account |
293
311
 
294
312
  Store the password in the Keychain once (an app-specific password for Gmail/
295
- iCloud), e.g.:
313
+ iCloud). A generic-password item with an explicit service name keeps it from
314
+ colliding with the system mail account password, and matches
315
+ `APPLE_MAIL_MCP_SMTP_KEYCHAIN_SERVICE`:
296
316
 
297
317
  ```bash
318
+ # Fastmail (Keychain service defaults to the host)
298
319
  security add-internet-password -s smtp.fastmail.com -a you@example.com -w
320
+
321
+ # Gmail / Google Workspace, using a dedicated Keychain service name:
322
+ # APPLE_MAIL_MCP_SMTP_HOST=smtp.gmail.com
323
+ # APPLE_MAIL_MCP_SMTP_USER=you@gmail.com
324
+ # APPLE_MAIL_MCP_SMTP_KEYCHAIN_SERVICE=apple-mail-mcp-smtp
325
+ security add-generic-password -s apple-mail-mcp-smtp -a you@gmail.com -w
299
326
  ```
300
327
 
301
- Then send:
328
+ Once the env vars are set, a plain `send-email` (no `transport`) already goes
329
+ out clean:
302
330
  ```json
303
331
  {
304
332
  "to": ["colleague@company.com"],
305
333
  "subject": "Standings",
306
- "body": "Plain body — no blockquote wrapping.",
307
- "transport": "smtp"
334
+ "body": "Plain body — no blockquote wrapping."
308
335
  }
309
336
  ```
310
337
 
311
- The default `applescript` transport is unchanged; SMTP is opt-in per call.
338
+ ###### `apple-mail-send` CLI (no MCP server required)
339
+
340
+ The package also installs an `apple-mail-send` binary — a standalone CLI over the
341
+ same SMTP path, for cron jobs, scheduled tasks, and scripts that can't run an MCP
342
+ session. It reads the identical `APPLE_MAIL_MCP_SMTP_*` env + Keychain config:
343
+
344
+ ```bash
345
+ apple-mail-send \
346
+ --from you@example.com --to colleague@company.com \
347
+ --subject "Standings" --body-file /tmp/body.txt \
348
+ [--html-body-file /tmp/body.html] [--attach /tmp/report.pdf]
349
+ ```
350
+
351
+ Repeatable `--to`/`--cc`/`--bcc`/`--attach`; an `--html-body-file` is sent as a
352
+ `multipart/alternative` alongside the plain `--body-file`. Exit codes follow
353
+ `sysexits.h`: `0` success, `64` usage error, `66` unreadable body file, `78`
354
+ SMTP not configured.
312
355
 
313
356
  ##### IMAP backend — opt-in
314
357
 
package/build/cli.d.ts ADDED
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env node
2
+ import { sendViaSmtp, resolveSmtpConfig } from "./services/smtpMailer.js";
3
+ /** sysexits.h codes used so callers can distinguish failure modes. */
4
+ export declare const EX_USAGE = 64;
5
+ export declare const EX_NOINPUT = 66;
6
+ export declare const EX_CONFIG = 78;
7
+ /** Injectable dependencies, defaulted to the real implementations. */
8
+ export interface CliDeps {
9
+ send?: typeof sendViaSmtp;
10
+ resolveConfig?: typeof resolveSmtpConfig;
11
+ readTextFile?: (path: string) => string;
12
+ stdout?: (line: string) => void;
13
+ stderr?: (line: string) => void;
14
+ env?: NodeJS.ProcessEnv;
15
+ }
16
+ /**
17
+ * Parses argv, sends the email, and returns a process exit code. Pure with
18
+ * respect to its deps (no direct process/console access) so it can be unit
19
+ * tested without the network or Keychain.
20
+ *
21
+ * @param argv - arguments AFTER `node cli.js` (i.e. `process.argv.slice(2)`)
22
+ */
23
+ export declare function runCli(argv: string[], deps?: CliDeps): Promise<number>;
24
+ //# sourceMappingURL=cli.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAyBA,OAAO,EAAE,WAAW,EAAE,iBAAiB,EAAY,MAAM,0BAA0B,CAAC;AAGpF,sEAAsE;AACtE,eAAO,MAAM,QAAQ,KAAK,CAAC;AAC3B,eAAO,MAAM,UAAU,KAAK,CAAC;AAC7B,eAAO,MAAM,SAAS,KAAK,CAAC;AAqB5B,sEAAsE;AACtE,MAAM,WAAW,OAAO;IACtB,IAAI,CAAC,EAAE,OAAO,WAAW,CAAC;IAC1B,aAAa,CAAC,EAAE,OAAO,iBAAiB,CAAC;IACzC,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAC;IACxC,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAChC,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAChC,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;CACzB;AAED;;;;;;GAMG;AACH,wBAAsB,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,IAAI,GAAE,OAAY,GAAG,OAAO,CAAC,MAAM,CAAC,CAwGhF"}
package/build/cli.js ADDED
@@ -0,0 +1,165 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `apple-mail-send` — standalone clean-SMTP email CLI.
4
+ *
5
+ * A thin command-line front end over {@link sendViaSmtp} so scheduled tasks,
6
+ * cron jobs, and other scripts can send clean MIME (no Mail.app blockquote
7
+ * wrapping, issue #12) WITHOUT a running MCP server. It reuses the exact same
8
+ * SMTP config resolution as the MCP `send-email` tool — env vars first, then the
9
+ * macOS Keychain (see the README "SMTP transport" section).
10
+ *
11
+ * The flag surface intentionally mirrors the legacy nhl-bracket-tracker
12
+ * `send_email.py` (--from/--to/--cc/--bcc/--subject/--body-file/
13
+ * --html-body-file/--attach) and its exit codes (78 = EX_CONFIG when SMTP is
14
+ * not configured) so callers can drop this in as a replacement.
15
+ *
16
+ * Usage:
17
+ * apple-mail-send --from me@example.com --to you@example.com \
18
+ * --subject "Hi" --body-file /tmp/body.txt \
19
+ * [--html-body-file /tmp/body.html] [--attach /tmp/report.pdf]
20
+ *
21
+ * @module cli
22
+ */
23
+ import { readFileSync, realpathSync } from "fs";
24
+ import { fileURLToPath } from "url";
25
+ import { parseArgs } from "util";
26
+ import { sendViaSmtp, resolveSmtpConfig, SMTP_ENV } from "./services/smtpMailer.js";
27
+ /** sysexits.h codes used so callers can distinguish failure modes. */
28
+ export const EX_USAGE = 64;
29
+ export const EX_NOINPUT = 66;
30
+ export const EX_CONFIG = 78;
31
+ const USAGE = `apple-mail-send — send a clean email via SMTP (no Mail.app blockquote wrapping).
32
+
33
+ Required:
34
+ --from <addr> Sender address (must be allowed by the SMTP server)
35
+ --to <addr> Recipient (repeatable)
36
+ --subject <text> Subject line
37
+ --body-file <path> UTF-8 file with the plain-text body
38
+
39
+ Optional:
40
+ --cc <addr> CC recipient (repeatable)
41
+ --bcc <addr> BCC recipient (repeatable)
42
+ --html-body-file <p> UTF-8 file with an HTML alternative body (sends
43
+ multipart/alternative)
44
+ --attach <path> Absolute path to a file to attach (repeatable)
45
+ --help Show this help
46
+
47
+ SMTP connection comes from ${SMTP_ENV.host} / ${SMTP_ENV.user} (+ password via
48
+ ${SMTP_ENV.password} or the macOS Keychain). See the README "SMTP transport".`;
49
+ /**
50
+ * Parses argv, sends the email, and returns a process exit code. Pure with
51
+ * respect to its deps (no direct process/console access) so it can be unit
52
+ * tested without the network or Keychain.
53
+ *
54
+ * @param argv - arguments AFTER `node cli.js` (i.e. `process.argv.slice(2)`)
55
+ */
56
+ export async function runCli(argv, deps = {}) {
57
+ const send = deps.send ?? sendViaSmtp;
58
+ const resolveConfig = deps.resolveConfig ?? resolveSmtpConfig;
59
+ const readTextFile = deps.readTextFile ?? ((p) => readFileSync(p, "utf8"));
60
+ const out = deps.stdout ?? ((l) => console.log(l));
61
+ const err = deps.stderr ?? ((l) => console.error(l));
62
+ const env = deps.env ?? process.env;
63
+ let values;
64
+ try {
65
+ ({ values } = parseArgs({
66
+ args: argv,
67
+ options: {
68
+ from: { type: "string" },
69
+ to: { type: "string", multiple: true },
70
+ cc: { type: "string", multiple: true },
71
+ bcc: { type: "string", multiple: true },
72
+ subject: { type: "string" },
73
+ "body-file": { type: "string" },
74
+ "html-body-file": { type: "string" },
75
+ attach: { type: "string", multiple: true },
76
+ help: { type: "boolean" },
77
+ },
78
+ allowPositionals: false,
79
+ }));
80
+ }
81
+ catch (e) {
82
+ err(e instanceof Error ? e.message : String(e));
83
+ err(USAGE);
84
+ return EX_USAGE;
85
+ }
86
+ if (values.help) {
87
+ out(USAGE);
88
+ return 0;
89
+ }
90
+ const missing = [];
91
+ if (!values.from)
92
+ missing.push("--from");
93
+ if (!values.to || values.to.length === 0)
94
+ missing.push("--to");
95
+ if (!values.subject)
96
+ missing.push("--subject");
97
+ if (!values["body-file"])
98
+ missing.push("--body-file");
99
+ if (missing.length > 0) {
100
+ err(`Missing required argument(s): ${missing.join(", ")}`);
101
+ err(USAGE);
102
+ return EX_USAGE;
103
+ }
104
+ // Resolve the full SMTP config up front (host/user/port + Keychain password)
105
+ // so a missing password counts as EX_CONFIG (78) — the same actionable
106
+ // "needs setup" signal as a missing host/user — rather than a generic failure.
107
+ let config;
108
+ try {
109
+ config = resolveConfig(env);
110
+ }
111
+ catch (e) {
112
+ err(e instanceof Error ? e.message : String(e));
113
+ err(`See the README "SMTP transport" section.`);
114
+ return EX_CONFIG;
115
+ }
116
+ let body;
117
+ let htmlBody;
118
+ try {
119
+ body = readTextFile(values["body-file"]);
120
+ if (values["html-body-file"])
121
+ htmlBody = readTextFile(values["html-body-file"]);
122
+ }
123
+ catch (e) {
124
+ err(`Could not read body file: ${e instanceof Error ? e.message : String(e)}`);
125
+ return EX_NOINPUT;
126
+ }
127
+ const attachments = values.attach?.length
128
+ ? values.attach
129
+ : undefined;
130
+ const result = await send({
131
+ from: values.from,
132
+ to: values.to,
133
+ cc: values.cc,
134
+ bcc: values.bcc,
135
+ subject: values.subject,
136
+ body,
137
+ htmlBody,
138
+ attachments,
139
+ }, config);
140
+ if (!result.success) {
141
+ err(result.error ?? "SMTP send failed.");
142
+ return 1;
143
+ }
144
+ out(`sent to ${values.to.join(", ")} from ${values.from}`);
145
+ return 0;
146
+ }
147
+ // Entry point when invoked directly (not when imported by tests). Resolve both
148
+ // sides through the filesystem so it still matches when launched via the npm
149
+ // `bin` symlink (argv[1] is the symlink; import.meta.url is the real build file).
150
+ function isInvokedDirectly() {
151
+ if (typeof process === "undefined" || !process.argv?.[1])
152
+ return false;
153
+ try {
154
+ return realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
155
+ }
156
+ catch {
157
+ return false;
158
+ }
159
+ }
160
+ if (isInvokedDirectly()) {
161
+ runCli(process.argv.slice(2)).then((code) => process.exit(code), (e) => {
162
+ console.error(e instanceof Error ? (e.stack ?? e.message) : String(e));
163
+ process.exit(1);
164
+ });
165
+ }