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 +51 -8
- package/build/cli.d.ts +24 -0
- package/build/cli.d.ts.map +1 -0
- package/build/cli.js +165 -0
- package/build/index.js +579 -209
- package/build/services/smtpMailer.d.ts +33 -2
- package/build/services/smtpMailer.d.ts.map +1 -1
- package/build/services/smtpMailer.js +39 -2
- package/build/tools/doctor.js +5 -5
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -6,6 +6,7 @@ A [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server that e
|
|
|
6
6
|
[](https://www.npmjs.com/package/apple-mail-mcp)
|
|
7
7
|
[](https://www.npmjs.com/package/apple-mail-mcp)
|
|
8
8
|
[](https://github.com/sweetrb/apple-mail-mcp/actions/workflows/ci.yml)
|
|
9
|
+
[](https://scorecard.dev/viewer/?uri=github.com/sweetrb/apple-mail-mcp)
|
|
9
10
|
[](https://www.apple.com/macos/)
|
|
10
11
|
[](https://opensource.org/licenses/MIT)
|
|
11
12
|
[](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 (
|
|
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).
|
|
278
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|