index.js 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725
  1. #!/usr/bin/env node
  2. /**
  3. * Apple Mail MCP Server
  4. *
  5. * A Model Context Protocol (MCP) server that provides AI assistants
  6. * with the ability to interact with Apple Mail on macOS.
  7. *
  8. * This server exposes tools for:
  9. * - Reading and searching emails
  10. * - Sending emails
  11. * - Managing mailboxes
  12. * - Managing multiple accounts (iCloud, Gmail, Exchange, etc.)
  13. *
  14. * Architecture:
  15. * - Tool definitions are declarative (schema + handler)
  16. * - The AppleMailManager class handles all AppleScript operations
  17. * - Error handling is consistent across all tools
  18. *
  19. * @module apple-mail-mcp
  20. * @see https://modelcontextprotocol.io
  21. */
  22. import { createRequire } from "module";
  23. import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
  24. import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
  25. import { z } from "zod";
  26. import { AppleMailManager } from "./services/appleMailManager.js";
  27. // =============================================================================
  28. // Shared Validation Schemas
  29. // =============================================================================
  30. /** Message IDs in Apple Mail are always numeric. Enforce this at the schema level
  31. * to prevent AppleScript injection via the `whose id is ${id}` interpolation. */
  32. const MESSAGE_ID_SCHEMA = z.string().regex(/^\d+$/, "Message ID must be numeric");
  33. /** Batch operations are capped to prevent unbounded loops / DoS. */
  34. const BATCH_IDS_SCHEMA = z
  35. .array(MESSAGE_ID_SCHEMA)
  36. .min(1, "At least one message ID is required")
  37. .max(100, "Cannot process more than 100 messages in a single batch");
  38. /** Date filter strings must look like natural-language dates (e.g. "March 1, 2026").
  39. * Block characters that could escape an AppleScript `date "..."` literal. */
  40. const DATE_FILTER_SCHEMA = z
  41. .string()
  42. .regex(/^[a-zA-Z0-9 ,/\-:]+$/, "Date must contain only alphanumeric characters, spaces, commas, slashes, hyphens, and colons")
  43. .refine((val) => !isNaN(new Date(val).getTime()), {
  44. message: "Date string must be a valid date (e.g., 'January 1, 2026' or '2026-03-15')",
  45. })
  46. .optional();
  47. // Read version from package.json to keep it in sync
  48. const require = createRequire(import.meta.url);
  49. const { version } = require("../package.json");
  50. // =============================================================================
  51. // Server Initialization
  52. // =============================================================================
  53. /**
  54. * MCP server instance configured for Apple Mail operations.
  55. */
  56. const server = new McpServer({
  57. name: "apple-mail",
  58. version,
  59. description: "MCP server for managing Apple Mail - read, search, send, and organize emails",
  60. });
  61. /**
  62. * Singleton instance of the Apple Mail manager.
  63. * Handles all AppleScript execution and mail operations.
  64. */
  65. const mailManager = new AppleMailManager();
  66. // =============================================================================
  67. // Response Helpers
  68. // =============================================================================
  69. /**
  70. * Creates a successful MCP tool response.
  71. */
  72. function successResponse(message) {
  73. return {
  74. content: [{ type: "text", text: message }],
  75. };
  76. }
  77. /**
  78. * Creates an error MCP tool response.
  79. */
  80. function errorResponse(message) {
  81. return {
  82. content: [{ type: "text", text: message }],
  83. isError: true,
  84. };
  85. }
  86. /**
  87. * Wraps a tool handler with consistent error handling.
  88. */
  89. function withErrorHandling(handler, errorPrefix) {
  90. return async (params) => {
  91. try {
  92. return handler(params);
  93. }
  94. catch (error) {
  95. const message = error instanceof Error ? error.message : "Unknown error";
  96. return errorResponse(`${errorPrefix}: ${message}`);
  97. }
  98. };
  99. }
  100. // =============================================================================
  101. // Message Tools
  102. // =============================================================================
  103. // --- search-messages ---
  104. server.tool("search-messages", {
  105. query: z.string().optional().describe("Text to search for in subject, sender, or content"),
  106. from: z
  107. .string()
  108. .optional()
  109. .describe("Filter by sender (substring match against the full sender string, i.e. display name + address — not an exact address match)"),
  110. subject: z.string().optional().describe("Filter by subject line (substring match)"),
  111. mailbox: z
  112. .string()
  113. .optional()
  114. .describe("Mailbox to search in (e.g., 'INBOX'). Omit to search all mailboxes."),
  115. account: z.string().optional().describe("Account to search in (omit to search all accounts)"),
  116. isRead: z.boolean().optional().describe("Filter by read status"),
  117. isFlagged: z.boolean().optional().describe("Filter by flagged status"),
  118. dateFrom: DATE_FILTER_SCHEMA.describe("Start date filter (e.g., 'January 1, 2026')"),
  119. dateTo: DATE_FILTER_SCHEMA.describe("End date filter (e.g., 'March 1, 2026')"),
  120. limit: z.number().optional().describe("Maximum number of results (default: 50)"),
  121. }, withErrorHandling(({ query, mailbox, account, limit = 50, dateFrom, dateTo, from, subject, isRead, isFlagged, }) => {
  122. const messages = mailManager.searchMessages(query, mailbox, account, limit, dateFrom, dateTo, from, subject, isRead, isFlagged);
  123. if (messages.length === 0) {
  124. return successResponse("No messages found matching criteria");
  125. }
  126. const messageList = messages
  127. .map((m) => ` - ID: ${m.id} | ${m.dateReceived.toLocaleDateString()} | ${m.subject} (from: ${m.sender}) [${m.isRead ? "read" : "unread"}]`)
  128. .join("\n");
  129. return successResponse(`Found ${messages.length} message(s):\n${messageList}`);
  130. }, "Error searching messages"));
  131. // --- get-message ---
  132. server.tool("get-message", {
  133. id: MESSAGE_ID_SCHEMA,
  134. preferHtml: z.boolean().optional().describe("Return HTML source instead of plain text"),
  135. }, withErrorHandling(({ id, preferHtml }) => {
  136. const content = mailManager.getMessageContent(id);
  137. if (!content) {
  138. return errorResponse(`Message with ID "${id}" not found`);
  139. }
  140. if (preferHtml && content.htmlContent) {
  141. return successResponse(`Subject: ${content.subject}\n\n${content.htmlContent}`);
  142. }
  143. return successResponse(`Subject: ${content.subject}\n\n${content.plainText}`);
  144. }, "Error retrieving message"));
  145. // --- list-messages ---
  146. server.tool("list-messages", {
  147. mailbox: z
  148. .string()
  149. .optional()
  150. .describe("Mailbox to list messages from. Omit to list from all mailboxes."),
  151. account: z.string().optional().describe("Account to list messages from"),
  152. limit: z.number().optional().describe("Maximum number of messages (default: 50)"),
  153. offset: z.number().optional().describe("Number of messages to skip (for pagination)"),
  154. from: z.string().optional().describe("Filter by sender email address or name"),
  155. unreadOnly: z.boolean().optional().describe("Only show unread messages"),
  156. }, withErrorHandling(({ mailbox, account, limit = 50, offset = 0, from }) => {
  157. const messages = mailManager.listMessages(mailbox, account, limit, from, offset);
  158. if (messages.length === 0) {
  159. return successResponse("No messages found");
  160. }
  161. const messageList = messages
  162. .map((m) => ` - ID: ${m.id} | ${m.dateReceived.toLocaleDateString()} | ${m.subject} (from: ${m.sender})`)
  163. .join("\n");
  164. return successResponse(`Found ${messages.length} message(s):\n${messageList}`);
  165. }, "Error listing messages"));
  166. // --- send-email ---
  167. server.tool("send-email", {
  168. to: z.array(z.string()).min(1, "At least one recipient is required"),
  169. subject: z.string().min(1, "Subject is required"),
  170. body: z.string().min(1, "Body is required"),
  171. cc: z.array(z.string()).optional().describe("CC recipients"),
  172. bcc: z.array(z.string()).optional().describe("BCC recipients"),
  173. account: z.string().optional().describe("Account to send from"),
  174. attachments: z
  175. .array(z.string())
  176. .max(20, "Cannot attach more than 20 files")
  177. .optional()
  178. .describe("Absolute file paths to attach (e.g., ['/Users/me/report.pdf'])"),
  179. }, withErrorHandling(({ to, subject, body, cc, bcc, account, attachments }) => {
  180. const success = mailManager.sendEmail(to, subject, body, cc, bcc, account, attachments);
  181. if (!success) {
  182. return errorResponse("Failed to send email. Check Mail.app configuration.");
  183. }
  184. const attachInfo = attachments?.length ? ` with ${attachments.length} attachment(s)` : "";
  185. return successResponse(`Email sent to ${to.join(", ")}${attachInfo}`);
  186. }, "Error sending email"));
  187. // --- send-serial-email ---
  188. server.tool("send-serial-email", {
  189. recipients: z
  190. .array(z.object({
  191. email: z.string().min(1, "Recipient email is required"),
  192. variables: z
  193. .record(z.string())
  194. .describe("Placeholder values, e.g. { Name: 'Alice', Company: 'Acme' }"),
  195. }))
  196. .min(1, "At least one recipient is required")
  197. .max(100, "Cannot send to more than 100 recipients in a single batch")
  198. .describe("List of recipients with personalization variables (max 100)"),
  199. subject: z
  200. .string()
  201. .min(1, "Subject is required")
  202. .describe("Subject line — use {{Key}} for placeholders"),
  203. body: z
  204. .string()
  205. .min(1, "Body is required")
  206. .describe("Email body — use {{Key}} for placeholders"),
  207. account: z.string().optional().describe("Account to send from"),
  208. delayMs: z
  209. .number()
  210. .min(0)
  211. .max(10000)
  212. .optional()
  213. .describe("Delay between sends in ms (default: 500, max: 10000)"),
  214. }, withErrorHandling(({ recipients, subject, body, account, delayMs }) => {
  215. const results = mailManager.sendSerialEmail(recipients, subject, body, account, delayMs);
  216. const successCount = results.filter((r) => r.success).length;
  217. const failCount = results.length - successCount;
  218. const details = results
  219. .map((r) => ` - ${r.email}: ${r.success ? "sent" : `FAILED (${r.error})`}`)
  220. .join("\n");
  221. if (failCount === 0) {
  222. return successResponse(`Successfully sent ${successCount} email(s):\n${details}`);
  223. }
  224. else if (successCount === 0) {
  225. return errorResponse(`Failed to send all ${failCount} email(s):\n${details}`);
  226. }
  227. else {
  228. return successResponse(`Sent ${successCount} of ${results.length} email(s), ${failCount} failed:\n${details}`);
  229. }
  230. }, "Error sending serial emails"));
  231. // --- create-draft ---
  232. server.tool("create-draft", {
  233. to: z.array(z.string()).min(1, "At least one recipient is required"),
  234. subject: z.string().min(1, "Subject is required"),
  235. body: z.string().min(1, "Body is required"),
  236. cc: z.array(z.string()).optional().describe("CC recipients"),
  237. bcc: z.array(z.string()).optional().describe("BCC recipients"),
  238. account: z.string().optional().describe("Account to create draft in"),
  239. attachments: z
  240. .array(z.string())
  241. .max(20, "Cannot attach more than 20 files")
  242. .optional()
  243. .describe("Absolute file paths to attach (e.g., ['/Users/me/report.pdf'])"),
  244. }, withErrorHandling(({ to, subject, body, cc, bcc, account, attachments }) => {
  245. const success = mailManager.createDraft(to, subject, body, cc, bcc, account, attachments);
  246. if (!success) {
  247. return errorResponse("Failed to create draft. Check Mail.app configuration.");
  248. }
  249. const attachInfo = attachments?.length ? ` with ${attachments.length} attachment(s)` : "";
  250. return successResponse(`Draft created for ${to.join(", ")}${attachInfo}`);
  251. }, "Error creating draft"));
  252. // --- reply-to-message ---
  253. server.tool("reply-to-message", {
  254. id: MESSAGE_ID_SCHEMA,
  255. body: z.string().min(1, "Reply body is required"),
  256. replyAll: z.boolean().optional().default(false).describe("Reply to all recipients"),
  257. send: z.boolean().optional().default(true).describe("Send immediately (false = save as draft)"),
  258. }, withErrorHandling(({ id, body, replyAll, send }) => {
  259. const success = mailManager.replyToMessage(id, body, replyAll, send);
  260. if (!success) {
  261. return errorResponse(`Failed to reply to message "${id}"`);
  262. }
  263. return successResponse(send ? "Reply sent" : "Reply saved as draft");
  264. }, "Error replying to message"));
  265. // --- forward-message ---
  266. server.tool("forward-message", {
  267. id: MESSAGE_ID_SCHEMA,
  268. to: z.array(z.string()).min(1, "At least one recipient is required"),
  269. body: z.string().optional().describe("Optional message to prepend"),
  270. send: z.boolean().optional().default(true).describe("Send immediately (false = save as draft)"),
  271. }, withErrorHandling(({ id, to, body, send }) => {
  272. const success = mailManager.forwardMessage(id, to, body, send);
  273. if (!success) {
  274. return errorResponse(`Failed to forward message "${id}"`);
  275. }
  276. return successResponse(send ? `Message forwarded to ${to.join(", ")}` : "Forward saved as draft");
  277. }, "Error forwarding message"));
  278. // --- mark-as-read ---
  279. server.tool("mark-as-read", {
  280. id: MESSAGE_ID_SCHEMA,
  281. }, withErrorHandling(({ id }) => {
  282. const success = mailManager.markAsRead(id);
  283. if (!success) {
  284. return errorResponse(`Failed to mark message "${id}" as read`);
  285. }
  286. return successResponse("Message marked as read");
  287. }, "Error marking message as read"));
  288. // --- mark-as-unread ---
  289. server.tool("mark-as-unread", {
  290. id: MESSAGE_ID_SCHEMA,
  291. }, withErrorHandling(({ id }) => {
  292. const success = mailManager.markAsUnread(id);
  293. if (!success) {
  294. return errorResponse(`Failed to mark message "${id}" as unread`);
  295. }
  296. return successResponse("Message marked as unread");
  297. }, "Error marking message as unread"));
  298. // --- flag-message ---
  299. server.tool("flag-message", {
  300. id: MESSAGE_ID_SCHEMA,
  301. }, withErrorHandling(({ id }) => {
  302. const success = mailManager.flagMessage(id);
  303. if (!success) {
  304. return errorResponse(`Failed to flag message "${id}"`);
  305. }
  306. return successResponse("Message flagged");
  307. }, "Error flagging message"));
  308. // --- unflag-message ---
  309. server.tool("unflag-message", {
  310. id: MESSAGE_ID_SCHEMA,
  311. }, withErrorHandling(({ id }) => {
  312. const success = mailManager.unflagMessage(id);
  313. if (!success) {
  314. return errorResponse(`Failed to unflag message "${id}"`);
  315. }
  316. return successResponse("Message unflagged");
  317. }, "Error unflagging message"));
  318. // --- delete-message ---
  319. server.tool("delete-message", {
  320. id: MESSAGE_ID_SCHEMA,
  321. }, withErrorHandling(({ id }) => {
  322. const success = mailManager.deleteMessage(id);
  323. if (!success) {
  324. return errorResponse(`Failed to delete message "${id}"`);
  325. }
  326. return successResponse("Message deleted");
  327. }, "Error deleting message"));
  328. // --- move-message ---
  329. server.tool("move-message", {
  330. id: MESSAGE_ID_SCHEMA,
  331. mailbox: z.string().min(1, "Destination mailbox is required"),
  332. account: z.string().optional().describe("Account containing the destination mailbox"),
  333. }, withErrorHandling(({ id, mailbox, account }) => {
  334. const success = mailManager.moveMessage(id, mailbox, account);
  335. if (!success) {
  336. return errorResponse(`Failed to move message to "${mailbox}"`);
  337. }
  338. return successResponse(`Message moved to "${mailbox}"`);
  339. }, "Error moving message"));
  340. // --- batch-delete-messages ---
  341. server.tool("batch-delete-messages", {
  342. ids: BATCH_IDS_SCHEMA,
  343. }, withErrorHandling(({ ids }) => {
  344. const results = mailManager.batchDeleteMessages(ids);
  345. const successCount = results.filter((r) => r.success).length;
  346. const failCount = results.length - successCount;
  347. if (failCount === 0) {
  348. return successResponse(`Successfully deleted ${successCount} message(s)`);
  349. }
  350. else if (successCount === 0) {
  351. return errorResponse(`Failed to delete all ${failCount} message(s)`);
  352. }
  353. else {
  354. return successResponse(`Deleted ${successCount} message(s), ${failCount} failed`);
  355. }
  356. }, "Error batch deleting messages"));
  357. // --- batch-move-messages ---
  358. server.tool("batch-move-messages", {
  359. ids: BATCH_IDS_SCHEMA,
  360. mailbox: z.string().min(1, "Destination mailbox is required"),
  361. account: z.string().optional().describe("Account containing the destination mailbox"),
  362. }, withErrorHandling(({ ids, mailbox, account }) => {
  363. const results = mailManager.batchMoveMessages(ids, mailbox, account);
  364. const successCount = results.filter((r) => r.success).length;
  365. const failCount = results.length - successCount;
  366. if (failCount === 0) {
  367. return successResponse(`Successfully moved ${successCount} message(s) to "${mailbox}"`);
  368. }
  369. else if (successCount === 0) {
  370. return errorResponse(`Failed to move all ${failCount} message(s)`);
  371. }
  372. else {
  373. return successResponse(`Moved ${successCount} message(s) to "${mailbox}", ${failCount} failed`);
  374. }
  375. }, "Error batch moving messages"));
  376. // --- batch-mark-as-read ---
  377. server.tool("batch-mark-as-read", {
  378. ids: BATCH_IDS_SCHEMA,
  379. }, withErrorHandling(({ ids }) => {
  380. const results = mailManager.batchMarkAsRead(ids);
  381. const successCount = results.filter((r) => r.success).length;
  382. const failCount = results.length - successCount;
  383. if (failCount === 0) {
  384. return successResponse(`Successfully marked ${successCount} message(s) as read`);
  385. }
  386. else if (successCount === 0) {
  387. return errorResponse(`Failed to mark all ${failCount} message(s) as read`);
  388. }
  389. else {
  390. return successResponse(`Marked ${successCount} message(s) as read, ${failCount} failed`);
  391. }
  392. }, "Error batch marking messages as read"));
  393. // --- batch-mark-as-unread ---
  394. server.tool("batch-mark-as-unread", {
  395. ids: BATCH_IDS_SCHEMA,
  396. }, withErrorHandling(({ ids }) => {
  397. const results = mailManager.batchMarkAsUnread(ids);
  398. const successCount = results.filter((r) => r.success).length;
  399. const failCount = results.length - successCount;
  400. if (failCount === 0) {
  401. return successResponse(`Successfully marked ${successCount} message(s) as unread`);
  402. }
  403. else if (successCount === 0) {
  404. return errorResponse(`Failed to mark all ${failCount} message(s) as unread`);
  405. }
  406. else {
  407. return successResponse(`Marked ${successCount} message(s) as unread, ${failCount} failed`);
  408. }
  409. }, "Error batch marking messages as unread"));
  410. // --- batch-flag-messages ---
  411. server.tool("batch-flag-messages", {
  412. ids: BATCH_IDS_SCHEMA,
  413. }, withErrorHandling(({ ids }) => {
  414. const results = mailManager.batchFlagMessages(ids);
  415. const successCount = results.filter((r) => r.success).length;
  416. const failCount = results.length - successCount;
  417. if (failCount === 0) {
  418. return successResponse(`Successfully flagged ${successCount} message(s)`);
  419. }
  420. else if (successCount === 0) {
  421. return errorResponse(`Failed to flag all ${failCount} message(s)`);
  422. }
  423. else {
  424. return successResponse(`Flagged ${successCount} message(s), ${failCount} failed`);
  425. }
  426. }, "Error batch flagging messages"));
  427. // --- batch-unflag-messages ---
  428. server.tool("batch-unflag-messages", {
  429. ids: BATCH_IDS_SCHEMA,
  430. }, withErrorHandling(({ ids }) => {
  431. const results = mailManager.batchUnflagMessages(ids);
  432. const successCount = results.filter((r) => r.success).length;
  433. const failCount = results.length - successCount;
  434. if (failCount === 0) {
  435. return successResponse(`Successfully unflagged ${successCount} message(s)`);
  436. }
  437. else if (successCount === 0) {
  438. return errorResponse(`Failed to unflag all ${failCount} message(s)`);
  439. }
  440. else {
  441. return successResponse(`Unflagged ${successCount} message(s), ${failCount} failed`);
  442. }
  443. }, "Error batch unflagging messages"));
  444. // --- list-attachments ---
  445. server.tool("list-attachments", {
  446. id: MESSAGE_ID_SCHEMA,
  447. }, withErrorHandling(({ id }) => {
  448. const attachments = mailManager.listAttachments(id);
  449. if (attachments.length === 0) {
  450. return successResponse("No attachments found");
  451. }
  452. const attachmentList = attachments
  453. .map((a) => {
  454. const sizeKb = Math.round(a.size / 1024);
  455. return ` - ${a.name} (${a.mimeType}, ${sizeKb} KB)`;
  456. })
  457. .join("\n");
  458. return successResponse(`Found ${attachments.length} attachment(s):\n${attachmentList}`);
  459. }, "Error listing attachments"));
  460. // --- save-attachment ---
  461. server.tool("save-attachment", {
  462. id: MESSAGE_ID_SCHEMA,
  463. attachmentName: z.string().min(1, "Attachment name is required"),
  464. savePath: z.string().min(1, "Save directory path is required"),
  465. }, withErrorHandling(({ id, attachmentName, savePath }) => {
  466. const success = mailManager.saveAttachment(id, attachmentName, savePath);
  467. if (!success) {
  468. return errorResponse(`Failed to save attachment "${attachmentName}"`);
  469. }
  470. return successResponse(`Attachment "${attachmentName}" saved to ${savePath}`);
  471. }, "Error saving attachment"));
  472. // =============================================================================
  473. // Mailbox Tools
  474. // =============================================================================
  475. // --- list-mailboxes ---
  476. server.tool("list-mailboxes", {
  477. account: z.string().optional().describe("Account to list mailboxes from"),
  478. }, withErrorHandling(({ account }) => {
  479. const mailboxes = mailManager.listMailboxes(account);
  480. if (mailboxes.length === 0) {
  481. return successResponse("No mailboxes found");
  482. }
  483. const mailboxList = mailboxes.map((m) => ` - ${m.name} (${m.unreadCount} unread)`).join("\n");
  484. return successResponse(`Found ${mailboxes.length} mailbox(es):\n${mailboxList}`);
  485. }, "Error listing mailboxes"));
  486. // --- get-unread-count ---
  487. server.tool("get-unread-count", {
  488. mailbox: z.string().optional().describe("Mailbox to check (default: all)"),
  489. account: z.string().optional().describe("Account to check"),
  490. }, withErrorHandling(({ mailbox, account }) => {
  491. const count = mailManager.getUnreadCount(mailbox, account);
  492. const location = mailbox ? ` in "${mailbox}"` : "";
  493. return successResponse(`${count} unread message(s)${location}`);
  494. }, "Error getting unread count"));
  495. // --- create-mailbox ---
  496. server.tool("create-mailbox", {
  497. name: z.string().min(1, "Mailbox name is required"),
  498. account: z.string().optional().describe("Account to create the mailbox in"),
  499. }, withErrorHandling(({ name, account }) => {
  500. const success = mailManager.createMailbox(name, account);
  501. if (!success) {
  502. return errorResponse(`Failed to create mailbox "${name}"`);
  503. }
  504. return successResponse(`Mailbox "${name}" created`);
  505. }, "Error creating mailbox"));
  506. // --- delete-mailbox ---
  507. server.tool("delete-mailbox", {
  508. name: z.string().min(1, "Mailbox name is required"),
  509. account: z.string().optional().describe("Account containing the mailbox"),
  510. }, withErrorHandling(({ name, account }) => {
  511. const success = mailManager.deleteMailbox(name, account);
  512. if (!success) {
  513. return errorResponse(`Failed to delete mailbox "${name}"`);
  514. }
  515. return successResponse(`Mailbox "${name}" deleted`);
  516. }, "Error deleting mailbox"));
  517. // --- rename-mailbox ---
  518. server.tool("rename-mailbox", {
  519. oldName: z.string().min(1, "Current mailbox name is required"),
  520. newName: z.string().min(1, "New mailbox name is required"),
  521. account: z.string().optional().describe("Account containing the mailbox"),
  522. }, withErrorHandling(({ oldName, newName, account }) => {
  523. const success = mailManager.renameMailbox(oldName, newName, account);
  524. if (!success) {
  525. return errorResponse(`Failed to rename mailbox "${oldName}" to "${newName}"`);
  526. }
  527. return successResponse(`Mailbox renamed from "${oldName}" to "${newName}"`);
  528. }, "Error renaming mailbox"));
  529. // =============================================================================
  530. // Account Tools
  531. // =============================================================================
  532. // --- list-accounts ---
  533. server.tool("list-accounts", {}, withErrorHandling(() => {
  534. const accounts = mailManager.listAccounts();
  535. if (accounts.length === 0) {
  536. return successResponse("No Mail accounts found");
  537. }
  538. const accountList = accounts.map((a) => ` - ${a.name}`).join("\n");
  539. return successResponse(`Found ${accounts.length} account(s):\n${accountList}`);
  540. }, "Error listing accounts"));
  541. // =============================================================================
  542. // Mail Rules Tools
  543. // =============================================================================
  544. // --- list-rules ---
  545. server.tool("list-rules", {}, withErrorHandling(() => {
  546. const rules = mailManager.listRules();
  547. if (rules.length === 0) {
  548. return successResponse("No mail rules found");
  549. }
  550. const ruleList = rules
  551. .map((r) => ` - ${r.name} [${r.enabled ? "enabled" : "disabled"}]`)
  552. .join("\n");
  553. return successResponse(`Found ${rules.length} rule(s):\n${ruleList}`);
  554. }, "Error listing rules"));
  555. // --- enable-rule ---
  556. server.tool("enable-rule", {
  557. name: z.string().min(1, "Rule name is required"),
  558. }, withErrorHandling(({ name }) => {
  559. const success = mailManager.setRuleEnabled(name, true);
  560. if (!success) {
  561. return errorResponse(`Failed to enable rule "${name}"`);
  562. }
  563. return successResponse(`Rule "${name}" enabled`);
  564. }, "Error enabling rule"));
  565. // --- disable-rule ---
  566. server.tool("disable-rule", {
  567. name: z.string().min(1, "Rule name is required"),
  568. }, withErrorHandling(({ name }) => {
  569. const success = mailManager.setRuleEnabled(name, false);
  570. if (!success) {
  571. return errorResponse(`Failed to disable rule "${name}"`);
  572. }
  573. return successResponse(`Rule "${name}" disabled`);
  574. }, "Error disabling rule"));
  575. // =============================================================================
  576. // Contacts Tools
  577. // =============================================================================
  578. // --- search-contacts ---
  579. server.tool("search-contacts", {
  580. query: z.string().min(1, "Search query is required"),
  581. }, withErrorHandling(({ query }) => {
  582. const contacts = mailManager.searchContacts(query);
  583. if (contacts.length === 0) {
  584. return successResponse("No contacts found");
  585. }
  586. const contactList = contacts
  587. .map((c) => {
  588. const emails = c.emails.length > 0 ? c.emails.join(", ") : "no email";
  589. return ` - ${c.name} (${emails})`;
  590. })
  591. .join("\n");
  592. return successResponse(`Found ${contacts.length} contact(s):\n${contactList}`);
  593. }, "Error searching contacts"));
  594. // =============================================================================
  595. // Email Template Tools
  596. // =============================================================================
  597. // --- save-template ---
  598. server.tool("save-template", {
  599. name: z.string().min(1, "Template name is required"),
  600. subject: z.string().min(1, "Subject is required"),
  601. body: z.string().min(1, "Body is required"),
  602. to: z.array(z.string()).optional().describe("Default recipients"),
  603. cc: z.array(z.string()).optional().describe("Default CC recipients"),
  604. id: z.string().optional().describe("Template ID (for updating existing template)"),
  605. }, withErrorHandling(({ name, subject, body, to, cc, id }) => {
  606. const template = mailManager.saveTemplate(name, subject, body, to, cc, id);
  607. return successResponse(`Template "${template.name}" saved with ID: ${template.id}`);
  608. }, "Error saving template"));
  609. // --- list-templates ---
  610. server.tool("list-templates", {}, withErrorHandling(() => {
  611. const templates = mailManager.listTemplates();
  612. if (templates.length === 0) {
  613. return successResponse("No templates saved");
  614. }
  615. const templateList = templates
  616. .map((t) => ` - [${t.id}] ${t.name} — "${t.subject}"`)
  617. .join("\n");
  618. return successResponse(`Found ${templates.length} template(s):\n${templateList}`);
  619. }, "Error listing templates"));
  620. // --- get-template ---
  621. server.tool("get-template", {
  622. id: z.string().min(1, "Template ID is required"),
  623. }, withErrorHandling(({ id }) => {
  624. const template = mailManager.getTemplate(id);
  625. if (!template) {
  626. return errorResponse(`Template "${id}" not found`);
  627. }
  628. const lines = [
  629. `Name: ${template.name}`,
  630. `Subject: ${template.subject}`,
  631. template.to ? `To: ${template.to.join(", ")}` : null,
  632. template.cc ? `CC: ${template.cc.join(", ")}` : null,
  633. `\n${template.body}`,
  634. ]
  635. .filter(Boolean)
  636. .join("\n");
  637. return successResponse(lines);
  638. }, "Error getting template"));
  639. // --- delete-template ---
  640. server.tool("delete-template", {
  641. id: z.string().min(1, "Template ID is required"),
  642. }, withErrorHandling(({ id }) => {
  643. const success = mailManager.deleteTemplate(id);
  644. if (!success) {
  645. return errorResponse(`Template "${id}" not found`);
  646. }
  647. return successResponse(`Template "${id}" deleted`);
  648. }, "Error deleting template"));
  649. // --- use-template ---
  650. server.tool("use-template", {
  651. id: z.string().min(1, "Template ID is required"),
  652. to: z.array(z.string()).optional().describe("Override recipients"),
  653. cc: z.array(z.string()).optional().describe("Override CC recipients"),
  654. subject: z.string().optional().describe("Override subject"),
  655. body: z.string().optional().describe("Override body"),
  656. }, withErrorHandling(({ id, to, cc, subject, body }) => {
  657. const success = mailManager.useTemplate(id, { to, cc, subject, body });
  658. if (!success) {
  659. return errorResponse(`Failed to use template "${id}". Template not found or no recipients.`);
  660. }
  661. return successResponse(`Draft created from template "${id}"`);
  662. }, "Error using template"));
  663. // =============================================================================
  664. // Diagnostics Tools
  665. // =============================================================================
  666. // --- health-check ---
  667. server.tool("health-check", {}, withErrorHandling(() => {
  668. const result = mailManager.healthCheck();
  669. const statusIcon = result.healthy ? "✓" : "✗";
  670. const statusText = result.healthy ? "All checks passed" : "Issues detected";
  671. const checkLines = result.checks
  672. .map((c) => {
  673. const icon = c.passed ? "✓" : "✗";
  674. return ` ${icon} ${c.name}: ${c.message}`;
  675. })
  676. .join("\n");
  677. return successResponse(`${statusIcon} ${statusText}\n\n${checkLines}`);
  678. }, "Error running health check"));
  679. // --- get-mail-stats ---
  680. server.tool("get-mail-stats", {}, withErrorHandling(() => {
  681. const stats = mailManager.getMailStats();
  682. const lines = [];
  683. lines.push(`📊 Mail Statistics`);
  684. lines.push(`══════════════════`);
  685. lines.push(`Total messages: ${stats.totalMessages}`);
  686. lines.push(`Unread messages: ${stats.totalUnread}`);
  687. lines.push(``);
  688. if (stats.recentlyReceived) {
  689. lines.push(`📥 Recently Received:`);
  690. lines.push(` Last 24 hours: ${stats.recentlyReceived.last24h}`);
  691. lines.push(` Last 7 days: ${stats.recentlyReceived.last7d}`);
  692. lines.push(` Last 30 days: ${stats.recentlyReceived.last30d}`);
  693. lines.push(``);
  694. }
  695. if (stats.accounts.length > 0) {
  696. lines.push(`📁 By Account:`);
  697. for (const account of stats.accounts) {
  698. lines.push(` ${account.name}: ${account.totalMessages} messages (${account.unreadMessages} unread)`);
  699. }
  700. }
  701. return successResponse(lines.join("\n"));
  702. }, "Error getting mail statistics"));
  703. // --- get-sync-status ---
  704. server.tool("get-sync-status", {}, withErrorHandling(() => {
  705. const status = mailManager.getSyncStatus();
  706. const lines = [];
  707. lines.push(`🔄 Mail Sync Status`);
  708. lines.push(`═══════════════════`);
  709. if (status.error) {
  710. lines.push(`Status: ⚠️ ${status.error}`);
  711. }
  712. else {
  713. lines.push(`Mail.app: ${status.recentActivity ? "Running" : "Not running"}`);
  714. lines.push(`Sync active: ${status.syncDetected ? "Yes" : "No"}`);
  715. }
  716. return successResponse(lines.join("\n"));
  717. }, "Error getting sync status"));
  718. // =============================================================================
  719. // Server Startup
  720. // =============================================================================
  721. /**
  722. * Initialize and start the MCP server.
  723. */
  724. const transport = new StdioServerTransport();
  725. await server.connect(transport);