apple-mail-mcp 1.5.6 → 1.6.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 +55 -1
- package/build/index.js +40 -10
- package/build/services/appleMailManager.d.ts.map +1 -1
- package/build/services/appleMailManager.js +5 -7
- package/build/services/smtpMailer.d.ts +85 -0
- package/build/services/smtpMailer.d.ts.map +1 -0
- package/build/services/smtpMailer.js +172 -0
- package/build/utils/applescript.d.ts.map +1 -1
- package/build/utils/applescript.js +35 -2
- package/build/utils/serialize.d.ts +30 -0
- package/build/utils/serialize.d.ts.map +1 -0
- package/build/utils/serialize.js +41 -0
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -192,8 +192,9 @@ Send a new email immediately.
|
|
|
192
192
|
| `body` | string | Yes | Email body (plain text) |
|
|
193
193
|
| `cc` | string[] | No | CC recipients |
|
|
194
194
|
| `bcc` | string[] | No | BCC recipients |
|
|
195
|
-
| `account` | string | No | Send from specific account |
|
|
195
|
+
| `account` | string | No | Send from specific account (with `transport: "smtp"`, overrides the From address) |
|
|
196
196
|
| `attachments` | string[] | No | Absolute file paths to attach, max 20 files (e.g., `["/Users/me/report.pdf"]`) |
|
|
197
|
+
| `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) |
|
|
197
198
|
|
|
198
199
|
**Example:**
|
|
199
200
|
```json
|
|
@@ -206,6 +207,48 @@ Send a new email immediately.
|
|
|
206
207
|
}
|
|
207
208
|
```
|
|
208
209
|
|
|
210
|
+
##### SMTP transport
|
|
211
|
+
|
|
212
|
+
On macOS 15+ (Sequoia/Tahoe), Mail.app wraps any AppleScript-injected body in
|
|
213
|
+
`<blockquote type="cite">` under the `Apple-Mail-URLShareWrapperClass` template,
|
|
214
|
+
so emails sent through the default `applescript` transport render to recipients
|
|
215
|
+
as if they were quoted/forwarded (Apple radar **FB11734014**, open since
|
|
216
|
+
Ventura). Passing `transport: "smtp"` bypasses Mail.app entirely and submits
|
|
217
|
+
clean MIME via SMTP.
|
|
218
|
+
|
|
219
|
+
Configure SMTP via environment variables on the MCP server. The password is
|
|
220
|
+
read from the macOS **Keychain** by default, so no secret goes in config:
|
|
221
|
+
|
|
222
|
+
| Variable | Required | Default | Description |
|
|
223
|
+
|----------|----------|---------|-------------|
|
|
224
|
+
| `APPLE_MAIL_MCP_SMTP_HOST` | Yes | — | SMTP server hostname (e.g. `smtp.fastmail.com`) |
|
|
225
|
+
| `APPLE_MAIL_MCP_SMTP_USER` | Yes | — | SMTP username |
|
|
226
|
+
| `APPLE_MAIL_MCP_SMTP_PORT` | No | `465` if secure, else `587` | SMTP port |
|
|
227
|
+
| `APPLE_MAIL_MCP_SMTP_SECURE` | No | `false` | `true` for implicit TLS (port 465); otherwise STARTTLS |
|
|
228
|
+
| `APPLE_MAIL_MCP_SMTP_FROM` | No | = user | From address |
|
|
229
|
+
| `APPLE_MAIL_MCP_SMTP_PASSWORD` | No | — | Password (if set, used instead of the Keychain) |
|
|
230
|
+
| `APPLE_MAIL_MCP_SMTP_KEYCHAIN_SERVICE` | No | = host | Keychain item service/server name |
|
|
231
|
+
| `APPLE_MAIL_MCP_SMTP_KEYCHAIN_ACCOUNT` | No | = user | Keychain item account |
|
|
232
|
+
|
|
233
|
+
Store the password in the Keychain once (an app-specific password for Gmail/
|
|
234
|
+
iCloud), e.g.:
|
|
235
|
+
|
|
236
|
+
```bash
|
|
237
|
+
security add-internet-password -s smtp.fastmail.com -a you@example.com -w
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Then send:
|
|
241
|
+
```json
|
|
242
|
+
{
|
|
243
|
+
"to": ["colleague@company.com"],
|
|
244
|
+
"subject": "Standings",
|
|
245
|
+
"body": "Plain body — no blockquote wrapping.",
|
|
246
|
+
"transport": "smtp"
|
|
247
|
+
}
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
The default `applescript` transport is unchanged; SMTP is opt-in per call.
|
|
251
|
+
|
|
209
252
|
---
|
|
210
253
|
|
|
211
254
|
#### `send-serial-email`
|
|
@@ -742,6 +785,17 @@ The entrypoint is written as:
|
|
|
742
785
|
| Attachment save path restrictions | `save-attachment` only allows saving to home directory, `/tmp`, `/private/tmp`, and `/Volumes`; path traversal is blocked |
|
|
743
786
|
| Attachment count limit | `send-email` and `create-draft` accept a maximum of 20 file attachments |
|
|
744
787
|
|
|
788
|
+
### Mail.app `<blockquote>` wrapping on macOS 15+ (workaround in v1.6.0)
|
|
789
|
+
|
|
790
|
+
On macOS 15+ Mail.app wraps AppleScript-injected message bodies in
|
|
791
|
+
`<blockquote type="cite">` under the `Apple-Mail-URLShareWrapperClass` template,
|
|
792
|
+
so mail sent via the default `applescript` transport renders to recipients as
|
|
793
|
+
quoted/forwarded content (Apple radar **FB11734014**, open since Ventura, no
|
|
794
|
+
fix). Since v1.6.0, `send-email` accepts `transport: "smtp"` to bypass Mail.app
|
|
795
|
+
and send clean MIME directly — see [SMTP transport](#smtp-transport). The
|
|
796
|
+
AppleScript path is still the default and still exhibits Apple's wrapping.
|
|
797
|
+
([#12](https://github.com/sweetrb/apple-mail-mcp/issues/12))
|
|
798
|
+
|
|
745
799
|
### Reply / Forward from Background Processes (Fixed in v1.4.0)
|
|
746
800
|
|
|
747
801
|
Prior to v1.4.0, `reply-to-message` and `forward-message` would send messages with **empty body text** when the MCP server ran as a background process (e.g., spawned via `execSync` from Node.js, which is how Claude Code invokes it).
|
package/build/index.js
CHANGED
|
@@ -24,6 +24,8 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
24
24
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
25
25
|
import { z } from "zod";
|
|
26
26
|
import { AppleMailManager } from "./services/appleMailManager.js";
|
|
27
|
+
import { sendViaSmtp } from "./services/smtpMailer.js";
|
|
28
|
+
import { createSerialGate } from "./utils/serialize.js";
|
|
27
29
|
// =============================================================================
|
|
28
30
|
// Shared Validation Schemas
|
|
29
31
|
// =============================================================================
|
|
@@ -84,17 +86,32 @@ function errorResponse(message) {
|
|
|
84
86
|
};
|
|
85
87
|
}
|
|
86
88
|
/**
|
|
87
|
-
*
|
|
89
|
+
* Serial execution gate for AppleScript-backed tool calls (issue #11).
|
|
90
|
+
*
|
|
91
|
+
* Concurrent MCP tool calls are funneled through this single gate so only one
|
|
92
|
+
* osascript invocation hits Mail.app's single-threaded AppleScript dispatch at
|
|
93
|
+
* a time, with a short settle delay between calls so the dispatch queue drains.
|
|
94
|
+
* Without it, a concurrent batch races into Mail.app, the later calls blow past
|
|
95
|
+
* their timeouts, and Mail.app is left half-recovered for the next batch.
|
|
96
|
+
*/
|
|
97
|
+
const serializeAppleScript = createSerialGate();
|
|
98
|
+
/**
|
|
99
|
+
* Wraps a tool handler with consistent error handling, serialized through the
|
|
100
|
+
* AppleScript gate so concurrent MCP tool calls don't race into Mail.app (#11).
|
|
101
|
+
* Handlers may be synchronous or async (the SMTP send path in send-email is
|
|
102
|
+
* async), so the handler result is awaited inside the gate.
|
|
88
103
|
*/
|
|
89
104
|
function withErrorHandling(handler, errorPrefix) {
|
|
90
105
|
return async (params) => {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
106
|
+
return serializeAppleScript(async () => {
|
|
107
|
+
try {
|
|
108
|
+
return await handler(params);
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
112
|
+
return errorResponse(`${errorPrefix}: ${message}`);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
98
115
|
};
|
|
99
116
|
}
|
|
100
117
|
// =============================================================================
|
|
@@ -176,12 +193,25 @@ server.tool("send-email", {
|
|
|
176
193
|
.max(20, "Cannot attach more than 20 files")
|
|
177
194
|
.optional()
|
|
178
195
|
.describe("Absolute file paths to attach (e.g., ['/Users/me/report.pdf'])"),
|
|
179
|
-
|
|
196
|
+
transport: z
|
|
197
|
+
.enum(["applescript", "smtp"])
|
|
198
|
+
.optional()
|
|
199
|
+
.describe("Send transport. 'applescript' (default) sends through Mail.app. " +
|
|
200
|
+
"'smtp' submits clean MIME directly via SMTP, avoiding the macOS 15+ " +
|
|
201
|
+
"Mail.app <blockquote> wrapping (issue #12); requires APPLE_MAIL_MCP_SMTP_* env config."),
|
|
202
|
+
}, withErrorHandling(async ({ to, subject, body, cc, bcc, account, attachments, transport }) => {
|
|
203
|
+
const attachInfo = attachments?.length ? ` with ${attachments.length} attachment(s)` : "";
|
|
204
|
+
if (transport === "smtp") {
|
|
205
|
+
const result = await sendViaSmtp({ to, subject, body, cc, bcc, from: account, attachments });
|
|
206
|
+
if (!result.success) {
|
|
207
|
+
return errorResponse(result.error ?? "Failed to send email via SMTP.");
|
|
208
|
+
}
|
|
209
|
+
return successResponse(`Email sent via SMTP to ${to.join(", ")}${attachInfo}`);
|
|
210
|
+
}
|
|
180
211
|
const success = mailManager.sendEmail(to, subject, body, cc, bcc, account, attachments);
|
|
181
212
|
if (!success) {
|
|
182
213
|
return errorResponse("Failed to send email. Check Mail.app configuration.");
|
|
183
214
|
}
|
|
184
|
-
const attachInfo = attachments?.length ? ` with ${attachments.length} attachment(s)` : "";
|
|
185
215
|
return successResponse(`Email sent to ${to.join(", ")}${attachInfo}`);
|
|
186
216
|
}, "Error sending email"));
|
|
187
217
|
// --- send-serial-email ---
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"appleMailManager.d.ts","sourceRoot":"","sources":["../../src/services/appleMailManager.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAQH,OAAO,KAAK,EACV,OAAO,EACP,cAAc,EACd,OAAO,EACP,OAAO,EACP,UAAU,EACV,iBAAiB,EACjB,SAAS,EAET,oBAAoB,EACpB,UAAU,EACV,qBAAqB,EACrB,QAAQ,EACR,OAAO,EACP,aAAa,EACb,oBAAoB,EACpB,iBAAiB,EAClB,MAAM,YAAY,CAAC;AAoFpB;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,MAAM,EAAE,CAAC,EAAE,IAAI,GAAG,MAAM,CAWrE;AA2CD;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,sBAAsB;IACrC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,sBAAsB,GAAG,MAAM,CAoB5E;AAED,qBAAa,gBAAgB;IAC3B;;OAEG;IACH,OAAO,CAAC,cAAc,CAAuB;IAE7C;;;;OAIG;IACH,OAAO,CAAC,KAAK,CAGX;IAEF,8CAA8C;IAC9C,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAU;IAEvC;;OAEG;IACH,OAAO,CAAC,iBAAiB;IAUzB;;;;OAIG;IACH,OAAO,CAAC,qBAAqB;IAW7B;;;OAGG;IACH,OAAO,CAAC,eAAe;IAKvB;;;;OAIG;IACH,OAAO,CAAC,cAAc;IAwCtB;;;;;;;;;;;;;;;;;OAiBG;IACH,OAAO,CAAC,cAAc;IAyCtB;;;;;;;;OAQG;IACH,cAAc,CACZ,KAAK,CAAC,EAAE,MAAM,EACd,OAAO,CAAC,EAAE,MAAM,EAChB,OAAO,CAAC,EAAE,MAAM,EAChB,KAAK,SAAK,EACV,QAAQ,CAAC,EAAE,MAAM,EACjB,MAAM,CAAC,EAAE,MAAM,EACf,IAAI,CAAC,EAAE,MAAM,EACb,OAAO,CAAC,EAAE,MAAM,EAChB,MAAM,CAAC,EAAE,OAAO,EAChB,SAAS,CAAC,EAAE,OAAO,GAClB,OAAO,EAAE;IAwIZ;;;;;OAKG;IACH,cAAc,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,GAAG,IAAI;IAyE1C;;OAEG;IACH,iBAAiB,CAAC,EAAE,EAAE,MAAM,GAAG,cAAc,GAAG,IAAI;IAgDpD;;;;;;;;OAQG;IACH,YAAY,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI;IA6BvC;;;;;;;OAOG;IACH,YAAY,CACV,OAAO,CAAC,EAAE,MAAM,EAChB,OAAO,CAAC,EAAE,MAAM,EAChB,KAAK,SAAK,EACV,IAAI,CAAC,EAAE,MAAM,EACb,MAAM,SAAI,GACT,OAAO,EAAE;IA8GZ;;;;;;;;;;OAUG;IACH,OAAO,CAAC,gBAAgB;IAoCxB;;;;;;;;;;OAUG;
|
|
1
|
+
{"version":3,"file":"appleMailManager.d.ts","sourceRoot":"","sources":["../../src/services/appleMailManager.ts"],"names":[],"mappings":"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"}
|
|
@@ -789,13 +789,11 @@ export class AppleMailManager {
|
|
|
789
789
|
// Discussion: https://forums.macrumors.com/threads/applescript-creating-a-
|
|
790
790
|
// new-message-in-mail-app-is-causing-weird-formatting-issues.2385052/
|
|
791
791
|
//
|
|
792
|
-
//
|
|
793
|
-
//
|
|
794
|
-
//
|
|
795
|
-
//
|
|
796
|
-
//
|
|
797
|
-
// 2. Switch to smtplib-style direct send with Keychain-stored creds.
|
|
798
|
-
// 3. Use Mail.app's NSSharingService rather than AppleScript.
|
|
792
|
+
// FIX (v1.6.0): send-email now accepts `transport: "smtp"`, which bypasses
|
|
793
|
+
// Mail.app and submits clean MIME directly via nodemailer (creds from the
|
|
794
|
+
// Keychain). See src/services/smtpMailer.ts. This AppleScript path remains
|
|
795
|
+
// the default for back-compat and for users who don't configure SMTP, so the
|
|
796
|
+
// wrapping behavior below is unchanged for them.
|
|
799
797
|
// Tracking issue: https://github.com/sweetrb/apple-mail-mcp/issues/12
|
|
800
798
|
// ───────────────────────────────────────────────────────────────────
|
|
801
799
|
sendEmail(to, subject, body, cc, bcc, account, attachments) {
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SMTP transport for sending mail (issue #12).
|
|
3
|
+
*
|
|
4
|
+
* Mail.app's AppleScript send path wraps any injected body in
|
|
5
|
+
* `<blockquote type="cite">` under the Apple-Mail-URLShareWrapperClass template
|
|
6
|
+
* on macOS 15+, so messages render to recipients as quoted/forwarded content
|
|
7
|
+
* (Apple radar FB11734014, open since Ventura). This module bypasses Mail.app
|
|
8
|
+
* entirely and submits clean MIME directly over SMTP via nodemailer.
|
|
9
|
+
*
|
|
10
|
+
* Connection settings come from environment variables; the password is read
|
|
11
|
+
* from the macOS Keychain via the `security` CLI by default so no secret is
|
|
12
|
+
* ever placed in config. AppleScript remains the default transport — SMTP is
|
|
13
|
+
* opt-in per call (`transport: "smtp"`).
|
|
14
|
+
*
|
|
15
|
+
* @module services/smtpMailer
|
|
16
|
+
*/
|
|
17
|
+
import nodemailer from "nodemailer";
|
|
18
|
+
/** Options for an SMTP send, mirroring the AppleScript send-email surface. */
|
|
19
|
+
export interface SmtpSendOptions {
|
|
20
|
+
to: string[];
|
|
21
|
+
subject: string;
|
|
22
|
+
body: string;
|
|
23
|
+
cc?: string[];
|
|
24
|
+
bcc?: string[];
|
|
25
|
+
/** Overrides the configured From address (must be allowed by the SMTP server). */
|
|
26
|
+
from?: string;
|
|
27
|
+
/** Absolute paths to files to attach. */
|
|
28
|
+
attachments?: string[];
|
|
29
|
+
}
|
|
30
|
+
/** Resolved SMTP connection configuration. */
|
|
31
|
+
export interface SmtpConfig {
|
|
32
|
+
host: string;
|
|
33
|
+
port: number;
|
|
34
|
+
secure: boolean;
|
|
35
|
+
user: string;
|
|
36
|
+
pass: string;
|
|
37
|
+
from: string;
|
|
38
|
+
}
|
|
39
|
+
/** Result of an SMTP send. */
|
|
40
|
+
export interface SmtpSendResult {
|
|
41
|
+
success: boolean;
|
|
42
|
+
messageId?: string;
|
|
43
|
+
error?: string;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Environment variables consumed by {@link resolveSmtpConfig}. Documented here
|
|
47
|
+
* (and in the README) so the error path can point users at exactly what to set.
|
|
48
|
+
*/
|
|
49
|
+
export declare const SMTP_ENV: {
|
|
50
|
+
readonly host: "APPLE_MAIL_MCP_SMTP_HOST";
|
|
51
|
+
readonly port: "APPLE_MAIL_MCP_SMTP_PORT";
|
|
52
|
+
readonly secure: "APPLE_MAIL_MCP_SMTP_SECURE";
|
|
53
|
+
readonly user: "APPLE_MAIL_MCP_SMTP_USER";
|
|
54
|
+
readonly from: "APPLE_MAIL_MCP_SMTP_FROM";
|
|
55
|
+
readonly password: "APPLE_MAIL_MCP_SMTP_PASSWORD";
|
|
56
|
+
readonly keychainService: "APPLE_MAIL_MCP_SMTP_KEYCHAIN_SERVICE";
|
|
57
|
+
readonly keychainAccount: "APPLE_MAIL_MCP_SMTP_KEYCHAIN_ACCOUNT";
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* Reads a password from the macOS login Keychain via the `security` CLI.
|
|
61
|
+
*
|
|
62
|
+
* Tries `find-internet-password` first (where Mail.app stores account
|
|
63
|
+
* passwords) and falls back to `find-generic-password`. Returns null if no
|
|
64
|
+
* matching item exists or the lookup fails for any reason — callers fall back
|
|
65
|
+
* to the password env var and ultimately surface a clear configuration error.
|
|
66
|
+
*
|
|
67
|
+
* @param service - Keychain service / server name (typically the SMTP host)
|
|
68
|
+
* @param account - Keychain account (typically the SMTP username)
|
|
69
|
+
*/
|
|
70
|
+
export declare function readKeychainPassword(service: string, account: string): string | null;
|
|
71
|
+
/**
|
|
72
|
+
* Resolves SMTP connection configuration from environment + Keychain.
|
|
73
|
+
*
|
|
74
|
+
* @throws Error with an actionable message listing the missing settings.
|
|
75
|
+
*/
|
|
76
|
+
export declare function resolveSmtpConfig(env?: NodeJS.ProcessEnv): SmtpConfig;
|
|
77
|
+
/**
|
|
78
|
+
* Sends an email over SMTP, producing clean MIME with no blockquote wrapping.
|
|
79
|
+
*
|
|
80
|
+
* Config is resolved via {@link resolveSmtpConfig} unless one is injected (the
|
|
81
|
+
* `config` parameter exists for testing). The body is sent as plain text; pass
|
|
82
|
+
* a transporter factory only in tests.
|
|
83
|
+
*/
|
|
84
|
+
export declare function sendViaSmtp(opts: SmtpSendOptions, config?: SmtpConfig, createTransport?: typeof nodemailer.createTransport): Promise<SmtpSendResult>;
|
|
85
|
+
//# sourceMappingURL=smtpMailer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"smtpMailer.d.ts","sourceRoot":"","sources":["../../src/services/smtpMailer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,UAAU,MAAM,YAAY,CAAC;AAKpC,8EAA8E;AAC9E,MAAM,WAAW,eAAe;IAC9B,EAAE,EAAE,MAAM,EAAE,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC;IACd,GAAG,CAAC,EAAE,MAAM,EAAE,CAAC;IACf,kFAAkF;IAClF,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,yCAAyC;IACzC,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;CACxB;AAED,8CAA8C;AAC9C,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,OAAO,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;CACd;AAED,8BAA8B;AAC9B,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,OAAO,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED;;;GAGG;AACH,eAAO,MAAM,QAAQ;;;;;;;;;CASX,CAAC;AAEX;;;;;;;;;;GAUG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAcpF;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,UAAU,CA8ClF;AAmBD;;;;;;GAMG;AACH,wBAAsB,WAAW,CAC/B,IAAI,EAAE,eAAe,EACrB,MAAM,CAAC,EAAE,UAAU,EACnB,eAAe,GAAE,OAAO,UAAU,CAAC,eAA4C,GAC9E,OAAO,CAAC,cAAc,CAAC,CAyCzB"}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SMTP transport for sending mail (issue #12).
|
|
3
|
+
*
|
|
4
|
+
* Mail.app's AppleScript send path wraps any injected body in
|
|
5
|
+
* `<blockquote type="cite">` under the Apple-Mail-URLShareWrapperClass template
|
|
6
|
+
* on macOS 15+, so messages render to recipients as quoted/forwarded content
|
|
7
|
+
* (Apple radar FB11734014, open since Ventura). This module bypasses Mail.app
|
|
8
|
+
* entirely and submits clean MIME directly over SMTP via nodemailer.
|
|
9
|
+
*
|
|
10
|
+
* Connection settings come from environment variables; the password is read
|
|
11
|
+
* from the macOS Keychain via the `security` CLI by default so no secret is
|
|
12
|
+
* ever placed in config. AppleScript remains the default transport — SMTP is
|
|
13
|
+
* opt-in per call (`transport: "smtp"`).
|
|
14
|
+
*
|
|
15
|
+
* @module services/smtpMailer
|
|
16
|
+
*/
|
|
17
|
+
import nodemailer from "nodemailer";
|
|
18
|
+
import { execFileSync } from "child_process";
|
|
19
|
+
import { isAbsolute } from "path";
|
|
20
|
+
import { existsSync } from "fs";
|
|
21
|
+
/**
|
|
22
|
+
* Environment variables consumed by {@link resolveSmtpConfig}. Documented here
|
|
23
|
+
* (and in the README) so the error path can point users at exactly what to set.
|
|
24
|
+
*/
|
|
25
|
+
export const SMTP_ENV = {
|
|
26
|
+
host: "APPLE_MAIL_MCP_SMTP_HOST",
|
|
27
|
+
port: "APPLE_MAIL_MCP_SMTP_PORT",
|
|
28
|
+
secure: "APPLE_MAIL_MCP_SMTP_SECURE",
|
|
29
|
+
user: "APPLE_MAIL_MCP_SMTP_USER",
|
|
30
|
+
from: "APPLE_MAIL_MCP_SMTP_FROM",
|
|
31
|
+
password: "APPLE_MAIL_MCP_SMTP_PASSWORD",
|
|
32
|
+
keychainService: "APPLE_MAIL_MCP_SMTP_KEYCHAIN_SERVICE",
|
|
33
|
+
keychainAccount: "APPLE_MAIL_MCP_SMTP_KEYCHAIN_ACCOUNT",
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Reads a password from the macOS login Keychain via the `security` CLI.
|
|
37
|
+
*
|
|
38
|
+
* Tries `find-internet-password` first (where Mail.app stores account
|
|
39
|
+
* passwords) and falls back to `find-generic-password`. Returns null if no
|
|
40
|
+
* matching item exists or the lookup fails for any reason — callers fall back
|
|
41
|
+
* to the password env var and ultimately surface a clear configuration error.
|
|
42
|
+
*
|
|
43
|
+
* @param service - Keychain service / server name (typically the SMTP host)
|
|
44
|
+
* @param account - Keychain account (typically the SMTP username)
|
|
45
|
+
*/
|
|
46
|
+
export function readKeychainPassword(service, account) {
|
|
47
|
+
for (const kind of ["find-internet-password", "find-generic-password"]) {
|
|
48
|
+
try {
|
|
49
|
+
const out = execFileSync("security", [kind, "-s", service, "-a", account, "-w"], {
|
|
50
|
+
encoding: "utf8",
|
|
51
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
52
|
+
});
|
|
53
|
+
const pass = out.replace(/\n$/, "");
|
|
54
|
+
if (pass)
|
|
55
|
+
return pass;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// Not found via this kind; try the next.
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Resolves SMTP connection configuration from environment + Keychain.
|
|
65
|
+
*
|
|
66
|
+
* @throws Error with an actionable message listing the missing settings.
|
|
67
|
+
*/
|
|
68
|
+
export function resolveSmtpConfig(env = process.env) {
|
|
69
|
+
const host = env[SMTP_ENV.host]?.trim();
|
|
70
|
+
const user = env[SMTP_ENV.user]?.trim();
|
|
71
|
+
const missing = [];
|
|
72
|
+
if (!host)
|
|
73
|
+
missing.push(SMTP_ENV.host);
|
|
74
|
+
if (!user)
|
|
75
|
+
missing.push(SMTP_ENV.user);
|
|
76
|
+
if (missing.length > 0) {
|
|
77
|
+
throw new Error(`SMTP transport is not configured. Set ${missing.join(" and ")} ` +
|
|
78
|
+
`(plus a password via ${SMTP_ENV.password} or the Keychain). ` +
|
|
79
|
+
`See the README "SMTP transport" section.`);
|
|
80
|
+
}
|
|
81
|
+
// secure=true => implicit TLS (port 465); otherwise STARTTLS (port 587).
|
|
82
|
+
const secure = /^(1|true|yes)$/i.test(env[SMTP_ENV.secure]?.trim() ?? "");
|
|
83
|
+
const port = env[SMTP_ENV.port]
|
|
84
|
+
? Number.parseInt(env[SMTP_ENV.port], 10)
|
|
85
|
+
: secure
|
|
86
|
+
? 465
|
|
87
|
+
: 587;
|
|
88
|
+
if (!Number.isInteger(port) || port <= 0) {
|
|
89
|
+
throw new Error(`Invalid ${SMTP_ENV.port}: "${env[SMTP_ENV.port]}" is not a valid port.`);
|
|
90
|
+
}
|
|
91
|
+
const from = env[SMTP_ENV.from]?.trim() || user;
|
|
92
|
+
// Password: explicit env var wins, otherwise Keychain (service/account
|
|
93
|
+
// default to the host/user but can be overridden).
|
|
94
|
+
let pass = env[SMTP_ENV.password];
|
|
95
|
+
if (!pass) {
|
|
96
|
+
const service = env[SMTP_ENV.keychainService]?.trim() || host;
|
|
97
|
+
const account = env[SMTP_ENV.keychainAccount]?.trim() || user;
|
|
98
|
+
pass = readKeychainPassword(service, account) ?? undefined;
|
|
99
|
+
}
|
|
100
|
+
if (!pass) {
|
|
101
|
+
throw new Error(`No SMTP password found. Set ${SMTP_ENV.password}, or store an internet ` +
|
|
102
|
+
`password in the Keychain for service "${env[SMTP_ENV.keychainService]?.trim() || host}" / account "${env[SMTP_ENV.keychainAccount]?.trim() || user}".`);
|
|
103
|
+
}
|
|
104
|
+
return { host: host, port, secure, user: user, pass, from };
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Validates attachment paths the same way the AppleScript path does: absolute
|
|
108
|
+
* and existing. Returns nodemailer attachment descriptors.
|
|
109
|
+
*/
|
|
110
|
+
function buildAttachments(attachments) {
|
|
111
|
+
if (!attachments || attachments.length === 0)
|
|
112
|
+
return undefined;
|
|
113
|
+
for (const filePath of attachments) {
|
|
114
|
+
if (!isAbsolute(filePath)) {
|
|
115
|
+
throw new Error(`Attachment path must be absolute: "${filePath}"`);
|
|
116
|
+
}
|
|
117
|
+
if (!existsSync(filePath)) {
|
|
118
|
+
throw new Error(`Attachment file not found: "${filePath}"`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return attachments.map((path) => ({ path }));
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Sends an email over SMTP, producing clean MIME with no blockquote wrapping.
|
|
125
|
+
*
|
|
126
|
+
* Config is resolved via {@link resolveSmtpConfig} unless one is injected (the
|
|
127
|
+
* `config` parameter exists for testing). The body is sent as plain text; pass
|
|
128
|
+
* a transporter factory only in tests.
|
|
129
|
+
*/
|
|
130
|
+
export async function sendViaSmtp(opts, config, createTransport = nodemailer.createTransport) {
|
|
131
|
+
let cfg;
|
|
132
|
+
try {
|
|
133
|
+
cfg = config ?? resolveSmtpConfig();
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
|
137
|
+
}
|
|
138
|
+
let attachments;
|
|
139
|
+
try {
|
|
140
|
+
attachments = buildAttachments(opts.attachments);
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
|
144
|
+
}
|
|
145
|
+
const transporter = createTransport({
|
|
146
|
+
host: cfg.host,
|
|
147
|
+
port: cfg.port,
|
|
148
|
+
secure: cfg.secure,
|
|
149
|
+
auth: { user: cfg.user, pass: cfg.pass },
|
|
150
|
+
});
|
|
151
|
+
try {
|
|
152
|
+
const info = await transporter.sendMail({
|
|
153
|
+
from: opts.from?.trim() || cfg.from,
|
|
154
|
+
to: opts.to,
|
|
155
|
+
cc: opts.cc,
|
|
156
|
+
bcc: opts.bcc,
|
|
157
|
+
subject: opts.subject,
|
|
158
|
+
text: opts.body,
|
|
159
|
+
attachments,
|
|
160
|
+
});
|
|
161
|
+
return { success: true, messageId: info.messageId };
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
return {
|
|
165
|
+
success: false,
|
|
166
|
+
error: `SMTP send failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
finally {
|
|
170
|
+
transporter.close();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"applescript.d.ts","sourceRoot":"","sources":["../../src/utils/applescript.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,OAAO,KAAK,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;
|
|
1
|
+
{"version":3,"file":"applescript.d.ts","sourceRoot":"","sources":["../../src/utils/applescript.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,OAAO,KAAK,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAmQxE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,MAAM,EACd,OAAO,GAAE,kBAAuB,GAC/B,iBAAiB,CAoInB"}
|
|
@@ -65,6 +65,32 @@ function escapeForShell(script) {
|
|
|
65
65
|
// This is the standard shell escaping pattern for single-quoted strings
|
|
66
66
|
return script.replace(/'/g, "'\\''");
|
|
67
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* Headroom (ms) between the in-AppleScript `with timeout` and the outer
|
|
70
|
+
* osascript process timeout. The script-level timeout must fire *first* so
|
|
71
|
+
* Mail.app aborts the operation from inside its own AppleScript dispatch —
|
|
72
|
+
* cleanly releasing the event queue — before Node SIGKILLs the osascript
|
|
73
|
+
* process. Killing osascript alone does not stop work already dispatched into
|
|
74
|
+
* Mail.app, which is what wedges Mail.app for subsequent calls (issue #11).
|
|
75
|
+
*/
|
|
76
|
+
const SCRIPT_TIMEOUT_HEADROOM_MS = 5000;
|
|
77
|
+
/**
|
|
78
|
+
* Wraps a script body in an AppleScript `with timeout` block so any Apple
|
|
79
|
+
* Event that honors timeouts (most Mail.app operations do) aborts cleanly
|
|
80
|
+
* rather than holding Mail.app's single-threaded AppleScript dispatch queue
|
|
81
|
+
* open indefinitely.
|
|
82
|
+
*
|
|
83
|
+
* The timeout is set a few seconds below the osascript process timeout so the
|
|
84
|
+
* in-Mail abort wins the race against the outer process kill.
|
|
85
|
+
*
|
|
86
|
+
* @param script - The AppleScript body
|
|
87
|
+
* @param processTimeoutMs - The outer osascript process timeout
|
|
88
|
+
* @returns The script wrapped in `with timeout … end timeout`
|
|
89
|
+
*/
|
|
90
|
+
function wrapWithTimeout(script, processTimeoutMs) {
|
|
91
|
+
const seconds = Math.max(1, Math.ceil((processTimeoutMs - SCRIPT_TIMEOUT_HEADROOM_MS) / 1000));
|
|
92
|
+
return `with timeout of ${seconds} seconds\n${script}\nend timeout`;
|
|
93
|
+
}
|
|
68
94
|
/**
|
|
69
95
|
* Checks if an error is a timeout error from execSync.
|
|
70
96
|
*
|
|
@@ -270,8 +296,10 @@ export function executeAppleScript(script, options = {}) {
|
|
|
270
296
|
// Prepare the script:
|
|
271
297
|
// 1. Trim leading/trailing whitespace (cosmetic)
|
|
272
298
|
// 2. Preserve internal newlines (required for AppleScript syntax)
|
|
273
|
-
// 3.
|
|
274
|
-
|
|
299
|
+
// 3. Wrap in `with timeout` so a stuck Mail.app operation aborts from
|
|
300
|
+
// inside its own dispatch before the process timeout kills osascript (#11)
|
|
301
|
+
// 4. Escape for shell execution
|
|
302
|
+
const preparedScript = escapeForShell(wrapWithTimeout(script.trim(), timeoutMs));
|
|
275
303
|
// Build the osascript command
|
|
276
304
|
// We use single quotes to wrap the script, which is why we escape
|
|
277
305
|
// single quotes within the script itself
|
|
@@ -292,6 +320,11 @@ export function executeAppleScript(script, options = {}) {
|
|
|
292
320
|
const output = execSync(command, {
|
|
293
321
|
encoding: "utf8",
|
|
294
322
|
timeout: timeoutMs,
|
|
323
|
+
// SIGKILL (not the default SIGTERM): a wedged osascript blocked on an
|
|
324
|
+
// unresponsive Mail.app can ignore SIGTERM, leaking processes that pile
|
|
325
|
+
// up and worsen the contention. SIGKILL guarantees the process is reaped
|
|
326
|
+
// when the timeout fires. (#11)
|
|
327
|
+
killSignal: "SIGKILL",
|
|
295
328
|
// Capture stderr separately to get error details
|
|
296
329
|
stdio: ["pipe", "pipe", "pipe"],
|
|
297
330
|
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serial execution gate.
|
|
3
|
+
*
|
|
4
|
+
* Mail.app's AppleScript handler is effectively single-threaded: concurrent
|
|
5
|
+
* `tell application "Mail"` calls queue inside Mail.app, and once enough stack
|
|
6
|
+
* up the later ones blow past their timeouts while earlier ones are still
|
|
7
|
+
* draining — the client sees a cascade of "Request timed out" errors and
|
|
8
|
+
* Mail.app is left half-recovered for the next batch (issue #11).
|
|
9
|
+
*
|
|
10
|
+
* A gate chains every task through a single promise so only one runs at a time,
|
|
11
|
+
* with an optional settle delay between tasks so Mail.app's dispatch queue can
|
|
12
|
+
* drain. Because tasks are chained on promises, awaiting the gate also yields
|
|
13
|
+
* the event loop between calls, keeping the server responsive to protocol
|
|
14
|
+
* traffic (pings, health-check) during a batch.
|
|
15
|
+
*
|
|
16
|
+
* @module utils/serialize
|
|
17
|
+
*/
|
|
18
|
+
/** A function that serializes a task behind all previously enqueued tasks. */
|
|
19
|
+
export type SerialGate = <R>(task: () => R | PromiseLike<R>) => Promise<R>;
|
|
20
|
+
/**
|
|
21
|
+
* Creates a serial execution gate.
|
|
22
|
+
*
|
|
23
|
+
* @param settleMs - Delay inserted after each task before the next one starts
|
|
24
|
+
* (default 50ms). Set to 0 to disable.
|
|
25
|
+
* @returns A `serialize(task)` function. Tasks run strictly in enqueue order and
|
|
26
|
+
* never overlap; each call resolves/rejects with its task's own result, and a
|
|
27
|
+
* failing task never breaks serialization for later tasks.
|
|
28
|
+
*/
|
|
29
|
+
export declare function createSerialGate(settleMs?: number): SerialGate;
|
|
30
|
+
//# sourceMappingURL=serialize.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"serialize.d.ts","sourceRoot":"","sources":["../../src/utils/serialize.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,8EAA8E;AAC9E,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,CAAC,GAAG,WAAW,CAAC,CAAC,CAAC,KAAK,OAAO,CAAC,CAAC,CAAC,CAAC;AAE3E;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,SAAK,GAAG,UAAU,CAa1D"}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serial execution gate.
|
|
3
|
+
*
|
|
4
|
+
* Mail.app's AppleScript handler is effectively single-threaded: concurrent
|
|
5
|
+
* `tell application "Mail"` calls queue inside Mail.app, and once enough stack
|
|
6
|
+
* up the later ones blow past their timeouts while earlier ones are still
|
|
7
|
+
* draining — the client sees a cascade of "Request timed out" errors and
|
|
8
|
+
* Mail.app is left half-recovered for the next batch (issue #11).
|
|
9
|
+
*
|
|
10
|
+
* A gate chains every task through a single promise so only one runs at a time,
|
|
11
|
+
* with an optional settle delay between tasks so Mail.app's dispatch queue can
|
|
12
|
+
* drain. Because tasks are chained on promises, awaiting the gate also yields
|
|
13
|
+
* the event loop between calls, keeping the server responsive to protocol
|
|
14
|
+
* traffic (pings, health-check) during a batch.
|
|
15
|
+
*
|
|
16
|
+
* @module utils/serialize
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* Creates a serial execution gate.
|
|
20
|
+
*
|
|
21
|
+
* @param settleMs - Delay inserted after each task before the next one starts
|
|
22
|
+
* (default 50ms). Set to 0 to disable.
|
|
23
|
+
* @returns A `serialize(task)` function. Tasks run strictly in enqueue order and
|
|
24
|
+
* never overlap; each call resolves/rejects with its task's own result, and a
|
|
25
|
+
* failing task never breaks serialization for later tasks.
|
|
26
|
+
*/
|
|
27
|
+
export function createSerialGate(settleMs = 50) {
|
|
28
|
+
let tail = Promise.resolve();
|
|
29
|
+
return (task) => {
|
|
30
|
+
const result = tail.then(task);
|
|
31
|
+
// Keep the chain alive regardless of this task's outcome, with a settle
|
|
32
|
+
// delay so the next task doesn't race straight back into Mail.app.
|
|
33
|
+
tail = result.then(() => settle(settleMs), () => settle(settleMs));
|
|
34
|
+
return result;
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function settle(ms) {
|
|
38
|
+
if (ms <= 0)
|
|
39
|
+
return Promise.resolve();
|
|
40
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
41
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "apple-mail-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"description": "MCP server for Apple Mail - read, search, send, and manage emails via Claude",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "build/index.js",
|
|
@@ -59,10 +59,12 @@
|
|
|
59
59
|
],
|
|
60
60
|
"dependencies": {
|
|
61
61
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
62
|
+
"nodemailer": "^9.0.0",
|
|
62
63
|
"zod": "^3.22.4"
|
|
63
64
|
},
|
|
64
65
|
"devDependencies": {
|
|
65
66
|
"@types/node": "^20.0.0",
|
|
67
|
+
"@types/nodemailer": "^8.0.1",
|
|
66
68
|
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
|
67
69
|
"@typescript-eslint/parser": "^8.0.0",
|
|
68
70
|
"@vitest/coverage-v8": "^4.1.9",
|