apple-mail-mcp 1.9.0 → 2.0.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 +65 -5
- package/build/index.js +288 -193
- package/build/services/appleMailManager.d.ts +32 -7
- package/build/services/appleMailManager.d.ts.map +1 -1
- package/build/services/appleMailManager.js +256 -104
- package/build/services/imapClient.d.ts +50 -34
- package/build/services/imapClient.d.ts.map +1 -1
- package/build/services/imapClient.js +276 -45
- package/build/services/imapIdle.d.ts +58 -0
- package/build/services/imapIdle.d.ts.map +1 -0
- package/build/services/imapIdle.js +145 -0
- package/build/services/messageRouter.d.ts +16 -0
- package/build/services/messageRouter.d.ts.map +1 -0
- package/build/services/messageRouter.js +29 -0
- package/build/services/smtpMailer.d.ts +3 -2
- package/build/services/smtpMailer.d.ts.map +1 -1
- package/build/services/smtpMailer.js +11 -7
- package/build/services/templateStore.d.ts +18 -0
- package/build/services/templateStore.d.ts.map +1 -0
- package/build/services/templateStore.js +91 -0
- package/build/tools/doctor.d.ts +23 -0
- package/build/tools/doctor.d.ts.map +1 -0
- package/build/tools/doctor.js +74 -0
- package/build/tools/resourcesAndPrompts.d.ts +14 -0
- package/build/tools/resourcesAndPrompts.d.ts.map +1 -0
- package/build/tools/resourcesAndPrompts.js +109 -0
- package/build/tools/respond.d.ts +48 -0
- package/build/tools/respond.d.ts.map +1 -0
- package/build/tools/respond.js +95 -0
- package/build/tools/thread.d.ts +19 -0
- package/build/tools/thread.d.ts.map +1 -0
- package/build/tools/thread.js +32 -0
- package/build/types.d.ts +38 -0
- package/build/types.d.ts.map +1 -1
- package/build/utils/attachmentMaterialize.d.ts +9 -0
- package/build/utils/attachmentMaterialize.d.ts.map +1 -0
- package/build/utils/attachmentMaterialize.js +38 -0
- package/package.json +2 -1
package/build/index.js
CHANGED
|
@@ -25,8 +25,13 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
25
25
|
import { z } from "zod";
|
|
26
26
|
import { AppleMailManager } from "./services/appleMailManager.js";
|
|
27
27
|
import { sendViaSmtp } from "./services/smtpMailer.js";
|
|
28
|
-
import { isImapAccount, imapSearchMessages, imapListMessages, imapCreateMailbox, imapDeleteMailbox, imapRenameMailbox,
|
|
29
|
-
import {
|
|
28
|
+
import { isImapAccount, resolveImapConfigs, imapSearchMessages, imapListMessages, imapCreateMailbox, imapDeleteMailbox, imapRenameMailbox, imapGetMessage, imapMarkRead, imapMarkUnread, imapFlagMessage, imapUnflagMessage, imapMoveMessageById, imapDeleteMessageById, } from "./services/imapClient.js";
|
|
29
|
+
import { successResponse, errorResponse, partialCoverageBlock, withErrorHandling, messageSummary, } from "./tools/respond.js";
|
|
30
|
+
import { routeMessage } from "./services/messageRouter.js";
|
|
31
|
+
import { runDoctor, formatDoctorReport } from "./tools/doctor.js";
|
|
32
|
+
import { registerResourcesAndPrompts } from "./tools/resourcesAndPrompts.js";
|
|
33
|
+
import { normalizeSubject, subjectFromGetMessage } from "./tools/thread.js";
|
|
34
|
+
import { ImapIdleWatcher } from "./services/imapIdle.js";
|
|
30
35
|
// =============================================================================
|
|
31
36
|
// Shared Validation Schemas
|
|
32
37
|
// =============================================================================
|
|
@@ -55,6 +60,19 @@ const DATE_FILTER_SCHEMA = z
|
|
|
55
60
|
message: "Date string must be a valid date (e.g., 'January 1, 2026' or '2026-03-15')",
|
|
56
61
|
})
|
|
57
62
|
.optional();
|
|
63
|
+
// Attachments: absolute file paths and/or inline base64 content (B4).
|
|
64
|
+
const ATTACHMENTS_SCHEMA = z
|
|
65
|
+
.array(z.union([
|
|
66
|
+
z.string().describe("Absolute path to an existing file"),
|
|
67
|
+
z.object({
|
|
68
|
+
filename: z.string().min(1).describe("Filename to give the attachment"),
|
|
69
|
+
contentBase64: z.string().min(1).describe("Base64-encoded file content"),
|
|
70
|
+
}),
|
|
71
|
+
]))
|
|
72
|
+
.max(20, "Cannot attach more than 20 files")
|
|
73
|
+
.optional()
|
|
74
|
+
.describe("Files to attach: absolute paths (e.g. '/Users/me/report.pdf') and/or " +
|
|
75
|
+
"inline {filename, contentBase64} objects for content not on disk.");
|
|
58
76
|
// Read version from package.json to keep it in sync
|
|
59
77
|
const require = createRequire(import.meta.url);
|
|
60
78
|
const { version } = require("../package.json");
|
|
@@ -68,83 +86,19 @@ const server = new McpServer({
|
|
|
68
86
|
name: "apple-mail",
|
|
69
87
|
version,
|
|
70
88
|
description: "MCP server for managing Apple Mail - read, search, send, and organize emails",
|
|
71
|
-
}
|
|
89
|
+
},
|
|
90
|
+
// logging capability lets the IMAP IDLE watcher push new-mail notifications (B5).
|
|
91
|
+
{ capabilities: { logging: {} } });
|
|
72
92
|
/**
|
|
73
93
|
* Singleton instance of the Apple Mail manager.
|
|
74
94
|
* Handles all AppleScript execution and mail operations.
|
|
75
95
|
*/
|
|
76
96
|
const mailManager = new AppleMailManager();
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
*/
|
|
83
|
-
function successResponse(message) {
|
|
84
|
-
return {
|
|
85
|
-
content: [{ type: "text", text: message }],
|
|
86
|
-
};
|
|
87
|
-
}
|
|
88
|
-
/**
|
|
89
|
-
* Creates an error MCP tool response.
|
|
90
|
-
*/
|
|
91
|
-
function errorResponse(message) {
|
|
92
|
-
return {
|
|
93
|
-
content: [{ type: "text", text: message }],
|
|
94
|
-
isError: true,
|
|
95
|
-
};
|
|
96
|
-
}
|
|
97
|
-
/**
|
|
98
|
-
* Render a partial-coverage warning from search/list diagnostics, so a caller
|
|
99
|
-
* never mistakes an incomplete scan for a confirmed "no matches" (#24/#29).
|
|
100
|
-
* Returns "" when coverage was complete.
|
|
101
|
-
*/
|
|
102
|
-
function partialCoverageBlock(diagnostics) {
|
|
103
|
-
const notes = [];
|
|
104
|
-
if (diagnostics.timedOutAccounts.length > 0) {
|
|
105
|
-
notes.push(`timed out (no results) for account(s): ${diagnostics.timedOutAccounts.join(", ")}`);
|
|
106
|
-
}
|
|
107
|
-
if (diagnostics.skippedLargeMailboxes.length > 0) {
|
|
108
|
-
notes.push(`skipped mailbox(es) too large to scan via AppleScript: ${diagnostics.skippedLargeMailboxes.join(", ")} — scope with \`mailbox\` (+ a \`dateFrom\`/\`dateTo\` window for search) to reach them`);
|
|
109
|
-
}
|
|
110
|
-
if (diagnostics.notSearchedMailboxes.length > 0) {
|
|
111
|
-
notes.push(`could not finish scanning mailbox(es): ${diagnostics.notSearchedMailboxes.join(", ")}`);
|
|
112
|
-
}
|
|
113
|
-
if (notes.length === 0)
|
|
114
|
-
return "";
|
|
115
|
-
return `\n\n⚠️ Partial results — this is NOT a confirmed "no such mail":\n${notes
|
|
116
|
-
.map((n) => ` - ${n}`)
|
|
117
|
-
.join("\n")}`;
|
|
118
|
-
}
|
|
119
|
-
/**
|
|
120
|
-
* Serial execution gate for AppleScript-backed tool calls (issue #11).
|
|
121
|
-
*
|
|
122
|
-
* Concurrent MCP tool calls are funneled through this single gate so only one
|
|
123
|
-
* osascript invocation hits Mail.app's single-threaded AppleScript dispatch at
|
|
124
|
-
* a time, with a short settle delay between calls so the dispatch queue drains.
|
|
125
|
-
* Without it, a concurrent batch races into Mail.app, the later calls blow past
|
|
126
|
-
* their timeouts, and Mail.app is left half-recovered for the next batch.
|
|
127
|
-
*/
|
|
128
|
-
const serializeAppleScript = createSerialGate();
|
|
129
|
-
/**
|
|
130
|
-
* Wraps a tool handler with consistent error handling, serialized through the
|
|
131
|
-
* AppleScript gate so concurrent MCP tool calls don't race into Mail.app (#11).
|
|
132
|
-
* Handlers may be synchronous or async (the SMTP send path in send-email is
|
|
133
|
-
* async), so the handler result is awaited inside the gate.
|
|
134
|
-
*/
|
|
135
|
-
function withErrorHandling(handler, errorPrefix) {
|
|
136
|
-
return async (params) => {
|
|
137
|
-
return serializeAppleScript(async () => {
|
|
138
|
-
try {
|
|
139
|
-
return await handler(params);
|
|
140
|
-
}
|
|
141
|
-
catch (error) {
|
|
142
|
-
const message = error instanceof Error ? error.message : "Unknown error";
|
|
143
|
-
return errorResponse(`${errorPrefix}: ${message}`);
|
|
144
|
-
}
|
|
145
|
-
});
|
|
146
|
-
};
|
|
147
|
-
}
|
|
97
|
+
// MCP resources (accounts/templates/mailboxes) and prompts (triage/reply/
|
|
98
|
+
// summary) — additive context + workflows alongside the tools (D2).
|
|
99
|
+
registerResourcesAndPrompts(server, mailManager);
|
|
100
|
+
// Response helpers, the AppleScript serial gate, withErrorHandling, and the
|
|
101
|
+
// message backend router now live in @/tools/respond and @/services/messageRouter.
|
|
148
102
|
// =============================================================================
|
|
149
103
|
// Message Tools
|
|
150
104
|
// =============================================================================
|
|
@@ -185,16 +139,24 @@ server.tool("search-messages", {
|
|
|
185
139
|
}
|
|
186
140
|
const { messages, diagnostics } = mailManager.searchMessagesWithDiagnostics(query, mailbox, account, limit, dateFrom, dateTo, from, subject, isRead, isFlagged);
|
|
187
141
|
const coverageBlock = partialCoverageBlock(diagnostics);
|
|
142
|
+
const structured = {
|
|
143
|
+
messages: messages.map(messageSummary),
|
|
144
|
+
count: messages.length,
|
|
145
|
+
partial: diagnostics.partial,
|
|
146
|
+
skippedLargeMailboxes: diagnostics.skippedLargeMailboxes,
|
|
147
|
+
notSearchedMailboxes: diagnostics.notSearchedMailboxes,
|
|
148
|
+
timedOutAccounts: diagnostics.timedOutAccounts,
|
|
149
|
+
};
|
|
188
150
|
if (messages.length === 0) {
|
|
189
151
|
const base = diagnostics.partial
|
|
190
152
|
? "No messages found in the portions that were searched."
|
|
191
153
|
: "No messages found matching criteria";
|
|
192
|
-
return successResponse(`${base}${coverageBlock}
|
|
154
|
+
return successResponse(`${base}${coverageBlock}`, structured);
|
|
193
155
|
}
|
|
194
156
|
const messageList = messages
|
|
195
157
|
.map((m) => ` - ID: ${m.id} | ${m.dateReceived.toLocaleDateString()} | ${m.subject} (from: ${m.sender}) [${m.isRead ? "read" : "unread"}]`)
|
|
196
158
|
.join("\n");
|
|
197
|
-
return successResponse(`Found ${messages.length} message(s):\n${messageList}${coverageBlock}
|
|
159
|
+
return successResponse(`Found ${messages.length} message(s):\n${messageList}${coverageBlock}`, structured);
|
|
198
160
|
}, "Error searching messages"));
|
|
199
161
|
// --- get-message ---
|
|
200
162
|
server.tool("get-message", {
|
|
@@ -203,24 +165,76 @@ server.tool("get-message", {
|
|
|
203
165
|
.boolean()
|
|
204
166
|
.optional()
|
|
205
167
|
.describe("Return the HTML body (extracted from the message source) instead of plain text"),
|
|
206
|
-
}, withErrorHandling(
|
|
168
|
+
}, withErrorHandling(({ id, preferHtml }) => routeMessage(id, {
|
|
207
169
|
// IMAP id (imap:…) → fetch via IMAP (#43 Phase 3); else AppleScript.
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
170
|
+
imap: () => imapGetMessage(id, preferHtml === true),
|
|
171
|
+
apple: () => {
|
|
172
|
+
// Only fetch/parse the raw source when HTML is actually requested (#32).
|
|
173
|
+
const content = mailManager.getMessageContent(id, preferHtml === true);
|
|
174
|
+
if (!content)
|
|
175
|
+
return errorResponse(`Message with ID "${id}" not found`);
|
|
176
|
+
const isHtml = preferHtml === true && !!content.htmlContent;
|
|
177
|
+
const body = isHtml ? content.htmlContent : content.plainText;
|
|
178
|
+
return successResponse(`Subject: ${content.subject}\n\n${body}`, {
|
|
179
|
+
id,
|
|
180
|
+
subject: content.subject,
|
|
181
|
+
body,
|
|
182
|
+
isHtml,
|
|
183
|
+
});
|
|
184
|
+
},
|
|
185
|
+
ok: "",
|
|
186
|
+
fail: `Message with ID "${id}" not found`,
|
|
187
|
+
}), "Error retrieving message"));
|
|
188
|
+
// --- get-thread ---
|
|
189
|
+
server.tool("get-thread", {
|
|
190
|
+
id: MESSAGE_ID_SCHEMA.describe("A message ID in the conversation (numeric or imap:…)"),
|
|
191
|
+
account: z.string().optional().describe("Account to search (omit to search all)"),
|
|
192
|
+
mailbox: z.string().optional().describe("Mailbox to search (omit to search all)"),
|
|
193
|
+
limit: z.number().optional().describe("Max messages in the thread (default 50)"),
|
|
194
|
+
}, withErrorHandling(async ({ id, account, mailbox, limit = 50 }) => {
|
|
195
|
+
// Resolve the seed message's subject, then gather the conversation by
|
|
196
|
+
// normalized subject (B1). Works across the AppleScript and IMAP backends.
|
|
197
|
+
let seedSubject = null;
|
|
198
|
+
if (id.startsWith("imap:")) {
|
|
199
|
+
const r = await imapGetMessage(id, false, { account });
|
|
200
|
+
if (!r.success || !r.info)
|
|
201
|
+
return errorResponse(r.error || `Message "${id}" not found`);
|
|
202
|
+
seedSubject = subjectFromGetMessage(r.info);
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
const msg = mailManager.getMessageById(id);
|
|
206
|
+
if (!msg)
|
|
207
|
+
return errorResponse(`Message with ID "${id}" not found`);
|
|
208
|
+
seedSubject = msg.subject;
|
|
209
|
+
}
|
|
210
|
+
if (!seedSubject)
|
|
211
|
+
return errorResponse(`Could not determine the subject of message "${id}"`);
|
|
212
|
+
const base = normalizeSubject(seedSubject);
|
|
213
|
+
// IMAP backend: server-side subject search.
|
|
214
|
+
if (isImapAccount(account)) {
|
|
215
|
+
const text = await imapSearchMessages({ subject: base, mailbox, account, limit });
|
|
216
|
+
return successResponse(`Thread "${base}":\n${text}`, { subject: base });
|
|
217
|
+
}
|
|
218
|
+
const { messages, diagnostics } = mailManager.searchMessagesWithDiagnostics(undefined, mailbox, account, limit, undefined, undefined, undefined, base);
|
|
219
|
+
// Oldest-first is the natural reading order for a conversation.
|
|
220
|
+
const ordered = messages
|
|
221
|
+
.slice()
|
|
222
|
+
.sort((a, b) => a.dateReceived.getTime() - b.dateReceived.getTime());
|
|
223
|
+
const coverageBlock = partialCoverageBlock(diagnostics);
|
|
224
|
+
const structured = {
|
|
225
|
+
subject: base,
|
|
226
|
+
messages: ordered.map(messageSummary),
|
|
227
|
+
count: ordered.length,
|
|
228
|
+
partial: diagnostics.partial,
|
|
229
|
+
};
|
|
230
|
+
if (ordered.length === 0) {
|
|
231
|
+
return successResponse(`No messages found in thread "${base}".${coverageBlock}`, structured);
|
|
232
|
+
}
|
|
233
|
+
const list = ordered
|
|
234
|
+
.map((m) => ` - ID: ${m.id} | ${m.dateReceived.toLocaleDateString()} | ${m.subject} (from: ${m.sender}) [${m.isRead ? "read" : "unread"}]`)
|
|
235
|
+
.join("\n");
|
|
236
|
+
return successResponse(`Thread "${base}" — ${ordered.length} message(s), oldest first:\n${list}${coverageBlock}`, structured);
|
|
237
|
+
}, "Error retrieving thread"));
|
|
224
238
|
// --- list-messages ---
|
|
225
239
|
server.tool("list-messages", {
|
|
226
240
|
mailbox: z
|
|
@@ -240,16 +254,24 @@ server.tool("list-messages", {
|
|
|
240
254
|
}
|
|
241
255
|
const { messages, diagnostics } = mailManager.listMessagesWithDiagnostics(mailbox, account, limit, from, offset);
|
|
242
256
|
const coverageBlock = partialCoverageBlock(diagnostics);
|
|
257
|
+
const structured = {
|
|
258
|
+
messages: messages.map(messageSummary),
|
|
259
|
+
count: messages.length,
|
|
260
|
+
partial: diagnostics.partial,
|
|
261
|
+
skippedLargeMailboxes: diagnostics.skippedLargeMailboxes,
|
|
262
|
+
notSearchedMailboxes: diagnostics.notSearchedMailboxes,
|
|
263
|
+
timedOutAccounts: diagnostics.timedOutAccounts,
|
|
264
|
+
};
|
|
243
265
|
if (messages.length === 0) {
|
|
244
266
|
const base = diagnostics.partial
|
|
245
267
|
? "No messages found in the portions that were listed."
|
|
246
268
|
: "No messages found";
|
|
247
|
-
return successResponse(`${base}${coverageBlock}
|
|
269
|
+
return successResponse(`${base}${coverageBlock}`, structured);
|
|
248
270
|
}
|
|
249
271
|
const messageList = messages
|
|
250
272
|
.map((m) => ` - ID: ${m.id} | ${m.dateReceived.toLocaleDateString()} | ${m.subject} (from: ${m.sender})`)
|
|
251
273
|
.join("\n");
|
|
252
|
-
return successResponse(`Found ${messages.length} message(s):\n${messageList}${coverageBlock}
|
|
274
|
+
return successResponse(`Found ${messages.length} message(s):\n${messageList}${coverageBlock}`, structured);
|
|
253
275
|
}, "Error listing messages"));
|
|
254
276
|
// --- send-email ---
|
|
255
277
|
server.tool("send-email", {
|
|
@@ -259,11 +281,7 @@ server.tool("send-email", {
|
|
|
259
281
|
cc: z.array(z.string()).optional().describe("CC recipients"),
|
|
260
282
|
bcc: z.array(z.string()).optional().describe("BCC recipients"),
|
|
261
283
|
account: z.string().optional().describe("Account to send from"),
|
|
262
|
-
attachments:
|
|
263
|
-
.array(z.string())
|
|
264
|
-
.max(20, "Cannot attach more than 20 files")
|
|
265
|
-
.optional()
|
|
266
|
-
.describe("Absolute file paths to attach (e.g., ['/Users/me/report.pdf'])"),
|
|
284
|
+
attachments: ATTACHMENTS_SCHEMA,
|
|
267
285
|
transport: z
|
|
268
286
|
.enum(["applescript", "smtp"])
|
|
269
287
|
.optional()
|
|
@@ -337,11 +355,7 @@ server.tool("create-draft", {
|
|
|
337
355
|
cc: z.array(z.string()).optional().describe("CC recipients"),
|
|
338
356
|
bcc: z.array(z.string()).optional().describe("BCC recipients"),
|
|
339
357
|
account: z.string().optional().describe("Account to create draft in"),
|
|
340
|
-
attachments:
|
|
341
|
-
.array(z.string())
|
|
342
|
-
.max(20, "Cannot attach more than 20 files")
|
|
343
|
-
.optional()
|
|
344
|
-
.describe("Absolute file paths to attach (e.g., ['/Users/me/report.pdf'])"),
|
|
358
|
+
attachments: ATTACHMENTS_SCHEMA,
|
|
345
359
|
}, withErrorHandling(({ to, subject, body, cc, bcc, account, attachments }) => {
|
|
346
360
|
const success = mailManager.createDraft(to, subject, body, cc, bcc, account, attachments);
|
|
347
361
|
if (!success) {
|
|
@@ -379,101 +393,77 @@ server.tool("forward-message", {
|
|
|
379
393
|
// --- mark-as-read ---
|
|
380
394
|
server.tool("mark-as-read", {
|
|
381
395
|
id: MESSAGE_ID_SCHEMA,
|
|
382
|
-
}, withErrorHandling(
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
if (!success) {
|
|
391
|
-
return errorResponse(`Failed to mark message "${id}" as read`);
|
|
392
|
-
}
|
|
393
|
-
return successResponse("Message marked as read");
|
|
394
|
-
}, "Error marking message as read"));
|
|
396
|
+
}, withErrorHandling(({ id }) => routeMessage(id, {
|
|
397
|
+
imap: () => imapMarkRead(id),
|
|
398
|
+
apple: () => mailManager.markAsRead(id)
|
|
399
|
+
? successResponse("Message marked as read")
|
|
400
|
+
: errorResponse(`Failed to mark message "${id}" as read`),
|
|
401
|
+
ok: "Message marked as read",
|
|
402
|
+
fail: `Failed to mark message "${id}" as read`,
|
|
403
|
+
}), "Error marking message as read"));
|
|
395
404
|
// --- mark-as-unread ---
|
|
396
405
|
server.tool("mark-as-unread", {
|
|
397
406
|
id: MESSAGE_ID_SCHEMA,
|
|
398
|
-
}, withErrorHandling(
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
if (!success) {
|
|
407
|
-
return errorResponse(`Failed to mark message "${id}" as unread`);
|
|
408
|
-
}
|
|
409
|
-
return successResponse("Message marked as unread");
|
|
410
|
-
}, "Error marking message as unread"));
|
|
407
|
+
}, withErrorHandling(({ id }) => routeMessage(id, {
|
|
408
|
+
imap: () => imapMarkUnread(id),
|
|
409
|
+
apple: () => mailManager.markAsUnread(id)
|
|
410
|
+
? successResponse("Message marked as unread")
|
|
411
|
+
: errorResponse(`Failed to mark message "${id}" as unread`),
|
|
412
|
+
ok: "Message marked as unread",
|
|
413
|
+
fail: `Failed to mark message "${id}" as unread`,
|
|
414
|
+
}), "Error marking message as unread"));
|
|
411
415
|
// --- flag-message ---
|
|
412
416
|
server.tool("flag-message", {
|
|
413
417
|
id: MESSAGE_ID_SCHEMA,
|
|
414
|
-
}, withErrorHandling(
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
if (!success) {
|
|
423
|
-
return errorResponse(`Failed to flag message "${id}"`);
|
|
424
|
-
}
|
|
425
|
-
return successResponse("Message flagged");
|
|
426
|
-
}, "Error flagging message"));
|
|
418
|
+
}, withErrorHandling(({ id }) => routeMessage(id, {
|
|
419
|
+
imap: () => imapFlagMessage(id),
|
|
420
|
+
apple: () => mailManager.flagMessage(id)
|
|
421
|
+
? successResponse("Message flagged")
|
|
422
|
+
: errorResponse(`Failed to flag message "${id}"`),
|
|
423
|
+
ok: "Message flagged",
|
|
424
|
+
fail: `Failed to flag message "${id}"`,
|
|
425
|
+
}), "Error flagging message"));
|
|
427
426
|
// --- unflag-message ---
|
|
428
427
|
server.tool("unflag-message", {
|
|
429
428
|
id: MESSAGE_ID_SCHEMA,
|
|
430
|
-
}, withErrorHandling(
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
if (!success) {
|
|
439
|
-
return errorResponse(`Failed to unflag message "${id}"`);
|
|
440
|
-
}
|
|
441
|
-
return successResponse("Message unflagged");
|
|
442
|
-
}, "Error unflagging message"));
|
|
429
|
+
}, withErrorHandling(({ id }) => routeMessage(id, {
|
|
430
|
+
imap: () => imapUnflagMessage(id),
|
|
431
|
+
apple: () => mailManager.unflagMessage(id)
|
|
432
|
+
? successResponse("Message unflagged")
|
|
433
|
+
: errorResponse(`Failed to unflag message "${id}"`),
|
|
434
|
+
ok: "Message unflagged",
|
|
435
|
+
fail: `Failed to unflag message "${id}"`,
|
|
436
|
+
}), "Error unflagging message"));
|
|
443
437
|
// --- delete-message ---
|
|
444
438
|
server.tool("delete-message", {
|
|
445
439
|
id: MESSAGE_ID_SCHEMA,
|
|
446
|
-
}, withErrorHandling(
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
440
|
+
}, withErrorHandling(({ id }) => routeMessage(id, {
|
|
441
|
+
imap: () => imapDeleteMessageById(id),
|
|
442
|
+
apple: () => {
|
|
443
|
+
const { success, error } = mailManager.deleteMessage(id);
|
|
444
|
+
return success
|
|
450
445
|
? successResponse("Message deleted")
|
|
451
|
-
: errorResponse(
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
}
|
|
457
|
-
return successResponse("Message deleted");
|
|
458
|
-
}, "Error deleting message"));
|
|
446
|
+
: errorResponse(error || `Failed to delete message "${id}"`);
|
|
447
|
+
},
|
|
448
|
+
ok: "Message deleted",
|
|
449
|
+
fail: `Failed to delete message "${id}"`,
|
|
450
|
+
}), "Error deleting message"));
|
|
459
451
|
// --- move-message ---
|
|
460
452
|
server.tool("move-message", {
|
|
461
453
|
id: MESSAGE_ID_SCHEMA,
|
|
462
454
|
mailbox: z.string().min(1, "Destination mailbox is required"),
|
|
463
455
|
account: z.string().optional().describe("Account containing the destination mailbox"),
|
|
464
|
-
}, withErrorHandling(
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
456
|
+
}, withErrorHandling(({ id, mailbox, account }) => routeMessage(id, {
|
|
457
|
+
imap: () => imapMoveMessageById(id, mailbox),
|
|
458
|
+
apple: () => {
|
|
459
|
+
const { success, error } = mailManager.moveMessage(id, mailbox, account);
|
|
460
|
+
return success
|
|
468
461
|
? successResponse(`Message moved to "${mailbox}"`)
|
|
469
|
-
: errorResponse(
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
}
|
|
475
|
-
return successResponse(`Message moved to "${mailbox}"`);
|
|
476
|
-
}, "Error moving message"));
|
|
462
|
+
: errorResponse(error || `Failed to move message to "${mailbox}"`);
|
|
463
|
+
},
|
|
464
|
+
ok: `Message moved to "${mailbox}"`,
|
|
465
|
+
fail: `Failed to move message to "${mailbox}"`,
|
|
466
|
+
}), "Error moving message"));
|
|
477
467
|
// --- batch-delete-messages ---
|
|
478
468
|
server.tool("batch-delete-messages", {
|
|
479
469
|
ids: BATCH_IDS_SCHEMA,
|
|
@@ -583,8 +573,9 @@ server.tool("list-attachments", {
|
|
|
583
573
|
id: MESSAGE_ID_SCHEMA,
|
|
584
574
|
}, withErrorHandling(({ id }) => {
|
|
585
575
|
const attachments = mailManager.listAttachments(id);
|
|
576
|
+
const structured = { attachments, count: attachments.length };
|
|
586
577
|
if (attachments.length === 0) {
|
|
587
|
-
return successResponse("No attachments found");
|
|
578
|
+
return successResponse("No attachments found", structured);
|
|
588
579
|
}
|
|
589
580
|
const attachmentList = attachments
|
|
590
581
|
.map((a) => {
|
|
@@ -592,7 +583,7 @@ server.tool("list-attachments", {
|
|
|
592
583
|
return ` - ${a.name} (${a.mimeType}, ${sizeKb} KB)`;
|
|
593
584
|
})
|
|
594
585
|
.join("\n");
|
|
595
|
-
return successResponse(`Found ${attachments.length} attachment(s):\n${attachmentList}
|
|
586
|
+
return successResponse(`Found ${attachments.length} attachment(s):\n${attachmentList}`, structured);
|
|
596
587
|
}, "Error listing attachments"));
|
|
597
588
|
// --- save-attachment ---
|
|
598
589
|
server.tool("save-attachment", {
|
|
@@ -606,6 +597,20 @@ server.tool("save-attachment", {
|
|
|
606
597
|
}
|
|
607
598
|
return successResponse(`Attachment "${attachmentName}" saved to ${savePath}`);
|
|
608
599
|
}, "Error saving attachment"));
|
|
600
|
+
// --- fetch-attachment ---
|
|
601
|
+
server.tool("fetch-attachment", {
|
|
602
|
+
id: NUMERIC_MESSAGE_ID_SCHEMA,
|
|
603
|
+
attachmentName: z.string().min(1, "Attachment name is required"),
|
|
604
|
+
}, withErrorHandling(({ id, attachmentName }) => {
|
|
605
|
+
// Returns the attachment bytes as base64 (B4) — the read counterpart to
|
|
606
|
+
// sending inline base64 content, for clients that want the bytes directly
|
|
607
|
+
// rather than writing to disk via save-attachment.
|
|
608
|
+
const r = mailManager.getAttachmentBase64(id, attachmentName);
|
|
609
|
+
if (!r.success) {
|
|
610
|
+
return errorResponse(r.error || `Failed to fetch attachment "${attachmentName}"`);
|
|
611
|
+
}
|
|
612
|
+
return successResponse(`Fetched "${attachmentName}" (${r.bytes} bytes, base64-encoded below).\n\n${r.base64}`, { attachmentName, bytes: r.bytes, contentBase64: r.base64 });
|
|
613
|
+
}, "Error fetching attachment"));
|
|
609
614
|
// =============================================================================
|
|
610
615
|
// Mailbox Tools
|
|
611
616
|
// =============================================================================
|
|
@@ -614,11 +619,12 @@ server.tool("list-mailboxes", {
|
|
|
614
619
|
account: z.string().optional().describe("Account to list mailboxes from"),
|
|
615
620
|
}, withErrorHandling(({ account }) => {
|
|
616
621
|
const mailboxes = mailManager.listMailboxes(account);
|
|
622
|
+
const structured = { mailboxes, count: mailboxes.length };
|
|
617
623
|
if (mailboxes.length === 0) {
|
|
618
|
-
return successResponse("No mailboxes found");
|
|
624
|
+
return successResponse("No mailboxes found", structured);
|
|
619
625
|
}
|
|
620
626
|
const mailboxList = mailboxes.map((m) => ` - ${m.name} (${m.unreadCount} unread)`).join("\n");
|
|
621
|
-
return successResponse(`Found ${mailboxes.length} mailbox(es):\n${mailboxList}
|
|
627
|
+
return successResponse(`Found ${mailboxes.length} mailbox(es):\n${mailboxList}`, structured);
|
|
622
628
|
}, "Error listing mailboxes"));
|
|
623
629
|
// --- get-unread-count ---
|
|
624
630
|
server.tool("get-unread-count", {
|
|
@@ -627,7 +633,11 @@ server.tool("get-unread-count", {
|
|
|
627
633
|
}, withErrorHandling(({ mailbox, account }) => {
|
|
628
634
|
const count = mailManager.getUnreadCount(mailbox, account);
|
|
629
635
|
const location = mailbox ? ` in "${mailbox}"` : "";
|
|
630
|
-
return successResponse(`${count} unread message(s)${location}
|
|
636
|
+
return successResponse(`${count} unread message(s)${location}`, {
|
|
637
|
+
unread: count,
|
|
638
|
+
mailbox,
|
|
639
|
+
account,
|
|
640
|
+
});
|
|
631
641
|
}, "Error getting unread count"));
|
|
632
642
|
// --- create-mailbox ---
|
|
633
643
|
server.tool("create-mailbox", {
|
|
@@ -637,7 +647,7 @@ server.tool("create-mailbox", {
|
|
|
637
647
|
// IMAP backend (issue #43, Phase 2): server-side folder op when this account
|
|
638
648
|
// is IMAP-configured; otherwise AppleScript.
|
|
639
649
|
if (isImapAccount(account)) {
|
|
640
|
-
const r = await imapCreateMailbox(name);
|
|
650
|
+
const r = await imapCreateMailbox(name, { account });
|
|
641
651
|
if (!r.success)
|
|
642
652
|
return errorResponse(r.error || `Failed to create mailbox "${name}"`);
|
|
643
653
|
return successResponse(r.info || `Mailbox "${name}" created`);
|
|
@@ -654,7 +664,7 @@ server.tool("delete-mailbox", {
|
|
|
654
664
|
account: z.string().optional().describe("Account containing the mailbox"),
|
|
655
665
|
}, withErrorHandling(async ({ name, account }) => {
|
|
656
666
|
if (isImapAccount(account)) {
|
|
657
|
-
const r = await imapDeleteMailbox(name);
|
|
667
|
+
const r = await imapDeleteMailbox(name, { account });
|
|
658
668
|
if (!r.success)
|
|
659
669
|
return errorResponse(r.error || `Failed to delete mailbox "${name}"`);
|
|
660
670
|
return successResponse(r.info || `Mailbox "${name}" deleted`);
|
|
@@ -672,7 +682,7 @@ server.tool("rename-mailbox", {
|
|
|
672
682
|
account: z.string().optional().describe("Account containing the mailbox"),
|
|
673
683
|
}, withErrorHandling(async ({ oldName, newName, account }) => {
|
|
674
684
|
if (isImapAccount(account)) {
|
|
675
|
-
const r = await imapRenameMailbox(oldName, newName);
|
|
685
|
+
const r = await imapRenameMailbox(oldName, newName, { account });
|
|
676
686
|
if (!r.success) {
|
|
677
687
|
return errorResponse(r.error || `Failed to rename mailbox "${oldName}" to "${newName}"`);
|
|
678
688
|
}
|
|
@@ -690,11 +700,12 @@ server.tool("rename-mailbox", {
|
|
|
690
700
|
// --- list-accounts ---
|
|
691
701
|
server.tool("list-accounts", {}, withErrorHandling(() => {
|
|
692
702
|
const accounts = mailManager.listAccounts();
|
|
703
|
+
const structured = { accounts, count: accounts.length };
|
|
693
704
|
if (accounts.length === 0) {
|
|
694
|
-
return successResponse("No Mail accounts found");
|
|
705
|
+
return successResponse("No Mail accounts found", structured);
|
|
695
706
|
}
|
|
696
707
|
const accountList = accounts.map((a) => ` - ${a.name}`).join("\n");
|
|
697
|
-
return successResponse(`Found ${accounts.length} account(s):\n${accountList}
|
|
708
|
+
return successResponse(`Found ${accounts.length} account(s):\n${accountList}`, structured);
|
|
698
709
|
}, "Error listing accounts"));
|
|
699
710
|
// =============================================================================
|
|
700
711
|
// Mail Rules Tools
|
|
@@ -730,6 +741,46 @@ server.tool("disable-rule", {
|
|
|
730
741
|
}
|
|
731
742
|
return successResponse(`Rule "${name}" disabled`);
|
|
732
743
|
}, "Error disabling rule"));
|
|
744
|
+
// --- create-rule ---
|
|
745
|
+
server.tool("create-rule", {
|
|
746
|
+
name: z.string().min(1, "Rule name is required"),
|
|
747
|
+
conditions: z
|
|
748
|
+
.array(z.object({
|
|
749
|
+
field: z.enum(["from", "to", "cc", "subject", "content"]),
|
|
750
|
+
operator: z
|
|
751
|
+
.enum(["contains", "notContains", "equals", "beginsWith", "endsWith"])
|
|
752
|
+
.default("contains"),
|
|
753
|
+
value: z.string().min(1, "Condition value is required"),
|
|
754
|
+
}))
|
|
755
|
+
.min(1, "At least one condition is required"),
|
|
756
|
+
actions: z
|
|
757
|
+
.object({
|
|
758
|
+
markRead: z.boolean().optional(),
|
|
759
|
+
markFlagged: z.boolean().optional(),
|
|
760
|
+
delete: z.boolean().optional(),
|
|
761
|
+
moveTo: z.string().optional(),
|
|
762
|
+
moveToAccount: z.string().optional(),
|
|
763
|
+
})
|
|
764
|
+
.refine((a) => a.markRead || a.markFlagged || a.delete || a.moveTo, "At least one action is required (markRead, markFlagged, delete, or moveTo)"),
|
|
765
|
+
matchAll: z.boolean().default(true),
|
|
766
|
+
enabled: z.boolean().default(true),
|
|
767
|
+
}, withErrorHandling((args) => {
|
|
768
|
+
const result = mailManager.createRule(args);
|
|
769
|
+
if (!result.success) {
|
|
770
|
+
return errorResponse(`Failed to create rule "${args.name}": ${result.error}`);
|
|
771
|
+
}
|
|
772
|
+
return successResponse(`Rule "${args.name}" created with ${args.conditions.length} condition(s).`, { name: args.name, created: true });
|
|
773
|
+
}, "Error creating rule"));
|
|
774
|
+
// --- delete-rule ---
|
|
775
|
+
server.tool("delete-rule", {
|
|
776
|
+
name: z.string().min(1, "Rule name is required"),
|
|
777
|
+
}, withErrorHandling(({ name }) => {
|
|
778
|
+
const success = mailManager.deleteRule(name);
|
|
779
|
+
if (!success) {
|
|
780
|
+
return errorResponse(`Failed to delete rule "${name}" (not found?)`);
|
|
781
|
+
}
|
|
782
|
+
return successResponse(`Rule "${name}" deleted`, { name, deleted: true });
|
|
783
|
+
}, "Error deleting rule"));
|
|
733
784
|
// =============================================================================
|
|
734
785
|
// Contacts Tools
|
|
735
786
|
// =============================================================================
|
|
@@ -834,6 +885,13 @@ server.tool("health-check", {}, withErrorHandling(() => {
|
|
|
834
885
|
.join("\n");
|
|
835
886
|
return successResponse(`${statusIcon} ${statusText}\n\n${checkLines}`);
|
|
836
887
|
}, "Error running health check"));
|
|
888
|
+
// --- doctor ---
|
|
889
|
+
server.tool("doctor", {}, withErrorHandling(async () => {
|
|
890
|
+
// Diagnoses Mail.app permission, account state, and the IMAP/SMTP backends
|
|
891
|
+
// with actionable messages (C3). structuredContent carries the raw checks.
|
|
892
|
+
const report = await runDoctor(mailManager);
|
|
893
|
+
return successResponse(formatDoctorReport(report), { ...report });
|
|
894
|
+
}, "Error running doctor"));
|
|
837
895
|
// --- get-mail-stats ---
|
|
838
896
|
server.tool("get-mail-stats", {}, withErrorHandling(() => {
|
|
839
897
|
const stats = mailManager.getMailStats();
|
|
@@ -856,7 +914,7 @@ server.tool("get-mail-stats", {}, withErrorHandling(() => {
|
|
|
856
914
|
lines.push(` ${account.name}: ${account.totalMessages} messages (${account.unreadMessages} unread)`);
|
|
857
915
|
}
|
|
858
916
|
}
|
|
859
|
-
return successResponse(lines.join("\n"));
|
|
917
|
+
return successResponse(lines.join("\n"), { ...stats });
|
|
860
918
|
}, "Error getting mail statistics"));
|
|
861
919
|
// --- get-sync-status ---
|
|
862
920
|
server.tool("get-sync-status", {}, withErrorHandling(() => {
|
|
@@ -871,7 +929,7 @@ server.tool("get-sync-status", {}, withErrorHandling(() => {
|
|
|
871
929
|
lines.push(`Mail.app: ${status.recentActivity ? "Running" : "Not running"}`);
|
|
872
930
|
lines.push(`Sync active: ${status.syncDetected ? "Yes" : "No"}`);
|
|
873
931
|
}
|
|
874
|
-
return successResponse(lines.join("\n"));
|
|
932
|
+
return successResponse(lines.join("\n"), { ...status });
|
|
875
933
|
}, "Error getting sync status"));
|
|
876
934
|
// =============================================================================
|
|
877
935
|
// Server Startup
|
|
@@ -881,3 +939,40 @@ server.tool("get-sync-status", {}, withErrorHandling(() => {
|
|
|
881
939
|
*/
|
|
882
940
|
const transport = new StdioServerTransport();
|
|
883
941
|
await server.connect(transport);
|
|
942
|
+
// IMAP IDLE push notifications (B5) — opt-in. When enabled, watch every
|
|
943
|
+
// configured IMAP account's INBOX and notify the client on new mail via a
|
|
944
|
+
// logging message + a resource-updated signal for the account's mailbox.
|
|
945
|
+
let idleWatcher;
|
|
946
|
+
if (/^(1|true|yes|on)$/i.test(process.env.APPLE_MAIL_MCP_IMAP_IDLE?.trim() ?? "")) {
|
|
947
|
+
try {
|
|
948
|
+
const configs = resolveImapConfigs();
|
|
949
|
+
if (configs.length > 0) {
|
|
950
|
+
idleWatcher = new ImapIdleWatcher({
|
|
951
|
+
configs,
|
|
952
|
+
onNewMail: (e) => {
|
|
953
|
+
const newCount = e.count - e.prevCount;
|
|
954
|
+
void server.server
|
|
955
|
+
.sendLoggingMessage({
|
|
956
|
+
level: "info",
|
|
957
|
+
logger: "apple-mail-mcp",
|
|
958
|
+
data: `New mail in "${e.account}": ${newCount} new message(s) (INBOX now ${e.count}).`,
|
|
959
|
+
})
|
|
960
|
+
.catch(() => undefined);
|
|
961
|
+
void server.server
|
|
962
|
+
.sendResourceUpdated({ uri: `mail://mailboxes/${encodeURIComponent(e.account)}` })
|
|
963
|
+
.catch(() => undefined);
|
|
964
|
+
},
|
|
965
|
+
});
|
|
966
|
+
await idleWatcher.start();
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
catch (e) {
|
|
970
|
+
console.error(`IMAP IDLE watcher failed to start: ${String(e)}`);
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
// Clean up the long-lived IDLE connections on shutdown.
|
|
974
|
+
for (const sig of ["SIGINT", "SIGTERM"]) {
|
|
975
|
+
process.on(sig, () => {
|
|
976
|
+
void idleWatcher?.stop().finally(() => process.exit(0));
|
|
977
|
+
});
|
|
978
|
+
}
|