index.ts 32 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066
  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(
  43. /^[a-zA-Z0-9 ,/\-:]+$/,
  44. "Date must contain only alphanumeric characters, spaces, commas, slashes, hyphens, and colons"
  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") as { version: string };
  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: string) {
  73. return {
  74. content: [{ type: "text" as const, text: message }],
  75. };
  76. }
  77. /**
  78. * Creates an error MCP tool response.
  79. */
  80. function errorResponse(message: string) {
  81. return {
  82. content: [{ type: "text" as const, text: message }],
  83. isError: true,
  84. };
  85. }
  86. /**
  87. * Wraps a tool handler with consistent error handling.
  88. */
  89. function withErrorHandling<T extends Record<string, unknown>>(
  90. handler: (params: T) => ReturnType<typeof successResponse>,
  91. errorPrefix: string
  92. ) {
  93. return async (params: T) => {
  94. try {
  95. return handler(params);
  96. } catch (error) {
  97. const message = error instanceof Error ? error.message : "Unknown error";
  98. return errorResponse(`${errorPrefix}: ${message}`);
  99. }
  100. };
  101. }
  102. // =============================================================================
  103. // Message Tools
  104. // =============================================================================
  105. // --- search-messages ---
  106. server.tool(
  107. "search-messages",
  108. {
  109. query: z.string().optional().describe("Text to search for in subject, sender, or content"),
  110. from: z.string().optional().describe("Filter by sender email address"),
  111. subject: z.string().optional().describe("Filter by subject line"),
  112. mailbox: z.string().optional().describe("Mailbox to search in (e.g., 'INBOX')"),
  113. account: z.string().optional().describe("Account to search in (omit to search all accounts)"),
  114. isRead: z.boolean().optional().describe("Filter by read status"),
  115. isFlagged: z.boolean().optional().describe("Filter by flagged status"),
  116. dateFrom: DATE_FILTER_SCHEMA.describe("Start date filter (e.g., 'January 1, 2026')"),
  117. dateTo: DATE_FILTER_SCHEMA.describe("End date filter (e.g., 'March 1, 2026')"),
  118. limit: z.number().optional().describe("Maximum number of results (default: 50)"),
  119. },
  120. withErrorHandling(({ query, mailbox, account, limit = 50, dateFrom, dateTo }) => {
  121. const messages = mailManager.searchMessages(query, mailbox, account, limit, dateFrom, dateTo);
  122. if (messages.length === 0) {
  123. return successResponse("No messages found matching criteria");
  124. }
  125. const messageList = messages
  126. .map(
  127. (m) =>
  128. ` - ID: ${m.id} | ${m.dateReceived.toLocaleDateString()} | ${m.subject} (from: ${m.sender}) [${m.isRead ? "read" : "unread"}]`
  129. )
  130. .join("\n");
  131. return successResponse(`Found ${messages.length} message(s):\n${messageList}`);
  132. }, "Error searching messages")
  133. );
  134. // --- get-message ---
  135. server.tool(
  136. "get-message",
  137. {
  138. id: MESSAGE_ID_SCHEMA,
  139. preferHtml: z.boolean().optional().describe("Return HTML source instead of plain text"),
  140. },
  141. withErrorHandling(({ id, preferHtml }) => {
  142. const content = mailManager.getMessageContent(id);
  143. if (!content) {
  144. return errorResponse(`Message with ID "${id}" not found`);
  145. }
  146. if (preferHtml && content.htmlContent) {
  147. return successResponse(`Subject: ${content.subject}\n\n${content.htmlContent}`);
  148. }
  149. return successResponse(`Subject: ${content.subject}\n\n${content.plainText}`);
  150. }, "Error retrieving message")
  151. );
  152. // --- list-messages ---
  153. server.tool(
  154. "list-messages",
  155. {
  156. mailbox: z.string().optional().describe("Mailbox to list messages from (default: INBOX)"),
  157. account: z.string().optional().describe("Account to list messages from"),
  158. limit: z.number().optional().describe("Maximum number of messages (default: 50)"),
  159. offset: z.number().optional().describe("Number of messages to skip (for pagination)"),
  160. from: z.string().optional().describe("Filter by sender email address or name"),
  161. unreadOnly: z.boolean().optional().describe("Only show unread messages"),
  162. },
  163. withErrorHandling(({ mailbox, account, limit = 50, offset = 0, from }) => {
  164. const messages = mailManager.listMessages(mailbox, account, limit, from, offset);
  165. if (messages.length === 0) {
  166. return successResponse("No messages found");
  167. }
  168. const messageList = messages
  169. .map(
  170. (m) =>
  171. ` - ID: ${m.id} | ${m.dateReceived.toLocaleDateString()} | ${m.subject} (from: ${m.sender})`
  172. )
  173. .join("\n");
  174. return successResponse(`Found ${messages.length} message(s):\n${messageList}`);
  175. }, "Error listing messages")
  176. );
  177. // --- send-email ---
  178. server.tool(
  179. "send-email",
  180. {
  181. to: z.array(z.string()).min(1, "At least one recipient is required"),
  182. subject: z.string().min(1, "Subject is required"),
  183. body: z.string().min(1, "Body is required"),
  184. cc: z.array(z.string()).optional().describe("CC recipients"),
  185. bcc: z.array(z.string()).optional().describe("BCC recipients"),
  186. account: z.string().optional().describe("Account to send from"),
  187. attachments: z
  188. .array(z.string())
  189. .max(20, "Cannot attach more than 20 files")
  190. .optional()
  191. .describe("Absolute file paths to attach (e.g., ['/Users/me/report.pdf'])"),
  192. },
  193. withErrorHandling(({ to, subject, body, cc, bcc, account, attachments }) => {
  194. const success = mailManager.sendEmail(to, subject, body, cc, bcc, account, attachments);
  195. if (!success) {
  196. return errorResponse("Failed to send email. Check Mail.app configuration.");
  197. }
  198. const attachInfo = attachments?.length ? ` with ${attachments.length} attachment(s)` : "";
  199. return successResponse(`Email sent to ${to.join(", ")}${attachInfo}`);
  200. }, "Error sending email")
  201. );
  202. // --- send-serial-email ---
  203. server.tool(
  204. "send-serial-email",
  205. {
  206. recipients: z
  207. .array(
  208. z.object({
  209. email: z.string().min(1, "Recipient email is required"),
  210. variables: z
  211. .record(z.string())
  212. .describe("Placeholder values, e.g. { Name: 'Alice', Company: 'Acme' }"),
  213. })
  214. )
  215. .min(1, "At least one recipient is required")
  216. .max(100, "Cannot send to more than 100 recipients in a single batch")
  217. .describe("List of recipients with personalization variables (max 100)"),
  218. subject: z
  219. .string()
  220. .min(1, "Subject is required")
  221. .describe("Subject line — use {{Key}} for placeholders"),
  222. body: z
  223. .string()
  224. .min(1, "Body is required")
  225. .describe("Email body — use {{Key}} for placeholders"),
  226. account: z.string().optional().describe("Account to send from"),
  227. delayMs: z
  228. .number()
  229. .min(0)
  230. .max(10000)
  231. .optional()
  232. .describe("Delay between sends in ms (default: 500, max: 10000)"),
  233. },
  234. withErrorHandling(({ recipients, subject, body, account, delayMs }) => {
  235. const results = mailManager.sendSerialEmail(recipients, subject, body, account, delayMs);
  236. const successCount = results.filter((r) => r.success).length;
  237. const failCount = results.length - successCount;
  238. const details = results
  239. .map((r) => ` - ${r.email}: ${r.success ? "sent" : `FAILED (${r.error})`}`)
  240. .join("\n");
  241. if (failCount === 0) {
  242. return successResponse(`Successfully sent ${successCount} email(s):\n${details}`);
  243. } else if (successCount === 0) {
  244. return errorResponse(`Failed to send all ${failCount} email(s):\n${details}`);
  245. } else {
  246. return successResponse(
  247. `Sent ${successCount} of ${results.length} email(s), ${failCount} failed:\n${details}`
  248. );
  249. }
  250. }, "Error sending serial emails")
  251. );
  252. // --- create-draft ---
  253. server.tool(
  254. "create-draft",
  255. {
  256. to: z.array(z.string()).min(1, "At least one recipient is required"),
  257. subject: z.string().min(1, "Subject is required"),
  258. body: z.string().min(1, "Body is required"),
  259. cc: z.array(z.string()).optional().describe("CC recipients"),
  260. bcc: z.array(z.string()).optional().describe("BCC recipients"),
  261. account: z.string().optional().describe("Account to create draft in"),
  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'])"),
  267. },
  268. withErrorHandling(({ to, subject, body, cc, bcc, account, attachments }) => {
  269. const success = mailManager.createDraft(to, subject, body, cc, bcc, account, attachments);
  270. if (!success) {
  271. return errorResponse("Failed to create draft. Check Mail.app configuration.");
  272. }
  273. const attachInfo = attachments?.length ? ` with ${attachments.length} attachment(s)` : "";
  274. return successResponse(`Draft created for ${to.join(", ")}${attachInfo}`);
  275. }, "Error creating draft")
  276. );
  277. // --- reply-to-message ---
  278. server.tool(
  279. "reply-to-message",
  280. {
  281. id: MESSAGE_ID_SCHEMA,
  282. body: z.string().min(1, "Reply body is required"),
  283. replyAll: z.boolean().optional().default(false).describe("Reply to all recipients"),
  284. send: z.boolean().optional().default(true).describe("Send immediately (false = save as draft)"),
  285. },
  286. withErrorHandling(({ id, body, replyAll, send }) => {
  287. const success = mailManager.replyToMessage(id, body, replyAll, send);
  288. if (!success) {
  289. return errorResponse(`Failed to reply to message "${id}"`);
  290. }
  291. return successResponse(send ? "Reply sent" : "Reply saved as draft");
  292. }, "Error replying to message")
  293. );
  294. // --- forward-message ---
  295. server.tool(
  296. "forward-message",
  297. {
  298. id: MESSAGE_ID_SCHEMA,
  299. to: z.array(z.string()).min(1, "At least one recipient is required"),
  300. body: z.string().optional().describe("Optional message to prepend"),
  301. send: z.boolean().optional().default(true).describe("Send immediately (false = save as draft)"),
  302. },
  303. withErrorHandling(({ id, to, body, send }) => {
  304. const success = mailManager.forwardMessage(id, to, body, send);
  305. if (!success) {
  306. return errorResponse(`Failed to forward message "${id}"`);
  307. }
  308. return successResponse(
  309. send ? `Message forwarded to ${to.join(", ")}` : "Forward saved as draft"
  310. );
  311. }, "Error forwarding message")
  312. );
  313. // --- mark-as-read ---
  314. server.tool(
  315. "mark-as-read",
  316. {
  317. id: MESSAGE_ID_SCHEMA,
  318. },
  319. withErrorHandling(({ id }) => {
  320. const success = mailManager.markAsRead(id);
  321. if (!success) {
  322. return errorResponse(`Failed to mark message "${id}" as read`);
  323. }
  324. return successResponse("Message marked as read");
  325. }, "Error marking message as read")
  326. );
  327. // --- mark-as-unread ---
  328. server.tool(
  329. "mark-as-unread",
  330. {
  331. id: MESSAGE_ID_SCHEMA,
  332. },
  333. withErrorHandling(({ id }) => {
  334. const success = mailManager.markAsUnread(id);
  335. if (!success) {
  336. return errorResponse(`Failed to mark message "${id}" as unread`);
  337. }
  338. return successResponse("Message marked as unread");
  339. }, "Error marking message as unread")
  340. );
  341. // --- flag-message ---
  342. server.tool(
  343. "flag-message",
  344. {
  345. id: MESSAGE_ID_SCHEMA,
  346. },
  347. withErrorHandling(({ id }) => {
  348. const success = mailManager.flagMessage(id);
  349. if (!success) {
  350. return errorResponse(`Failed to flag message "${id}"`);
  351. }
  352. return successResponse("Message flagged");
  353. }, "Error flagging message")
  354. );
  355. // --- unflag-message ---
  356. server.tool(
  357. "unflag-message",
  358. {
  359. id: MESSAGE_ID_SCHEMA,
  360. },
  361. withErrorHandling(({ id }) => {
  362. const success = mailManager.unflagMessage(id);
  363. if (!success) {
  364. return errorResponse(`Failed to unflag message "${id}"`);
  365. }
  366. return successResponse("Message unflagged");
  367. }, "Error unflagging message")
  368. );
  369. // --- delete-message ---
  370. server.tool(
  371. "delete-message",
  372. {
  373. id: MESSAGE_ID_SCHEMA,
  374. },
  375. withErrorHandling(({ id }) => {
  376. const success = mailManager.deleteMessage(id);
  377. if (!success) {
  378. return errorResponse(`Failed to delete message "${id}"`);
  379. }
  380. return successResponse("Message deleted");
  381. }, "Error deleting message")
  382. );
  383. // --- move-message ---
  384. server.tool(
  385. "move-message",
  386. {
  387. id: MESSAGE_ID_SCHEMA,
  388. mailbox: z.string().min(1, "Destination mailbox is required"),
  389. account: z.string().optional().describe("Account containing the destination mailbox"),
  390. },
  391. withErrorHandling(({ id, mailbox, account }) => {
  392. const success = mailManager.moveMessage(id, mailbox, account);
  393. if (!success) {
  394. return errorResponse(`Failed to move message to "${mailbox}"`);
  395. }
  396. return successResponse(`Message moved to "${mailbox}"`);
  397. }, "Error moving message")
  398. );
  399. // --- batch-delete-messages ---
  400. server.tool(
  401. "batch-delete-messages",
  402. {
  403. ids: BATCH_IDS_SCHEMA,
  404. },
  405. withErrorHandling(({ ids }) => {
  406. const results = mailManager.batchDeleteMessages(ids);
  407. const successCount = results.filter((r) => r.success).length;
  408. const failCount = results.length - successCount;
  409. if (failCount === 0) {
  410. return successResponse(`Successfully deleted ${successCount} message(s)`);
  411. } else if (successCount === 0) {
  412. return errorResponse(`Failed to delete all ${failCount} message(s)`);
  413. } else {
  414. return successResponse(`Deleted ${successCount} message(s), ${failCount} failed`);
  415. }
  416. }, "Error batch deleting messages")
  417. );
  418. // --- batch-move-messages ---
  419. server.tool(
  420. "batch-move-messages",
  421. {
  422. ids: BATCH_IDS_SCHEMA,
  423. mailbox: z.string().min(1, "Destination mailbox is required"),
  424. account: z.string().optional().describe("Account containing the destination mailbox"),
  425. },
  426. withErrorHandling(({ ids, mailbox, account }) => {
  427. const results = mailManager.batchMoveMessages(ids, mailbox, account);
  428. const successCount = results.filter((r) => r.success).length;
  429. const failCount = results.length - successCount;
  430. if (failCount === 0) {
  431. return successResponse(`Successfully moved ${successCount} message(s) to "${mailbox}"`);
  432. } else if (successCount === 0) {
  433. return errorResponse(`Failed to move all ${failCount} message(s)`);
  434. } else {
  435. return successResponse(
  436. `Moved ${successCount} message(s) to "${mailbox}", ${failCount} failed`
  437. );
  438. }
  439. }, "Error batch moving messages")
  440. );
  441. // --- batch-mark-as-read ---
  442. server.tool(
  443. "batch-mark-as-read",
  444. {
  445. ids: BATCH_IDS_SCHEMA,
  446. },
  447. withErrorHandling(({ ids }) => {
  448. const results = mailManager.batchMarkAsRead(ids);
  449. const successCount = results.filter((r) => r.success).length;
  450. const failCount = results.length - successCount;
  451. if (failCount === 0) {
  452. return successResponse(`Successfully marked ${successCount} message(s) as read`);
  453. } else if (successCount === 0) {
  454. return errorResponse(`Failed to mark all ${failCount} message(s) as read`);
  455. } else {
  456. return successResponse(`Marked ${successCount} message(s) as read, ${failCount} failed`);
  457. }
  458. }, "Error batch marking messages as read")
  459. );
  460. // --- batch-mark-as-unread ---
  461. server.tool(
  462. "batch-mark-as-unread",
  463. {
  464. ids: BATCH_IDS_SCHEMA,
  465. },
  466. withErrorHandling(({ ids }) => {
  467. const results = mailManager.batchMarkAsUnread(ids);
  468. const successCount = results.filter((r) => r.success).length;
  469. const failCount = results.length - successCount;
  470. if (failCount === 0) {
  471. return successResponse(`Successfully marked ${successCount} message(s) as unread`);
  472. } else if (successCount === 0) {
  473. return errorResponse(`Failed to mark all ${failCount} message(s) as unread`);
  474. } else {
  475. return successResponse(`Marked ${successCount} message(s) as unread, ${failCount} failed`);
  476. }
  477. }, "Error batch marking messages as unread")
  478. );
  479. // --- batch-flag-messages ---
  480. server.tool(
  481. "batch-flag-messages",
  482. {
  483. ids: BATCH_IDS_SCHEMA,
  484. },
  485. withErrorHandling(({ ids }) => {
  486. const results = mailManager.batchFlagMessages(ids);
  487. const successCount = results.filter((r) => r.success).length;
  488. const failCount = results.length - successCount;
  489. if (failCount === 0) {
  490. return successResponse(`Successfully flagged ${successCount} message(s)`);
  491. } else if (successCount === 0) {
  492. return errorResponse(`Failed to flag all ${failCount} message(s)`);
  493. } else {
  494. return successResponse(`Flagged ${successCount} message(s), ${failCount} failed`);
  495. }
  496. }, "Error batch flagging messages")
  497. );
  498. // --- batch-unflag-messages ---
  499. server.tool(
  500. "batch-unflag-messages",
  501. {
  502. ids: BATCH_IDS_SCHEMA,
  503. },
  504. withErrorHandling(({ ids }) => {
  505. const results = mailManager.batchUnflagMessages(ids);
  506. const successCount = results.filter((r) => r.success).length;
  507. const failCount = results.length - successCount;
  508. if (failCount === 0) {
  509. return successResponse(`Successfully unflagged ${successCount} message(s)`);
  510. } else if (successCount === 0) {
  511. return errorResponse(`Failed to unflag all ${failCount} message(s)`);
  512. } else {
  513. return successResponse(`Unflagged ${successCount} message(s), ${failCount} failed`);
  514. }
  515. }, "Error batch unflagging messages")
  516. );
  517. // --- list-attachments ---
  518. server.tool(
  519. "list-attachments",
  520. {
  521. id: MESSAGE_ID_SCHEMA,
  522. },
  523. withErrorHandling(({ id }) => {
  524. const attachments = mailManager.listAttachments(id);
  525. if (attachments.length === 0) {
  526. return successResponse("No attachments found");
  527. }
  528. const attachmentList = attachments
  529. .map((a) => {
  530. const sizeKb = Math.round(a.size / 1024);
  531. return ` - ${a.name} (${a.mimeType}, ${sizeKb} KB)`;
  532. })
  533. .join("\n");
  534. return successResponse(`Found ${attachments.length} attachment(s):\n${attachmentList}`);
  535. }, "Error listing attachments")
  536. );
  537. // --- save-attachment ---
  538. server.tool(
  539. "save-attachment",
  540. {
  541. id: MESSAGE_ID_SCHEMA,
  542. attachmentName: z.string().min(1, "Attachment name is required"),
  543. savePath: z.string().min(1, "Save directory path is required"),
  544. },
  545. withErrorHandling(({ id, attachmentName, savePath }) => {
  546. const success = mailManager.saveAttachment(id, attachmentName, savePath);
  547. if (!success) {
  548. return errorResponse(`Failed to save attachment "${attachmentName}"`);
  549. }
  550. return successResponse(`Attachment "${attachmentName}" saved to ${savePath}`);
  551. }, "Error saving attachment")
  552. );
  553. // =============================================================================
  554. // Mailbox Tools
  555. // =============================================================================
  556. // --- list-mailboxes ---
  557. server.tool(
  558. "list-mailboxes",
  559. {
  560. account: z.string().optional().describe("Account to list mailboxes from"),
  561. },
  562. withErrorHandling(({ account }) => {
  563. const mailboxes = mailManager.listMailboxes(account);
  564. if (mailboxes.length === 0) {
  565. return successResponse("No mailboxes found");
  566. }
  567. const mailboxList = mailboxes.map((m) => ` - ${m.name} (${m.unreadCount} unread)`).join("\n");
  568. return successResponse(`Found ${mailboxes.length} mailbox(es):\n${mailboxList}`);
  569. }, "Error listing mailboxes")
  570. );
  571. // --- get-unread-count ---
  572. server.tool(
  573. "get-unread-count",
  574. {
  575. mailbox: z.string().optional().describe("Mailbox to check (default: all)"),
  576. account: z.string().optional().describe("Account to check"),
  577. },
  578. withErrorHandling(({ mailbox, account }) => {
  579. const count = mailManager.getUnreadCount(mailbox, account);
  580. const location = mailbox ? ` in "${mailbox}"` : "";
  581. return successResponse(`${count} unread message(s)${location}`);
  582. }, "Error getting unread count")
  583. );
  584. // --- create-mailbox ---
  585. server.tool(
  586. "create-mailbox",
  587. {
  588. name: z.string().min(1, "Mailbox name is required"),
  589. account: z.string().optional().describe("Account to create the mailbox in"),
  590. },
  591. withErrorHandling(({ name, account }) => {
  592. const success = mailManager.createMailbox(name, account);
  593. if (!success) {
  594. return errorResponse(`Failed to create mailbox "${name}"`);
  595. }
  596. return successResponse(`Mailbox "${name}" created`);
  597. }, "Error creating mailbox")
  598. );
  599. // --- delete-mailbox ---
  600. server.tool(
  601. "delete-mailbox",
  602. {
  603. name: z.string().min(1, "Mailbox name is required"),
  604. account: z.string().optional().describe("Account containing the mailbox"),
  605. },
  606. withErrorHandling(({ name, account }) => {
  607. const success = mailManager.deleteMailbox(name, account);
  608. if (!success) {
  609. return errorResponse(`Failed to delete mailbox "${name}"`);
  610. }
  611. return successResponse(`Mailbox "${name}" deleted`);
  612. }, "Error deleting mailbox")
  613. );
  614. // --- rename-mailbox ---
  615. server.tool(
  616. "rename-mailbox",
  617. {
  618. oldName: z.string().min(1, "Current mailbox name is required"),
  619. newName: z.string().min(1, "New mailbox name is required"),
  620. account: z.string().optional().describe("Account containing the mailbox"),
  621. },
  622. withErrorHandling(({ oldName, newName, account }) => {
  623. const success = mailManager.renameMailbox(oldName, newName, account);
  624. if (!success) {
  625. return errorResponse(`Failed to rename mailbox "${oldName}" to "${newName}"`);
  626. }
  627. return successResponse(`Mailbox renamed from "${oldName}" to "${newName}"`);
  628. }, "Error renaming mailbox")
  629. );
  630. // =============================================================================
  631. // Account Tools
  632. // =============================================================================
  633. // --- list-accounts ---
  634. server.tool(
  635. "list-accounts",
  636. {},
  637. withErrorHandling(() => {
  638. const accounts = mailManager.listAccounts();
  639. if (accounts.length === 0) {
  640. return successResponse("No Mail accounts found");
  641. }
  642. const accountList = accounts.map((a) => ` - ${a.name}`).join("\n");
  643. return successResponse(`Found ${accounts.length} account(s):\n${accountList}`);
  644. }, "Error listing accounts")
  645. );
  646. // =============================================================================
  647. // Mail Rules Tools
  648. // =============================================================================
  649. // --- list-rules ---
  650. server.tool(
  651. "list-rules",
  652. {},
  653. withErrorHandling(() => {
  654. const rules = mailManager.listRules();
  655. if (rules.length === 0) {
  656. return successResponse("No mail rules found");
  657. }
  658. const ruleList = rules
  659. .map((r) => ` - ${r.name} [${r.enabled ? "enabled" : "disabled"}]`)
  660. .join("\n");
  661. return successResponse(`Found ${rules.length} rule(s):\n${ruleList}`);
  662. }, "Error listing rules")
  663. );
  664. // --- enable-rule ---
  665. server.tool(
  666. "enable-rule",
  667. {
  668. name: z.string().min(1, "Rule name is required"),
  669. },
  670. withErrorHandling(({ name }) => {
  671. const success = mailManager.setRuleEnabled(name, true);
  672. if (!success) {
  673. return errorResponse(`Failed to enable rule "${name}"`);
  674. }
  675. return successResponse(`Rule "${name}" enabled`);
  676. }, "Error enabling rule")
  677. );
  678. // --- disable-rule ---
  679. server.tool(
  680. "disable-rule",
  681. {
  682. name: z.string().min(1, "Rule name is required"),
  683. },
  684. withErrorHandling(({ name }) => {
  685. const success = mailManager.setRuleEnabled(name, false);
  686. if (!success) {
  687. return errorResponse(`Failed to disable rule "${name}"`);
  688. }
  689. return successResponse(`Rule "${name}" disabled`);
  690. }, "Error disabling rule")
  691. );
  692. // =============================================================================
  693. // Contacts Tools
  694. // =============================================================================
  695. // --- search-contacts ---
  696. server.tool(
  697. "search-contacts",
  698. {
  699. query: z.string().min(1, "Search query is required"),
  700. },
  701. withErrorHandling(({ query }) => {
  702. const contacts = mailManager.searchContacts(query);
  703. if (contacts.length === 0) {
  704. return successResponse("No contacts found");
  705. }
  706. const contactList = contacts
  707. .map((c) => {
  708. const emails = c.emails.length > 0 ? c.emails.join(", ") : "no email";
  709. return ` - ${c.name} (${emails})`;
  710. })
  711. .join("\n");
  712. return successResponse(`Found ${contacts.length} contact(s):\n${contactList}`);
  713. }, "Error searching contacts")
  714. );
  715. // =============================================================================
  716. // Email Template Tools
  717. // =============================================================================
  718. // --- save-template ---
  719. server.tool(
  720. "save-template",
  721. {
  722. name: z.string().min(1, "Template name is required"),
  723. subject: z.string().min(1, "Subject is required"),
  724. body: z.string().min(1, "Body is required"),
  725. to: z.array(z.string()).optional().describe("Default recipients"),
  726. cc: z.array(z.string()).optional().describe("Default CC recipients"),
  727. id: z.string().optional().describe("Template ID (for updating existing template)"),
  728. },
  729. withErrorHandling(({ name, subject, body, to, cc, id }) => {
  730. const template = mailManager.saveTemplate(name, subject, body, to, cc, id);
  731. return successResponse(`Template "${template.name}" saved with ID: ${template.id}`);
  732. }, "Error saving template")
  733. );
  734. // --- list-templates ---
  735. server.tool(
  736. "list-templates",
  737. {},
  738. withErrorHandling(() => {
  739. const templates = mailManager.listTemplates();
  740. if (templates.length === 0) {
  741. return successResponse("No templates saved");
  742. }
  743. const templateList = templates
  744. .map((t) => ` - [${t.id}] ${t.name} — "${t.subject}"`)
  745. .join("\n");
  746. return successResponse(`Found ${templates.length} template(s):\n${templateList}`);
  747. }, "Error listing templates")
  748. );
  749. // --- get-template ---
  750. server.tool(
  751. "get-template",
  752. {
  753. id: z.string().min(1, "Template ID is required"),
  754. },
  755. withErrorHandling(({ id }) => {
  756. const template = mailManager.getTemplate(id);
  757. if (!template) {
  758. return errorResponse(`Template "${id}" not found`);
  759. }
  760. const lines = [
  761. `Name: ${template.name}`,
  762. `Subject: ${template.subject}`,
  763. template.to ? `To: ${template.to.join(", ")}` : null,
  764. template.cc ? `CC: ${template.cc.join(", ")}` : null,
  765. `\n${template.body}`,
  766. ]
  767. .filter(Boolean)
  768. .join("\n");
  769. return successResponse(lines);
  770. }, "Error getting template")
  771. );
  772. // --- delete-template ---
  773. server.tool(
  774. "delete-template",
  775. {
  776. id: z.string().min(1, "Template ID is required"),
  777. },
  778. withErrorHandling(({ id }) => {
  779. const success = mailManager.deleteTemplate(id);
  780. if (!success) {
  781. return errorResponse(`Template "${id}" not found`);
  782. }
  783. return successResponse(`Template "${id}" deleted`);
  784. }, "Error deleting template")
  785. );
  786. // --- use-template ---
  787. server.tool(
  788. "use-template",
  789. {
  790. id: z.string().min(1, "Template ID is required"),
  791. to: z.array(z.string()).optional().describe("Override recipients"),
  792. cc: z.array(z.string()).optional().describe("Override CC recipients"),
  793. subject: z.string().optional().describe("Override subject"),
  794. body: z.string().optional().describe("Override body"),
  795. },
  796. withErrorHandling(({ id, to, cc, subject, body }) => {
  797. const success = mailManager.useTemplate(id, { to, cc, subject, body });
  798. if (!success) {
  799. return errorResponse(`Failed to use template "${id}". Template not found or no recipients.`);
  800. }
  801. return successResponse(`Draft created from template "${id}"`);
  802. }, "Error using template")
  803. );
  804. // =============================================================================
  805. // Diagnostics Tools
  806. // =============================================================================
  807. // --- health-check ---
  808. server.tool(
  809. "health-check",
  810. {},
  811. withErrorHandling(() => {
  812. const result = mailManager.healthCheck();
  813. const statusIcon = result.healthy ? "✓" : "✗";
  814. const statusText = result.healthy ? "All checks passed" : "Issues detected";
  815. const checkLines = result.checks
  816. .map((c) => {
  817. const icon = c.passed ? "✓" : "✗";
  818. return ` ${icon} ${c.name}: ${c.message}`;
  819. })
  820. .join("\n");
  821. return successResponse(`${statusIcon} ${statusText}\n\n${checkLines}`);
  822. }, "Error running health check")
  823. );
  824. // --- get-mail-stats ---
  825. server.tool(
  826. "get-mail-stats",
  827. {},
  828. withErrorHandling(() => {
  829. const stats = mailManager.getMailStats();
  830. const lines: string[] = [];
  831. lines.push(`📊 Mail Statistics`);
  832. lines.push(`══════════════════`);
  833. lines.push(`Total messages: ${stats.totalMessages}`);
  834. lines.push(`Unread messages: ${stats.totalUnread}`);
  835. lines.push(``);
  836. if (stats.recentlyReceived) {
  837. lines.push(`📥 Recently Received:`);
  838. lines.push(` Last 24 hours: ${stats.recentlyReceived.last24h}`);
  839. lines.push(` Last 7 days: ${stats.recentlyReceived.last7d}`);
  840. lines.push(` Last 30 days: ${stats.recentlyReceived.last30d}`);
  841. lines.push(``);
  842. }
  843. if (stats.accounts.length > 0) {
  844. lines.push(`📁 By Account:`);
  845. for (const account of stats.accounts) {
  846. lines.push(
  847. ` ${account.name}: ${account.totalMessages} messages (${account.unreadMessages} unread)`
  848. );
  849. }
  850. }
  851. return successResponse(lines.join("\n"));
  852. }, "Error getting mail statistics")
  853. );
  854. // --- get-sync-status ---
  855. server.tool(
  856. "get-sync-status",
  857. {},
  858. withErrorHandling(() => {
  859. const status = mailManager.getSyncStatus();
  860. const lines: string[] = [];
  861. lines.push(`🔄 Mail Sync Status`);
  862. lines.push(`═══════════════════`);
  863. if (status.error) {
  864. lines.push(`Status: ⚠️ ${status.error}`);
  865. } else {
  866. lines.push(`Mail.app: ${status.recentActivity ? "Running" : "Not running"}`);
  867. lines.push(`Sync active: ${status.syncDetected ? "Yes" : "No"}`);
  868. }
  869. return successResponse(lines.join("\n"));
  870. }, "Error getting sync status")
  871. );
  872. // =============================================================================
  873. // Server Startup
  874. // =============================================================================
  875. /**
  876. * Initialize and start the MCP server.
  877. */
  878. const transport = new StdioServerTransport();
  879. await server.connect(transport);