Эх сурвалжийг харах

build: commit build/ artifacts so marketplace plugin works without npm install

Claude Code installs path-source plugins (`"source": "./"` in
marketplace.json) by git clone only — no `npm install`, no
`prepare`/`postinstall` lifecycle scripts. The previously-gitignored
`build/` directory was therefore absent from the cached plugin
install, leaving the MCP server with no entrypoint to execute.

Fix:
- Track `build/` in git (override global ~/.gitignore)
- Drop `npm run build` from `prepare` so daily `npm install` doesn't
  churn `git status`; rely on the pre-commit hook instead
- Pre-commit hook rebuilds and re-stages `build/` whenever `src/`
  changes, keeping committed artifacts in sync with source
- CI fails if the committed `build/` drifts from a fresh build

Closes the secondary concern raised in #10 (build/ missing from
cached plugin install).
Robert Sweet 1 сар өмнө
parent
commit
61f2bb9755

+ 8 - 0
.github/workflows/ci.yml

@@ -46,3 +46,11 @@ jobs:
 
       - name: Build
         run: npm run build
+
+      - name: Verify committed build/ matches source
+        run: |
+          if ! git diff --quiet build/; then
+            echo "::error::build/ is out of date. Run 'npm run build' locally and commit the changes."
+            git --no-pager diff --stat build/
+            exit 1
+          fi

+ 5 - 2
.gitignore

@@ -1,8 +1,11 @@
 # Dependencies
 node_modules/
 
-# Build output
-build/
+# Build output is intentionally tracked so the marketplace plugin works
+# without an npm install step (Claude Code does not run npm install on
+# path-source plugins). Override the global ~/.gitignore entry.
+!build/
+!build/**
 
 # Test coverage
 coverage/

+ 11 - 0
.husky/pre-commit

@@ -1 +1,12 @@
+set -e
+
 npx lint-staged
+
+# Marketplace path-source plugins are git-cloned without `npm install`, so the
+# `build/` artifacts must be committed for the plugin to work after install.
+# Keep them fresh: if any staged file is under src/, rebuild and stage build/.
+if git diff --cached --name-only --diff-filter=ACMR | grep -q '^src/'; then
+  echo "src/ changed — rebuilding build/ for plugin distribution"
+  npm run build --silent
+  git add build
+fi

+ 9 - 0
CHANGELOG.md

@@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
+## [Unreleased]
+
+### Fixed
+- **Plugin install: `build/` not produced on marketplace install** — Claude Code installs path-source plugins by `git clone` only; it does not run `npm install`, so the `prepare` hook never fired and the cached plugin directory was missing `build/index.js`. The MCP server failed to start until the user manually ran `npm run build` inside the cached plugin dir. Build artifacts in `build/` are now committed to the repository so they are present immediately after marketplace clone. A pre-commit hook keeps `build/` in sync with `src/`, and CI fails if the committed `build/` is stale. ([#10](https://github.com/sweetrb/apple-mail-mcp/pull/10))
+- **`.mcp.json` plugin entrypoint resolved against user cwd** — `args` referenced `build/index.js` as a relative path, so the MCP server only started when Claude Code was launched from inside a clone of this repo. Now uses `${CLAUDE_PLUGIN_ROOT}/build/index.js` so the path resolves against the plugin's install location. ([#10](https://github.com/sweetrb/apple-mail-mcp/pull/10) by @natekettles)
+
+### Changed
+- **`prepare` script no longer rebuilds.** It now runs `husky` only. Build artifacts are committed and kept current by the pre-commit hook; rebuilding on every `npm install` was producing churn in `git status` for daily development.
+
 ## [1.5.0] - 2026-04-20
 
 ### Fixed

+ 23 - 0
build/index.d.ts

@@ -0,0 +1,23 @@
+#!/usr/bin/env node
+/**
+ * Apple Mail MCP Server
+ *
+ * A Model Context Protocol (MCP) server that provides AI assistants
+ * with the ability to interact with Apple Mail on macOS.
+ *
+ * This server exposes tools for:
+ * - Reading and searching emails
+ * - Sending emails
+ * - Managing mailboxes
+ * - Managing multiple accounts (iCloud, Gmail, Exchange, etc.)
+ *
+ * Architecture:
+ * - Tool definitions are declarative (schema + handler)
+ * - The AppleMailManager class handles all AppleScript operations
+ * - Error handling is consistent across all tools
+ *
+ * @module apple-mail-mcp
+ * @see https://modelcontextprotocol.io
+ */
+export {};
+//# sourceMappingURL=index.d.ts.map

+ 1 - 0
build/index.d.ts.map

@@ -0,0 +1 @@
+{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AACA;;;;;;;;;;;;;;;;;;;GAmBG"}

+ 722 - 0
build/index.js

@@ -0,0 +1,722 @@
+#!/usr/bin/env node
+/**
+ * Apple Mail MCP Server
+ *
+ * A Model Context Protocol (MCP) server that provides AI assistants
+ * with the ability to interact with Apple Mail on macOS.
+ *
+ * This server exposes tools for:
+ * - Reading and searching emails
+ * - Sending emails
+ * - Managing mailboxes
+ * - Managing multiple accounts (iCloud, Gmail, Exchange, etc.)
+ *
+ * Architecture:
+ * - Tool definitions are declarative (schema + handler)
+ * - The AppleMailManager class handles all AppleScript operations
+ * - Error handling is consistent across all tools
+ *
+ * @module apple-mail-mcp
+ * @see https://modelcontextprotocol.io
+ */
+import { createRequire } from "module";
+import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
+import { z } from "zod";
+import { AppleMailManager } from "./services/appleMailManager.js";
+// =============================================================================
+// Shared Validation Schemas
+// =============================================================================
+/** Message IDs in Apple Mail are always numeric. Enforce this at the schema level
+ *  to prevent AppleScript injection via the `whose id is ${id}` interpolation. */
+const MESSAGE_ID_SCHEMA = z.string().regex(/^\d+$/, "Message ID must be numeric");
+/** Batch operations are capped to prevent unbounded loops / DoS. */
+const BATCH_IDS_SCHEMA = z
+    .array(MESSAGE_ID_SCHEMA)
+    .min(1, "At least one message ID is required")
+    .max(100, "Cannot process more than 100 messages in a single batch");
+/** Date filter strings must look like natural-language dates (e.g. "March 1, 2026").
+ *  Block characters that could escape an AppleScript `date "..."` literal. */
+const DATE_FILTER_SCHEMA = z
+    .string()
+    .regex(/^[a-zA-Z0-9 ,/\-:]+$/, "Date must contain only alphanumeric characters, spaces, commas, slashes, hyphens, and colons")
+    .refine((val) => !isNaN(new Date(val).getTime()), {
+    message: "Date string must be a valid date (e.g., 'January 1, 2026' or '2026-03-15')",
+})
+    .optional();
+// Read version from package.json to keep it in sync
+const require = createRequire(import.meta.url);
+const { version } = require("../package.json");
+// =============================================================================
+// Server Initialization
+// =============================================================================
+/**
+ * MCP server instance configured for Apple Mail operations.
+ */
+const server = new McpServer({
+    name: "apple-mail",
+    version,
+    description: "MCP server for managing Apple Mail - read, search, send, and organize emails",
+});
+/**
+ * Singleton instance of the Apple Mail manager.
+ * Handles all AppleScript execution and mail operations.
+ */
+const mailManager = new AppleMailManager();
+// =============================================================================
+// Response Helpers
+// =============================================================================
+/**
+ * Creates a successful MCP tool response.
+ */
+function successResponse(message) {
+    return {
+        content: [{ type: "text", text: message }],
+    };
+}
+/**
+ * Creates an error MCP tool response.
+ */
+function errorResponse(message) {
+    return {
+        content: [{ type: "text", text: message }],
+        isError: true,
+    };
+}
+/**
+ * Wraps a tool handler with consistent error handling.
+ */
+function withErrorHandling(handler, errorPrefix) {
+    return async (params) => {
+        try {
+            return handler(params);
+        }
+        catch (error) {
+            const message = error instanceof Error ? error.message : "Unknown error";
+            return errorResponse(`${errorPrefix}: ${message}`);
+        }
+    };
+}
+// =============================================================================
+// Message Tools
+// =============================================================================
+// --- search-messages ---
+server.tool("search-messages", {
+    query: z.string().optional().describe("Text to search for in subject, sender, or content"),
+    from: z.string().optional().describe("Filter by sender email address"),
+    subject: z.string().optional().describe("Filter by subject line"),
+    mailbox: z
+        .string()
+        .optional()
+        .describe("Mailbox to search in (e.g., 'INBOX'). Omit to search all mailboxes."),
+    account: z.string().optional().describe("Account to search in (omit to search all accounts)"),
+    isRead: z.boolean().optional().describe("Filter by read status"),
+    isFlagged: z.boolean().optional().describe("Filter by flagged status"),
+    dateFrom: DATE_FILTER_SCHEMA.describe("Start date filter (e.g., 'January 1, 2026')"),
+    dateTo: DATE_FILTER_SCHEMA.describe("End date filter (e.g., 'March 1, 2026')"),
+    limit: z.number().optional().describe("Maximum number of results (default: 50)"),
+}, withErrorHandling(({ query, mailbox, account, limit = 50, dateFrom, dateTo }) => {
+    const messages = mailManager.searchMessages(query, mailbox, account, limit, dateFrom, dateTo);
+    if (messages.length === 0) {
+        return successResponse("No messages found matching criteria");
+    }
+    const messageList = messages
+        .map((m) => `  - ID: ${m.id} | ${m.dateReceived.toLocaleDateString()} | ${m.subject} (from: ${m.sender}) [${m.isRead ? "read" : "unread"}]`)
+        .join("\n");
+    return successResponse(`Found ${messages.length} message(s):\n${messageList}`);
+}, "Error searching messages"));
+// --- get-message ---
+server.tool("get-message", {
+    id: MESSAGE_ID_SCHEMA,
+    preferHtml: z.boolean().optional().describe("Return HTML source instead of plain text"),
+}, withErrorHandling(({ id, preferHtml }) => {
+    const content = mailManager.getMessageContent(id);
+    if (!content) {
+        return errorResponse(`Message with ID "${id}" not found`);
+    }
+    if (preferHtml && content.htmlContent) {
+        return successResponse(`Subject: ${content.subject}\n\n${content.htmlContent}`);
+    }
+    return successResponse(`Subject: ${content.subject}\n\n${content.plainText}`);
+}, "Error retrieving message"));
+// --- list-messages ---
+server.tool("list-messages", {
+    mailbox: z
+        .string()
+        .optional()
+        .describe("Mailbox to list messages from. Omit to list from all mailboxes."),
+    account: z.string().optional().describe("Account to list messages from"),
+    limit: z.number().optional().describe("Maximum number of messages (default: 50)"),
+    offset: z.number().optional().describe("Number of messages to skip (for pagination)"),
+    from: z.string().optional().describe("Filter by sender email address or name"),
+    unreadOnly: z.boolean().optional().describe("Only show unread messages"),
+}, withErrorHandling(({ mailbox, account, limit = 50, offset = 0, from }) => {
+    const messages = mailManager.listMessages(mailbox, account, limit, from, offset);
+    if (messages.length === 0) {
+        return successResponse("No messages found");
+    }
+    const messageList = messages
+        .map((m) => `  - ID: ${m.id} | ${m.dateReceived.toLocaleDateString()} | ${m.subject} (from: ${m.sender})`)
+        .join("\n");
+    return successResponse(`Found ${messages.length} message(s):\n${messageList}`);
+}, "Error listing messages"));
+// --- send-email ---
+server.tool("send-email", {
+    to: z.array(z.string()).min(1, "At least one recipient is required"),
+    subject: z.string().min(1, "Subject is required"),
+    body: z.string().min(1, "Body is required"),
+    cc: z.array(z.string()).optional().describe("CC recipients"),
+    bcc: z.array(z.string()).optional().describe("BCC recipients"),
+    account: z.string().optional().describe("Account to send from"),
+    attachments: z
+        .array(z.string())
+        .max(20, "Cannot attach more than 20 files")
+        .optional()
+        .describe("Absolute file paths to attach (e.g., ['/Users/me/report.pdf'])"),
+}, withErrorHandling(({ to, subject, body, cc, bcc, account, attachments }) => {
+    const success = mailManager.sendEmail(to, subject, body, cc, bcc, account, attachments);
+    if (!success) {
+        return errorResponse("Failed to send email. Check Mail.app configuration.");
+    }
+    const attachInfo = attachments?.length ? ` with ${attachments.length} attachment(s)` : "";
+    return successResponse(`Email sent to ${to.join(", ")}${attachInfo}`);
+}, "Error sending email"));
+// --- send-serial-email ---
+server.tool("send-serial-email", {
+    recipients: z
+        .array(z.object({
+        email: z.string().min(1, "Recipient email is required"),
+        variables: z
+            .record(z.string())
+            .describe("Placeholder values, e.g. { Name: 'Alice', Company: 'Acme' }"),
+    }))
+        .min(1, "At least one recipient is required")
+        .max(100, "Cannot send to more than 100 recipients in a single batch")
+        .describe("List of recipients with personalization variables (max 100)"),
+    subject: z
+        .string()
+        .min(1, "Subject is required")
+        .describe("Subject line — use {{Key}} for placeholders"),
+    body: z
+        .string()
+        .min(1, "Body is required")
+        .describe("Email body — use {{Key}} for placeholders"),
+    account: z.string().optional().describe("Account to send from"),
+    delayMs: z
+        .number()
+        .min(0)
+        .max(10000)
+        .optional()
+        .describe("Delay between sends in ms (default: 500, max: 10000)"),
+}, withErrorHandling(({ recipients, subject, body, account, delayMs }) => {
+    const results = mailManager.sendSerialEmail(recipients, subject, body, account, delayMs);
+    const successCount = results.filter((r) => r.success).length;
+    const failCount = results.length - successCount;
+    const details = results
+        .map((r) => `  - ${r.email}: ${r.success ? "sent" : `FAILED (${r.error})`}`)
+        .join("\n");
+    if (failCount === 0) {
+        return successResponse(`Successfully sent ${successCount} email(s):\n${details}`);
+    }
+    else if (successCount === 0) {
+        return errorResponse(`Failed to send all ${failCount} email(s):\n${details}`);
+    }
+    else {
+        return successResponse(`Sent ${successCount} of ${results.length} email(s), ${failCount} failed:\n${details}`);
+    }
+}, "Error sending serial emails"));
+// --- create-draft ---
+server.tool("create-draft", {
+    to: z.array(z.string()).min(1, "At least one recipient is required"),
+    subject: z.string().min(1, "Subject is required"),
+    body: z.string().min(1, "Body is required"),
+    cc: z.array(z.string()).optional().describe("CC recipients"),
+    bcc: z.array(z.string()).optional().describe("BCC recipients"),
+    account: z.string().optional().describe("Account to create draft in"),
+    attachments: z
+        .array(z.string())
+        .max(20, "Cannot attach more than 20 files")
+        .optional()
+        .describe("Absolute file paths to attach (e.g., ['/Users/me/report.pdf'])"),
+}, withErrorHandling(({ to, subject, body, cc, bcc, account, attachments }) => {
+    const success = mailManager.createDraft(to, subject, body, cc, bcc, account, attachments);
+    if (!success) {
+        return errorResponse("Failed to create draft. Check Mail.app configuration.");
+    }
+    const attachInfo = attachments?.length ? ` with ${attachments.length} attachment(s)` : "";
+    return successResponse(`Draft created for ${to.join(", ")}${attachInfo}`);
+}, "Error creating draft"));
+// --- reply-to-message ---
+server.tool("reply-to-message", {
+    id: MESSAGE_ID_SCHEMA,
+    body: z.string().min(1, "Reply body is required"),
+    replyAll: z.boolean().optional().default(false).describe("Reply to all recipients"),
+    send: z.boolean().optional().default(true).describe("Send immediately (false = save as draft)"),
+}, withErrorHandling(({ id, body, replyAll, send }) => {
+    const success = mailManager.replyToMessage(id, body, replyAll, send);
+    if (!success) {
+        return errorResponse(`Failed to reply to message "${id}"`);
+    }
+    return successResponse(send ? "Reply sent" : "Reply saved as draft");
+}, "Error replying to message"));
+// --- forward-message ---
+server.tool("forward-message", {
+    id: MESSAGE_ID_SCHEMA,
+    to: z.array(z.string()).min(1, "At least one recipient is required"),
+    body: z.string().optional().describe("Optional message to prepend"),
+    send: z.boolean().optional().default(true).describe("Send immediately (false = save as draft)"),
+}, withErrorHandling(({ id, to, body, send }) => {
+    const success = mailManager.forwardMessage(id, to, body, send);
+    if (!success) {
+        return errorResponse(`Failed to forward message "${id}"`);
+    }
+    return successResponse(send ? `Message forwarded to ${to.join(", ")}` : "Forward saved as draft");
+}, "Error forwarding message"));
+// --- mark-as-read ---
+server.tool("mark-as-read", {
+    id: MESSAGE_ID_SCHEMA,
+}, withErrorHandling(({ id }) => {
+    const success = mailManager.markAsRead(id);
+    if (!success) {
+        return errorResponse(`Failed to mark message "${id}" as read`);
+    }
+    return successResponse("Message marked as read");
+}, "Error marking message as read"));
+// --- mark-as-unread ---
+server.tool("mark-as-unread", {
+    id: MESSAGE_ID_SCHEMA,
+}, withErrorHandling(({ id }) => {
+    const success = mailManager.markAsUnread(id);
+    if (!success) {
+        return errorResponse(`Failed to mark message "${id}" as unread`);
+    }
+    return successResponse("Message marked as unread");
+}, "Error marking message as unread"));
+// --- flag-message ---
+server.tool("flag-message", {
+    id: MESSAGE_ID_SCHEMA,
+}, withErrorHandling(({ id }) => {
+    const success = mailManager.flagMessage(id);
+    if (!success) {
+        return errorResponse(`Failed to flag message "${id}"`);
+    }
+    return successResponse("Message flagged");
+}, "Error flagging message"));
+// --- unflag-message ---
+server.tool("unflag-message", {
+    id: MESSAGE_ID_SCHEMA,
+}, withErrorHandling(({ id }) => {
+    const success = mailManager.unflagMessage(id);
+    if (!success) {
+        return errorResponse(`Failed to unflag message "${id}"`);
+    }
+    return successResponse("Message unflagged");
+}, "Error unflagging message"));
+// --- delete-message ---
+server.tool("delete-message", {
+    id: MESSAGE_ID_SCHEMA,
+}, withErrorHandling(({ id }) => {
+    const success = mailManager.deleteMessage(id);
+    if (!success) {
+        return errorResponse(`Failed to delete message "${id}"`);
+    }
+    return successResponse("Message deleted");
+}, "Error deleting message"));
+// --- move-message ---
+server.tool("move-message", {
+    id: MESSAGE_ID_SCHEMA,
+    mailbox: z.string().min(1, "Destination mailbox is required"),
+    account: z.string().optional().describe("Account containing the destination mailbox"),
+}, withErrorHandling(({ id, mailbox, account }) => {
+    const success = mailManager.moveMessage(id, mailbox, account);
+    if (!success) {
+        return errorResponse(`Failed to move message to "${mailbox}"`);
+    }
+    return successResponse(`Message moved to "${mailbox}"`);
+}, "Error moving message"));
+// --- batch-delete-messages ---
+server.tool("batch-delete-messages", {
+    ids: BATCH_IDS_SCHEMA,
+}, withErrorHandling(({ ids }) => {
+    const results = mailManager.batchDeleteMessages(ids);
+    const successCount = results.filter((r) => r.success).length;
+    const failCount = results.length - successCount;
+    if (failCount === 0) {
+        return successResponse(`Successfully deleted ${successCount} message(s)`);
+    }
+    else if (successCount === 0) {
+        return errorResponse(`Failed to delete all ${failCount} message(s)`);
+    }
+    else {
+        return successResponse(`Deleted ${successCount} message(s), ${failCount} failed`);
+    }
+}, "Error batch deleting messages"));
+// --- batch-move-messages ---
+server.tool("batch-move-messages", {
+    ids: BATCH_IDS_SCHEMA,
+    mailbox: z.string().min(1, "Destination mailbox is required"),
+    account: z.string().optional().describe("Account containing the destination mailbox"),
+}, withErrorHandling(({ ids, mailbox, account }) => {
+    const results = mailManager.batchMoveMessages(ids, mailbox, account);
+    const successCount = results.filter((r) => r.success).length;
+    const failCount = results.length - successCount;
+    if (failCount === 0) {
+        return successResponse(`Successfully moved ${successCount} message(s) to "${mailbox}"`);
+    }
+    else if (successCount === 0) {
+        return errorResponse(`Failed to move all ${failCount} message(s)`);
+    }
+    else {
+        return successResponse(`Moved ${successCount} message(s) to "${mailbox}", ${failCount} failed`);
+    }
+}, "Error batch moving messages"));
+// --- batch-mark-as-read ---
+server.tool("batch-mark-as-read", {
+    ids: BATCH_IDS_SCHEMA,
+}, withErrorHandling(({ ids }) => {
+    const results = mailManager.batchMarkAsRead(ids);
+    const successCount = results.filter((r) => r.success).length;
+    const failCount = results.length - successCount;
+    if (failCount === 0) {
+        return successResponse(`Successfully marked ${successCount} message(s) as read`);
+    }
+    else if (successCount === 0) {
+        return errorResponse(`Failed to mark all ${failCount} message(s) as read`);
+    }
+    else {
+        return successResponse(`Marked ${successCount} message(s) as read, ${failCount} failed`);
+    }
+}, "Error batch marking messages as read"));
+// --- batch-mark-as-unread ---
+server.tool("batch-mark-as-unread", {
+    ids: BATCH_IDS_SCHEMA,
+}, withErrorHandling(({ ids }) => {
+    const results = mailManager.batchMarkAsUnread(ids);
+    const successCount = results.filter((r) => r.success).length;
+    const failCount = results.length - successCount;
+    if (failCount === 0) {
+        return successResponse(`Successfully marked ${successCount} message(s) as unread`);
+    }
+    else if (successCount === 0) {
+        return errorResponse(`Failed to mark all ${failCount} message(s) as unread`);
+    }
+    else {
+        return successResponse(`Marked ${successCount} message(s) as unread, ${failCount} failed`);
+    }
+}, "Error batch marking messages as unread"));
+// --- batch-flag-messages ---
+server.tool("batch-flag-messages", {
+    ids: BATCH_IDS_SCHEMA,
+}, withErrorHandling(({ ids }) => {
+    const results = mailManager.batchFlagMessages(ids);
+    const successCount = results.filter((r) => r.success).length;
+    const failCount = results.length - successCount;
+    if (failCount === 0) {
+        return successResponse(`Successfully flagged ${successCount} message(s)`);
+    }
+    else if (successCount === 0) {
+        return errorResponse(`Failed to flag all ${failCount} message(s)`);
+    }
+    else {
+        return successResponse(`Flagged ${successCount} message(s), ${failCount} failed`);
+    }
+}, "Error batch flagging messages"));
+// --- batch-unflag-messages ---
+server.tool("batch-unflag-messages", {
+    ids: BATCH_IDS_SCHEMA,
+}, withErrorHandling(({ ids }) => {
+    const results = mailManager.batchUnflagMessages(ids);
+    const successCount = results.filter((r) => r.success).length;
+    const failCount = results.length - successCount;
+    if (failCount === 0) {
+        return successResponse(`Successfully unflagged ${successCount} message(s)`);
+    }
+    else if (successCount === 0) {
+        return errorResponse(`Failed to unflag all ${failCount} message(s)`);
+    }
+    else {
+        return successResponse(`Unflagged ${successCount} message(s), ${failCount} failed`);
+    }
+}, "Error batch unflagging messages"));
+// --- list-attachments ---
+server.tool("list-attachments", {
+    id: MESSAGE_ID_SCHEMA,
+}, withErrorHandling(({ id }) => {
+    const attachments = mailManager.listAttachments(id);
+    if (attachments.length === 0) {
+        return successResponse("No attachments found");
+    }
+    const attachmentList = attachments
+        .map((a) => {
+        const sizeKb = Math.round(a.size / 1024);
+        return `  - ${a.name} (${a.mimeType}, ${sizeKb} KB)`;
+    })
+        .join("\n");
+    return successResponse(`Found ${attachments.length} attachment(s):\n${attachmentList}`);
+}, "Error listing attachments"));
+// --- save-attachment ---
+server.tool("save-attachment", {
+    id: MESSAGE_ID_SCHEMA,
+    attachmentName: z.string().min(1, "Attachment name is required"),
+    savePath: z.string().min(1, "Save directory path is required"),
+}, withErrorHandling(({ id, attachmentName, savePath }) => {
+    const success = mailManager.saveAttachment(id, attachmentName, savePath);
+    if (!success) {
+        return errorResponse(`Failed to save attachment "${attachmentName}"`);
+    }
+    return successResponse(`Attachment "${attachmentName}" saved to ${savePath}`);
+}, "Error saving attachment"));
+// =============================================================================
+// Mailbox Tools
+// =============================================================================
+// --- list-mailboxes ---
+server.tool("list-mailboxes", {
+    account: z.string().optional().describe("Account to list mailboxes from"),
+}, withErrorHandling(({ account }) => {
+    const mailboxes = mailManager.listMailboxes(account);
+    if (mailboxes.length === 0) {
+        return successResponse("No mailboxes found");
+    }
+    const mailboxList = mailboxes.map((m) => `  - ${m.name} (${m.unreadCount} unread)`).join("\n");
+    return successResponse(`Found ${mailboxes.length} mailbox(es):\n${mailboxList}`);
+}, "Error listing mailboxes"));
+// --- get-unread-count ---
+server.tool("get-unread-count", {
+    mailbox: z.string().optional().describe("Mailbox to check (default: all)"),
+    account: z.string().optional().describe("Account to check"),
+}, withErrorHandling(({ mailbox, account }) => {
+    const count = mailManager.getUnreadCount(mailbox, account);
+    const location = mailbox ? ` in "${mailbox}"` : "";
+    return successResponse(`${count} unread message(s)${location}`);
+}, "Error getting unread count"));
+// --- create-mailbox ---
+server.tool("create-mailbox", {
+    name: z.string().min(1, "Mailbox name is required"),
+    account: z.string().optional().describe("Account to create the mailbox in"),
+}, withErrorHandling(({ name, account }) => {
+    const success = mailManager.createMailbox(name, account);
+    if (!success) {
+        return errorResponse(`Failed to create mailbox "${name}"`);
+    }
+    return successResponse(`Mailbox "${name}" created`);
+}, "Error creating mailbox"));
+// --- delete-mailbox ---
+server.tool("delete-mailbox", {
+    name: z.string().min(1, "Mailbox name is required"),
+    account: z.string().optional().describe("Account containing the mailbox"),
+}, withErrorHandling(({ name, account }) => {
+    const success = mailManager.deleteMailbox(name, account);
+    if (!success) {
+        return errorResponse(`Failed to delete mailbox "${name}"`);
+    }
+    return successResponse(`Mailbox "${name}" deleted`);
+}, "Error deleting mailbox"));
+// --- rename-mailbox ---
+server.tool("rename-mailbox", {
+    oldName: z.string().min(1, "Current mailbox name is required"),
+    newName: z.string().min(1, "New mailbox name is required"),
+    account: z.string().optional().describe("Account containing the mailbox"),
+}, withErrorHandling(({ oldName, newName, account }) => {
+    const success = mailManager.renameMailbox(oldName, newName, account);
+    if (!success) {
+        return errorResponse(`Failed to rename mailbox "${oldName}" to "${newName}"`);
+    }
+    return successResponse(`Mailbox renamed from "${oldName}" to "${newName}"`);
+}, "Error renaming mailbox"));
+// =============================================================================
+// Account Tools
+// =============================================================================
+// --- list-accounts ---
+server.tool("list-accounts", {}, withErrorHandling(() => {
+    const accounts = mailManager.listAccounts();
+    if (accounts.length === 0) {
+        return successResponse("No Mail accounts found");
+    }
+    const accountList = accounts.map((a) => `  - ${a.name}`).join("\n");
+    return successResponse(`Found ${accounts.length} account(s):\n${accountList}`);
+}, "Error listing accounts"));
+// =============================================================================
+// Mail Rules Tools
+// =============================================================================
+// --- list-rules ---
+server.tool("list-rules", {}, withErrorHandling(() => {
+    const rules = mailManager.listRules();
+    if (rules.length === 0) {
+        return successResponse("No mail rules found");
+    }
+    const ruleList = rules
+        .map((r) => `  - ${r.name} [${r.enabled ? "enabled" : "disabled"}]`)
+        .join("\n");
+    return successResponse(`Found ${rules.length} rule(s):\n${ruleList}`);
+}, "Error listing rules"));
+// --- enable-rule ---
+server.tool("enable-rule", {
+    name: z.string().min(1, "Rule name is required"),
+}, withErrorHandling(({ name }) => {
+    const success = mailManager.setRuleEnabled(name, true);
+    if (!success) {
+        return errorResponse(`Failed to enable rule "${name}"`);
+    }
+    return successResponse(`Rule "${name}" enabled`);
+}, "Error enabling rule"));
+// --- disable-rule ---
+server.tool("disable-rule", {
+    name: z.string().min(1, "Rule name is required"),
+}, withErrorHandling(({ name }) => {
+    const success = mailManager.setRuleEnabled(name, false);
+    if (!success) {
+        return errorResponse(`Failed to disable rule "${name}"`);
+    }
+    return successResponse(`Rule "${name}" disabled`);
+}, "Error disabling rule"));
+// =============================================================================
+// Contacts Tools
+// =============================================================================
+// --- search-contacts ---
+server.tool("search-contacts", {
+    query: z.string().min(1, "Search query is required"),
+}, withErrorHandling(({ query }) => {
+    const contacts = mailManager.searchContacts(query);
+    if (contacts.length === 0) {
+        return successResponse("No contacts found");
+    }
+    const contactList = contacts
+        .map((c) => {
+        const emails = c.emails.length > 0 ? c.emails.join(", ") : "no email";
+        return `  - ${c.name} (${emails})`;
+    })
+        .join("\n");
+    return successResponse(`Found ${contacts.length} contact(s):\n${contactList}`);
+}, "Error searching contacts"));
+// =============================================================================
+// Email Template Tools
+// =============================================================================
+// --- save-template ---
+server.tool("save-template", {
+    name: z.string().min(1, "Template name is required"),
+    subject: z.string().min(1, "Subject is required"),
+    body: z.string().min(1, "Body is required"),
+    to: z.array(z.string()).optional().describe("Default recipients"),
+    cc: z.array(z.string()).optional().describe("Default CC recipients"),
+    id: z.string().optional().describe("Template ID (for updating existing template)"),
+}, withErrorHandling(({ name, subject, body, to, cc, id }) => {
+    const template = mailManager.saveTemplate(name, subject, body, to, cc, id);
+    return successResponse(`Template "${template.name}" saved with ID: ${template.id}`);
+}, "Error saving template"));
+// --- list-templates ---
+server.tool("list-templates", {}, withErrorHandling(() => {
+    const templates = mailManager.listTemplates();
+    if (templates.length === 0) {
+        return successResponse("No templates saved");
+    }
+    const templateList = templates
+        .map((t) => `  - [${t.id}] ${t.name} — "${t.subject}"`)
+        .join("\n");
+    return successResponse(`Found ${templates.length} template(s):\n${templateList}`);
+}, "Error listing templates"));
+// --- get-template ---
+server.tool("get-template", {
+    id: z.string().min(1, "Template ID is required"),
+}, withErrorHandling(({ id }) => {
+    const template = mailManager.getTemplate(id);
+    if (!template) {
+        return errorResponse(`Template "${id}" not found`);
+    }
+    const lines = [
+        `Name: ${template.name}`,
+        `Subject: ${template.subject}`,
+        template.to ? `To: ${template.to.join(", ")}` : null,
+        template.cc ? `CC: ${template.cc.join(", ")}` : null,
+        `\n${template.body}`,
+    ]
+        .filter(Boolean)
+        .join("\n");
+    return successResponse(lines);
+}, "Error getting template"));
+// --- delete-template ---
+server.tool("delete-template", {
+    id: z.string().min(1, "Template ID is required"),
+}, withErrorHandling(({ id }) => {
+    const success = mailManager.deleteTemplate(id);
+    if (!success) {
+        return errorResponse(`Template "${id}" not found`);
+    }
+    return successResponse(`Template "${id}" deleted`);
+}, "Error deleting template"));
+// --- use-template ---
+server.tool("use-template", {
+    id: z.string().min(1, "Template ID is required"),
+    to: z.array(z.string()).optional().describe("Override recipients"),
+    cc: z.array(z.string()).optional().describe("Override CC recipients"),
+    subject: z.string().optional().describe("Override subject"),
+    body: z.string().optional().describe("Override body"),
+}, withErrorHandling(({ id, to, cc, subject, body }) => {
+    const success = mailManager.useTemplate(id, { to, cc, subject, body });
+    if (!success) {
+        return errorResponse(`Failed to use template "${id}". Template not found or no recipients.`);
+    }
+    return successResponse(`Draft created from template "${id}"`);
+}, "Error using template"));
+// =============================================================================
+// Diagnostics Tools
+// =============================================================================
+// --- health-check ---
+server.tool("health-check", {}, withErrorHandling(() => {
+    const result = mailManager.healthCheck();
+    const statusIcon = result.healthy ? "✓" : "✗";
+    const statusText = result.healthy ? "All checks passed" : "Issues detected";
+    const checkLines = result.checks
+        .map((c) => {
+        const icon = c.passed ? "✓" : "✗";
+        return `  ${icon} ${c.name}: ${c.message}`;
+    })
+        .join("\n");
+    return successResponse(`${statusIcon} ${statusText}\n\n${checkLines}`);
+}, "Error running health check"));
+// --- get-mail-stats ---
+server.tool("get-mail-stats", {}, withErrorHandling(() => {
+    const stats = mailManager.getMailStats();
+    const lines = [];
+    lines.push(`📊 Mail Statistics`);
+    lines.push(`══════════════════`);
+    lines.push(`Total messages: ${stats.totalMessages}`);
+    lines.push(`Unread messages: ${stats.totalUnread}`);
+    lines.push(``);
+    if (stats.recentlyReceived) {
+        lines.push(`📥 Recently Received:`);
+        lines.push(`  Last 24 hours: ${stats.recentlyReceived.last24h}`);
+        lines.push(`  Last 7 days: ${stats.recentlyReceived.last7d}`);
+        lines.push(`  Last 30 days: ${stats.recentlyReceived.last30d}`);
+        lines.push(``);
+    }
+    if (stats.accounts.length > 0) {
+        lines.push(`📁 By Account:`);
+        for (const account of stats.accounts) {
+            lines.push(`  ${account.name}: ${account.totalMessages} messages (${account.unreadMessages} unread)`);
+        }
+    }
+    return successResponse(lines.join("\n"));
+}, "Error getting mail statistics"));
+// --- get-sync-status ---
+server.tool("get-sync-status", {}, withErrorHandling(() => {
+    const status = mailManager.getSyncStatus();
+    const lines = [];
+    lines.push(`🔄 Mail Sync Status`);
+    lines.push(`═══════════════════`);
+    if (status.error) {
+        lines.push(`Status: ⚠️ ${status.error}`);
+    }
+    else {
+        lines.push(`Mail.app: ${status.recentActivity ? "Running" : "Not running"}`);
+        lines.push(`Sync active: ${status.syncDetected ? "Yes" : "No"}`);
+    }
+    return successResponse(lines.join("\n"));
+}, "Error getting sync status"));
+// =============================================================================
+// Server Startup
+// =============================================================================
+/**
+ * Initialize and start the MCP server.
+ */
+const transport = new StdioServerTransport();
+await server.connect(transport);

+ 366 - 0
build/services/appleMailManager.d.ts

@@ -0,0 +1,366 @@
+/**
+ * Apple Mail Manager
+ *
+ * Handles all interactions with Apple Mail via AppleScript.
+ * This is the core service layer for the MCP server.
+ *
+ * Architecture:
+ * - Text escaping is handled by dedicated helper functions
+ * - AppleScript generation uses template builders for consistency
+ * - All public methods return typed results (no raw strings)
+ * - Error handling is consistent across all operations
+ *
+ * @module services/appleMailManager
+ */
+import type { Message, MessageContent, Mailbox, Account, Attachment, HealthCheckResult, MailStats, BatchOperationResult, SyncStatus, RecentlyReceivedStats, MailRule, Contact, EmailTemplate, SerialEmailRecipient, SerialEmailResult } from "../types.js";
+/**
+ * Manager class for Apple Mail operations.
+ *
+ * Provides methods for:
+ * - Reading and searching messages
+ * - Sending emails
+ * - Managing mailboxes
+ * - Listing accounts
+ *
+ * All operations are synchronous since they rely on AppleScript
+ * execution via osascript. Error handling is consistent: methods
+ * return null/false/empty-array on failure rather than throwing.
+ */
+export declare class AppleMailManager {
+    /**
+     * Default account used when no account is specified.
+     */
+    private defaultAccount;
+    /**
+     * TTL cache for expensive AppleScript queries that rarely change.
+     * Caches account list and per-account mailbox names to avoid
+     * redundant AppleScript roundtrips on every tool call.
+     */
+    private cache;
+    /** Cache TTL in milliseconds (60 seconds). */
+    private readonly CACHE_TTL_MS;
+    /**
+     * Returns cached accounts or fetches fresh data if cache is expired/empty.
+     */
+    private getCachedAccounts;
+    /**
+     * Returns cached mailbox names for an account, or fetches fresh.
+     * This caches only the name list used by resolveMailbox(), not the
+     * full Mailbox objects with counts (which change frequently).
+     */
+    private getCachedMailboxNames;
+    /**
+     * Invalidate all caches. Call after operations that change
+     * mailbox structure (create/delete/rename mailbox).
+     */
+    private invalidateCache;
+    /**
+     * Resolves the account to use for an operation.
+     * Queries Mail.app's configured default send account, then falls back
+     * to the first available account.
+     */
+    private resolveAccount;
+    /**
+     * Resolves a mailbox name to its actual name in the account.
+     *
+     * Different account types (IMAP, Exchange, iCloud) use different
+     * mailbox naming conventions:
+     * - IMAP/Gmail: "INBOX", "Sent", "Drafts"
+     * - Exchange: "Inbox", "Sent Items", "Deleted Items"
+     * - iCloud: "INBOX", "Sent", "Trash"
+     *
+     * This method tries to find a matching mailbox by:
+     * 1. Exact match
+     * 2. Case-insensitive match
+     * 3. Known aliases (e.g., "Sent" -> "Sent Items")
+     *
+     * @param mailbox - Requested mailbox name
+     * @param account - Account to search in
+     * @returns Actual mailbox name, or original if not found
+     */
+    private resolveMailbox;
+    /**
+     * Search for messages matching criteria.
+     *
+     * @param query - Text to search for in subject or sender
+     * @param mailbox - Mailbox to search in (e.g., "INBOX")
+     * @param account - Account to search in
+     * @param limit - Maximum number of results
+     * @returns Array of matching messages
+     */
+    searchMessages(query?: string, mailbox?: string, account?: string, limit?: number, dateFrom?: string, dateTo?: string): Message[];
+    /**
+     * Get a message by ID.
+     *
+     * Note: Mail.app message IDs are unique per mailbox. This method searches
+     * all mailboxes in all accounts to find the message.
+     */
+    getMessageById(id: string): Message | null;
+    /**
+     * Get the content of a message.
+     */
+    getMessageContent(id: string): MessageContent | null;
+    /**
+     * Get the raw MIME source of a message.
+     * Used as fallback for attachment extraction when AppleScript
+     * mail attachments returns empty.
+     *
+     * Timeout is 2x the default (120s) because `source of msg` returns
+     * the entire raw message including base64-encoded attachments —
+     * a 20MB attachment can take several seconds over Exchange/IMAP.
+     */
+    getRawSource(id: string): string | null;
+    /**
+     * List messages in a mailbox.
+     *
+     * @param mailbox - Mailbox to list from (default: INBOX)
+     * @param account - Account to list from
+     * @param limit - Maximum number of messages
+     * @returns Array of messages
+     */
+    listMessages(mailbox?: string, account?: string, limit?: number, from?: string, offset?: number): Message[];
+    /**
+     * Parse message list output from AppleScript.
+     *
+     * Two emission schemas, disambiguated by length:
+     *   7 fields: single-mailbox — ...|hasAtt (mailbox from caller)
+     *   8 fields: all-mailboxes — ...|mailbox|hasAtt
+     *
+     * `hasAttachments` here is the fast-path AppleScript count only; it will
+     * false-negative for MIME-embedded attachments (a known AppleScript
+     * limitation). Use getMessage or list-attachments for authoritative info.
+     */
+    private parseMessageList;
+    /**
+     * Send an email.
+     *
+     * @param to - Recipient email addresses
+     * @param subject - Email subject
+     * @param body - Email body (plain text)
+     * @param cc - CC recipients
+     * @param bcc - BCC recipients
+     * @param account - Account to send from
+     * @returns true if sent successfully
+     */
+    sendEmail(to: string[], subject: string, body: string, cc?: string[], bcc?: string[], account?: string, attachments?: string[]): boolean;
+    /**
+     * Send individual personalized emails to a list of recipients (mail merge).
+     *
+     * Replaces {{placeholder}} tokens in subject and body with per-recipient values.
+     * Each recipient receives their own individual email.
+     *
+     * @param recipients - List of recipient objects with email and variable values
+     * @param subject - Email subject (may contain {{placeholders}})
+     * @param body - Email body (may contain {{placeholders}})
+     * @param account - Account to send from
+     * @param delayMs - Delay between sends in milliseconds (default: 500, max: 10000)
+     * @returns Array of per-recipient results
+     */
+    sendSerialEmail(recipients: SerialEmailRecipient[], subject: string, body: string, account?: string, delayMs?: number): SerialEmailResult[];
+    /**
+     * Create a draft email (saved to Drafts folder, not sent).
+     *
+     * @param to - Recipient email addresses
+     * @param subject - Email subject
+     * @param body - Email body (plain text)
+     * @param cc - CC recipients
+     * @param bcc - BCC recipients
+     * @param account - Account to create draft in
+     * @returns true if draft created successfully
+     */
+    createDraft(to: string[], subject: string, body: string, cc?: string[], bcc?: string[], account?: string, attachments?: string[]): boolean;
+    /**
+     * Reply to a message.
+     *
+     * @param id - Message ID to reply to
+     * @param body - Reply body
+     * @param replyAll - If true, reply to all recipients
+     * @param send - If true, send immediately; if false, save as draft
+     * @returns true if reply created/sent successfully
+     */
+    replyToMessage(id: string, body: string, replyAll?: boolean, send?: boolean): boolean;
+    /**
+     * Forward a message.
+     *
+     * @param id - Message ID to forward
+     * @param to - Recipients to forward to
+     * @param body - Optional body to prepend
+     * @param send - If true, send immediately; if false, save as draft
+     * @returns true if forward created/sent successfully
+     */
+    forwardMessage(id: string, to: string[], body?: string, send?: boolean): boolean;
+    /**
+     * Helper to find and operate on a message by ID.
+     */
+    private findMessageScript;
+    /**
+     * Mark a message as read.
+     */
+    markAsRead(id: string): boolean;
+    /**
+     * Mark a message as unread.
+     */
+    markAsUnread(id: string): boolean;
+    /**
+     * Flag a message.
+     */
+    flagMessage(id: string): boolean;
+    /**
+     * Unflag a message.
+     */
+    unflagMessage(id: string): boolean;
+    /**
+     * Delete a message.
+     */
+    deleteMessage(id: string): boolean;
+    /**
+     * Move a message to a different mailbox.
+     */
+    moveMessage(id: string, mailbox: string, account?: string): boolean;
+    /**
+     * Delete multiple messages at once.
+     *
+     * @param ids - Array of message IDs to delete
+     * @returns Array of results for each message
+     */
+    batchDeleteMessages(ids: string[]): BatchOperationResult[];
+    /**
+     * Move multiple messages to a mailbox at once.
+     *
+     * @param ids - Array of message IDs to move
+     * @param mailbox - Destination mailbox name
+     * @param account - Account containing the destination mailbox
+     * @returns Array of results for each message
+     */
+    batchMoveMessages(ids: string[], mailbox: string, account?: string): BatchOperationResult[];
+    /**
+     * Mark multiple messages as read at once.
+     */
+    batchMarkAsRead(ids: string[]): BatchOperationResult[];
+    /**
+     * Mark multiple messages as unread at once.
+     */
+    batchMarkAsUnread(ids: string[]): BatchOperationResult[];
+    /**
+     * Flag multiple messages at once.
+     */
+    batchFlagMessages(ids: string[]): BatchOperationResult[];
+    /**
+     * Unflag multiple messages at once.
+     */
+    batchUnflagMessages(ids: string[]): BatchOperationResult[];
+    /**
+     * List attachments for a message.
+     * Tries AppleScript first, falls back to MIME source parsing
+     * when AppleScript returns empty (known issue across all account types).
+     */
+    listAttachments(id: string): Attachment[];
+    /**
+     * Save an attachment from a message to disk.
+     * Tries AppleScript first, falls back to MIME source extraction
+     * when AppleScript can't find the attachment.
+     */
+    saveAttachment(id: string, attachmentName: string, savePath: string): boolean;
+    /**
+     * List all mailboxes for an account.
+     */
+    listMailboxes(account?: string): Mailbox[];
+    /**
+     * Get unread count for a mailbox.
+     */
+    getUnreadCount(mailbox?: string, account?: string): number;
+    /**
+     * Create a new mailbox.
+     */
+    createMailbox(name: string, account?: string): boolean;
+    /**
+     * Delete a mailbox.
+     */
+    deleteMailbox(name: string, account?: string): boolean;
+    /**
+     * Rename a mailbox by creating a new one, moving messages, and deleting the old one.
+     */
+    renameMailbox(oldName: string, newName: string, account?: string): boolean;
+    /**
+     * List all mail accounts (uses cache).
+     */
+    listAccounts(): Account[];
+    /**
+     * Fetches account list directly from Mail.app via AppleScript.
+     * Used internally by the cache; prefer getCachedAccounts() or listAccounts().
+     */
+    private fetchAccounts;
+    /**
+     * Fetches mailbox names for an account directly from Mail.app.
+     * Used internally by the cache; prefer getCachedMailboxNames().
+     */
+    private fetchMailboxNames;
+    /**
+     * List all mail rules.
+     */
+    listRules(): MailRule[];
+    /**
+     * Enable or disable a mail rule.
+     */
+    setRuleEnabled(ruleName: string, enabled: boolean): boolean;
+    /**
+     * Search contacts by name or email.
+     */
+    searchContacts(query: string): Contact[];
+    private templates;
+    private nextTemplateId;
+    /**
+     * List all stored templates.
+     */
+    listTemplates(): EmailTemplate[];
+    /**
+     * Get a template by ID.
+     */
+    getTemplate(id: string): EmailTemplate | null;
+    /**
+     * Create or update a template.
+     */
+    saveTemplate(name: string, subject: string, body: string, to?: string[], cc?: string[], id?: string): EmailTemplate;
+    /**
+     * Delete a template.
+     */
+    deleteTemplate(id: string): boolean;
+    /**
+     * Use a template to create a draft.
+     */
+    useTemplate(id: string, overrides?: {
+        to?: string[];
+        cc?: string[];
+        subject?: string;
+        body?: string;
+    }): boolean;
+    /**
+     * Run health check on Mail.app connectivity.
+     */
+    healthCheck(): HealthCheckResult;
+    /**
+     * Get mail statistics.
+     */
+    getMailStats(): MailStats;
+    /**
+     * Get counts of recently received messages.
+     *
+     * Only counts messages in INBOX for performance (scanning all mailboxes
+     * is too slow for large accounts).
+     *
+     * @returns Counts of messages received in last 24h, 7d, and 30d
+     */
+    getRecentlyReceivedStats(): RecentlyReceivedStats;
+    /**
+     * Get sync status for Mail.app.
+     *
+     * Checks for sync activity indicators like:
+     * - Activity monitor status
+     * - Network activity status
+     * - Background refresh indicators
+     *
+     * @returns Sync status information
+     */
+    getSyncStatus(): SyncStatus;
+}
+//# sourceMappingURL=appleMailManager.d.ts.map

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
build/services/appleMailManager.d.ts.map


+ 1970 - 0
build/services/appleMailManager.js

@@ -0,0 +1,1970 @@
+/**
+ * Apple Mail Manager
+ *
+ * Handles all interactions with Apple Mail via AppleScript.
+ * This is the core service layer for the MCP server.
+ *
+ * Architecture:
+ * - Text escaping is handled by dedicated helper functions
+ * - AppleScript generation uses template builders for consistency
+ * - All public methods return typed results (no raw strings)
+ * - Error handling is consistent across all operations
+ *
+ * @module services/appleMailManager
+ */
+import { spawnSync } from "child_process";
+import { existsSync, writeFileSync } from "fs";
+import { isAbsolute, resolve } from "path";
+import { homedir } from "os";
+import { executeAppleScript } from "../utils/applescript.js";
+import { parseMimeAttachments, extractMimeAttachment } from "../utils/mimeParse.js";
+// =============================================================================
+// Text Processing Utilities
+// =============================================================================
+/**
+ * Escapes text for safe embedding in AppleScript string literals.
+ *
+ * AppleScript strings use double quotes, so we need to escape:
+ * 1. Backslashes (\) - escaped as \\
+ * 2. Double quotes (") - escaped as \"
+ *
+ * @param text - Raw text to escape
+ * @returns Text safe for AppleScript string embedding
+ */
+function escapeForAppleScript(text) {
+    if (!text)
+        return "";
+    return text.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
+}
+/**
+ * Validates attachment file paths and builds AppleScript commands to attach them.
+ *
+ * @param attachments - Absolute file paths to attach
+ * @returns AppleScript commands to add attachments, or empty string if none
+ * @throws Error if any path is not absolute or does not exist
+ */
+function buildAttachmentCommands(attachments) {
+    if (!attachments || attachments.length === 0)
+        return "";
+    for (const filePath of attachments) {
+        if (!isAbsolute(filePath)) {
+            throw new Error(`Attachment path must be absolute: "${filePath}"`);
+        }
+        if (!existsSync(filePath)) {
+            throw new Error(`Attachment file not found: "${filePath}"`);
+        }
+    }
+    let commands = "";
+    for (const filePath of attachments) {
+        const safePath = escapeForAppleScript(filePath);
+        commands += `make new attachment with properties {file name:POSIX file "${safePath}"} at after the last paragraph\n`;
+    }
+    return commands;
+}
+/**
+ * AppleScript snippet that converts a date variable `d` into a
+ * locale-independent numeric string: "YYYY-M-D-H-m-s".
+ * Use: set d to date received of msg, then inline this snippet.
+ */
+const AS_DATE_TO_STRING = `((year of d) as string) & "-" & ((month of d as integer) as string) & "-" & ((day of d) as string) & "-" & ((hours of d) as string) & "-" & ((minutes of d) as string) & "-" & ((seconds of d) as string)`;
+/**
+ * Parses a locale-independent date string "YYYY-M-D-H-m-s"
+ * produced by the AppleScript snippet above.
+ *
+ * Falls back to the locale-dependent `as string` format for
+ * backwards compatibility, and finally to current date.
+ *
+ * @param dateStr - Date string from AppleScript
+ * @returns Parsed Date, or current date if parsing fails
+ */
+function parseAppleScriptDate(dateStr) {
+    // Try locale-independent numeric format first: "YYYY-M-D-H-m-s"
+    const numParts = dateStr.split("-").map(Number);
+    if (numParts.length === 6 && numParts.every((n) => !isNaN(n))) {
+        return new Date(numParts[0], numParts[1] - 1, numParts[2], numParts[3], numParts[4], numParts[5]);
+    }
+    // Fallback: try legacy locale-dependent format
+    const withoutPrefix = dateStr.replace(/^date\s+/, "");
+    const normalized = withoutPrefix.replace(" at ", " ");
+    const parsed = new Date(normalized);
+    return isNaN(parsed.getTime()) ? new Date() : parsed;
+}
+/**
+ * Builds an AppleScript command scoped to a specific account.
+ */
+function buildAccountScopedScript(account, command) {
+    return `
+    tell application "Mail"
+      tell account "${escapeForAppleScript(account)}"
+        ${command}
+      end tell
+    end tell
+  `;
+}
+/**
+ * Builds an AppleScript command at the application level.
+ */
+function buildAppLevelScript(command) {
+    return `
+    tell application "Mail"
+      ${command}
+    end tell
+  `;
+}
+/**
+ * Common mailbox name variations across different account types.
+ * Maps normalized (lowercase) names to possible actual names.
+ */
+const MAILBOX_ALIASES = {
+    inbox: ["INBOX", "Inbox", "inbox"],
+    sent: ["Sent", "Sent Items", "Sent Messages", "SENT", "sent"],
+    drafts: ["Drafts", "DRAFTS", "drafts", "Draft"],
+    trash: ["Trash", "Deleted Items", "Deleted Messages", "TRASH", "trash"],
+    junk: ["Junk", "Junk Email", "Spam", "JUNK", "junk"],
+    archive: ["Archive", "ARCHIVE", "archive", "All Mail"],
+};
+// =============================================================================
+// Apple Mail Manager Class
+// =============================================================================
+/**
+ * Manager class for Apple Mail operations.
+ *
+ * Provides methods for:
+ * - Reading and searching messages
+ * - Sending emails
+ * - Managing mailboxes
+ * - Listing accounts
+ *
+ * All operations are synchronous since they rely on AppleScript
+ * execution via osascript. Error handling is consistent: methods
+ * return null/false/empty-array on failure rather than throwing.
+ */
+export class AppleMailManager {
+    /**
+     * Default account used when no account is specified.
+     */
+    defaultAccount = null;
+    /**
+     * TTL cache for expensive AppleScript queries that rarely change.
+     * Caches account list and per-account mailbox names to avoid
+     * redundant AppleScript roundtrips on every tool call.
+     */
+    cache = {
+        accounts: null,
+        mailboxNames: new Map(),
+    };
+    /** Cache TTL in milliseconds (60 seconds). */
+    CACHE_TTL_MS = 60_000;
+    /**
+     * Returns cached accounts or fetches fresh data if cache is expired/empty.
+     */
+    getCachedAccounts() {
+        const now = Date.now();
+        if (this.cache.accounts && now < this.cache.accounts.expiry) {
+            return this.cache.accounts.data;
+        }
+        const accounts = this.fetchAccounts();
+        this.cache.accounts = { data: accounts, expiry: now + this.CACHE_TTL_MS };
+        return accounts;
+    }
+    /**
+     * Returns cached mailbox names for an account, or fetches fresh.
+     * This caches only the name list used by resolveMailbox(), not the
+     * full Mailbox objects with counts (which change frequently).
+     */
+    getCachedMailboxNames(account) {
+        const now = Date.now();
+        const cached = this.cache.mailboxNames.get(account);
+        if (cached && now < cached.expiry) {
+            return cached.data;
+        }
+        const names = this.fetchMailboxNames(account);
+        this.cache.mailboxNames.set(account, { data: names, expiry: now + this.CACHE_TTL_MS });
+        return names;
+    }
+    /**
+     * Invalidate all caches. Call after operations that change
+     * mailbox structure (create/delete/rename mailbox).
+     */
+    invalidateCache() {
+        this.cache.accounts = null;
+        this.cache.mailboxNames.clear();
+    }
+    /**
+     * Resolves the account to use for an operation.
+     * Queries Mail.app's configured default send account, then falls back
+     * to the first available account.
+     */
+    resolveAccount(account) {
+        if (account)
+            return account;
+        if (this.defaultAccount)
+            return this.defaultAccount;
+        // Query Mail.app's default send account by inspecting a temporary outgoing message
+        const defaultResult = executeAppleScript(buildAppLevelScript(`
+        set newMsg to make new outgoing message
+        set fromAddr to sender of newMsg
+        delete newMsg
+        return fromAddr
+      `));
+        if (defaultResult.success && defaultResult.output.trim()) {
+            // sender returns "Name <email>" — match to account by email address
+            const senderOutput = defaultResult.output.trim();
+            const emailMatch = senderOutput.match(/<([^>]+)>/);
+            const defaultEmail = emailMatch ? emailMatch[1] : senderOutput;
+            const accounts = this.getCachedAccounts();
+            const matchedAccount = accounts.find((a) => a.email.toLowerCase() === defaultEmail.toLowerCase());
+            if (matchedAccount) {
+                this.defaultAccount = matchedAccount.name;
+                return this.defaultAccount;
+            }
+        }
+        // Fall back to first available account
+        const accounts = this.getCachedAccounts();
+        if (accounts.length > 0) {
+            this.defaultAccount = accounts[0].name;
+            return this.defaultAccount;
+        }
+        return "iCloud"; // Last resort fallback
+    }
+    /**
+     * Resolves a mailbox name to its actual name in the account.
+     *
+     * Different account types (IMAP, Exchange, iCloud) use different
+     * mailbox naming conventions:
+     * - IMAP/Gmail: "INBOX", "Sent", "Drafts"
+     * - Exchange: "Inbox", "Sent Items", "Deleted Items"
+     * - iCloud: "INBOX", "Sent", "Trash"
+     *
+     * This method tries to find a matching mailbox by:
+     * 1. Exact match
+     * 2. Case-insensitive match
+     * 3. Known aliases (e.g., "Sent" -> "Sent Items")
+     *
+     * @param mailbox - Requested mailbox name
+     * @param account - Account to search in
+     * @returns Actual mailbox name, or original if not found
+     */
+    resolveMailbox(mailbox, account) {
+        const actualMailboxes = this.getCachedMailboxNames(account);
+        if (actualMailboxes.length === 0) {
+            return mailbox; // Fall back to original
+        }
+        // 1. Try exact match
+        if (actualMailboxes.includes(mailbox)) {
+            return mailbox;
+        }
+        // 2. Try case-insensitive match
+        const lowerMailbox = mailbox.toLowerCase();
+        const caseMatch = actualMailboxes.find((mb) => mb.toLowerCase() === lowerMailbox);
+        if (caseMatch) {
+            return caseMatch;
+        }
+        // 3. Try known aliases
+        const aliases = MAILBOX_ALIASES[lowerMailbox];
+        if (aliases) {
+            for (const alias of aliases) {
+                if (actualMailboxes.includes(alias)) {
+                    return alias;
+                }
+                // Also try case-insensitive alias match
+                const aliasMatch = actualMailboxes.find((mb) => mb.toLowerCase() === alias.toLowerCase());
+                if (aliasMatch) {
+                    return aliasMatch;
+                }
+            }
+        }
+        // No match found, return original and let AppleScript handle the error
+        return mailbox;
+    }
+    // ===========================================================================
+    // Message Operations
+    // ===========================================================================
+    /**
+     * Search for messages matching criteria.
+     *
+     * @param query - Text to search for in subject or sender
+     * @param mailbox - Mailbox to search in (e.g., "INBOX")
+     * @param account - Account to search in
+     * @param limit - Maximum number of results
+     * @returns Array of matching messages
+     */
+    searchMessages(query, mailbox, account, limit = 50, dateFrom, dateTo) {
+        // If no account specified, search across all accounts
+        if (!account) {
+            const accounts = this.listAccounts();
+            const allMessages = [];
+            for (const acct of accounts) {
+                if (allMessages.length >= limit)
+                    break;
+                const remaining = limit - allMessages.length;
+                const msgs = this.searchMessages(query, mailbox, acct.name, remaining, dateFrom, dateTo);
+                allMessages.push(...msgs);
+            }
+            return allMessages.slice(0, limit);
+        }
+        const targetAccount = this.resolveAccount(account);
+        // Build the search condition
+        let searchCondition = "";
+        if (query) {
+            const safeQuery = escapeForAppleScript(query);
+            searchCondition = `whose subject contains "${safeQuery}" or sender contains "${safeQuery}"`;
+        }
+        // Build date filter AppleScript.
+        // Note: dateFrom/dateTo are already validated by DATE_FILTER_SCHEMA (alphanumeric + safe
+        // punctuation only), so escapeForAppleScript() below is belt-and-suspenders — it won't
+        // alter valid date strings but guards against future schema changes.
+        let dateFilter = "";
+        if (dateFrom || dateTo) {
+            const dateChecks = [];
+            if (dateFrom) {
+                dateChecks.push(`date received of msg >= date "${escapeForAppleScript(dateFrom)}"`);
+            }
+            if (dateTo) {
+                dateChecks.push(`date received of msg <= date "${escapeForAppleScript(dateTo)}"`);
+            }
+            dateFilter = dateChecks.join(" and ");
+        }
+        let searchCommand;
+        if (mailbox) {
+            // Search a specific mailbox
+            const targetMailbox = this.resolveMailbox(mailbox, targetAccount);
+            searchCommand = `
+      set outputText to ""
+      set theMailbox to mailbox "${escapeForAppleScript(targetMailbox)}"
+      set allMessages to messages of theMailbox ${searchCondition}
+      set msgCount to 0
+      repeat with msg in allMessages
+        if msgCount >= ${limit} then exit repeat
+        try
+          ${dateFilter ? `set msgDate to date received of msg\n          if not (${dateFilter}) then\n            -- skip message outside date range\n          else` : ""}
+          set msgId to id of msg as string
+          set msgSubject to subject of msg
+          set msgSender to sender of msg
+          set d to date received of msg
+          set msgDateStr to ${AS_DATE_TO_STRING}
+          set msgRead to read status of msg as string
+          set msgFlagged to flagged status of msg as string
+          if msgCount > 0 then set outputText to outputText & "|||ITEM|||"
+          set outputText to outputText & msgId & "|||" & msgSubject & "|||" & msgSender & "|||" & msgDateStr & "|||" & msgRead & "|||" & msgFlagged
+          set msgCount to msgCount + 1
+          ${dateFilter ? "end if" : ""}
+        end try
+      end repeat
+      return outputText
+    `;
+        }
+        else {
+            // Search ALL mailboxes — iterate every mailbox in the account, dedup by message ID
+            searchCommand = `
+      set outputText to ""
+      set msgCount to 0
+      set seenIds to {}
+      repeat with mb in mailboxes
+        if msgCount >= ${limit} then exit repeat
+        try
+          set allMessages to messages of mb ${searchCondition}
+          repeat with msg in allMessages
+            if msgCount >= ${limit} then exit repeat
+            try
+              set msgId to id of msg as string
+              if seenIds does not contain msgId then
+                set end of seenIds to msgId
+                ${dateFilter ? `set msgDate to date received of msg\n                if not (${dateFilter}) then\n                  -- skip message outside date range\n                else` : ""}
+                set msgSubject to subject of msg
+                set msgSender to sender of msg
+                set d to date received of msg
+                set msgDateStr to ${AS_DATE_TO_STRING}
+                set msgRead to read status of msg as string
+                set msgFlagged to flagged status of msg as string
+                if msgCount > 0 then set outputText to outputText & "|||ITEM|||"
+                set outputText to outputText & msgId & "|||" & msgSubject & "|||" & msgSender & "|||" & msgDateStr & "|||" & msgRead & "|||" & msgFlagged & "|||" & name of mb
+                set msgCount to msgCount + 1
+                ${dateFilter ? "end if" : ""}
+              end if
+            end try
+          end repeat
+        end try
+      end repeat
+      return outputText
+    `;
+        }
+        const script = buildAccountScopedScript(targetAccount, searchCommand);
+        const result = executeAppleScript(script, { timeoutMs: 60000 });
+        if (!result.success) {
+            console.error(`Failed to search messages: ${result.error}`);
+            return [];
+        }
+        if (!result.output.trim())
+            return [];
+        return this.parseMessageList(result.output, mailbox || "INBOX", targetAccount);
+    }
+    /**
+     * Get a message by ID.
+     *
+     * Note: Mail.app message IDs are unique per mailbox. This method searches
+     * all mailboxes in all accounts to find the message.
+     */
+    getMessageById(id) {
+        const script = buildAppLevelScript(`
+      try
+        repeat with acct in accounts
+          repeat with mb in mailboxes of acct
+            try
+              set matchingMsgs to (messages of mb whose id is ${Number(id)})
+              if (count of matchingMsgs) > 0 then
+                set msg to item 1 of matchingMsgs
+                set msgSubject to subject of msg
+                set msgSender to sender of msg
+                set d to date received of msg
+                set msgDate to ${AS_DATE_TO_STRING}
+                set msgRead to read status of msg as string
+                set msgFlagged to flagged status of msg as string
+                set msgJunk to junk mail status of msg as string
+                set msgDeleted to deleted status of msg as string
+                set msgMailbox to name of mb
+                set msgAccount to name of acct
+                set hasAtt to "false"
+                try
+                  set attCount to count of mail attachments of msg
+                  if attCount > 0 then set hasAtt to "true"
+                end try
+                -- MIME-embedded attachments are invisible to AppleScript's
+                -- attachment object. Fall back to scanning the raw source.
+                -- This reads the full message source (can be MB-sized for
+                -- messages with large bodies), so it's the slowest part of
+                -- get-message for attachmentless messages. Accepted as the
+                -- cost of correct hasAttachments in the detail view.
+                if hasAtt is "false" then
+                  try
+                    set rawSrc to source of msg
+                    if rawSrc contains "Content-Disposition: attachment" then set hasAtt to "true"
+                  end try
+                end if
+                return msgSubject & "|||" & msgSender & "|||" & msgDate & "|||" & msgRead & "|||" & msgFlagged & "|||" & msgJunk & "|||" & msgDeleted & "|||" & msgMailbox & "|||" & msgAccount & "|||" & hasAtt
+              end if
+            end try
+          end repeat
+        end repeat
+        return ""
+      on error errMsg
+        return ""
+      end try
+    `);
+        const result = executeAppleScript(script, { timeoutMs: 60000 }); // Longer timeout for search
+        if (!result.success || !result.output.trim()) {
+            console.error(`Failed to get message ${id}: ${result.error}`);
+            return null;
+        }
+        const parts = result.output.split("|||");
+        if (parts.length < 9)
+            return null;
+        return {
+            id: id.toString(),
+            subject: parts[0],
+            sender: parts[1],
+            recipients: [],
+            dateReceived: parseAppleScriptDate(parts[2]),
+            isRead: parts[3] === "true",
+            isFlagged: parts[4] === "true",
+            isJunk: parts[5] === "true",
+            isDeleted: parts[6] === "true",
+            mailbox: parts[7],
+            account: parts[8],
+            hasAttachments: parts.length > 9 ? parts[9] === "true" : false,
+        };
+    }
+    /**
+     * Get the content of a message.
+     */
+    getMessageContent(id) {
+        const script = buildAppLevelScript(`
+      try
+        repeat with acct in accounts
+          repeat with mb in mailboxes of acct
+            try
+              set matchingMsgs to (messages of mb whose id is ${Number(id)})
+              if (count of matchingMsgs) > 0 then
+                set msg to item 1 of matchingMsgs
+                set msgSubject to subject of msg
+                set msgContent to content of msg
+                set htmlContent to ""
+                try
+                  set htmlContent to source of msg
+                end try
+                return msgSubject & "|||CONTENT|||" & msgContent & "|||HTML|||" & htmlContent
+              end if
+            end try
+          end repeat
+        end repeat
+        return ""
+      on error errMsg
+        return ""
+      end try
+    `);
+        const result = executeAppleScript(script, { timeoutMs: 60000 });
+        if (!result.success || !result.output.trim()) {
+            console.error(`Failed to get message content: ${result.error}`);
+            return null;
+        }
+        const htmlSplit = result.output.split("|||HTML|||");
+        const contentPart = htmlSplit[0];
+        const htmlContent = htmlSplit.length > 1 ? htmlSplit[1] : undefined;
+        const parts = contentPart.split("|||CONTENT|||");
+        if (parts.length < 2)
+            return null;
+        return {
+            id: id.toString(),
+            subject: parts[0],
+            plainText: parts[1],
+            htmlContent: htmlContent || undefined,
+        };
+    }
+    /**
+     * Get the raw MIME source of a message.
+     * Used as fallback for attachment extraction when AppleScript
+     * mail attachments returns empty.
+     *
+     * Timeout is 2x the default (120s) because `source of msg` returns
+     * the entire raw message including base64-encoded attachments —
+     * a 20MB attachment can take several seconds over Exchange/IMAP.
+     */
+    getRawSource(id) {
+        const script = buildAppLevelScript(`
+      try
+        repeat with acct in accounts
+          repeat with mb in mailboxes of acct
+            try
+              set matchingMsgs to (messages of mb whose id is ${Number(id)})
+              if (count of matchingMsgs) > 0 then
+                set msg to item 1 of matchingMsgs
+                return source of msg
+              end if
+            end try
+          end repeat
+        end repeat
+        return ""
+      on error errMsg
+        return ""
+      end try
+    `);
+        const result = executeAppleScript(script, { timeoutMs: 120000 });
+        if (!result.success || !result.output.trim()) {
+            return null;
+        }
+        return result.output;
+    }
+    /**
+     * List messages in a mailbox.
+     *
+     * @param mailbox - Mailbox to list from (default: INBOX)
+     * @param account - Account to list from
+     * @param limit - Maximum number of messages
+     * @returns Array of messages
+     */
+    listMessages(mailbox, account, limit = 50, from, offset = 0) {
+        // If no account specified, list across all accounts
+        if (!account) {
+            const accounts = this.listAccounts();
+            const allMessages = [];
+            for (const acct of accounts) {
+                if (allMessages.length >= limit)
+                    break;
+                const remaining = limit - allMessages.length;
+                const msgs = this.listMessages(mailbox, acct.name, remaining, from, offset);
+                allMessages.push(...msgs);
+            }
+            return allMessages.slice(0, limit);
+        }
+        const targetAccount = this.resolveAccount(account);
+        const safeFrom = from ? escapeForAppleScript(from) : "";
+        const fromFilter = from ? `whose sender contains "${safeFrom}"` : "";
+        let listCommand;
+        if (mailbox) {
+            // List from a specific mailbox
+            const targetMailbox = this.resolveMailbox(mailbox, targetAccount);
+            listCommand = `
+      set outputText to ""
+      set theMailbox to mailbox "${escapeForAppleScript(targetMailbox)}"
+      set msgCount to 0
+      set skipped to 0
+      repeat with msg in messages of theMailbox ${fromFilter}
+        if msgCount >= ${limit} then exit repeat
+        try
+          if skipped < ${offset} then
+            set skipped to skipped + 1
+          else
+            set msgId to id of msg as string
+            set msgSubject to subject of msg
+            set msgSender to sender of msg
+            set d to date received of msg
+            set msgDate to ${AS_DATE_TO_STRING}
+            set msgRead to read status of msg as string
+            set msgFlagged to flagged status of msg as string
+            set msgHasAtt to "false"
+            try
+              if (count of mail attachments of msg) > 0 then set msgHasAtt to "true"
+            end try
+            if msgCount > 0 then set outputText to outputText & "|||ITEM|||"
+            set outputText to outputText & msgId & "|||" & msgSubject & "|||" & msgSender & "|||" & msgDate & "|||" & msgRead & "|||" & msgFlagged & "|||" & msgHasAtt
+            set msgCount to msgCount + 1
+          end if
+        end try
+      end repeat
+      return outputText
+    `;
+        }
+        else {
+            // List from ALL mailboxes — iterate every mailbox in the account, dedup by message ID
+            listCommand = `
+      set outputText to ""
+      set msgCount to 0
+      set skipped to 0
+      set seenIds to {}
+      repeat with mb in mailboxes
+        if msgCount >= ${limit} then exit repeat
+        try
+          repeat with msg in messages of mb ${fromFilter}
+            if msgCount >= ${limit} then exit repeat
+            try
+              set msgId to id of msg as string
+              if seenIds does not contain msgId then
+                set end of seenIds to msgId
+                if skipped < ${offset} then
+                  set skipped to skipped + 1
+                else
+                  set msgSubject to subject of msg
+                  set msgSender to sender of msg
+                  set d to date received of msg
+                  set msgDate to ${AS_DATE_TO_STRING}
+                  set msgRead to read status of msg as string
+                  set msgFlagged to flagged status of msg as string
+                  set msgHasAtt to "false"
+                  try
+                    if (count of mail attachments of msg) > 0 then set msgHasAtt to "true"
+                  end try
+                  if msgCount > 0 then set outputText to outputText & "|||ITEM|||"
+                  set outputText to outputText & msgId & "|||" & msgSubject & "|||" & msgSender & "|||" & msgDate & "|||" & msgRead & "|||" & msgFlagged & "|||" & name of mb & "|||" & msgHasAtt
+                  set msgCount to msgCount + 1
+                end if
+              end if
+            end try
+          end repeat
+        end try
+      end repeat
+      return outputText
+    `;
+        }
+        const script = buildAccountScopedScript(targetAccount, listCommand);
+        const result = executeAppleScript(script, { timeoutMs: 60000 });
+        if (!result.success) {
+            console.error(`Failed to list messages: ${result.error}`);
+            return [];
+        }
+        if (!result.output.trim())
+            return [];
+        return this.parseMessageList(result.output, mailbox || "INBOX", targetAccount);
+    }
+    /**
+     * Parse message list output from AppleScript.
+     *
+     * Two emission schemas, disambiguated by length:
+     *   7 fields: single-mailbox — ...|hasAtt (mailbox from caller)
+     *   8 fields: all-mailboxes — ...|mailbox|hasAtt
+     *
+     * `hasAttachments` here is the fast-path AppleScript count only; it will
+     * false-negative for MIME-embedded attachments (a known AppleScript
+     * limitation). Use getMessage or list-attachments for authoritative info.
+     */
+    parseMessageList(output, mailbox, account) {
+        const items = output.split("|||ITEM|||");
+        const messages = [];
+        for (const item of items) {
+            const parts = item.split("|||");
+            if (parts.length < 6)
+                continue;
+            let msgMailbox = mailbox;
+            let hasAttachments = false;
+            if (parts.length >= 8) {
+                msgMailbox = parts[6];
+                hasAttachments = parts[7] === "true";
+            }
+            else if (parts.length === 7) {
+                hasAttachments = parts[6] === "true";
+            }
+            messages.push({
+                id: parts[0].trim(),
+                subject: parts[1],
+                sender: parts[2],
+                recipients: [],
+                dateReceived: parseAppleScriptDate(parts[3]),
+                isRead: parts[4] === "true",
+                isFlagged: parts[5] === "true",
+                isJunk: false,
+                isDeleted: false,
+                mailbox: msgMailbox,
+                account,
+                hasAttachments,
+            });
+        }
+        return messages;
+    }
+    /**
+     * Send an email.
+     *
+     * @param to - Recipient email addresses
+     * @param subject - Email subject
+     * @param body - Email body (plain text)
+     * @param cc - CC recipients
+     * @param bcc - BCC recipients
+     * @param account - Account to send from
+     * @returns true if sent successfully
+     */
+    sendEmail(to, subject, body, cc, bcc, account, attachments) {
+        const safeSubject = escapeForAppleScript(subject);
+        const safeBody = escapeForAppleScript(body);
+        // Build recipient additions
+        let recipientCommands = "";
+        for (const addr of to) {
+            recipientCommands += `make new to recipient at end of to recipients with properties {address:"${escapeForAppleScript(addr)}"}\n`;
+        }
+        if (cc) {
+            for (const addr of cc) {
+                recipientCommands += `make new cc recipient at end of cc recipients with properties {address:"${escapeForAppleScript(addr)}"}\n`;
+            }
+        }
+        if (bcc) {
+            for (const addr of bcc) {
+                recipientCommands += `make new bcc recipient at end of bcc recipients with properties {address:"${escapeForAppleScript(addr)}"}\n`;
+            }
+        }
+        const attachmentCommands = buildAttachmentCommands(attachments);
+        let sendCommand;
+        if (account) {
+            const safeAccount = escapeForAppleScript(account);
+            sendCommand = `
+        set newMessage to make new outgoing message with properties {subject:"${safeSubject}", content:"${safeBody}", visible:true}
+        tell newMessage
+          ${recipientCommands}
+          set sender to "${safeAccount}"
+          ${attachmentCommands}
+        end tell
+        send newMessage
+        return "sent"
+      `;
+        }
+        else {
+            sendCommand = `
+        set newMessage to make new outgoing message with properties {subject:"${safeSubject}", content:"${safeBody}", visible:true}
+        tell newMessage
+          ${recipientCommands}
+          ${attachmentCommands}
+        end tell
+        send newMessage
+        return "sent"
+      `;
+        }
+        const script = buildAppLevelScript(sendCommand);
+        const result = executeAppleScript(script, { timeoutMs: 60000, maxRetries: 2 });
+        if (!result.success) {
+            console.error(`Failed to send email: ${result.error}`);
+            return false;
+        }
+        return result.output.includes("sent");
+    }
+    /**
+     * Send individual personalized emails to a list of recipients (mail merge).
+     *
+     * Replaces {{placeholder}} tokens in subject and body with per-recipient values.
+     * Each recipient receives their own individual email.
+     *
+     * @param recipients - List of recipient objects with email and variable values
+     * @param subject - Email subject (may contain {{placeholders}})
+     * @param body - Email body (may contain {{placeholders}})
+     * @param account - Account to send from
+     * @param delayMs - Delay between sends in milliseconds (default: 500, max: 10000)
+     * @returns Array of per-recipient results
+     */
+    sendSerialEmail(recipients, subject, body, account, delayMs = 500) {
+        const effectiveDelay = Math.min(Math.max(delayMs, 0), 10000);
+        const results = [];
+        for (let i = 0; i < recipients.length; i++) {
+            const recipient = recipients[i];
+            try {
+                // Replace all {{Key}} placeholders with recipient's values
+                let personalizedSubject = subject;
+                let personalizedBody = body;
+                for (const [key, value] of Object.entries(recipient.variables)) {
+                    const safeKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+                    const placeholder = new RegExp(`\\{\\{${safeKey}\\}\\}`, "g");
+                    personalizedSubject = personalizedSubject.replace(placeholder, value);
+                    personalizedBody = personalizedBody.replace(placeholder, value);
+                }
+                const success = this.sendEmail([recipient.email], personalizedSubject, personalizedBody, undefined, undefined, account);
+                results.push({
+                    email: recipient.email,
+                    success,
+                    error: success ? undefined : "Failed to send email",
+                });
+            }
+            catch (error) {
+                results.push({
+                    email: recipient.email,
+                    success: false,
+                    error: error instanceof Error ? error.message : "Unknown error",
+                });
+            }
+            // Brief delay between sends to avoid overwhelming Mail.app
+            if (effectiveDelay > 0 && i < recipients.length - 1) {
+                spawnSync("sleep", [(effectiveDelay / 1000).toString()], { stdio: "ignore" });
+            }
+        }
+        return results;
+    }
+    /**
+     * Create a draft email (saved to Drafts folder, not sent).
+     *
+     * @param to - Recipient email addresses
+     * @param subject - Email subject
+     * @param body - Email body (plain text)
+     * @param cc - CC recipients
+     * @param bcc - BCC recipients
+     * @param account - Account to create draft in
+     * @returns true if draft created successfully
+     */
+    createDraft(to, subject, body, cc, bcc, account, attachments) {
+        const safeSubject = escapeForAppleScript(subject);
+        const safeBody = escapeForAppleScript(body);
+        // Build recipient additions
+        let recipientCommands = "";
+        for (const addr of to) {
+            recipientCommands += `make new to recipient at end of to recipients with properties {address:"${escapeForAppleScript(addr)}"}\n`;
+        }
+        if (cc) {
+            for (const addr of cc) {
+                recipientCommands += `make new cc recipient at end of cc recipients with properties {address:"${escapeForAppleScript(addr)}"}\n`;
+            }
+        }
+        if (bcc) {
+            for (const addr of bcc) {
+                recipientCommands += `make new bcc recipient at end of bcc recipients with properties {address:"${escapeForAppleScript(addr)}"}\n`;
+            }
+        }
+        const attachmentCommands = buildAttachmentCommands(attachments);
+        let draftCommand;
+        if (account) {
+            const safeAccount = escapeForAppleScript(account);
+            draftCommand = `
+        set newMessage to make new outgoing message with properties {subject:"${safeSubject}", content:"${safeBody}", visible:false}
+        tell newMessage
+          ${recipientCommands}
+          set sender to "${safeAccount}"
+          ${attachmentCommands}
+        end tell
+        return "draft created"
+      `;
+        }
+        else {
+            draftCommand = `
+        set newMessage to make new outgoing message with properties {subject:"${safeSubject}", content:"${safeBody}", visible:false}
+        tell newMessage
+          ${recipientCommands}
+          ${attachmentCommands}
+        end tell
+        return "draft created"
+      `;
+        }
+        const script = buildAppLevelScript(draftCommand);
+        const result = executeAppleScript(script, { timeoutMs: 60000, maxRetries: 2 });
+        if (!result.success) {
+            console.error(`Failed to create draft: ${result.error}`);
+            return false;
+        }
+        return result.output.includes("draft created");
+    }
+    /**
+     * Reply to a message.
+     *
+     * @param id - Message ID to reply to
+     * @param body - Reply body
+     * @param replyAll - If true, reply to all recipients
+     * @param send - If true, send immediately; if false, save as draft
+     * @returns true if reply created/sent successfully
+     */
+    replyToMessage(id, body, replyAll = false, send = true) {
+        const safeBody = escapeForAppleScript(body);
+        const replyAllClause = replyAll ? " with reply to all" : "";
+        const sendAction = send ? "send theReply" : "";
+        const script = buildAppLevelScript(`
+      try
+        repeat with acct in accounts
+          repeat with mb in mailboxes of acct
+            try
+              set matchingMsgs to (messages of mb whose id is ${Number(id)})
+              if (count of matchingMsgs) > 0 then
+                set msg to item 1 of matchingMsgs
+                set theReply to reply msg without opening window${replyAllClause}
+                set content of theReply to "${safeBody}"
+                ${sendAction}
+                return "ok"
+              end if
+            end try
+          end repeat
+        end repeat
+        return "error:Message not found"
+      on error errMsg
+        return "error:" & errMsg
+      end try
+    `);
+        const result = executeAppleScript(script, { timeoutMs: 60000 });
+        if (!result.success || result.output.startsWith("error:")) {
+            console.error(`Failed to reply to message: ${result.error || result.output}`);
+            return false;
+        }
+        return true;
+    }
+    /**
+     * Forward a message.
+     *
+     * @param id - Message ID to forward
+     * @param to - Recipients to forward to
+     * @param body - Optional body to prepend
+     * @param send - If true, send immediately; if false, save as draft
+     * @returns true if forward created/sent successfully
+     */
+    forwardMessage(id, to, body, send = true) {
+        const safeBody = body ? escapeForAppleScript(body) : "";
+        const sendAction = send ? "send theForward" : "";
+        // Build recipient additions
+        let recipientCommands = "";
+        for (const addr of to) {
+            recipientCommands += `make new to recipient at end of to recipients of theForward with properties {address:"${escapeForAppleScript(addr)}"}\n`;
+        }
+        const script = buildAppLevelScript(`
+      try
+        repeat with acct in accounts
+          repeat with mb in mailboxes of acct
+            try
+              set matchingMsgs to (messages of mb whose id is ${Number(id)})
+              if (count of matchingMsgs) > 0 then
+                set msg to item 1 of matchingMsgs
+                set theForward to forward msg without opening window
+                ${recipientCommands}
+                ${safeBody ? `set content of theForward to "${safeBody}"` : ""}
+                ${sendAction}
+                return "ok"
+              end if
+            end try
+          end repeat
+        end repeat
+        return "error:Message not found"
+      on error errMsg
+        return "error:" & errMsg
+      end try
+    `);
+        const result = executeAppleScript(script, { timeoutMs: 60000 });
+        if (!result.success || result.output.startsWith("error:")) {
+            console.error(`Failed to forward message: ${result.error || result.output}`);
+            return false;
+        }
+        return true;
+    }
+    /**
+     * Helper to find and operate on a message by ID.
+     */
+    findMessageScript(id, operation) {
+        return buildAppLevelScript(`
+      try
+        repeat with acct in accounts
+          repeat with mb in mailboxes of acct
+            try
+              set matchingMsgs to (messages of mb whose id is ${Number(id)})
+              if (count of matchingMsgs) > 0 then
+                set msg to item 1 of matchingMsgs
+                ${operation}
+                return "ok"
+              end if
+            end try
+          end repeat
+        end repeat
+        return "error:Message not found"
+      on error errMsg
+        return "error:" & errMsg
+      end try
+    `);
+    }
+    /**
+     * Mark a message as read.
+     */
+    markAsRead(id) {
+        const script = this.findMessageScript(id, "set read status of msg to true");
+        const result = executeAppleScript(script, { timeoutMs: 60000 });
+        if (!result.success || result.output.startsWith("error:")) {
+            console.error(`Failed to mark message as read: ${result.error || result.output}`);
+            return false;
+        }
+        return true;
+    }
+    /**
+     * Mark a message as unread.
+     */
+    markAsUnread(id) {
+        const script = this.findMessageScript(id, "set read status of msg to false");
+        const result = executeAppleScript(script, { timeoutMs: 60000 });
+        if (!result.success || result.output.startsWith("error:")) {
+            console.error(`Failed to mark message as unread: ${result.error || result.output}`);
+            return false;
+        }
+        return true;
+    }
+    /**
+     * Flag a message.
+     */
+    flagMessage(id) {
+        const script = this.findMessageScript(id, "set flagged status of msg to true");
+        const result = executeAppleScript(script, { timeoutMs: 60000 });
+        if (!result.success || result.output.startsWith("error:")) {
+            console.error(`Failed to flag message: ${result.error || result.output}`);
+            return false;
+        }
+        return true;
+    }
+    /**
+     * Unflag a message.
+     */
+    unflagMessage(id) {
+        const script = this.findMessageScript(id, "set flagged status of msg to false");
+        const result = executeAppleScript(script, { timeoutMs: 60000 });
+        if (!result.success || result.output.startsWith("error:")) {
+            console.error(`Failed to unflag message: ${result.error || result.output}`);
+            return false;
+        }
+        return true;
+    }
+    /**
+     * Delete a message.
+     */
+    deleteMessage(id) {
+        const script = this.findMessageScript(id, "delete msg");
+        const result = executeAppleScript(script, { timeoutMs: 60000 });
+        if (!result.success || result.output.startsWith("error:")) {
+            console.error(`Failed to delete message: ${result.error || result.output}`);
+            return false;
+        }
+        return true;
+    }
+    /**
+     * Move a message to a different mailbox.
+     */
+    moveMessage(id, mailbox, account) {
+        const targetAccount = this.resolveAccount(account);
+        const targetMailbox = this.resolveMailbox(mailbox, targetAccount);
+        const safeMailbox = escapeForAppleScript(targetMailbox);
+        const safeAccount = escapeForAppleScript(targetAccount);
+        const script = buildAppLevelScript(`
+      try
+        repeat with acct in accounts
+          repeat with mb in mailboxes of acct
+            try
+              set matchingMsgs to (messages of mb whose id is ${Number(id)})
+              if (count of matchingMsgs) > 0 then
+                set msg to item 1 of matchingMsgs
+                set destMailbox to mailbox "${safeMailbox}" of account "${safeAccount}"
+                move msg to destMailbox
+                return "ok"
+              end if
+            end try
+          end repeat
+        end repeat
+        return "error:Message not found"
+      on error errMsg
+        return "error:" & errMsg
+      end try
+    `);
+        const result = executeAppleScript(script, { timeoutMs: 60000 });
+        if (!result.success || result.output.startsWith("error:")) {
+            console.error(`Failed to move message: ${result.error || result.output}`);
+            return false;
+        }
+        return true;
+    }
+    // ===========================================================================
+    // Batch Operations
+    // ===========================================================================
+    /**
+     * Delete multiple messages at once.
+     *
+     * @param ids - Array of message IDs to delete
+     * @returns Array of results for each message
+     */
+    batchDeleteMessages(ids) {
+        const results = [];
+        for (const id of ids) {
+            const success = this.deleteMessage(id);
+            results.push({
+                id,
+                success,
+                error: success ? undefined : "Failed to delete message",
+            });
+        }
+        return results;
+    }
+    /**
+     * Move multiple messages to a mailbox at once.
+     *
+     * @param ids - Array of message IDs to move
+     * @param mailbox - Destination mailbox name
+     * @param account - Account containing the destination mailbox
+     * @returns Array of results for each message
+     */
+    batchMoveMessages(ids, mailbox, account) {
+        const results = [];
+        for (const id of ids) {
+            const success = this.moveMessage(id, mailbox, account);
+            results.push({
+                id,
+                success,
+                error: success ? undefined : "Failed to move message",
+            });
+        }
+        return results;
+    }
+    /**
+     * Mark multiple messages as read at once.
+     */
+    batchMarkAsRead(ids) {
+        const results = [];
+        for (const id of ids) {
+            const success = this.markAsRead(id);
+            results.push({ id, success, error: success ? undefined : "Failed to mark message as read" });
+        }
+        return results;
+    }
+    /**
+     * Mark multiple messages as unread at once.
+     */
+    batchMarkAsUnread(ids) {
+        const results = [];
+        for (const id of ids) {
+            const success = this.markAsUnread(id);
+            results.push({
+                id,
+                success,
+                error: success ? undefined : "Failed to mark message as unread",
+            });
+        }
+        return results;
+    }
+    /**
+     * Flag multiple messages at once.
+     */
+    batchFlagMessages(ids) {
+        const results = [];
+        for (const id of ids) {
+            const success = this.flagMessage(id);
+            results.push({ id, success, error: success ? undefined : "Failed to flag message" });
+        }
+        return results;
+    }
+    /**
+     * Unflag multiple messages at once.
+     */
+    batchUnflagMessages(ids) {
+        const results = [];
+        for (const id of ids) {
+            const success = this.unflagMessage(id);
+            results.push({ id, success, error: success ? undefined : "Failed to unflag message" });
+        }
+        return results;
+    }
+    /**
+     * List attachments for a message.
+     * Tries AppleScript first, falls back to MIME source parsing
+     * when AppleScript returns empty (known issue across all account types).
+     */
+    listAttachments(id) {
+        // Attempt 1: AppleScript mail attachments
+        const script = buildAppLevelScript(`
+      try
+        repeat with acct in accounts
+          repeat with mb in mailboxes of acct
+            try
+              set matchingMsgs to (messages of mb whose id is ${Number(id)})
+              if (count of matchingMsgs) > 0 then
+                set msg to item 1 of matchingMsgs
+                set outputText to ""
+                set attCount to 0
+                repeat with att in mail attachments of msg
+                  set attName to name of att
+                  set attType to MIME type of att
+                  set attSize to file size of att as string
+                  if attCount > 0 then set outputText to outputText & "|||ITEM|||"
+                  set outputText to outputText & attName & "|||" & attType & "|||" & attSize
+                  set attCount to attCount + 1
+                end repeat
+                return outputText
+              end if
+            end try
+          end repeat
+        end repeat
+        return ""
+      on error errMsg
+        return ""
+      end try
+    `);
+        const result = executeAppleScript(script, { timeoutMs: 60000 });
+        if (result.success && result.output.trim()) {
+            const items = result.output.split("|||ITEM|||");
+            const attachments = [];
+            for (const item of items) {
+                const parts = item.split("|||");
+                if (parts.length < 3)
+                    continue;
+                attachments.push({
+                    id: `${id}-${parts[0]}`,
+                    name: parts[0],
+                    mimeType: parts[1],
+                    size: parseInt(parts[2]) || 0,
+                });
+            }
+            if (attachments.length > 0)
+                return attachments;
+        }
+        // Attempt 2: MIME source fallback
+        const rawSource = this.getRawSource(id);
+        if (!rawSource)
+            return [];
+        const mimeAttachments = parseMimeAttachments(rawSource);
+        return mimeAttachments.map((att) => ({
+            id: `${id}-${att.name}`,
+            name: att.name,
+            mimeType: att.mimeType,
+            size: att.size,
+        }));
+    }
+    /**
+     * Save an attachment from a message to disk.
+     * Tries AppleScript first, falls back to MIME source extraction
+     * when AppleScript can't find the attachment.
+     */
+    saveAttachment(id, attachmentName, savePath) {
+        // Validate attachment name: block path separators, traversal, null bytes, and backslashes
+        if (/[/\\\0]/.test(attachmentName) || attachmentName.includes("..")) {
+            console.error(`Invalid attachment name: "${attachmentName}"`);
+            return false;
+        }
+        // Resolve the save path to prevent symlink / ".." traversal bypass
+        const resolvedPath = resolve(savePath);
+        const allowedPrefixes = [homedir(), "/tmp", "/private/tmp", "/Volumes"];
+        const isAllowed = allowedPrefixes.some((prefix) => resolvedPath.startsWith(prefix));
+        if (!isAllowed) {
+            console.error(`Save path "${savePath}" is outside allowed directories`);
+            return false;
+        }
+        const safeName = escapeForAppleScript(attachmentName);
+        const safePath = escapeForAppleScript(resolvedPath);
+        const numericId = Number(id);
+        // Attempt 1: AppleScript save
+        const script = buildAppLevelScript(`
+      try
+        repeat with acct in accounts
+          repeat with mb in mailboxes of acct
+            try
+              set matchingMsgs to (messages of mb whose id is ${numericId})
+              if (count of matchingMsgs) > 0 then
+                set msg to item 1 of matchingMsgs
+                repeat with att in mail attachments of msg
+                  if name of att is "${safeName}" then
+                    set savePath to POSIX file "${safePath}/${safeName}"
+                    save att in savePath
+                    return "ok"
+                  end if
+                end repeat
+                return "error:Attachment not found"
+              end if
+            end try
+          end repeat
+        end repeat
+        return "error:Message not found"
+      on error errMsg
+        return "error:" & errMsg
+      end try
+    `);
+        const result = executeAppleScript(script, { timeoutMs: 60000 });
+        if (result.success && result.output === "ok") {
+            return true;
+        }
+        // Attempt 2: MIME source fallback
+        const rawSource = this.getRawSource(id);
+        if (!rawSource) {
+            console.error(`Failed to save attachment: could not retrieve message source`);
+            return false;
+        }
+        const attachment = extractMimeAttachment(rawSource, attachmentName);
+        if (!attachment) {
+            console.error(`Failed to save attachment: "${attachmentName}" not found in MIME source`);
+            return false;
+        }
+        try {
+            const outPath = resolve(resolvedPath, attachmentName);
+            // Verify the resolved output path is still within allowed directories
+            const isOutAllowed = allowedPrefixes.some((prefix) => outPath.startsWith(prefix));
+            if (!isOutAllowed) {
+                console.error(`Output path "${outPath}" is outside allowed directories`);
+                return false;
+            }
+            writeFileSync(outPath, attachment.data);
+            return true;
+        }
+        catch (err) {
+            console.error(`Failed to write attachment to disk: ${err}`);
+            return false;
+        }
+    }
+    // ===========================================================================
+    // Mailbox Operations
+    // ===========================================================================
+    /**
+     * List all mailboxes for an account.
+     */
+    listMailboxes(account) {
+        const targetAccount = this.resolveAccount(account);
+        const listCommand = `
+      set mailboxList to {}
+      repeat with mb in mailboxes
+        set mbName to name of mb
+        set mbUnread to unread count of mb
+        set mbCount to count of messages of mb
+        set end of mailboxList to mbName & "|||" & mbUnread & "|||" & mbCount
+      end repeat
+      set AppleScript's text item delimiters to "|||ITEM|||"
+      return mailboxList as text
+    `;
+        const script = buildAccountScopedScript(targetAccount, listCommand);
+        const result = executeAppleScript(script);
+        if (!result.success) {
+            console.error(`Failed to list mailboxes: ${result.error}`);
+            return [];
+        }
+        if (!result.output.trim())
+            return [];
+        const items = result.output.split("|||ITEM|||");
+        const mailboxes = [];
+        for (const item of items) {
+            const parts = item.split("|||");
+            if (parts.length < 3)
+                continue;
+            mailboxes.push({
+                name: parts[0],
+                account: targetAccount,
+                unreadCount: parseInt(parts[1]) || 0,
+                messageCount: parseInt(parts[2]) || 0,
+            });
+        }
+        return mailboxes;
+    }
+    /**
+     * Get unread count for a mailbox.
+     */
+    getUnreadCount(mailbox, account) {
+        const targetAccount = this.resolveAccount(account);
+        let command;
+        if (mailbox) {
+            const targetMailbox = this.resolveMailbox(mailbox, targetAccount);
+            const safeMailbox = escapeForAppleScript(targetMailbox);
+            command = `return unread count of mailbox "${safeMailbox}"`;
+        }
+        else {
+            // Get total unread across all mailboxes
+            command = `
+        set total to 0
+        repeat with mb in mailboxes
+          set total to total + (unread count of mb)
+        end repeat
+        return total
+      `;
+        }
+        const script = buildAccountScopedScript(targetAccount, command);
+        const result = executeAppleScript(script);
+        if (!result.success) {
+            console.error(`Failed to get unread count: ${result.error}`);
+            return 0;
+        }
+        return parseInt(result.output) || 0;
+    }
+    /**
+     * Create a new mailbox.
+     */
+    createMailbox(name, account) {
+        const targetAccount = this.resolveAccount(account);
+        const safeName = escapeForAppleScript(name);
+        const safeAccount = escapeForAppleScript(targetAccount);
+        const script = buildAppLevelScript(`
+      try
+        make new mailbox with properties {name:"${safeName}"} at account "${safeAccount}"
+        return "ok"
+      on error errMsg
+        return "error:" & errMsg
+      end try
+    `);
+        const result = executeAppleScript(script);
+        if (!result.success || result.output.startsWith("error:")) {
+            console.error(`Failed to create mailbox: ${result.error || result.output}`);
+            return false;
+        }
+        this.invalidateCache();
+        return true;
+    }
+    /**
+     * Delete a mailbox.
+     */
+    deleteMailbox(name, account) {
+        const targetAccount = this.resolveAccount(account);
+        const targetMailbox = this.resolveMailbox(name, targetAccount);
+        const safeName = escapeForAppleScript(targetMailbox);
+        const safeAccount = escapeForAppleScript(targetAccount);
+        const script = buildAppLevelScript(`
+      try
+        delete mailbox "${safeName}" of account "${safeAccount}"
+        return "ok"
+      on error errMsg
+        return "error:" & errMsg
+      end try
+    `);
+        const result = executeAppleScript(script);
+        if (!result.success || result.output.startsWith("error:")) {
+            console.error(`Failed to delete mailbox: ${result.error || result.output}`);
+            return false;
+        }
+        this.invalidateCache();
+        return true;
+    }
+    /**
+     * Rename a mailbox by creating a new one, moving messages, and deleting the old one.
+     */
+    renameMailbox(oldName, newName, account) {
+        const targetAccount = this.resolveAccount(account);
+        // Create the new mailbox
+        if (!this.createMailbox(newName, targetAccount)) {
+            return false;
+        }
+        // Move all messages from old to new
+        const resolvedOld = this.resolveMailbox(oldName, targetAccount);
+        const resolvedNew = this.resolveMailbox(newName, targetAccount);
+        const safeOld = escapeForAppleScript(resolvedOld);
+        const safeNew = escapeForAppleScript(resolvedNew);
+        const safeAccount = escapeForAppleScript(targetAccount);
+        const moveScript = buildAppLevelScript(`
+      try
+        set srcMailbox to mailbox "${safeOld}" of account "${safeAccount}"
+        set destMailbox to mailbox "${safeNew}" of account "${safeAccount}"
+        repeat with msg in messages of srcMailbox
+          move msg to destMailbox
+        end repeat
+        delete mailbox "${safeOld}" of account "${safeAccount}"
+        return "ok"
+      on error errMsg
+        return "error:" & errMsg
+      end try
+    `);
+        const result = executeAppleScript(moveScript, { timeoutMs: 60000 });
+        if (!result.success || result.output.startsWith("error:")) {
+            console.error(`Failed to rename mailbox: ${result.error || result.output}`);
+            return false;
+        }
+        this.invalidateCache();
+        return true;
+    }
+    // ===========================================================================
+    // Account Operations
+    // ===========================================================================
+    /**
+     * List all mail accounts (uses cache).
+     */
+    listAccounts() {
+        return this.getCachedAccounts();
+    }
+    /**
+     * Fetches account list directly from Mail.app via AppleScript.
+     * Used internally by the cache; prefer getCachedAccounts() or listAccounts().
+     */
+    fetchAccounts() {
+        const script = buildAppLevelScript(`
+      set accountList to {}
+      repeat with acct in accounts
+        set acctName to name of acct
+        set acctEmail to email addresses of acct
+        set acctEnabled to enabled of acct
+        set emailStr to ""
+        if (count of acctEmail) > 0 then
+          set emailStr to item 1 of acctEmail
+        end if
+        set end of accountList to acctName & "|||" & emailStr & "|||" & acctEnabled
+      end repeat
+      set AppleScript's text item delimiters to "|||ITEM|||"
+      return accountList as text
+    `);
+        const result = executeAppleScript(script);
+        if (!result.success) {
+            console.error(`Failed to list accounts: ${result.error}`);
+            return [];
+        }
+        if (!result.output.trim())
+            return [];
+        const items = result.output.split("|||ITEM|||");
+        const accounts = [];
+        for (const item of items) {
+            const parts = item.split("|||");
+            if (parts.length < 3)
+                continue;
+            accounts.push({
+                name: parts[0],
+                email: parts[1],
+                enabled: parts[2] === "true",
+            });
+        }
+        return accounts;
+    }
+    /**
+     * Fetches mailbox names for an account directly from Mail.app.
+     * Used internally by the cache; prefer getCachedMailboxNames().
+     */
+    fetchMailboxNames(account) {
+        const script = buildAccountScopedScript(account, `
+      set mbNames to {}
+      repeat with mb in mailboxes
+        set end of mbNames to name of mb
+      end repeat
+      return mbNames
+    `);
+        const result = executeAppleScript(script);
+        if (!result.success || !result.output) {
+            return [];
+        }
+        return result.output.split(", ").map((s) => s.trim());
+    }
+    // ===========================================================================
+    // Mail Rules
+    // ===========================================================================
+    /**
+     * List all mail rules.
+     */
+    listRules() {
+        const script = buildAppLevelScript(`
+      set ruleList to {}
+      repeat with r in rules
+        set ruleName to name of r
+        set ruleEnabled to enabled of r
+        set end of ruleList to ruleName & "|||" & (ruleEnabled as string)
+      end repeat
+      set AppleScript's text item delimiters to "|||ITEM|||"
+      return ruleList as text
+    `);
+        const result = executeAppleScript(script);
+        if (!result.success || !result.output.trim()) {
+            return [];
+        }
+        const items = result.output.split("|||ITEM|||");
+        const rules = [];
+        for (const item of items) {
+            const parts = item.split("|||");
+            if (parts.length < 2)
+                continue;
+            rules.push({
+                name: parts[0],
+                enabled: parts[1] === "true",
+            });
+        }
+        return rules;
+    }
+    /**
+     * Enable or disable a mail rule.
+     */
+    setRuleEnabled(ruleName, enabled) {
+        const safeName = escapeForAppleScript(ruleName);
+        const script = buildAppLevelScript(`
+      try
+        repeat with r in rules
+          if name of r is "${safeName}" then
+            set enabled of r to ${enabled}
+            return "ok"
+          end if
+        end repeat
+        return "error:Rule not found"
+      on error errMsg
+        return "error:" & errMsg
+      end try
+    `);
+        const result = executeAppleScript(script);
+        if (!result.success || result.output.startsWith("error:")) {
+            console.error(`Failed to set rule state: ${result.error || result.output}`);
+            return false;
+        }
+        return true;
+    }
+    // ===========================================================================
+    // Contacts Integration
+    // ===========================================================================
+    /**
+     * Search contacts by name or email.
+     */
+    searchContacts(query) {
+        const safeQuery = escapeForAppleScript(query);
+        const script = `
+      tell application "Contacts"
+        set matchedContacts to {}
+        set foundPeople to (every person whose name contains "${safeQuery}") & (every person whose value of emails contains "${safeQuery}")
+
+        -- Deduplicate by tracking IDs
+        set seenIds to {}
+        repeat with p in foundPeople
+          set pid to id of p
+          if seenIds does not contain pid then
+            set end of seenIds to pid
+            set pName to name of p
+            set pEmails to ""
+            repeat with e in emails of p
+              if pEmails is not "" then set pEmails to pEmails & ","
+              set pEmails to pEmails & (value of e)
+            end repeat
+            set pPhones to ""
+            repeat with ph in phones of p
+              if pPhones is not "" then set pPhones to pPhones & ","
+              set pPhones to pPhones & (value of ph)
+            end repeat
+            set end of matchedContacts to pName & "|||" & pEmails & "|||" & pPhones
+          end if
+        end repeat
+
+        set AppleScript's text item delimiters to "|||ITEM|||"
+        return matchedContacts as text
+      end tell
+    `;
+        const result = executeAppleScript(script);
+        if (!result.success || !result.output.trim()) {
+            return [];
+        }
+        const items = result.output.split("|||ITEM|||");
+        const contacts = [];
+        for (const item of items) {
+            const parts = item.split("|||");
+            if (parts.length < 3)
+                continue;
+            contacts.push({
+                name: parts[0],
+                emails: parts[1] ? parts[1].split(",").filter(Boolean) : [],
+                phones: parts[2] ? parts[2].split(",").filter(Boolean) : [],
+            });
+        }
+        return contacts;
+    }
+    // ===========================================================================
+    // Email Templates
+    // ===========================================================================
+    templates = new Map();
+    nextTemplateId = 1;
+    /**
+     * List all stored templates.
+     */
+    listTemplates() {
+        return Array.from(this.templates.values());
+    }
+    /**
+     * Get a template by ID.
+     */
+    getTemplate(id) {
+        return this.templates.get(id) || null;
+    }
+    /**
+     * Create or update a template.
+     */
+    saveTemplate(name, subject, body, to, cc, id) {
+        const templateId = id || `tmpl_${this.nextTemplateId++}`;
+        const template = { id: templateId, name, subject, body, to, cc };
+        this.templates.set(templateId, template);
+        return template;
+    }
+    /**
+     * Delete a template.
+     */
+    deleteTemplate(id) {
+        return this.templates.delete(id);
+    }
+    /**
+     * Use a template to create a draft.
+     */
+    useTemplate(id, overrides) {
+        const template = this.templates.get(id);
+        if (!template)
+            return false;
+        const to = overrides?.to || template.to || [];
+        const cc = overrides?.cc || template.cc;
+        const subject = overrides?.subject || template.subject;
+        const body = overrides?.body || template.body;
+        if (to.length === 0)
+            return false;
+        return this.createDraft(to, subject, body, cc);
+    }
+    // ===========================================================================
+    // Diagnostics
+    // ===========================================================================
+    /**
+     * Run health check on Mail.app connectivity.
+     */
+    healthCheck() {
+        const checks = [];
+        // Check 1: Mail.app is accessible
+        const mailCheck = executeAppleScript('tell application "Mail" to return "ok"');
+        if (mailCheck.success && mailCheck.output === "ok") {
+            checks.push({
+                name: "mail_app",
+                passed: true,
+                message: "Mail.app is accessible",
+            });
+        }
+        else {
+            const errorHint = mailCheck.error?.includes("not authorized")
+                ? " (check Automation permissions in System Preferences)"
+                : "";
+            checks.push({
+                name: "mail_app",
+                passed: false,
+                message: `Mail.app is not accessible${errorHint}`,
+            });
+            return { healthy: false, checks };
+        }
+        // Check 2: AppleScript permissions
+        const permCheck = executeAppleScript('tell application "Mail" to get name of account 1');
+        if (permCheck.success) {
+            checks.push({
+                name: "permissions",
+                passed: true,
+                message: "AppleScript automation permissions granted",
+            });
+        }
+        else {
+            const isPermError = permCheck.error?.includes("not authorized") || permCheck.error?.includes("not permitted");
+            checks.push({
+                name: "permissions",
+                passed: !isPermError,
+                message: isPermError
+                    ? "AppleScript permissions denied. Grant access in System Preferences > Privacy & Security > Automation"
+                    : `Permission check returned: ${permCheck.error}`,
+            });
+            if (isPermError) {
+                return { healthy: false, checks };
+            }
+        }
+        // Check 3: At least one account accessible
+        const accounts = this.listAccounts();
+        if (accounts.length > 0) {
+            const accountNames = accounts.map((a) => a.name).join(", ");
+            checks.push({
+                name: "accounts",
+                passed: true,
+                message: `Found ${accounts.length} account(s): ${accountNames}`,
+            });
+        }
+        else {
+            checks.push({
+                name: "accounts",
+                passed: false,
+                message: "No Mail accounts found. Set up an account in Mail.app first.",
+            });
+            return { healthy: false, checks };
+        }
+        // Check 4: Basic operations work
+        const mailboxes = this.listMailboxes(accounts[0].name);
+        checks.push({
+            name: "operations",
+            passed: true,
+            message: `Basic operations working (${mailboxes.length} mailbox(es) in ${accounts[0].name})`,
+        });
+        return {
+            healthy: checks.every((c) => c.passed),
+            checks,
+        };
+    }
+    /**
+     * Get mail statistics.
+     */
+    getMailStats() {
+        const accounts = this.listAccounts();
+        const accountStats = [];
+        let totalMessages = 0;
+        let totalUnread = 0;
+        for (const account of accounts) {
+            const mailboxes = this.listMailboxes(account.name);
+            let accountMessages = 0;
+            let accountUnread = 0;
+            const mailboxStats = mailboxes.map((mb) => {
+                accountMessages += mb.messageCount;
+                accountUnread += mb.unreadCount;
+                return {
+                    name: mb.name,
+                    messageCount: mb.messageCount,
+                    unreadCount: mb.unreadCount,
+                };
+            });
+            totalMessages += accountMessages;
+            totalUnread += accountUnread;
+            accountStats.push({
+                name: account.name,
+                totalMessages: accountMessages,
+                unreadMessages: accountUnread,
+                mailboxCount: mailboxes.length,
+                mailboxes: mailboxStats,
+            });
+        }
+        // Get recently received stats
+        const recentlyReceived = this.getRecentlyReceivedStats();
+        return {
+            totalMessages,
+            totalUnread,
+            accounts: accountStats,
+            recentlyReceived,
+        };
+    }
+    /**
+     * Get counts of recently received messages.
+     *
+     * Only counts messages in INBOX for performance (scanning all mailboxes
+     * is too slow for large accounts).
+     *
+     * @returns Counts of messages received in last 24h, 7d, and 30d
+     */
+    getRecentlyReceivedStats() {
+        // Get message counts for different time periods
+        const now = new Date();
+        const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
+        const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
+        const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
+        // Format dates for AppleScript comparison
+        const formatDate = (d) => {
+            const months = [
+                "January",
+                "February",
+                "March",
+                "April",
+                "May",
+                "June",
+                "July",
+                "August",
+                "September",
+                "October",
+                "November",
+                "December",
+            ];
+            return `date "${months[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}"`;
+        };
+        // Only scan INBOX for performance - scanning all mailboxes is too slow
+        const script = buildAppLevelScript(`
+      set last24h to 0
+      set last7d to 0
+      set last30d to 0
+      set oneDayAgo to ${formatDate(oneDayAgo)}
+      set sevenDaysAgo to ${formatDate(sevenDaysAgo)}
+      set thirtyDaysAgo to ${formatDate(thirtyDaysAgo)}
+
+      repeat with acct in accounts
+        try
+          -- Try common inbox names
+          set inboxNames to {"INBOX", "Inbox", "inbox"}
+          repeat with inboxName in inboxNames
+            try
+              set theInbox to mailbox inboxName of acct
+              set last24h to last24h + (count of (messages of theInbox whose date received >= oneDayAgo))
+              set last7d to last7d + (count of (messages of theInbox whose date received >= sevenDaysAgo))
+              set last30d to last30d + (count of (messages of theInbox whose date received >= thirtyDaysAgo))
+              exit repeat
+            end try
+          end repeat
+        end try
+      end repeat
+
+      return (last24h as string) & "|||" & (last7d as string) & "|||" & (last30d as string)
+    `);
+        const result = executeAppleScript(script, { timeoutMs: 60000 });
+        if (!result.success || !result.output.trim()) {
+            console.error(`Failed to get recently received stats: ${result.error}`);
+            return { last24h: 0, last7d: 0, last30d: 0 };
+        }
+        const parts = result.output.split("|||");
+        if (parts.length < 3) {
+            return { last24h: 0, last7d: 0, last30d: 0 };
+        }
+        return {
+            last24h: parseInt(parts[0]) || 0,
+            last7d: parseInt(parts[1]) || 0,
+            last30d: parseInt(parts[2]) || 0,
+        };
+    }
+    /**
+     * Get sync status for Mail.app.
+     *
+     * Checks for sync activity indicators like:
+     * - Activity monitor status
+     * - Network activity status
+     * - Background refresh indicators
+     *
+     * @returns Sync status information
+     */
+    getSyncStatus() {
+        // Check for Mail.app background activity and sync status
+        // Mail.app doesn't expose sync status directly through AppleScript,
+        // so we check for recent changes and activity indicators
+        const script = buildAppLevelScript(`
+      set syncInfo to ""
+
+      -- Check if Mail.app is running
+      tell application "System Events"
+        set mailRunning to (name of processes) contains "Mail"
+      end tell
+
+      if not mailRunning then
+        return "not_running"
+      end if
+
+      -- Check for background activity by looking at message counts changing
+      -- This is a proxy for sync activity since Mail doesn't expose sync status
+      set accountCount to count of accounts
+      set totalMailboxes to 0
+      repeat with acct in accounts
+        set totalMailboxes to totalMailboxes + (count of mailboxes of acct)
+      end repeat
+
+      return "running|||" & accountCount & "|||" & totalMailboxes
+    `);
+        const result = executeAppleScript(script);
+        if (!result.success) {
+            return {
+                syncDetected: false,
+                pendingUpload: 0,
+                recentActivity: false,
+                secondsSinceLastChange: -1,
+                error: result.error,
+            };
+        }
+        if (result.output === "not_running") {
+            return {
+                syncDetected: false,
+                pendingUpload: 0,
+                recentActivity: false,
+                secondsSinceLastChange: -1,
+                error: "Mail.app is not running",
+            };
+        }
+        // Parse the response
+        const parts = result.output.split("|||");
+        const isRunning = parts[0] === "running";
+        const accountCount = parseInt(parts[1]) || 0;
+        // Mail.app is running with accounts configured - assume sync is active
+        // (Mail.app syncs automatically when running)
+        return {
+            syncDetected: isRunning && accountCount > 0,
+            pendingUpload: 0, // Not exposed by Mail.app
+            recentActivity: isRunning,
+            secondsSinceLastChange: 0,
+        };
+    }
+}

+ 363 - 0
build/types.d.ts

@@ -0,0 +1,363 @@
+/**
+ * Type Definitions for Apple Mail MCP Server
+ *
+ * This module contains all TypeScript interfaces and types used throughout
+ * the Apple Mail MCP server. These types model:
+ *
+ * - Apple Mail data structures (messages, mailboxes, accounts)
+ * - AppleScript execution results
+ * - MCP tool parameters
+ *
+ * @module types
+ */
+/**
+ * Represents an email message in Apple Mail.
+ */
+export interface Message {
+    /** Unique identifier for the message */
+    id: string;
+    /** Subject line of the email */
+    subject: string;
+    /** Sender email address */
+    sender: string;
+    /** Sender display name (if available) */
+    senderName?: string;
+    /** Recipients (To field) */
+    recipients: string[];
+    /** CC recipients */
+    ccRecipients?: string[];
+    /** BCC recipients (only available for sent mail) */
+    bccRecipients?: string[];
+    /** Date the message was received */
+    dateReceived: Date;
+    /** Date the message was sent */
+    dateSent?: Date;
+    /** Whether the message has been read */
+    isRead: boolean;
+    /** Whether the message is flagged */
+    isFlagged: boolean;
+    /** Whether the message is marked as junk */
+    isJunk: boolean;
+    /** Whether the message has been deleted */
+    isDeleted: boolean;
+    /** Name of the mailbox containing the message */
+    mailbox: string;
+    /** Name of the account containing the message */
+    account: string;
+    /** Whether the message has attachments */
+    hasAttachments: boolean;
+}
+/**
+ * Represents the content of an email message.
+ */
+export interface MessageContent {
+    /** Message identifier */
+    id: string;
+    /** Subject line */
+    subject: string;
+    /** Plain text content */
+    plainText: string;
+    /** HTML content (if available) */
+    htmlContent?: string;
+}
+/**
+ * Represents a mailbox (folder) in Apple Mail.
+ */
+export interface Mailbox {
+    /** Display name of the mailbox */
+    name: string;
+    /** Account containing the mailbox */
+    account: string;
+    /** Number of unread messages */
+    unreadCount: number;
+    /** Total number of messages */
+    messageCount: number;
+}
+/**
+ * Represents an email account in Apple Mail.
+ */
+export interface Account {
+    /** Display name of the account */
+    name: string;
+    /** Primary email address for the account */
+    email: string;
+    /** Account type (e.g., "iCloud", "Gmail", "Exchange") */
+    accountType?: string;
+    /** Whether the account is enabled */
+    enabled: boolean;
+}
+/**
+ * Represents an email attachment.
+ */
+export interface Attachment {
+    /** Attachment identifier */
+    id: string;
+    /** Filename of the attachment */
+    name: string;
+    /** MIME type of the attachment */
+    mimeType: string;
+    /** Size in bytes */
+    size: number;
+}
+/**
+ * Options for AppleScript execution.
+ */
+export interface AppleScriptOptions {
+    /** Maximum execution time in milliseconds */
+    timeoutMs?: number;
+    /** Maximum number of retry attempts */
+    maxRetries?: number;
+    /** Initial delay between retries in milliseconds */
+    retryDelayMs?: number;
+}
+/**
+ * Result from executing an AppleScript command.
+ */
+export interface AppleScriptResult {
+    /** Whether the script executed successfully */
+    success: boolean;
+    /** Output from the script (stdout) */
+    output: string;
+    /** Error message if execution failed */
+    error?: string;
+}
+/**
+ * Parameters for searching messages.
+ */
+export interface SearchMessagesParams {
+    /** Text to search for (searches subject, sender, content) */
+    query?: string;
+    /** Filter by sender email address */
+    from?: string;
+    /** Filter by recipient email address */
+    to?: string;
+    /** Filter by subject line */
+    subject?: string;
+    /** Mailbox to search in */
+    mailbox?: string;
+    /** Account to search in */
+    account?: string;
+    /** Filter by read status */
+    isRead?: boolean;
+    /** Filter by flagged status */
+    isFlagged?: boolean;
+    /** Start date for search range */
+    dateFrom?: Date;
+    /** End date for search range */
+    dateTo?: Date;
+    /** Maximum number of results to return */
+    limit?: number;
+}
+/**
+ * Parameters for sending an email.
+ */
+export interface SendEmailParams {
+    /** Recipient email addresses (To field) */
+    to: string[];
+    /** CC recipients */
+    cc?: string[];
+    /** BCC recipients */
+    bcc?: string[];
+    /** Email subject line */
+    subject: string;
+    /** Email body content */
+    body: string;
+    /** Whether the body is HTML formatted */
+    isHtml?: boolean;
+    /** Account to send from */
+    account?: string;
+}
+/**
+ * Parameters for getting a message by ID.
+ */
+export interface GetMessageParams {
+    /** Message identifier */
+    id: string;
+}
+/**
+ * Parameters for listing messages.
+ */
+export interface ListMessagesParams {
+    /** Mailbox to list messages from */
+    mailbox?: string;
+    /** Account to list messages from */
+    account?: string;
+    /** Maximum number of messages to return */
+    limit?: number;
+    /** Filter to unread messages only */
+    unreadOnly?: boolean;
+}
+/**
+ * Parameters for mailbox operations.
+ */
+export interface MailboxParams {
+    /** Mailbox name */
+    name: string;
+    /** Account containing the mailbox */
+    account?: string;
+}
+/**
+ * Parameters for moving a message.
+ */
+export interface MoveMessageParams {
+    /** Message identifier */
+    id: string;
+    /** Destination mailbox name */
+    mailbox: string;
+    /** Account containing the destination mailbox */
+    account?: string;
+}
+/**
+ * A single recipient in a serial email (mail merge) operation.
+ */
+export interface SerialEmailRecipient {
+    /** Recipient email address */
+    email: string;
+    /** Variable values for placeholder replacement (e.g., { Name: "Alice", Company: "Acme" }) */
+    variables: Record<string, string>;
+}
+/**
+ * Result of sending a serial email to a single recipient.
+ */
+export interface SerialEmailResult {
+    /** Recipient email address */
+    email: string;
+    /** Whether the email was sent successfully */
+    success: boolean;
+    /** Error message if sending failed */
+    error?: string;
+}
+/**
+ * Individual check result in a health check.
+ */
+export interface HealthCheckItem {
+    /** Name of the check */
+    name: string;
+    /** Whether the check passed */
+    passed: boolean;
+    /** Details about the check result */
+    message: string;
+}
+/**
+ * Result of a health check operation.
+ */
+export interface HealthCheckResult {
+    /** Whether all checks passed */
+    healthy: boolean;
+    /** Individual check results */
+    checks: HealthCheckItem[];
+}
+/**
+ * Statistics for a mailbox.
+ */
+export interface MailboxStats {
+    /** Mailbox name */
+    name: string;
+    /** Total message count */
+    messageCount: number;
+    /** Unread message count */
+    unreadCount: number;
+}
+/**
+ * Statistics for an account.
+ */
+export interface AccountStats {
+    /** Account name */
+    name: string;
+    /** Total messages in account */
+    totalMessages: number;
+    /** Total unread messages */
+    unreadMessages: number;
+    /** Number of mailboxes */
+    mailboxCount: number;
+    /** Per-mailbox statistics */
+    mailboxes: MailboxStats[];
+}
+/**
+ * Recently received message counts.
+ */
+export interface RecentlyReceivedStats {
+    /** Messages received in last 24 hours */
+    last24h: number;
+    /** Messages received in last 7 days */
+    last7d: number;
+    /** Messages received in last 30 days */
+    last30d: number;
+}
+/**
+ * Overall mail statistics.
+ */
+export interface MailStats {
+    /** Total messages across all accounts */
+    totalMessages: number;
+    /** Total unread messages */
+    totalUnread: number;
+    /** Per-account statistics */
+    accounts: AccountStats[];
+    /** Recently received message counts */
+    recentlyReceived?: RecentlyReceivedStats;
+}
+/**
+ * Result of a batch operation on a single item.
+ */
+export interface BatchOperationResult {
+    /** Item identifier */
+    id: string;
+    /** Whether the operation succeeded */
+    success: boolean;
+    /** Error message if operation failed */
+    error?: string;
+}
+/**
+ * Represents a mail rule in Apple Mail.
+ */
+export interface MailRule {
+    /** Rule name */
+    name: string;
+    /** Whether the rule is enabled */
+    enabled: boolean;
+}
+/**
+ * Represents a contact from Contacts.app.
+ */
+export interface Contact {
+    /** Full name */
+    name: string;
+    /** Email addresses */
+    emails: string[];
+    /** Phone numbers */
+    phones: string[];
+}
+/**
+ * Represents a stored email template.
+ */
+export interface EmailTemplate {
+    /** Template identifier */
+    id: string;
+    /** Template name */
+    name: string;
+    /** Default subject line */
+    subject: string;
+    /** Template body */
+    body: string;
+    /** Default recipients */
+    to?: string[];
+    /** Default CC recipients */
+    cc?: string[];
+}
+/**
+ * Status of Mail.app sync activity.
+ */
+export interface SyncStatus {
+    /** Whether sync activity was detected */
+    syncDetected: boolean;
+    /** Number of items pending upload */
+    pendingUpload: number;
+    /** Whether there was recent database activity */
+    recentActivity: boolean;
+    /** Seconds since last database change */
+    secondsSinceLastChange: number;
+    /** Error message if status check failed */
+    error?: string;
+}
+//# sourceMappingURL=types.d.ts.map

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
build/types.d.ts.map


+ 13 - 0
build/types.js

@@ -0,0 +1,13 @@
+/**
+ * Type Definitions for Apple Mail MCP Server
+ *
+ * This module contains all TypeScript interfaces and types used throughout
+ * the Apple Mail MCP server. These types model:
+ *
+ * - Apple Mail data structures (messages, mailboxes, accounts)
+ * - AppleScript execution results
+ * - MCP tool parameters
+ *
+ * @module types
+ */
+export {};

+ 45 - 0
build/utils/applescript.d.ts

@@ -0,0 +1,45 @@
+/**
+ * AppleScript Execution Utilities
+ *
+ * This module provides a safe interface for executing AppleScript commands
+ * on macOS. It handles script execution, error capture, and result parsing.
+ *
+ * @module utils/applescript
+ */
+import type { AppleScriptResult, AppleScriptOptions } from "../types.js";
+/**
+ * Executes an AppleScript command and returns a structured result.
+ *
+ * This function serves as the bridge between TypeScript and macOS AppleScript.
+ * It handles the complexity of shell escaping, execution, and error handling
+ * so that calling code can work with clean TypeScript interfaces.
+ *
+ * The script is executed synchronously via the `osascript` command-line tool.
+ * Multi-line scripts are supported and preserved (important for AppleScript
+ * tell blocks and repeat loops).
+ *
+ * @param script - The AppleScript code to execute
+ * @param options - Optional execution settings (timeout, etc.)
+ * @returns A result object with success status and output or error message
+ *
+ * @example
+ * ```typescript
+ * // Basic usage with default timeout (30 seconds)
+ * const result = executeAppleScript(`
+ *   tell application "Notes"
+ *     get name of every note
+ *   end tell
+ * `);
+ *
+ * // With custom timeout for complex operations
+ * const result = executeAppleScript(complexScript, { timeoutMs: 60000 });
+ *
+ * if (result.success) {
+ *   console.log("Notes:", result.output);
+ * } else {
+ *   console.error("Failed:", result.error);
+ * }
+ * ```
+ */
+export declare function executeAppleScript(script: string, options?: AppleScriptOptions): AppleScriptResult;
+//# sourceMappingURL=applescript.d.ts.map

+ 1 - 0
build/utils/applescript.d.ts.map

@@ -0,0 +1 @@
+{"version":3,"file":"applescript.d.ts","sourceRoot":"","sources":["../../src/utils/applescript.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,OAAO,KAAK,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAuOxE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,MAAM,EACd,OAAO,GAAE,kBAAuB,GAC/B,iBAAiB,CA6HnB"}

+ 372 - 0
build/utils/applescript.js

@@ -0,0 +1,372 @@
+/**
+ * AppleScript Execution Utilities
+ *
+ * This module provides a safe interface for executing AppleScript commands
+ * on macOS. It handles script execution, error capture, and result parsing.
+ *
+ * @module utils/applescript
+ */
+import { execSync, spawnSync } from "child_process";
+/**
+ * Default execution timeout for AppleScript commands in milliseconds.
+ * 30 seconds is sufficient for most operations, including complex
+ * searches on large mailboxes. Can be overridden per-call.
+ */
+const DEFAULT_TIMEOUT_MS = 30000;
+/**
+ * Default retry configuration.
+ * - 1 attempt means no retries (default behavior)
+ * - Use maxRetries: 3 for exponential backoff with 1s/2s delays
+ */
+const DEFAULT_MAX_RETRIES = 1;
+const DEFAULT_RETRY_DELAY_MS = 1000;
+/**
+ * Check if debug/verbose logging is enabled.
+ * Set DEBUG=1 or DEBUG=true or VERBOSE=1 to enable.
+ */
+const isDebugEnabled = () => {
+    const debug = process.env.DEBUG;
+    const verbose = process.env.VERBOSE;
+    return debug === "1" || debug === "true" || verbose === "1" || verbose === "true";
+};
+/**
+ * Log a debug message if debug mode is enabled.
+ *
+ * @param message - The message to log
+ * @param data - Optional additional data to log
+ */
+function debugLog(message, data) {
+    if (!isDebugEnabled())
+        return;
+    const timestamp = new Date().toISOString();
+    if (data !== undefined) {
+        console.error(`[DEBUG ${timestamp}] ${message}`, data);
+    }
+    else {
+        console.error(`[DEBUG ${timestamp}] ${message}`);
+    }
+}
+/**
+ * Escapes a string for safe inclusion in a shell command.
+ *
+ * When passing AppleScript to osascript via shell, we need to handle
+ * the interaction between shell quoting and AppleScript string literals.
+ * This function escapes single quotes since we wrap the script in single quotes.
+ *
+ * @param script - The raw AppleScript code
+ * @returns Shell-safe version of the script
+ *
+ * @example
+ * // Input: tell app "Notes" to get note "Rob's Note"
+ * // Output: tell app "Notes" to get note "Rob'\''s Note"
+ */
+function escapeForShell(script) {
+    // Replace single quotes with: end quote, escaped quote, start quote
+    // This is the standard shell escaping pattern for single-quoted strings
+    return script.replace(/'/g, "'\\''");
+}
+/**
+ * Checks if an error is a timeout error from execSync.
+ *
+ * Node.js throws errors with specific properties when a child process
+ * is killed due to timeout.
+ *
+ * @param error - The caught error object
+ * @returns True if this was a timeout error
+ */
+function isTimeoutError(error) {
+    if (error instanceof Error) {
+        const execError = error;
+        // execSync kills the process with SIGTERM on timeout
+        return execError.killed === true || execError.signal === "SIGTERM";
+    }
+    return false;
+}
+/**
+ * Error patterns that indicate transient failures worth retrying.
+ * These typically occur when Mail.app is busy or temporarily unresponsive.
+ */
+const RETRYABLE_ERROR_PATTERNS = [
+    /timed? out/i,
+    /not responding/i,
+    /connection.*invalid/i,
+    /lost connection/i,
+    /busy/i,
+];
+/**
+ * Checks if an error message indicates a transient failure that should be retried.
+ *
+ * @param errorMessage - The error message to check
+ * @returns True if this error is worth retrying
+ */
+function isRetryableError(errorMessage) {
+    return RETRYABLE_ERROR_PATTERNS.some((pattern) => pattern.test(errorMessage));
+}
+/**
+ * Synchronous sleep using the system's sleep command.
+ * Used between retry attempts for exponential backoff.
+ *
+ * This is more efficient than a busy-wait loop as it doesn't
+ * consume CPU cycles during the delay.
+ *
+ * Uses spawnSync instead of execSync to avoid interference with
+ * execSync mocks in tests.
+ *
+ * @param ms - Milliseconds to sleep
+ */
+function sleep(ms) {
+    // Use system sleep command with fractional seconds support
+    // This avoids CPU-spinning busy wait while keeping the code synchronous
+    const seconds = ms / 1000;
+    const result = spawnSync("sleep", [seconds.toString()], { stdio: "ignore" });
+    if (result.error) {
+        // Fallback to busy-wait if sleep command fails (shouldn't happen on macOS)
+        const end = Date.now() + ms;
+        while (Date.now() < end) {
+            // Busy wait fallback
+        }
+    }
+}
+/**
+ * User-friendly error messages mapped from common AppleScript errors.
+ * Each entry maps a pattern (regex or string) to a user-friendly message.
+ */
+const ERROR_MAPPINGS = [
+    // Permission errors
+    {
+        pattern: /not authorized|not permitted|access.*denied/i,
+        message: "Permission denied. Grant automation access in System Preferences > Privacy & Security > Automation.",
+    },
+    // Application not running
+    {
+        pattern: /application isn't running|not running/i,
+        message: "Mail.app is not responding. Try opening Mail.app manually.",
+    },
+    // Connection errors
+    {
+        pattern: /connection is invalid|lost connection/i,
+        message: "Lost connection to Mail.app. The app may have crashed or been restarted.",
+    },
+    // Message not found
+    {
+        pattern: /can't get message/i,
+        message: "Message not found. The message may have been deleted or moved.",
+    },
+    // Mailbox not found
+    {
+        pattern: /can't get mailbox "([^"]+)"/i,
+        message: 'Mailbox "$1" not found. Use list-mailboxes to see available mailboxes.',
+    },
+    // Account not found
+    {
+        pattern: /can't get account "([^"]+)"/i,
+        message: 'Account "$1" not found. Use list-accounts to see available accounts.',
+    },
+    // Send failed
+    {
+        pattern: /couldn't send|send failed|cannot send/i,
+        message: "Failed to send email. Check your network connection and Mail.app settings.",
+    },
+    // Offline
+    {
+        pattern: /offline|no connection/i,
+        message: "Mail.app is offline. Check your network connection.",
+    },
+    // Cannot delete (various reasons)
+    {
+        pattern: /can't delete|cannot delete/i,
+        message: "Cannot delete. The message may be locked or in use.",
+    },
+    // Syntax/script errors (usually programming bugs)
+    {
+        pattern: /syntax error|expected/i,
+        message: "Internal error. Please report this issue.",
+    },
+];
+/**
+ * Parses error output from osascript to extract meaningful error messages.
+ *
+ * osascript errors typically include execution error numbers and descriptions.
+ * This function attempts to extract the human-readable portion and map it
+ * to a user-friendly message with helpful suggestions.
+ *
+ * @param errorOutput - Raw error string from execSync
+ * @returns User-friendly error message with suggested action
+ */
+function parseErrorMessage(errorOutput) {
+    // First, extract the core error message from AppleScript format
+    let coreError = errorOutput;
+    // Check for execution error format: "execution error: Message (-1234)"
+    const executionError = errorOutput.match(/execution error: (.+?)(?:\s*\(-?\d+\))?$/m);
+    if (executionError) {
+        coreError = executionError[1].trim();
+    }
+    // Try to match against known error patterns for user-friendly messages
+    for (const { pattern, message } of ERROR_MAPPINGS) {
+        const match = coreError.match(pattern);
+        if (match) {
+            // Replace $1, $2, etc. with captured groups
+            let result = message;
+            for (let i = 1; i < match.length; i++) {
+                result = result.replace(`$${i}`, match[i] || "");
+            }
+            return result;
+        }
+    }
+    // Fall back to basic "Can't get X" parsing
+    const notFoundError = coreError.match(/Can't get (.+?)\./);
+    if (notFoundError) {
+        return `Not found: ${notFoundError[1]}`;
+    }
+    // Return cleaned version of original error
+    return coreError.trim() || "Unknown AppleScript error";
+}
+/**
+ * Executes an AppleScript command and returns a structured result.
+ *
+ * This function serves as the bridge between TypeScript and macOS AppleScript.
+ * It handles the complexity of shell escaping, execution, and error handling
+ * so that calling code can work with clean TypeScript interfaces.
+ *
+ * The script is executed synchronously via the `osascript` command-line tool.
+ * Multi-line scripts are supported and preserved (important for AppleScript
+ * tell blocks and repeat loops).
+ *
+ * @param script - The AppleScript code to execute
+ * @param options - Optional execution settings (timeout, etc.)
+ * @returns A result object with success status and output or error message
+ *
+ * @example
+ * ```typescript
+ * // Basic usage with default timeout (30 seconds)
+ * const result = executeAppleScript(`
+ *   tell application "Notes"
+ *     get name of every note
+ *   end tell
+ * `);
+ *
+ * // With custom timeout for complex operations
+ * const result = executeAppleScript(complexScript, { timeoutMs: 60000 });
+ *
+ * if (result.success) {
+ *   console.log("Notes:", result.output);
+ * } else {
+ *   console.error("Failed:", result.error);
+ * }
+ * ```
+ */
+export function executeAppleScript(script, options = {}) {
+    const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
+    const maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
+    const retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
+    // Validate input - empty scripts are likely programmer errors
+    if (!script || !script.trim()) {
+        return {
+            success: false,
+            output: "",
+            error: "Cannot execute empty AppleScript",
+        };
+    }
+    // Prepare the script:
+    // 1. Trim leading/trailing whitespace (cosmetic)
+    // 2. Preserve internal newlines (required for AppleScript syntax)
+    // 3. Escape for shell execution
+    const preparedScript = escapeForShell(script.trim());
+    // Build the osascript command
+    // We use single quotes to wrap the script, which is why we escape
+    // single quotes within the script itself
+    const command = `osascript -e '${preparedScript}'`;
+    // Debug: Log the script being executed
+    debugLog("Executing AppleScript", {
+        scriptPreview: script.trim().substring(0, 200) + (script.length > 200 ? "..." : ""),
+        timeout: timeoutMs,
+        maxRetries,
+    });
+    let lastError = null;
+    const startTime = Date.now();
+    for (let attempt = 1; attempt <= maxRetries; attempt++) {
+        const attemptStart = Date.now();
+        try {
+            // Execute synchronously - MCP tools are inherently synchronous
+            // and Apple Notes operations are fast enough that async isn't needed
+            const output = execSync(command, {
+                encoding: "utf8",
+                timeout: timeoutMs,
+                // Capture stderr separately to get error details
+                stdio: ["pipe", "pipe", "pipe"],
+            });
+            const duration = Date.now() - attemptStart;
+            debugLog("AppleScript succeeded", {
+                attempt,
+                duration: `${duration}ms`,
+                outputLength: output.length,
+                outputPreview: output.substring(0, 100) + (output.length > 100 ? "..." : ""),
+            });
+            return {
+                success: true,
+                output: output.trim(),
+            };
+        }
+        catch (error) {
+            // execSync throws on non-zero exit codes
+            // The error object contains stderr output with AppleScript error details
+            const attemptDuration = Date.now() - attemptStart;
+            let errorMessage;
+            let isTimeout = false;
+            let rawError;
+            // Check for timeout first - provide specific message
+            if (isTimeoutError(error)) {
+                isTimeout = true;
+                const timeoutSecs = Math.round(timeoutMs / 1000);
+                errorMessage = `Operation timed out after ${timeoutSecs} seconds. Mail.app may be unresponsive or the operation involves too many messages.`;
+            }
+            else if (error instanceof Error) {
+                rawError = error.message;
+                // Node's ExecException includes stderr in the message
+                errorMessage = parseErrorMessage(error.message);
+            }
+            else if (typeof error === "string") {
+                rawError = error;
+                errorMessage = parseErrorMessage(error);
+            }
+            else {
+                errorMessage = "AppleScript execution failed with unknown error";
+            }
+            // Debug: Log error details
+            debugLog("AppleScript failed", {
+                attempt,
+                duration: `${attemptDuration}ms`,
+                totalElapsed: `${Date.now() - startTime}ms`,
+                isTimeout,
+                errorMessage,
+                rawError: rawError?.substring(0, 500),
+            });
+            lastError = {
+                success: false,
+                output: "",
+                error: errorMessage,
+            };
+            // Check if we should retry
+            const canRetry = isTimeout || isRetryableError(errorMessage);
+            const hasAttemptsLeft = attempt < maxRetries;
+            if (canRetry && hasAttemptsLeft) {
+                const delayMs = retryDelayMs * Math.pow(2, attempt - 1);
+                console.error(`AppleScript retry: Attempt ${attempt}/${maxRetries} failed with "${errorMessage}". Retrying in ${delayMs}ms...`);
+                sleep(delayMs);
+                // Continue to next attempt
+            }
+            else {
+                // Log final error and return
+                if (isTimeout) {
+                    console.error(`AppleScript timeout: ${errorMessage}`);
+                }
+                else {
+                    console.error(`AppleScript error: ${errorMessage}`);
+                }
+                return lastError;
+            }
+        }
+    }
+    // Return the last error (all retries exhausted - shouldn't reach here normally)
+    return lastError;
+}

+ 41 - 0
build/utils/mimeParse.d.ts

@@ -0,0 +1,41 @@
+/**
+ * MIME Source Parser for Attachment Extraction
+ *
+ * Parses raw email MIME source to extract attachment metadata and content.
+ * Used as a fallback when AppleScript's `mail attachments` returns empty
+ * (which happens across all account types: iCloud, Google, Exchange).
+ *
+ * @module utils/mimeParse
+ */
+export interface MimeAttachmentInfo {
+    /** Filename from Content-Disposition or Content-Type name parameter */
+    name: string;
+    /** MIME type from Content-Type header */
+    mimeType: string;
+    /** Size in bytes from Content-Disposition size parameter, or estimated from body */
+    size: number;
+}
+export interface MimeAttachmentData extends MimeAttachmentInfo {
+    /** Decoded binary content */
+    data: Buffer;
+}
+/**
+ * Parse MIME source and return metadata for all file attachments.
+ * Skips inline dispositions (signature images, etc.). Descends into
+ * nested multipart/* containers.
+ *
+ * @param source - Raw MIME source of the email
+ * @returns Array of attachment metadata (name, mimeType, size)
+ */
+export declare function parseMimeAttachments(source: string): MimeAttachmentInfo[];
+/**
+ * Extract and decode a specific attachment from MIME source by filename.
+ * Supports base64, quoted-printable, and 7bit/8bit/binary transfer encodings.
+ * Descends into nested multipart/* containers.
+ *
+ * @param source - Raw MIME source of the email
+ * @param attachmentName - Filename to extract
+ * @returns Decoded attachment data, or null if not found
+ */
+export declare function extractMimeAttachment(source: string, attachmentName: string): MimeAttachmentData | null;
+//# sourceMappingURL=mimeParse.d.ts.map

+ 1 - 0
build/utils/mimeParse.d.ts.map

@@ -0,0 +1 @@
+{"version":3,"file":"mimeParse.d.ts","sourceRoot":"","sources":["../../src/utils/mimeParse.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,MAAM,WAAW,kBAAkB;IACjC,uEAAuE;IACvE,IAAI,EAAE,MAAM,CAAC;IACb,yCAAyC;IACzC,QAAQ,EAAE,MAAM,CAAC;IACjB,oFAAoF;IACpF,IAAI,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,kBAAmB,SAAQ,kBAAkB;IAC5D,6BAA6B;IAC7B,IAAI,EAAE,MAAM,CAAC;CACd;AAyLD;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,MAAM,GAAG,kBAAkB,EAAE,CAyBzE;AAED;;;;;;;;GAQG;AACH,wBAAgB,qBAAqB,CACnC,MAAM,EAAE,MAAM,EACd,cAAc,EAAE,MAAM,GACrB,kBAAkB,GAAG,IAAI,CAwB3B"}

+ 238 - 0
build/utils/mimeParse.js

@@ -0,0 +1,238 @@
+/**
+ * MIME Source Parser for Attachment Extraction
+ *
+ * Parses raw email MIME source to extract attachment metadata and content.
+ * Used as a fallback when AppleScript's `mail attachments` returns empty
+ * (which happens across all account types: iCloud, Google, Exchange).
+ *
+ * @module utils/mimeParse
+ */
+/**
+ * Extract the boundary string from a Content-Type header value
+ * (or from any string containing a boundary= parameter).
+ */
+function extractBoundary(source) {
+    const match = source.match(/boundary="?([^";\s\r\n]+)"?/i);
+    return match ? match[1] : null;
+}
+/**
+ * Extract a header value from a MIME part header block.
+ * Handles folded headers (continuation lines starting with whitespace).
+ */
+function getHeader(headers, name) {
+    const regex = new RegExp(`^${name}:\\s*(.+(?:\\r?\\n[ \\t]+.+)*)`, "im");
+    const match = headers.match(regex);
+    if (!match)
+        return null;
+    // Unfold: replace newline+whitespace with single space
+    return match[1].replace(/\r?\n[ \t]+/g, " ").trim();
+}
+/**
+ * Extract filename from Content-Disposition or Content-Type headers.
+ */
+function extractFilename(headers) {
+    // Try Content-Disposition filename first
+    const dispHeader = getHeader(headers, "Content-Disposition");
+    if (dispHeader) {
+        const fnMatch = dispHeader.match(/filename="?([^";\r\n]+)"?/i);
+        if (fnMatch)
+            return fnMatch[1].trim();
+    }
+    // Fall back to Content-Type name parameter
+    const ctHeader = getHeader(headers, "Content-Type");
+    if (ctHeader) {
+        const nameMatch = ctHeader.match(/name="?([^";\r\n]+)"?/i);
+        if (nameMatch)
+            return nameMatch[1].trim();
+    }
+    return null;
+}
+/**
+ * Check if a MIME part has inline disposition (not a real attachment).
+ */
+function isInlineDisposition(headers) {
+    const dispHeader = getHeader(headers, "Content-Disposition");
+    if (!dispHeader)
+        return false;
+    return dispHeader.toLowerCase().startsWith("inline");
+}
+/**
+ * Extract size from Content-Disposition size parameter.
+ */
+function extractSize(headers) {
+    const dispHeader = getHeader(headers, "Content-Disposition");
+    if (dispHeader) {
+        const sizeMatch = dispHeader.match(/size=(\d+)/i);
+        if (sizeMatch)
+            return parseInt(sizeMatch[1], 10);
+    }
+    return 0;
+}
+/**
+ * Extract MIME type from Content-Type header.
+ */
+function extractMimeType(headers) {
+    const ctHeader = getHeader(headers, "Content-Type");
+    if (!ctHeader)
+        return "application/octet-stream";
+    const typeMatch = ctHeader.match(/^([^;\s]+)/);
+    return typeMatch ? typeMatch[1].toLowerCase() : "application/octet-stream";
+}
+/**
+ * Estimate decoded size from base64 content length.
+ */
+function estimateBase64Size(base64Body) {
+    const cleaned = base64Body.replace(/[\s\r\n]/g, "");
+    return Math.floor((cleaned.length * 3) / 4);
+}
+/**
+ * Split a MIME block into parts using the given boundary.
+ * Does not recurse — call walkLeafParts for recursive traversal.
+ */
+function splitMimeParts(source, boundary) {
+    const parts = [];
+    const boundaryDelim = `--${boundary}`;
+    const sections = source.split(boundaryDelim);
+    for (const section of sections) {
+        const trimmed = section.trim();
+        if (!trimmed || trimmed.startsWith("--"))
+            continue;
+        // Split headers from body at first blank line
+        const blankLineIdx = trimmed.search(/\r?\n\r?\n/);
+        if (blankLineIdx === -1)
+            continue;
+        const headers = trimmed.substring(0, blankLineIdx);
+        const body = trimmed.substring(blankLineIdx).replace(/^\r?\n\r?\n/, "");
+        parts.push({ headers, body });
+    }
+    return parts;
+}
+/**
+ * Walk a multipart MIME block and return all non-multipart leaf parts,
+ * descending into nested multipart/* containers (alternative, related, mixed).
+ */
+function walkLeafParts(source, boundary) {
+    const result = [];
+    const parts = splitMimeParts(source, boundary);
+    for (const part of parts) {
+        const ct = getHeader(part.headers, "Content-Type");
+        if (ct && /^multipart\//i.test(ct)) {
+            const nestedBoundary = extractBoundary(ct);
+            if (nestedBoundary) {
+                result.push(...walkLeafParts(part.body, nestedBoundary));
+                continue;
+            }
+        }
+        result.push(part);
+    }
+    return result;
+}
+/**
+ * Decode a MIME part body to bytes based on its transfer encoding.
+ * Supports base64, quoted-printable, and 7bit/8bit/binary (raw).
+ */
+function decodeBody(body, encoding) {
+    const enc = (encoding || "").toLowerCase().trim();
+    if (enc === "base64") {
+        return Buffer.from(body.replace(/[\s\r\n]/g, ""), "base64");
+    }
+    if (enc === "quoted-printable") {
+        return decodeQuotedPrintable(body);
+    }
+    // 7bit, 8bit, binary, or unspecified — treat as raw bytes
+    return Buffer.from(body, "binary");
+}
+/**
+ * Decode quoted-printable-encoded body to bytes.
+ * Handles soft line breaks (=<CRLF>) and =XX hex escapes per RFC 2045 §6.7.
+ */
+function decodeQuotedPrintable(body) {
+    // Remove soft line breaks: `=` immediately followed by CRLF or LF
+    const noSoft = body.replace(/=\r?\n/g, "");
+    const bytes = [];
+    for (let i = 0; i < noSoft.length; i++) {
+        const c = noSoft[i];
+        if (c === "=" && i + 2 < noSoft.length) {
+            const hex = noSoft.substring(i + 1, i + 3);
+            if (/^[0-9A-Fa-f]{2}$/.test(hex)) {
+                bytes.push(parseInt(hex, 16));
+                i += 2;
+                continue;
+            }
+        }
+        bytes.push(c.charCodeAt(0) & 0xff);
+    }
+    return Buffer.from(bytes);
+}
+/**
+ * Estimate body size for metadata when Content-Disposition size is absent.
+ */
+function estimateSize(body, encoding) {
+    const enc = (encoding || "").toLowerCase().trim();
+    if (enc === "base64")
+        return estimateBase64Size(body);
+    // For other encodings the body length is a reasonable proxy
+    return body.length;
+}
+/**
+ * Parse MIME source and return metadata for all file attachments.
+ * Skips inline dispositions (signature images, etc.). Descends into
+ * nested multipart/* containers.
+ *
+ * @param source - Raw MIME source of the email
+ * @returns Array of attachment metadata (name, mimeType, size)
+ */
+export function parseMimeAttachments(source) {
+    if (!source || !source.trim())
+        return [];
+    const boundary = extractBoundary(source);
+    if (!boundary)
+        return [];
+    const parts = walkLeafParts(source, boundary);
+    const attachments = [];
+    for (const part of parts) {
+        const filename = extractFilename(part.headers);
+        if (!filename)
+            continue;
+        if (isInlineDisposition(part.headers))
+            continue;
+        const encoding = getHeader(part.headers, "Content-Transfer-Encoding");
+        attachments.push({
+            name: filename,
+            mimeType: extractMimeType(part.headers),
+            size: extractSize(part.headers) || estimateSize(part.body, encoding),
+        });
+    }
+    return attachments;
+}
+/**
+ * Extract and decode a specific attachment from MIME source by filename.
+ * Supports base64, quoted-printable, and 7bit/8bit/binary transfer encodings.
+ * Descends into nested multipart/* containers.
+ *
+ * @param source - Raw MIME source of the email
+ * @param attachmentName - Filename to extract
+ * @returns Decoded attachment data, or null if not found
+ */
+export function extractMimeAttachment(source, attachmentName) {
+    if (!source || !source.trim())
+        return null;
+    const boundary = extractBoundary(source);
+    if (!boundary)
+        return null;
+    const parts = walkLeafParts(source, boundary);
+    for (const part of parts) {
+        const filename = extractFilename(part.headers);
+        if (filename !== attachmentName)
+            continue;
+        const encoding = getHeader(part.headers, "Content-Transfer-Encoding");
+        const data = decodeBody(part.body, encoding);
+        return {
+            name: filename,
+            mimeType: extractMimeType(part.headers),
+            size: extractSize(part.headers) || data.length,
+            data,
+        };
+    }
+    return null;
+}

+ 1 - 1
package.json

@@ -29,7 +29,7 @@
     "typecheck": "tsc --noEmit",
     "version": "node -e \"const p=require('./package.json'); const f='.claude-plugin/plugin.json'; const c=JSON.parse(require('fs').readFileSync(f,'utf8')); c.version=p.version; require('fs').writeFileSync(f,JSON.stringify(c,null,2)+'\\n')\" && git add .claude-plugin/plugin.json",
     "prepublishOnly": "npm run lint && npm run test && npm run build",
-    "prepare": "husky && npm run build"
+    "prepare": "husky"
   },
   "keywords": [
     "mcp",

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно