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.
Files changed (38) hide show
  1. package/README.md +65 -5
  2. package/build/index.js +288 -193
  3. package/build/services/appleMailManager.d.ts +32 -7
  4. package/build/services/appleMailManager.d.ts.map +1 -1
  5. package/build/services/appleMailManager.js +256 -104
  6. package/build/services/imapClient.d.ts +50 -34
  7. package/build/services/imapClient.d.ts.map +1 -1
  8. package/build/services/imapClient.js +276 -45
  9. package/build/services/imapIdle.d.ts +58 -0
  10. package/build/services/imapIdle.d.ts.map +1 -0
  11. package/build/services/imapIdle.js +145 -0
  12. package/build/services/messageRouter.d.ts +16 -0
  13. package/build/services/messageRouter.d.ts.map +1 -0
  14. package/build/services/messageRouter.js +29 -0
  15. package/build/services/smtpMailer.d.ts +3 -2
  16. package/build/services/smtpMailer.d.ts.map +1 -1
  17. package/build/services/smtpMailer.js +11 -7
  18. package/build/services/templateStore.d.ts +18 -0
  19. package/build/services/templateStore.d.ts.map +1 -0
  20. package/build/services/templateStore.js +91 -0
  21. package/build/tools/doctor.d.ts +23 -0
  22. package/build/tools/doctor.d.ts.map +1 -0
  23. package/build/tools/doctor.js +74 -0
  24. package/build/tools/resourcesAndPrompts.d.ts +14 -0
  25. package/build/tools/resourcesAndPrompts.d.ts.map +1 -0
  26. package/build/tools/resourcesAndPrompts.js +109 -0
  27. package/build/tools/respond.d.ts +48 -0
  28. package/build/tools/respond.d.ts.map +1 -0
  29. package/build/tools/respond.js +95 -0
  30. package/build/tools/thread.d.ts +19 -0
  31. package/build/tools/thread.d.ts.map +1 -0
  32. package/build/tools/thread.js +32 -0
  33. package/build/types.d.ts +38 -0
  34. package/build/types.d.ts.map +1 -1
  35. package/build/utils/attachmentMaterialize.d.ts +9 -0
  36. package/build/utils/attachmentMaterialize.d.ts.map +1 -0
  37. package/build/utils/attachmentMaterialize.js +38 -0
  38. 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, decodeImapId, imapGetMessage, imapMarkRead, imapMarkUnread, imapFlagMessage, imapUnflagMessage, imapMoveMessageById, imapDeleteMessageById, } from "./services/imapClient.js";
29
- import { createSerialGate } from "./utils/serialize.js";
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
- // Response Helpers
79
- // =============================================================================
80
- /**
81
- * Creates a successful MCP tool response.
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(async ({ id, preferHtml }) => {
168
+ }, withErrorHandling(({ id, preferHtml }) => routeMessage(id, {
207
169
  // IMAP id (imap:…) → fetch via IMAP (#43 Phase 3); else AppleScript.
208
- if (decodeImapId(id)) {
209
- const r = await imapGetMessage(id, preferHtml === true);
210
- return r.success
211
- ? successResponse(r.info ?? "")
212
- : errorResponse(r.error ?? `Message with ID "${id}" not found`);
213
- }
214
- // Only fetch/parse the raw source when HTML is actually requested (#32).
215
- const content = mailManager.getMessageContent(id, preferHtml === true);
216
- if (!content) {
217
- return errorResponse(`Message with ID "${id}" not found`);
218
- }
219
- if (preferHtml && content.htmlContent) {
220
- return successResponse(`Subject: ${content.subject}\n\n${content.htmlContent}`);
221
- }
222
- return successResponse(`Subject: ${content.subject}\n\n${content.plainText}`);
223
- }, "Error retrieving message"));
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: z
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: z
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(async ({ id }) => {
383
- if (decodeImapId(id)) {
384
- const r = await imapMarkRead(id);
385
- return r.success
386
- ? successResponse("Message marked as read")
387
- : errorResponse(r.error ?? `Failed to mark message "${id}" as read`);
388
- }
389
- const success = mailManager.markAsRead(id);
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(async ({ id }) => {
399
- if (decodeImapId(id)) {
400
- const r = await imapMarkUnread(id);
401
- return r.success
402
- ? successResponse("Message marked as unread")
403
- : errorResponse(r.error ?? `Failed to mark message "${id}" as unread`);
404
- }
405
- const success = mailManager.markAsUnread(id);
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(async ({ id }) => {
415
- if (decodeImapId(id)) {
416
- const r = await imapFlagMessage(id);
417
- return r.success
418
- ? successResponse("Message flagged")
419
- : errorResponse(r.error ?? `Failed to flag message "${id}"`);
420
- }
421
- const success = mailManager.flagMessage(id);
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(async ({ id }) => {
431
- if (decodeImapId(id)) {
432
- const r = await imapUnflagMessage(id);
433
- return r.success
434
- ? successResponse("Message unflagged")
435
- : errorResponse(r.error ?? `Failed to unflag message "${id}"`);
436
- }
437
- const success = mailManager.unflagMessage(id);
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(async ({ id }) => {
447
- if (decodeImapId(id)) {
448
- const r = await imapDeleteMessageById(id);
449
- return r.success
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(r.error ?? `Failed to delete message "${id}"`);
452
- }
453
- const { success, error } = mailManager.deleteMessage(id);
454
- if (!success) {
455
- return errorResponse(error || `Failed to delete message "${id}"`);
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(async ({ id, mailbox, account }) => {
465
- if (decodeImapId(id)) {
466
- const r = await imapMoveMessageById(id, mailbox);
467
- return r.success
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(r.error ?? `Failed to move message to "${mailbox}"`);
470
- }
471
- const { success, error } = mailManager.moveMessage(id, mailbox, account);
472
- if (!success) {
473
- return errorResponse(error || `Failed to move message to "${mailbox}"`);
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
+ }