Explorar el Código

feat: add 15 new features and improvements

1. Expose unflag-message tool (was implemented but not wired up)
2. Add batch-mark-as-unread tool
3. Add batch-flag-messages and batch-unflag-messages tools
4. Add date received to search/list message output
5. Add from filter and offset/pagination to list-messages
6. Add save-attachment tool to download attachments to disk
7. Add HTML email content support (preferHtml option in get-message)
8. Add offset parameter to list-messages for pagination
9. Search across all accounts when no account specified
10. Add dateFrom/dateTo date range filtering to search-messages
11. Add create-mailbox, delete-mailbox, rename-mailbox tools
12. Add list-rules, enable-rule, disable-rule tools
13. Add search-contacts tool (Contacts.app integration)
14. Add email template tools (save/list/get/delete/use-template)
15. Add use-template tool to create drafts from templates

Total tools: 22 → 38
Robert Sweet hace 3 meses
padre
commit
720821f3cb
Se han modificado 5 ficheros con 897 adiciones y 33 borrados
  1. 2 2
      package-lock.json
  2. 1 1
      package.json
  3. 382 8
      src/index.ts
  4. 452 22
      src/services/appleMailManager.ts
  5. 60 0
      src/types.ts

+ 2 - 2
package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "apple-mail-mcp",
-  "version": "1.0.2",
+  "version": "1.1.0",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "apple-mail-mcp",
-      "version": "1.0.2",
+      "version": "1.1.0",
       "license": "MIT",
       "os": [
         "darwin"

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "apple-mail-mcp",
-  "version": "1.0.2",
+  "version": "1.1.0",
   "description": "MCP server for Apple Mail - read, search, send, and manage emails via Claude",
   "type": "module",
   "main": "build/index.js",

+ 382 - 8
src/index.ts

@@ -102,13 +102,15 @@ server.tool(
     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')"),
-    account: z.string().optional().describe("Account to search in"),
+    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: z.string().optional().describe("Start date filter (e.g., 'January 1, 2026')"),
+    dateTo: z.string().optional().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 }) => {
-    const messages = mailManager.searchMessages(query, mailbox, account, limit);
+  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");
@@ -117,7 +119,7 @@ server.tool(
     const messageList = messages
       .map(
         (m) =>
-          `  - ID: ${m.id} | ${m.subject} (from: ${m.sender}) [${m.isRead ? "read" : "unread"}]`
+          `  - ID: ${m.id} | ${m.dateReceived.toLocaleDateString()} | ${m.subject} (from: ${m.sender}) [${m.isRead ? "read" : "unread"}]`
       )
       .join("\n");
 
@@ -131,14 +133,19 @@ server.tool(
   "get-message",
   {
     id: z.string().min(1, "Message ID is required"),
+    preferHtml: z.boolean().optional().describe("Return HTML source instead of plain text"),
   },
-  withErrorHandling(({ id }) => {
+  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")
 );
@@ -151,17 +158,22 @@ server.tool(
     mailbox: z.string().optional().describe("Mailbox to list messages from (default: INBOX)"),
     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 }) => {
-    const messages = mailManager.listMessages(mailbox, account, limit);
+  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.subject} (from: ${m.sender})`)
+      .map(
+        (m) =>
+          `  - ID: ${m.id} | ${m.dateReceived.toLocaleDateString()} | ${m.subject} (from: ${m.sender})`
+      )
       .join("\n");
 
     return successResponse(`Found ${messages.length} message(s):\n${messageList}`);
@@ -312,6 +324,24 @@ server.tool(
   }, "Error flagging message")
 );
 
+// --- unflag-message ---
+
+server.tool(
+  "unflag-message",
+  {
+    id: z.string().min(1, "Message ID is required"),
+  },
+  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(
@@ -420,6 +450,72 @@ server.tool(
   }, "Error batch marking messages as read")
 );
 
+// --- batch-mark-as-unread ---
+
+server.tool(
+  "batch-mark-as-unread",
+  {
+    ids: z.array(z.string()).min(1, "At least one message ID is required"),
+  },
+  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: z.array(z.string()).min(1, "At least one message ID is required"),
+  },
+  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: z.array(z.string()).min(1, "At least one message ID is required"),
+  },
+  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(
@@ -445,6 +541,26 @@ server.tool(
   }, "Error listing attachments")
 );
 
+// --- save-attachment ---
+
+server.tool(
+  "save-attachment",
+  {
+    id: z.string().min(1, "Message ID is required"),
+    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
 // =============================================================================
@@ -485,6 +601,64 @@ server.tool(
   }, "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
 // =============================================================================
@@ -506,6 +680,206 @@ server.tool(
   }, "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
 // =============================================================================

+ 452 - 22
src/services/appleMailManager.ts

@@ -26,6 +26,9 @@ import type {
   BatchOperationResult,
   SyncStatus,
   RecentlyReceivedStats,
+  MailRule,
+  Contact,
+  EmailTemplate,
 } from "@/types.js";
 
 // =============================================================================
@@ -251,7 +254,27 @@ export class AppleMailManager {
    * @param limit - Maximum number of results
    * @returns Array of matching messages
    */
-  searchMessages(query?: string, mailbox?: string, account?: string, limit = 50): Message[] {
+  searchMessages(
+    query?: string,
+    mailbox?: string,
+    account?: string,
+    limit = 50,
+    dateFrom?: string,
+    dateTo?: string
+  ): Message[] {
+    // If no account specified, search across all accounts
+    if (!account) {
+      const accounts = this.listAccounts();
+      const allMessages: Message[] = [];
+      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);
     const requestedMailbox = mailbox || "INBOX";
     const targetMailbox = this.resolveMailbox(requestedMailbox, targetAccount);
@@ -263,6 +286,19 @@ export class AppleMailManager {
       searchCondition = `whose subject contains "${safeQuery}" or sender contains "${safeQuery}"`;
     }
 
+    // Build date filter AppleScript
+    let dateFilter = "";
+    if (dateFrom || dateTo) {
+      const dateChecks: string[] = [];
+      if (dateFrom) {
+        dateChecks.push(`date received of msg >= date "${dateFrom}"`);
+      }
+      if (dateTo) {
+        dateChecks.push(`date received of msg <= date "${dateTo}"`);
+      }
+      dateFilter = dateChecks.join(" and ");
+    }
+
     const searchCommand = `
       set outputText to ""
       set theMailbox to mailbox "${escapeForAppleScript(targetMailbox)}"
@@ -271,22 +307,24 @@ export class AppleMailManager {
       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 msgDate to date received of msg as string
+          set msgDateStr to date received of msg as 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 & "|||" & msgDate & "|||" & msgRead & "|||" & msgFlagged
+          set outputText to outputText & msgId & "|||" & msgSubject & "|||" & msgSender & "|||" & msgDateStr & "|||" & msgRead & "|||" & msgFlagged
           set msgCount to msgCount + 1
+          ${dateFilter ? "end if" : ""}
         end try
       end repeat
       return outputText
     `;
 
     const script = buildAccountScopedScript(targetAccount, searchCommand);
-    const result = executeAppleScript(script);
+    const result = executeAppleScript(script, { timeoutMs: 60000 });
 
     if (!result.success) {
       console.error(`Failed to search messages: ${result.error}`);
@@ -373,7 +411,11 @@ export class AppleMailManager {
                 set msg to item 1 of matchingMsgs
                 set msgSubject to subject of msg
                 set msgContent to content of msg
-                return msgSubject & "|||CONTENT|||" & msgContent
+                set htmlContent to ""
+                try
+                  set htmlContent to source of msg
+                end try
+                return msgSubject & "|||CONTENT|||" & msgContent & "|||HTML|||" & htmlContent
               end if
             end try
           end repeat
@@ -391,13 +433,18 @@ export class AppleMailManager {
       return null;
     }
 
-    const parts = result.output.split("|||CONTENT|||");
+    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,
     };
   }
 
@@ -409,27 +456,41 @@ export class AppleMailManager {
    * @param limit - Maximum number of messages
    * @returns Array of messages
    */
-  listMessages(mailbox?: string, account?: string, limit = 50): Message[] {
+  listMessages(
+    mailbox?: string,
+    account?: string,
+    limit = 50,
+    from?: string,
+    offset = 0
+  ): Message[] {
     const targetAccount = this.resolveAccount(account);
     const requestedMailbox = mailbox || "INBOX";
     const targetMailbox = this.resolveMailbox(requestedMailbox, targetAccount);
 
+    const safeFrom = from ? escapeForAppleScript(from) : "";
+    const fromFilter = from ? `whose sender contains "${safeFrom}"` : "";
+
     const listCommand = `
       set outputText to ""
       set theMailbox to mailbox "${escapeForAppleScript(targetMailbox)}"
       set msgCount to 0
-      repeat with msg in messages of theMailbox
+      set skipped to 0
+      repeat with msg in messages of theMailbox ${fromFilter}
         if msgCount >= ${limit} then exit repeat
         try
-          set msgId to id of msg as string
-          set msgSubject to subject of msg
-          set msgSender to sender of msg
-          set msgDate to date received of msg as 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 & "|||" & msgDate & "|||" & msgRead & "|||" & msgFlagged
-          set msgCount to msgCount + 1
+          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 msgDate to date received of msg as 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 & "|||" & msgDate & "|||" & msgRead & "|||" & msgFlagged
+            set msgCount to msgCount + 1
+          end if
         end try
       end repeat
       return outputText
@@ -908,22 +969,53 @@ export class AppleMailManager {
 
   /**
    * Mark multiple messages as read at once.
-   *
-   * @param ids - Array of message IDs to mark as read
-   * @returns Array of results for each message
    */
   batchMarkAsRead(ids: string[]): BatchOperationResult[] {
     const results: BatchOperationResult[] = [];
-
     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: string[]): BatchOperationResult[] {
+    const results: BatchOperationResult[] = [];
+    for (const id of ids) {
+      const success = this.markAsUnread(id);
       results.push({
         id,
         success,
-        error: success ? undefined : "Failed to mark message as read",
+        error: success ? undefined : "Failed to mark message as unread",
       });
     }
+    return results;
+  }
+
+  /**
+   * Flag multiple messages at once.
+   */
+  batchFlagMessages(ids: string[]): BatchOperationResult[] {
+    const results: BatchOperationResult[] = [];
+    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: string[]): BatchOperationResult[] {
+    const results: BatchOperationResult[] = [];
+    for (const id of ids) {
+      const success = this.unflagMessage(id);
+      results.push({ id, success, error: success ? undefined : "Failed to unflag message" });
+    }
     return results;
   }
 
@@ -984,6 +1076,49 @@ export class AppleMailManager {
     return attachments;
   }
 
+  /**
+   * Save an attachment from a message to disk.
+   */
+  saveAttachment(id: string, attachmentName: string, savePath: string): boolean {
+    const safeName = escapeForAppleScript(attachmentName);
+    const safePath = escapeForAppleScript(savePath);
+
+    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 ${id})
+              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.startsWith("error:")) {
+      console.error(`Failed to save attachment: ${result.error || result.output}`);
+      return false;
+    }
+
+    return true;
+  }
+
   // ===========================================================================
   // Mailbox Operations
   // ===========================================================================
@@ -1067,6 +1202,103 @@ export class AppleMailManager {
     return parseInt(result.output) || 0;
   }
 
+  /**
+   * Create a new mailbox.
+   */
+  createMailbox(name: string, account?: string): boolean {
+    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;
+    }
+
+    return true;
+  }
+
+  /**
+   * Delete a mailbox.
+   */
+  deleteMailbox(name: string, account?: string): boolean {
+    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;
+    }
+
+    return true;
+  }
+
+  /**
+   * Rename a mailbox by creating a new one, moving messages, and deleting the old one.
+   */
+  renameMailbox(oldName: string, newName: string, account?: string): boolean {
+    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;
+    }
+
+    return true;
+  }
+
   // ===========================================================================
   // Account Operations
   // ===========================================================================
@@ -1117,6 +1349,204 @@ export class AppleMailManager {
     return accounts;
   }
 
+  // ===========================================================================
+  // Mail Rules
+  // ===========================================================================
+
+  /**
+   * List all mail rules.
+   */
+  listRules(): MailRule[] {
+    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: MailRule[] = [];
+
+    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: string, enabled: boolean): boolean {
+    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: string): Contact[] {
+    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: Contact[] = [];
+
+    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
+  // ===========================================================================
+
+  private templates: Map<string, EmailTemplate> = new Map();
+  private nextTemplateId = 1;
+
+  /**
+   * List all stored templates.
+   */
+  listTemplates(): EmailTemplate[] {
+    return Array.from(this.templates.values());
+  }
+
+  /**
+   * Get a template by ID.
+   */
+  getTemplate(id: string): EmailTemplate | null {
+    return this.templates.get(id) || null;
+  }
+
+  /**
+   * Create or update a template.
+   */
+  saveTemplate(
+    name: string,
+    subject: string,
+    body: string,
+    to?: string[],
+    cc?: string[],
+    id?: string
+  ): EmailTemplate {
+    const templateId = id || `tmpl_${this.nextTemplateId++}`;
+    const template: EmailTemplate = { id: templateId, name, subject, body, to, cc };
+    this.templates.set(templateId, template);
+    return template;
+  }
+
+  /**
+   * Delete a template.
+   */
+  deleteTemplate(id: string): boolean {
+    return this.templates.delete(id);
+  }
+
+  /**
+   * Use a template to create a draft.
+   */
+  useTemplate(
+    id: string,
+    overrides?: { to?: string[]; cc?: string[]; subject?: string; body?: string }
+  ): boolean {
+    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
   // ===========================================================================

+ 60 - 0
src/types.ts

@@ -402,6 +402,66 @@ export interface BatchOperationResult {
   error?: string;
 }
 
+// =============================================================================
+// Mail Rules
+// =============================================================================
+
+/**
+ * Represents a mail rule in Apple Mail.
+ */
+export interface MailRule {
+  /** Rule name */
+  name: string;
+
+  /** Whether the rule is enabled */
+  enabled: boolean;
+}
+
+// =============================================================================
+// Contacts
+// =============================================================================
+
+/**
+ * Represents a contact from Contacts.app.
+ */
+export interface Contact {
+  /** Full name */
+  name: string;
+
+  /** Email addresses */
+  emails: string[];
+
+  /** Phone numbers */
+  phones: string[];
+}
+
+// =============================================================================
+// Email Templates
+// =============================================================================
+
+/**
+ * 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[];
+}
+
 // =============================================================================
 // Sync Detection
 // =============================================================================