diff --git a/.claude/worktrees/agent-a88053b6 b/.claude/worktrees/agent-a88053b6
new file mode 160000
index 0000000..ae25919
--- /dev/null
+++ b/.claude/worktrees/agent-a88053b6
@@ -0,0 +1 @@
+Subproject commit ae2591931891822bdfd4611981ee4d9bc8565907
diff --git a/CHANGELOG.md b/CHANGELOG.md
deleted file mode 100644
index 5e48b6e..0000000
--- a/CHANGELOG.md
+++ /dev/null
@@ -1,235 +0,0 @@
-# Changelog
-
-All notable changes to the Apple Mail MCP Server 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).
-
-## [1.6.0] - 2026-02-06
-
-### Added
-- **CC/BCC support on `reply_to_email`**: Optional `cc` and `bcc` parameters for adding recipients when replying
-- **CC/BCC support on `forward_email`**: Optional `cc` and `bcc` parameters for adding recipients when forwarding
-- **`get_recent_from_sender`**: Retrieve recent emails from a sender with human-friendly time filters (today, week, month, all)
-- **`inbox_dashboard`**: Interactive UI dashboard resource for compatible MCP clients (requires `mcp-ui-server`)
-
-### Changed
-- AppleScript execution now uses **stdin pipe** (`osascript -` with `subprocess.run(input=...)`) instead of `-e` flag, fixing reliability issues with multi-line scripts and special characters
-- Improved error surfacing: AppleScript stderr is now properly captured and raised
-- Tool count updated to 26 (from 25)
-- README fully rewritten for conciseness and scannability
-
-### Fixed
-- Multi-line AppleScript commands that previously failed due to shell escaping now execute reliably via stdin
-- AppleScript timeout handling consolidated (120s default)
-
-## [1.5.0] - 2026-02-01
-
-### Added
-- **search_by_sender**: Find emails from a specific sender across mailboxes
- - Search by sender email address or name
- - Configurable mailbox scope (specific or all)
- - Returns matching emails with subject, date, and read status
-
-- **search_all_accounts**: Cross-account search with advanced filtering
- - Search across all configured email accounts
- - Date range filtering support
- - Configurable sorting options
- - Unified results from multiple accounts
-
-- **search_email_content**: Full-text search in email bodies
- - Search within email message content
- - Find emails containing specific text or phrases
- - Searches both plain text and HTML content
-
-- **get_newsletters**: Find newsletter and subscription emails
- - Identifies newsletter/subscription patterns
- - Filters promotional and mailing list emails
- - Helps manage subscriptions and bulk mail
-
-### Changed
-- Updated manifest to include 4 new search tools (total: 24 tools)
-- Enhanced search capabilities across the server
-
-### Technical
-- Improved search performance for large mailboxes
-- Added missing value error handling for mailbox searches
-
-## [1.4.0] - 2025-10-14
-
-### Added
-- **User Preferences Configuration**: New configurable preference string in MCPB user_config
- - Allows users to set personal email preferences (default account, max emails, preferred folders, etc.)
- - Preferences automatically injected into all tool descriptions
- - Helps Claude understand user workflow and make context-aware decisions
- - Configurable via Claude Desktop UI for .mcpb installations
- - Environment variable support for manual installations (USER_EMAIL_PREFERENCES)
-
-### Changed
-- Updated manifest.json to include user_config section (version 1.4.0)
-- Enhanced all 20 tool functions with @inject_preferences decorator
-- Updated README.md with comprehensive configuration documentation
-
-### Technical
-- Added environment variable loading at server startup
-- Implemented decorator pattern for dynamic docstring injection
-- Zero-config default behavior maintained (preferences optional)
-
-## [1.3.0] - 2025-10-14
-
-### Added
-- **search_emails**: Advanced unified search tool with multi-criteria filtering
- - Search by subject keyword, sender, attachment presence, read status
- - Date range filtering (date_from, date_to)
- - Search across all mailboxes or specific mailbox
- - Optional content preview with configurable max results
-
-- **update_email_status**: Batch email status management
- - Actions: mark_read, mark_unread, flag, unflag
- - Search by subject keyword or sender
- - Safety limit on updates (default: 10)
-
-- **manage_trash**: Comprehensive deletion operations
- - Three actions: move_to_trash, delete_permanent, empty_trash
- - Search by subject or sender
- - Safety limits on deletions (default: 5)
-
-- **forward_email**: Email forwarding capability
- - Forward by subject keyword
- - Optional custom message prepended to forwarded content
-
-- **get_email_thread**: Conversation thread view
- - Groups related messages by subject
- - Strips Re:, Fwd: prefixes for proper threading
- - Searches across all mailboxes
-
-- **manage_drafts**: Complete draft lifecycle management
- - Four actions: list, create, send, delete
- - Full composition parameters support (TO, CC, BCC)
-
-- **get_statistics**: Email analytics dashboard
- - Three scopes: account_overview, sender_stats, mailbox_breakdown
- - Metrics: total emails, read/unread ratios, flagged count, top senders
- - Configurable time range
-
-- **export_emails**: Email export functionality
- - Two scopes: single_email, entire_mailbox
- - Export formats: TXT, HTML
- - Configurable save directory
-
-### Changed
-- Updated manifest to include all 8 new tools (total: 20 tools)
-- Enhanced error handling across all new tools
-- Improved AppleScript safety with proper escaping
-
-### Technical
-- Added comprehensive tool descriptions in manifest.json
-- Implemented safety limits for batch operations
-- Added support for nested mailbox paths with "/" separator
-
-## [1.2.0] - 2025-10-14
-
-### Added
-- **get_inbox_overview**: Email preview section
- - Shows 10 most recent emails across all accounts
- - Includes subject, sender, date, and read status
- - Provides quick snapshot of recent activity
-
-### Changed
-- Enhanced inbox overview to be more comprehensive
-- Improved formatting of overview output
-
-## [1.1.0] - 2025-10-14
-
-### Added
-- **get_inbox_overview**: Comprehensive inbox dashboard
- - Unread counts by account
- - Mailbox structure with unread indicators
- - AI-driven action suggestions
- - Identifies emails needing action or response
-
-### Changed
-- Updated description to highlight overview tool as primary entry point
-
-## [1.0.0] - 2025-10-14
-
-### Added
-- Initial release of Apple Mail MCP Server
-- Core email reading tools:
- - `list_inbox_emails`: List emails with filtering
- - `get_email_with_content`: Search with content preview
- - `get_unread_count`: Quick unread counts
- - `list_accounts`: List Mail accounts
- - `get_recent_emails`: Recent messages
-
-- Email organization tools:
- - `list_mailboxes`: View folder structure
- - `move_email`: Move between folders
-
-- Email composition tools:
- - `compose_email`: Send new emails
- - `reply_to_email`: Reply to messages
-
-- Attachment management:
- - `list_email_attachments`: View attachments
- - `save_email_attachment`: Download attachments
-
-- MCP Bundle (.mcpb) support with build script
-- FastMCP-based implementation
-- AppleScript automation for Mail.app
-- Comprehensive README documentation
-- Example Claude Desktop configuration
-
-### Technical
-- Python 3.7+ support
-- Virtual environment setup
-- Requirements: fastmcp
-- MIT License
-
----
-
-## Version History Summary
-
-- **v1.6.0** - CC/BCC on reply/forward, stdin-based AppleScript execution, interactive dashboard, README rewrite
-- **v1.5.0** - Advanced search tools (4 new tools: search_by_sender, search_all_accounts, search_email_content, get_newsletters)
-- **v1.4.0** - User preferences configuration
-- **v1.3.0** - Major feature expansion (8 new tools: search, status, trash, forward, threads, drafts, statistics, export)
-- **v1.2.0** - Enhanced overview with email preview
-- **v1.1.0** - Added inbox overview dashboard
-- **v1.0.0** - Initial release with core functionality
-
-## Upgrade Notes
-
-### Upgrading to 1.6.0
-- No breaking changes
-- `reply_to_email` and `forward_email` now accept optional `cc` and `bcc` parameters
-- AppleScript execution method changed internally (stdin pipe); no user action required
-- Install `mcp-ui-server` to use the new `inbox_dashboard` tool
-- Rebuild `.mcpb` bundle to include new tools
-
-### Upgrading to 1.5.0
-- No breaking changes
-- All existing tools remain compatible
-- New search tools available immediately after update
-- Rebuild .mcpb bundle to include new tools
-
-### Upgrading to 1.4.0
-- No breaking changes
-- Optional user preferences configuration available
-- Set USER_EMAIL_PREFERENCES environment variable for customization
-
-### Upgrading to 1.3.0
-- No breaking changes
-- All existing tools remain compatible
-- New tools available immediately after update
-- Rebuild .mcpb bundle to include new tools
-
-### Upgrading to 1.2.0
-- No breaking changes
-- Overview tool enhanced with email preview
-- No configuration changes required
-
-### Upgrading to 1.1.0
-- No breaking changes
-- New overview tool recommended as first interaction
-- No configuration changes required
diff --git a/README.md b/README.md
index a75e138..da3d768 100755
--- a/README.md
+++ b/README.md
@@ -7,7 +7,13 @@
## Star History
-[](https://star-history.com/#patrickfreyer/apple-mail-mcp&Date)
+
+
+
+
+
+
+
An MCP server that gives AI assistants full access to Apple Mail -- read, search, compose, organize, and analyze emails via natural language. Built with [FastMCP](https://github.com/jlowin/fastmcp).
@@ -39,40 +45,35 @@ Restart Claude Desktop and grant Mail.app permissions when prompted.
> **Tip:** An `.mcpb` bundle is also available on the [Releases](https://github.com/patrickfreyer/apple-mail-mcp/releases) page for one-click install in Claude Desktop.
-## Tools (26)
+## Tools (22)
### Reading & Search
| Tool | Description |
|------|-------------|
| `get_inbox_overview` | Dashboard with unread counts, folders, and recent emails |
-| `list_inbox_emails` | List emails with account/read-status filtering |
-| `get_email_with_content` | Search emails with full content preview |
-| `get_unread_count` | Unread count per account |
+| `list_inbox_emails` | List emails with account/read-status filtering and optional content preview |
+| `get_mailbox_unread_counts` | Unread counts per mailbox or per-account summary |
| `list_accounts` | List all configured Mail accounts |
-| `get_recent_emails` | Recent emails from a specific account |
-| `get_recent_from_sender` | Recent emails from a sender with time-range filters |
-| `search_emails` | Advanced multi-criteria search (subject, sender, dates, attachments) |
-| `search_by_sender` | Find all emails from a specific sender |
-| `search_email_content` | Full-text search in email bodies |
-| `search_all_accounts` | Cross-account unified search |
-| `get_newsletters` | Detect newsletter and subscription emails |
+| `search_emails` | Unified search — subject, sender, body text, dates, attachments, cross-account |
| `get_email_thread` | Conversation thread view |
### Organization
| Tool | Description |
|------|-------------|
| `list_mailboxes` | Folder hierarchy with message counts |
-| `move_email` | Move emails between folders (supports nested paths) |
-| `update_email_status` | Batch mark read/unread, flag/unflag |
-| `manage_trash` | Soft delete, permanent delete, empty trash |
+| `create_mailbox` | Create new mailboxes (supports nested paths) |
+| `move_email` | Move/archive emails with filters (subject, sender, date, read status, dry-run) |
+| `update_email_status` | Mark read/unread, flag/unflag — by filters or message IDs |
+| `manage_trash` | Soft delete, permanent delete, empty trash (with dry-run) |
### Composition
| Tool | Description |
|------|-------------|
-| `compose_email` | Send new emails (TO, CC, BCC) |
-| `reply_to_email` | Reply or reply-all with optional CC/BCC |
+| `compose_email` | Send new emails (plain text or HTML body) |
+| `reply_to_email` | Reply or reply-all with optional HTML body |
| `forward_email` | Forward with optional message, CC/BCC |
| `manage_drafts` | Create, list, send, and delete drafts |
+| `create_rich_email_draft` | Build a rich HTML `.eml` draft, open it in Mail, and optionally save it to Drafts |
### Attachments
| Tool | Description |
@@ -80,6 +81,13 @@ Restart Claude Desktop and grant Mail.app permissions when prompted.
| `list_email_attachments` | List attachments with names and sizes |
| `save_email_attachment` | Save attachments to disk |
+### Smart Inbox
+| Tool | Description |
+|------|-------------|
+| `get_awaiting_reply` | Find sent emails that haven't received a reply |
+| `get_needs_response` | Identify emails that likely need your response |
+| `get_top_senders` | Analyse most frequent senders by count or domain |
+
### Analytics & Export
| Tool | Description |
|------|-------------|
@@ -89,6 +97,21 @@ Restart Claude Desktop and grant Mail.app permissions when prompted.
## Configuration
+### Read-Only Mode
+
+Pass `--read-only` to disable tools that send email (`compose_email`, `reply_to_email`, `forward_email`). Draft management remains available (list, create, delete) but sending a draft via `manage_drafts` is blocked.
+
+```json
+{
+ "mcpServers": {
+ "apple-mail": {
+ "command": "/path/to/venv/bin/python3",
+ "args": ["/path/to/apple_mail_mcp.py", "--read-only"]
+ }
+ }
+}
+```
+
### User Preferences (Optional)
Set the `USER_EMAIL_PREFERENCES` environment variable to give the assistant context about your workflow:
@@ -129,8 +152,20 @@ Search for emails about "project update" in my Gmail
Reply to the email about "Domain name" with "Thanks for the update!"
Move emails with "invoice" in the subject to my Archive folder
Show me email statistics for the last 30 days
+Create a rich HTML draft for a weekly update and open it in Mail
```
+### Rich HTML Drafts
+
+Use `create_rich_email_draft` when you need a visually formatted email, newsletter, or leadership update.
+
+- It generates an unsent `.eml` file with multipart plain-text + HTML bodies
+- It can open the draft directly in Mail for editing
+- It can optionally ask Mail to save the opened compose window into Drafts
+- It accepts partial details, so you can start with just an account and subject and fill in the rest later
+
+This is more reliable than injecting raw HTML into AppleScript `content`, which Mail often stores as literal markup.
+
## Email Management Skill
A companion [Claude Code Skill](skill-email-management/) is included that teaches Claude expert email workflows (Inbox Zero, daily triage, folder organization). Install it alongside the MCP for intelligent, multi-step email management:
@@ -157,12 +192,13 @@ See [skill-email-management/README.md](skill-email-management/README.md) for det
| Slow searches | Set `include_content: false` and lower `max_results` |
| Mailbox not found | Use exact folder names; nested folders use `/` separator (e.g., `Projects/Alpha`) |
| Permission errors | Grant access in **System Settings > Privacy & Security > Automation** |
+| Rich draft shows raw HTML | Use `create_rich_email_draft` instead of pasting HTML into `manage_drafts` or AppleScript `content` |
## Project Structure
```
apple-mail-mcp/
-├── apple_mail_mcp.py # Main MCP server (26 tools)
+├── apple_mail_mcp.py # Main MCP server (27 tools)
├── requirements.txt # Python dependencies
├── apple-mail-mcpb/ # MCP Bundle build files
├── skill-email-management/ # Email Management Expert Skill
diff --git a/apple-mail-mcpb/manifest.json b/apple-mail-mcpb/manifest.json
index 028e34c..4171f2f 100644
--- a/apple-mail-mcpb/manifest.json
+++ b/apple-mail-mcpb/manifest.json
@@ -1,8 +1,8 @@
{
"dxt_version": "0.1",
"name": "Apple Mail MCP",
- "version": "1.6.0",
- "description": "Natural language interface for Apple Mail with Email Management Expert Skill and Interactive UI Dashboard - comprehensive email management including advanced search, intelligent workflows, productivity strategies, status updates, thread views, draft management, statistics, export capabilities, and MCP Apps UI support",
+ "version": "2.1.0",
+ "description": "Natural language interface for Apple Mail with 27 tools — email search, composition, bulk operations, smart inbox analytics, folder management, and security-hardened destructive operations. Includes Email Management Expert Skill and Interactive UI Dashboard.",
"author": {
"name": "Patrick Freyer"
},
@@ -38,15 +38,11 @@
"tools": [
{
"name": "get_inbox_overview",
- "description": "Get a comprehensive dashboard overview of your email inbox status across all accounts. Shows unread counts by account, mailbox structure with unread indicators, preview of 10 most recent emails, and provides AI-driven action suggestions (move emails, respond to messages, highlight action items, organize folders, etc.). Use this tool first to understand the overall inbox state before taking specific actions."
+ "description": "Get a comprehensive dashboard overview of your email inbox status across all accounts. Shows unread counts by account, mailbox structure with unread indicators, preview of recent emails, and provides AI-driven action suggestions."
},
{
"name": "list_inbox_emails",
- "description": "List all emails from inbox across all accounts or a specific account. Filter by account name, limit number of emails, and filter read/unread status."
- },
- {
- "name": "get_email_with_content",
- "description": "Search for emails by subject keyword and return with full content preview. Search within specific account with configurable content length."
+ "description": "List all emails from inbox across all accounts or a specific account. Filter by account name, limit number of emails, and filter read/unread status. Supports JSON output format."
},
{
"name": "get_unread_count",
@@ -58,67 +54,95 @@
},
{
"name": "get_recent_emails",
- "description": "Get the most recent emails from a specific account. Optional content preview with configurable count."
+ "description": "Get the most recent emails from a specific account. Optional content preview with configurable count. Supports JSON output format."
},
{
"name": "list_mailboxes",
- "description": "List all mailboxes (folders) for a specific account or all accounts. Shows folder structure with optional message counts (total and unread)."
+ "description": "List all mailboxes (folders) for a specific account or all accounts. Shows folder structure with optional message counts."
},
{
"name": "move_email",
- "description": "Move emails between mailboxes by searching for subject keywords. Supports nested mailboxes with path notation (e.g., 'Projects/Amplify Impact'). Includes safety limit on number of moves."
+ "description": "Move emails between mailboxes by searching for subject keywords. Supports nested mailboxes with path notation (e.g., 'Projects/Amplify Impact')."
},
{
"name": "reply_to_email",
- "description": "Reply to emails matching a subject keyword. Send reply with custom body and option to reply to all recipients."
+ "description": "Reply to emails matching a subject keyword. Send reply or save as draft with custom body and option to reply to all recipients. Supports CC/BCC."
},
{
"name": "compose_email",
"description": "Compose and send a new email from a specific account. Supports TO, CC, and BCC recipients with custom subject and body."
},
+ {
+ "name": "forward_email",
+ "description": "Forward emails matching a subject keyword to specified recipients. Optionally prepend a custom message. Supports CC/BCC."
+ },
{
"name": "list_email_attachments",
"description": "List attachments for emails matching a subject keyword. Shows attachment names and sizes."
},
{
"name": "save_email_attachment",
- "description": "Save a specific attachment from an email to disk. Search by subject keyword and attachment name."
+ "description": "Save a specific attachment from an email to disk. Validates save path for security — blocks writes to sensitive directories."
},
{
"name": "search_emails",
- "description": "Advanced unified email search with multiple filter options. Search by subject keyword, sender, attachment presence, read status, and date ranges. Can search within specific mailbox or across all mailboxes. Returns matching emails with optional content preview."
+ "description": "Unified email search with 16 filter parameters: account, mailbox, subject, body, sender, date range, read status, attachments, flagged, newsletter detection, thread view. Supports cross-account search, JSON output, and uses fast native Mail.app filtering."
},
{
"name": "update_email_status",
- "description": "Update email status in batch - mark as read/unread, flag/unflag messages. Search by subject keyword or sender within a specific mailbox. Includes safety limit on number of updates (default: 10)."
+ "description": "Update email status in batch — mark as read/unread, flag/unflag. Requires at least one filter or explicit apply_to_all for safety."
},
{
"name": "manage_trash",
- "description": "Manage email deletion with three actions: move_to_trash (soft delete), delete_permanent (immediate deletion), and empty_trash (clear trash mailbox). Search by subject keyword or sender. Includes safety limits on deletions (default: 5)."
+ "description": "Manage email deletion: move_to_trash, delete_permanent, empty_trash. Requires filters for safety. empty_trash requires confirm_empty=True."
},
{
- "name": "forward_email",
- "description": "Forward emails matching a subject keyword to specified recipients. Optionally prepend a custom message to the forwarded content. Search within specific mailbox."
+ "name": "manage_drafts",
+ "description": "Complete draft management: list, create, send, and delete drafts. Supports full composition parameters (TO, CC, BCC)."
},
{
- "name": "get_email_thread",
- "description": "View email conversation threads by finding all related messages. Searches by subject keyword and automatically groups messages with matching subjects (strips Re:, Fwd: prefixes). Shows thread across all mailboxes with configurable message limit."
+ "name": "create_mailbox",
+ "description": "Create a new mailbox (folder) in a specified account. Supports nested paths and auto-creates parent folders."
},
{
- "name": "manage_drafts",
- "description": "Complete draft management with four actions: list (show all drafts), create (save new draft), send (send existing draft by subject), and delete (remove draft by subject). Supports full email composition parameters (TO, CC, BCC, subject, body)."
+ "name": "archive_emails",
+ "description": "Move emails to Archive mailbox with safety features. Requires filters, dry_run=True by default, only_read=True by default, max 50 cap."
+ },
+ {
+ "name": "mark_emails",
+ "description": "Batch mark emails as read/unread/flagged/unflagged. Requires at least one filter (subject, sender, or date). Default max 50."
+ },
+ {
+ "name": "delete_emails",
+ "description": "Soft-delete emails (move to Trash) with safety features. dry_run=True by default, required filters, max 25 limit."
+ },
+ {
+ "name": "bulk_move_emails",
+ "description": "Batch move emails matching filters to a target mailbox. Supports nested paths, required filters, max 50 limit."
+ },
+ {
+ "name": "get_awaiting_reply",
+ "description": "Find sent emails that haven't received a reply. Cross-references Sent and Inbox to identify follow-up opportunities."
+ },
+ {
+ "name": "get_needs_response",
+ "description": "Identify unread emails needing a response. Filters out newsletters and automated senders, assigns priority hints."
+ },
+ {
+ "name": "get_top_senders",
+ "description": "Rank senders by email frequency. Supports date range filtering and optional grouping by domain."
},
{
"name": "get_statistics",
- "description": "Comprehensive email analytics with three scopes: account_overview (total emails, read/unread ratios, flagged count, top senders, mailbox distribution), sender_stats (detailed stats for specific sender), and mailbox_breakdown (stats for specific mailbox). Configurable time range with days_back parameter."
+ "description": "Email analytics: account overview, sender stats, or mailbox breakdown. Shows totals, read/unread ratios, top senders."
},
{
"name": "export_emails",
- "description": "Export emails to txt or html files. Two scopes: single_email (export one email by subject keyword) or entire_mailbox (export all emails from a mailbox to a directory). Configurable save location and format (txt/html)."
+ "description": "Export emails to txt or html. Single email or entire mailbox (capped at 1000). Configurable save location."
},
{
"name": "inbox_dashboard",
- "description": "Get an interactive UI dashboard view of your email inbox. Returns a UIResource that renders in MCP Apps-compatible clients (like Claude Desktop). Shows account cards with unread counts, recent emails list with action buttons (Mark Read, Archive, Delete), and search functionality. Requires mcp-ui-server package."
+ "description": "Interactive UI dashboard for MCP Apps-compatible clients. Shows account cards, recent emails, and search. Requires mcp-ui-server."
}
],
"prompts": []
diff --git a/apple_mail_mcp.py b/apple_mail_mcp.py
index 3f5a5f6..0edaf73 100755
--- a/apple_mail_mcp.py
+++ b/apple_mail_mcp.py
@@ -1,7 +1,37 @@
#!/usr/bin/env python3
-"""Apple Mail MCP Server - Entry point (thin wrapper)."""
+"""Apple Mail MCP Server - Entry point.
-from apple_mail_mcp import mcp
+Supports --read-only to disable tools that send email (compose, reply, forward).
+Draft management (list, create, delete) remains available; only the draft "send"
+action is blocked.
+"""
+
+import argparse
+import apple_mail_mcp.server as server
+
+parser = argparse.ArgumentParser(description="Apple Mail MCP Server")
+parser.add_argument(
+ "--read-only",
+ action="store_true",
+ help="Disable tools that send email (compose, reply, forward). "
+ "Drafts can still be created and listed.",
+)
+args = parser.parse_args()
+
+# Set the flag before tools are imported (decorator registration happens on import).
+server.READ_ONLY = args.read_only
+
+from apple_mail_mcp import mcp # noqa: E402
+
+# In read-only mode, remove send-capable tools that were registered by decorators.
+SEND_TOOLS = ["compose_email", "reply_to_email", "forward_email"]
+
+if args.read_only:
+ for name in SEND_TOOLS:
+ try:
+ mcp.remove_tool(name)
+ except (KeyError, ValueError):
+ pass # Tool may not exist — fine to skip.
if __name__ == "__main__":
mcp.run()
diff --git a/apple_mail_mcp/__init__.py b/apple_mail_mcp/__init__.py
index 6a31797..a6b9233 100644
--- a/apple_mail_mcp/__init__.py
+++ b/apple_mail_mcp/__init__.py
@@ -5,13 +5,15 @@
# UI availability flag
try:
from ui import create_inbox_dashboard_ui
+
UI_AVAILABLE = True
except ImportError:
UI_AVAILABLE = False
# Import all tool modules to register @mcp.tool() decorators
-from apple_mail_mcp.tools import inbox # noqa: F401 (6 tools)
-from apple_mail_mcp.tools import search # noqa: F401 (8 tools)
-from apple_mail_mcp.tools import compose # noqa: F401 (4 tools)
-from apple_mail_mcp.tools import manage # noqa: F401 (4 tools)
+from apple_mail_mcp.tools import inbox # noqa: F401 (5 tools)
+from apple_mail_mcp.tools import search # noqa: F401 (2 tools)
+from apple_mail_mcp.tools import compose # noqa: F401 (5 tools)
+from apple_mail_mcp.tools import manage # noqa: F401 (5 tools)
from apple_mail_mcp.tools import analytics # noqa: F401 (4 tools)
+from apple_mail_mcp.tools import smart_inbox # noqa: F401 (3 tools)
diff --git a/apple_mail_mcp/__main__.py b/apple_mail_mcp/__main__.py
new file mode 100644
index 0000000..3456bbc
--- /dev/null
+++ b/apple_mail_mcp/__main__.py
@@ -0,0 +1,12 @@
+"""Entry point for `python -m apple_mail_mcp`."""
+
+from apple_mail_mcp.server import mcp
+
+# Import tool modules to register @mcp.tool() decorators
+from apple_mail_mcp.tools import inbox # noqa: F401
+from apple_mail_mcp.tools import search # noqa: F401
+from apple_mail_mcp.tools import compose # noqa: F401
+from apple_mail_mcp.tools import manage # noqa: F401
+from apple_mail_mcp.tools import analytics # noqa: F401
+
+mcp.run()
diff --git a/apple_mail_mcp/core.py b/apple_mail_mcp/core.py
index b692752..cb0a2c5 100644
--- a/apple_mail_mcp/core.py
+++ b/apple_mail_mcp/core.py
@@ -1,7 +1,7 @@
"""Core helpers: AppleScript execution, escaping, parsing, and preference injection."""
import subprocess
-from typing import List, Dict, Any
+from typing import Optional, List, Dict, Any, Tuple
from apple_mail_mcp.server import USER_PREFERENCES
@@ -10,7 +10,9 @@ def inject_preferences(func):
"""Decorator that appends user preferences to tool docstrings"""
if USER_PREFERENCES:
if func.__doc__:
- func.__doc__ = func.__doc__.rstrip() + f"\n\nUser Preferences: {USER_PREFERENCES}"
+ func.__doc__ = (
+ func.__doc__.rstrip() + f"\n\nUser Preferences: {USER_PREFERENCES}"
+ )
else:
func.__doc__ = f"User Preferences: {USER_PREFERENCES}"
return func
@@ -19,59 +21,144 @@ def inject_preferences(func):
def escape_applescript(value: str) -> str:
"""Escape a string for safe injection into AppleScript double-quoted strings.
- Handles backslashes first, then double quotes, to prevent injection.
+ Handles backslashes first, then double quotes, then newlines/returns/tabs,
+ and Unicode line/paragraph separators to prevent injection and AppleScript
+ syntax errors.
"""
- return value.replace('\\', '\\\\').replace('"', '\\"')
+ return (
+ value.replace("\\", "\\\\")
+ .replace('"', '\\"')
+ .replace("\r\n", "\\n")
+ .replace("\r", "\\n")
+ .replace("\n", "\\n")
+ .replace("\t", "\\t")
+ # Unicode line/paragraph separators can break AppleScript string parsing
+ .replace("\u2028", "\\n")
+ .replace("\u2029", "\\n")
+ )
-def run_applescript(script: str) -> str:
- """Execute AppleScript via stdin pipe for reliable multi-line handling"""
+def _sanitize_for_json(text: str) -> str:
+ """Sanitize text for safe JSON serialization over MCP stdio transport.
+
+ Preserves Unicode (including Cyrillic, CJK, Arabic, etc.) while
+ stripping control characters.
+ """
+ # Normalize line endings first (AppleScript uses \r)
+ text = text.replace("\r\n", "\n").replace("\r", "\n")
+ # Strip control characters but keep \n, \t, and all printable Unicode
+ return "".join(ch for ch in text if ch in ("\n", "\t") or (ord(ch) >= 32))
+
+
+def run_applescript(script: str, timeout: int = 120) -> str:
+ """Execute AppleScript via stdin pipe for reliable multi-line handling."""
try:
result = subprocess.run(
- ['osascript', '-'],
- input=script,
+ ["osascript", "-"],
+ input=script.encode("utf-8"),
capture_output=True,
- text=True,
- timeout=120
+ timeout=timeout,
)
- if result.returncode != 0 and result.stderr.strip():
- raise Exception(f"AppleScript error: {result.stderr.strip()}")
- return result.stdout.strip()
+ if result.returncode != 0:
+ stderr = result.stderr.decode("utf-8", errors="replace").strip()
+ if stderr:
+ raise Exception(f"AppleScript error: {stderr}")
+ output = result.stdout.decode("utf-8", errors="replace").strip()
+ return _sanitize_for_json(output)
except subprocess.TimeoutExpired:
raise Exception("AppleScript execution timed out")
except Exception as e:
raise Exception(f"AppleScript execution failed: {str(e)}")
+def normalize_search_terms(
+ search_term: Optional[str] = None,
+ search_terms: Optional[List[str]] = None,
+) -> List[str]:
+ """Return de-duplicated, non-empty search terms preserving order."""
+ normalized = []
+
+ if search_term and search_term.strip():
+ normalized.append(search_term.strip())
+
+ if search_terms:
+ for term in search_terms:
+ if term and term.strip():
+ normalized.append(term.strip())
+
+ unique_terms = []
+ for term in normalized:
+ if term not in unique_terms:
+ unique_terms.append(term)
+
+ return unique_terms
+
+
+def contains_any_condition(field_name: str, values: List[str]) -> str:
+ """Return AppleScript OR conditions for substring matches."""
+ if not values:
+ return "true"
+
+ escaped_values = [escape_applescript(value) for value in values]
+ parts = [f'{field_name} contains "{value}"' for value in escaped_values]
+ return "(" + " or ".join(parts) + ")"
+
+
+def normalize_message_ids(message_ids: Optional[List[Any]]) -> List[str]:
+ """Return de-duplicated numeric Mail ids as strings preserving order."""
+ if not message_ids:
+ return []
+
+ normalized = []
+ for value in message_ids:
+ value_text = str(value).strip()
+ if value_text and value_text.isdigit() and value_text not in normalized:
+ normalized.append(value_text)
+
+ return normalized
+
+
+def equals_any_numeric_condition(field_name: str, values: List[str]) -> str:
+ """Return AppleScript OR conditions for numeric equality matches."""
+ if not values:
+ return "false"
+
+ parts = [f"{field_name} is {value}" for value in values]
+ return "(" + " or ".join(parts) + ")"
+
+
def parse_email_list(output: str) -> List[Dict[str, Any]]:
"""Parse the structured email output from AppleScript"""
emails = []
- lines = output.split('\n')
+ lines = output.split("\n")
current_email = {}
for line in lines:
line = line.strip()
- if not line or line.startswith('=') or line.startswith('━') or line.startswith('📧') or line.startswith('⚠'):
+ if (
+ not line
+ or line.startswith("=")
+ or line.startswith("━")
+ or line.startswith("📧")
+ or line.startswith("⚠")
+ ):
continue
- if line.startswith('✉') or line.startswith('✓'):
+ if line.startswith("✉") or line.startswith("✓"):
# New email entry
if current_email:
emails.append(current_email)
- is_read = line.startswith('✓')
+ is_read = line.startswith("✓")
subject = line[2:].strip() # Remove indicator
- current_email = {
- 'subject': subject,
- 'is_read': is_read
- }
- elif line.startswith('From:'):
- current_email['sender'] = line[5:].strip()
- elif line.startswith('Date:'):
- current_email['date'] = line[5:].strip()
- elif line.startswith('Preview:'):
- current_email['preview'] = line[8:].strip()
- elif line.startswith('TOTAL EMAILS'):
+ current_email = {"subject": subject, "is_read": is_read}
+ elif line.startswith("From:"):
+ current_email["sender"] = line[5:].strip()
+ elif line.startswith("Date:"):
+ current_email["date"] = line[5:].strip()
+ elif line.startswith("Preview:"):
+ current_email["preview"] = line[8:].strip()
+ elif line.startswith("TOTAL EMAILS"):
# End of email list
if current_email:
emails.append(current_email)
@@ -87,27 +174,29 @@ def parse_email_list(output: str) -> List[Dict[str, Any]]:
# Shared AppleScript template helpers
# ---------------------------------------------------------------------------
-LOWERCASE_HANDLER = '''
+LOWERCASE_HANDLER = """
on lowercase(str)
set lowerStr to do shell script "echo " & quoted form of str & " | tr '[:upper:]' '[:lower:]'"
return lowerStr
end lowercase
-'''
+"""
-def inbox_mailbox_script(var_name: str = "inboxMailbox", account_var: str = "anAccount") -> str:
+def inbox_mailbox_script(
+ var_name: str = "inboxMailbox", account_var: str = "anAccount"
+) -> str:
"""Return AppleScript snippet to get inbox mailbox with INBOX/Inbox fallback."""
- return f'''
+ return f"""
try
set {var_name} to mailbox "INBOX" of {account_var}
on error
set {var_name} to mailbox "Inbox" of {account_var}
- end try'''
+ end try"""
def content_preview_script(max_length: int, output_var: str = "outputText") -> str:
"""Return AppleScript snippet to extract and truncate email content preview."""
- return f'''
+ return f"""
try
set msgContent to content of aMessage
set AppleScript's text item delimiters to {{return, linefeed}}
@@ -125,19 +214,133 @@ def content_preview_script(max_length: int, output_var: str = "outputText") -> s
set {output_var} to {output_var} & " Content: " & contentPreview & return
on error
set {output_var} to {output_var} & " Content: [Not available]" & return
- end try'''
+ end try"""
def date_cutoff_script(days_back: int, var_name: str = "cutoffDate") -> str:
"""Return AppleScript snippet to set a date cutoff variable."""
if days_back <= 0:
return ""
- return f'''
- set {var_name} to (current date) - ({days_back} * days)'''
+ return f"""
+ set {var_name} to (current date) - ({days_back} * days)"""
def skip_folders_condition(var_name: str = "mailboxName") -> str:
"""Return AppleScript condition to skip system folders (Trash, Junk, etc)."""
from apple_mail_mcp.constants import SKIP_FOLDERS
- folder_list = ', '.join(f'"{f}"' for f in SKIP_FOLDERS)
- return f'{var_name} is not in {{{folder_list}}}'
+
+ folder_list = ", ".join(f'"{f}"' for f in SKIP_FOLDERS)
+ return f"{var_name} is not in {{{folder_list}}}"
+
+
+def build_mailbox_ref(
+ mailbox: str,
+ account_var: str = "targetAccount",
+ var_name: str = "targetMailbox",
+) -> str:
+ """Return AppleScript snippet to resolve a mailbox by name with INBOX fallback.
+
+ Handles:
+ - Normal mailbox names (e.g. "Archive")
+ - INBOX / Inbox case variation
+ - Nested mailbox paths using "/" separator (e.g. "Projects/2024")
+
+ The resulting variable *var_name* will hold the resolved mailbox reference.
+ """
+ escaped = escape_applescript(mailbox)
+ parts = mailbox.split("/")
+
+ if len(parts) > 1:
+ # Build nested mailbox reference: mailbox "Child" of mailbox "Parent" of account
+ ref = f'mailbox "{escape_applescript(parts[-1])}" of '
+ for i in range(len(parts) - 2, -1, -1):
+ ref += f'mailbox "{escape_applescript(parts[i])}" of '
+ ref += account_var
+ return f"set {var_name} to {ref}"
+
+ return f'''try
+ set {var_name} to mailbox "{escaped}" of {account_var}
+ on error
+ if "{escaped}" is "INBOX" then
+ set {var_name} to mailbox "Inbox" of {account_var}
+ else
+ error "Mailbox not found: {escaped}"
+ end if
+ end try'''
+
+
+def build_filter_condition(
+ subject: Optional[str] = None,
+ sender: Optional[str] = None,
+ subject_var: str = "messageSubject",
+ sender_var: str = "messageSender",
+) -> str:
+ """Return an AppleScript boolean expression combining subject/sender filters.
+
+ When both are provided they are ANDed together.
+ Returns ``"true"`` when neither filter is given.
+ """
+ conditions: list[str] = []
+ if subject:
+ conditions.append(f'{subject_var} contains "{escape_applescript(subject)}"')
+ if sender:
+ conditions.append(f'{sender_var} contains "{escape_applescript(sender)}"')
+ return " and ".join(conditions) if conditions else "true"
+
+
+def build_date_filter(
+ days_back: int,
+ var_name: str = "cutoffDate",
+) -> Tuple[str, str]:
+ """Return (setup_script, condition_fragment) for a date-based cutoff.
+
+ *setup_script* should be placed before the message loop.
+ *condition_fragment* is an AppleScript fragment like
+ ``"and messageDate > cutoffDate"`` suitable for appending to an ``if``
+ clause. When *days_back* is 0 both strings are empty.
+ """
+ if days_back <= 0:
+ return ("", "")
+ setup = f"set {var_name} to (current date) - ({days_back} * days)"
+ condition = f"and messageDate > {var_name}"
+ return (setup, condition)
+
+
+def build_email_fields_script(
+ message_var: str = "aMessage",
+ include_content: bool = False,
+ max_content_length: int = 300,
+ output_var: str = "outputText",
+) -> str:
+ """Return AppleScript snippet that extracts common fields from an email.
+
+ Sets local variables: messageSubject, messageSender, messageDate,
+ messageRead. Optionally appends a cleaned content preview to
+ *output_var*.
+ """
+ fields = f"""set messageSubject to subject of {message_var}
+ set messageSender to sender of {message_var}
+ set messageDate to date received of {message_var}
+ set messageRead to read status of {message_var}"""
+
+ if not include_content:
+ return fields
+
+ content = f"""
+ try
+ set msgContent to content of {message_var}
+ set AppleScript's text item delimiters to {{return, linefeed}}
+ set contentParts to text items of msgContent
+ set AppleScript's text item delimiters to " "
+ set cleanText to contentParts as string
+ set AppleScript's text item delimiters to ""
+ if length of cleanText > {max_content_length} then
+ set contentPreview to text 1 thru {max_content_length} of cleanText & "..."
+ else
+ set contentPreview to cleanText
+ end if
+ set {output_var} to {output_var} & " Content: " & contentPreview & return
+ on error
+ set {output_var} to {output_var} & " Content: [Not available]" & return
+ end try"""
+ return fields + content
diff --git a/apple_mail_mcp/server.py b/apple_mail_mcp/server.py
index 5fcf45f..4f0ce03 100644
--- a/apple_mail_mcp/server.py
+++ b/apple_mail_mcp/server.py
@@ -8,3 +8,7 @@
# Load user preferences from environment
USER_PREFERENCES = os.environ.get("USER_EMAIL_PREFERENCES", "")
+
+# Read-only mode flag — set via --read-only CLI argument.
+# When enabled, tools that send email are disabled. Drafts remain available.
+READ_ONLY = False
diff --git a/apple_mail_mcp/tools/analytics.py b/apple_mail_mcp/tools/analytics.py
index 40dbb15..dd0319c 100644
--- a/apple_mail_mcp/tools/analytics.py
+++ b/apple_mail_mcp/tools/analytics.py
@@ -5,6 +5,7 @@
from apple_mail_mcp.server import mcp
from apple_mail_mcp.core import inject_preferences, escape_applescript, run_applescript, inbox_mailbox_script
+from apple_mail_mcp.constants import SKIP_FOLDERS
@mcp.tool()
@@ -132,10 +133,11 @@ def get_statistics(
date_filter = f'''
set targetDate to (current date) - ({days_back} * days)
'''
- date_check = 'and messageDate > targetDate'
- else:
- date_filter = ""
- date_check = ""
+
+ # Build skip folders condition from constants
+ skip_folder_checks = ' and '.join(
+ f'mailboxName is not "{f}"' for f in SKIP_FOLDERS
+ )
if scope == "account_overview":
script = f'''
@@ -161,60 +163,70 @@ def get_statistics(
-- Analyze all mailboxes
repeat with aMailbox in allMailboxes
- set mailboxName to name of aMailbox
- set mailboxMessages to every message of aMailbox
- set mailboxTotal to 0
-
- repeat with aMessage in mailboxMessages
- try
- set messageDate to date received of aMessage
+ try
+ set mailboxName to name of aMailbox
- -- Apply date filter if specified
- if true {date_check} then
- set totalEmails to totalEmails + 1
- set mailboxTotal to mailboxTotal + 1
+ -- Skip system folders
+ if {skip_folder_checks} then
- -- Count read/unread
- if read status of aMessage then
- set totalRead to totalRead + 1
- else
- set totalUnread to totalUnread + 1
- end if
+ -- Use whose clause for date pre-filtering when days_back > 0
+ if {days_back} > 0 then
+ set mailboxMessages to (every message of aMailbox whose date received > targetDate)
+ else
+ set mailboxMessages to every message of aMailbox
+ end if
+ set mailboxTotal to 0
- -- Count flagged
+ repeat with aMessage in mailboxMessages
try
- if flagged status of aMessage then
- set totalFlagged to totalFlagged + 1
+ set totalEmails to totalEmails + 1
+ set mailboxTotal to mailboxTotal + 1
+
+ -- Count read/unread
+ if read status of aMessage then
+ set totalRead to totalRead + 1
+ else
+ set totalUnread to totalUnread + 1
end if
- end try
- -- Count attachments
- set attachmentCount to count of mail attachments of aMessage
- if attachmentCount > 0 then
- set totalWithAttachments to totalWithAttachments + 1
- end if
-
- -- Track senders (top 10)
- set messageSender to sender of aMessage
- set senderFound to false
- repeat with senderPair in senderCounts
- if item 1 of senderPair is messageSender then
- set item 2 of senderPair to (item 2 of senderPair) + 1
- set senderFound to true
- exit repeat
+ -- Count flagged
+ try
+ if flagged status of aMessage then
+ set totalFlagged to totalFlagged + 1
+ end if
+ end try
+
+ -- Count attachments
+ set attachmentCount to count of mail attachments of aMessage
+ if attachmentCount > 0 then
+ set totalWithAttachments to totalWithAttachments + 1
end if
- end repeat
- if not senderFound then
- set end of senderCounts to {{messageSender, 1}}
- end if
+
+ -- Track senders (top 10)
+ set messageSender to sender of aMessage
+ set senderFound to false
+ repeat with senderPair in senderCounts
+ if item 1 of senderPair is messageSender then
+ set item 2 of senderPair to (item 2 of senderPair) + 1
+ set senderFound to true
+ exit repeat
+ end if
+ end repeat
+ if not senderFound then
+ set end of senderCounts to {{messageSender, 1}}
+ end if
+ end try
+ end repeat
+
+ -- Store mailbox counts
+ if mailboxTotal > 0 then
+ set end of mailboxCounts to {{mailboxName, mailboxTotal}}
end if
- end try
- end repeat
- -- Store mailbox counts
- if mailboxTotal > 0 then
- set end of mailboxCounts to {{mailboxName, mailboxTotal}}
- end if
+ end if
+ on error
+ -- Skip mailboxes that throw errors (smart mailboxes, etc.)
+ end try
end repeat
-- Format output
@@ -272,6 +284,12 @@ def get_statistics(
if not sender:
return "Error: 'sender' parameter required for sender_stats scope"
+ # Build whose clause for fast app-level filtering
+ whose_parts = [f'sender contains "{escaped_sender}"']
+ if days_back > 0:
+ whose_parts.append('date received > targetDate')
+ whose_clause = ' and '.join(whose_parts)
+
script = f'''
tell application "Mail"
set outputText to "SENDER STATISTICS" & return & return
@@ -289,26 +307,33 @@ def get_statistics(
set withAttachments to 0
repeat with aMailbox in allMailboxes
- set mailboxMessages to every message of aMailbox
+ try
+ set mailboxName to name of aMailbox
- repeat with aMessage in mailboxMessages
- try
- set messageSender to sender of aMessage
- set messageDate to date received of aMessage
+ -- Skip system folders
+ if {skip_folder_checks} then
- if messageSender contains "{escaped_sender}" {date_check} then
- set totalFromSender to totalFromSender + 1
+ -- Use whose clause for fast app-level filtering
+ set matchedMessages to (every message of aMailbox whose {whose_clause})
- if not (read status of aMessage) then
- set unreadFromSender to unreadFromSender + 1
- end if
+ repeat with aMessage in matchedMessages
+ try
+ set totalFromSender to totalFromSender + 1
- if (count of mail attachments of aMessage) > 0 then
- set withAttachments to withAttachments + 1
- end if
- end if
- end try
- end repeat
+ if not (read status of aMessage) then
+ set unreadFromSender to unreadFromSender + 1
+ end if
+
+ if (count of mail attachments of aMessage) > 0 then
+ set withAttachments to withAttachments + 1
+ end if
+ end try
+ end repeat
+
+ end if
+ on error
+ -- Skip mailboxes that throw errors (smart mailboxes, etc.)
+ end try
end repeat
set outputText to outputText & "Total emails: " & totalFromSender & return
@@ -375,7 +400,8 @@ def export_emails(
subject_keyword: Optional[str] = None,
mailbox: str = "INBOX",
save_directory: str = "~/Desktop",
- format: str = "txt"
+ format: str = "txt",
+ max_emails: int = 1000
) -> str:
"""
Export emails to files for backup or analysis.
@@ -387,6 +413,7 @@ def export_emails(
mailbox: Mailbox to export from (default: "INBOX")
save_directory: Directory to save exports (default: "~/Desktop")
format: Export format: "txt", "html" (default: "txt")
+ max_emails: Maximum number of emails to export for entire_mailbox (default: 1000, safety cap)
Returns:
Confirmation message with export location
@@ -395,6 +422,31 @@ def export_emails(
# Expand home directory
save_dir = os.path.expanduser(save_directory)
+ # Path validation: resolve to absolute path and enforce safety constraints
+ resolved_path = os.path.realpath(save_dir)
+ home_dir = os.path.expanduser('~')
+
+ # Must be under the user's home directory
+ if not resolved_path.startswith(home_dir + os.sep) and resolved_path != home_dir:
+ return f"Error: Save path must be under your home directory ({home_dir}). Got: {resolved_path}"
+
+ # Block sensitive directories
+ sensitive_dirs = [
+ os.path.join(home_dir, '.ssh'),
+ os.path.join(home_dir, '.gnupg'),
+ os.path.join(home_dir, '.config'),
+ os.path.join(home_dir, '.aws'),
+ os.path.join(home_dir, '.claude'),
+ os.path.join(home_dir, 'Library', 'LaunchAgents'),
+ os.path.join(home_dir, 'Library', 'LaunchDaemons'),
+ os.path.join(home_dir, 'Library', 'Keychains'),
+ ]
+ for sensitive_dir in sensitive_dirs:
+ if resolved_path.startswith(sensitive_dir + os.sep) or resolved_path == sensitive_dir:
+ return f"Error: Cannot export emails to sensitive directory: {sensitive_dir}"
+
+ save_dir = resolved_path
+
# Escape all user inputs for AppleScript
safe_account = escape_applescript(account)
safe_mailbox = escape_applescript(mailbox)
@@ -523,6 +575,8 @@ def export_emails(
do shell script "mkdir -p " & quoted form of exportDir
repeat with aMessage in mailboxMessages
+ if exportCount >= {max_emails} then exit repeat
+
try
set messageSubject to subject of aMessage
set messageSender to sender of aMessage
@@ -570,8 +624,11 @@ def export_emails(
set outputText to outputText & "✓ Mailbox exported successfully!" & return & return
set outputText to outputText & "Mailbox: {safe_mailbox}" & return
- set outputText to outputText & "Total emails: " & messageCount & return
+ set outputText to outputText & "Total emails in mailbox: " & messageCount & return
set outputText to outputText & "Exported: " & exportCount & return
+ if exportCount < messageCount then
+ set outputText to outputText & "(capped at max_emails={max_emails})" & return
+ end if
set outputText to outputText & "Location: " & exportDir & return
on error errMsg
@@ -711,11 +768,11 @@ def inbox_dashboard() -> Any:
if not UI_AVAILABLE:
return "Error: UI module not available. Please install mcp-ui-server package."
- from apple_mail_mcp.tools.inbox import get_unread_count
+ from apple_mail_mcp.tools.inbox import get_mailbox_unread_counts
from ui import create_inbox_dashboard_ui
- # Get unread counts per account
- accounts_data = get_unread_count()
+ # Get unread counts per account (summary_only gives flat account->count dict)
+ accounts_data = get_mailbox_unread_counts(summary_only=True)
# Get recent emails across all accounts as structured data
recent_emails = _get_recent_emails_structured(
diff --git a/apple_mail_mcp/tools/compose.py b/apple_mail_mcp/tools/compose.py
index 32230b4..c6a0096 100644
--- a/apple_mail_mcp/tools/compose.py
+++ b/apple_mail_mcp/tools/compose.py
@@ -1,9 +1,465 @@
"""Composition tools: sending, replying, forwarding, and drafts."""
-from typing import Optional
+import os
+import subprocess
+import tempfile
+import re
+import time
+from email.message import EmailMessage
+from html import escape as html_escape
+from pathlib import Path
+from typing import Optional, List, Tuple
+
+from apple_mail_mcp.server import mcp, READ_ONLY
+from apple_mail_mcp.core import (
+ inject_preferences,
+ escape_applescript,
+ run_applescript,
+ inbox_mailbox_script,
+)
+
+
+def _split_addresses(value):
+ """Return trimmed recipient addresses preserving order."""
+ if not value:
+ return []
+ return [addr.strip() for addr in value.split(",") if addr and addr.strip()]
+
+
+def _safe_eml_name(subject):
+ """Return a filesystem-safe filename stem for draft exports."""
+ cleaned = re.sub(r"[^A-Za-z0-9._-]+", "-", (subject or "rich-email-draft").strip())
+ cleaned = cleaned.strip("-._") or "rich-email-draft"
+ return cleaned[:80]
+
+
+def _default_rich_draft_path(subject):
+ """Return default output path for generated rich draft EML files."""
+ drafts_dir = Path.home() / "Library" / "Caches" / "apple-mail-mcp" / "rich-drafts"
+ drafts_dir.mkdir(parents=True, exist_ok=True)
+ return drafts_dir / (_safe_eml_name(subject) + ".eml")
+
+
+def _resolve_sender_address(account):
+ """Return the primary sender address for a Mail account, if available."""
+ safe_account = escape_applescript(account)
+ script = f'''
+ tell application "Mail"
+ try
+ set targetAccount to account "{safe_account}"
+ set emailAddrs to email addresses of targetAccount
+ if (count of emailAddrs) > 0 then
+ return item 1 of emailAddrs
+ end if
+ return ""
+ on error
+ return ""
+ end try
+ end tell
+ '''
+ sender_address = run_applescript(script)
+ sender_address = sender_address.strip()
+ return sender_address or None
+
+
+def _build_html_from_text(text_body):
+ """Return a simple HTML wrapper for plain text content."""
+ safe_body = html_escape(text_body or "")
+ return (
+ '
"
+ '
'
+ + safe_body
+ + "
"
+ )
+
+
+def _prepare_rich_bodies(subject, text_body, html_body):
+ """Return plain-text and HTML bodies, filling sensible placeholders."""
+ plain_body = text_body or ""
+ rich_body = html_body or ""
+
+ if not plain_body and not rich_body:
+ plain_body = (
+ "Draft outline\n\n"
+ "- Add recipients\n"
+ "- Add the final rich-text content\n"
+ "- Review before sending"
+ )
+ rich_body = _build_html_from_text(plain_body)
+ return plain_body, rich_body, ["body"]
+
+ if rich_body and not plain_body:
+ plain_body = (
+ (subject.strip() + "\n\n" if subject and subject.strip() else "")
+ + "This message contains rich HTML content. Open it in Mail for the rendered version."
+ )
+
+ if plain_body and not rich_body:
+ rich_body = _build_html_from_text(plain_body)
+
+ return plain_body, rich_body, []
+
+
+def _save_open_message_as_draft(subject, retries=10, delay_seconds=0.5):
+ """Ask Mail to save the matching open outgoing message as a draft."""
+ if not subject:
+ return False
+
+ safe_subject = escape_applescript(subject)
+ script = f'''
+ tell application "Mail"
+ try
+ set matchingMessages to every outgoing message whose subject is "{safe_subject}"
+ if (count of matchingMessages) is 0 then
+ return "not-found"
+ end if
+ save item 1 of matchingMessages
+ return "saved"
+ on error errMsg
+ return "error: " & errMsg
+ end try
+ end tell
+ '''
-from apple_mail_mcp.server import mcp
-from apple_mail_mcp.core import inject_preferences, escape_applescript, run_applescript, inbox_mailbox_script
+ for _ in range(retries):
+ result = run_applescript(script).strip().lower()
+ if result == "saved":
+ return True
+ if result.startswith("error:"):
+ break
+ time.sleep(delay_seconds)
+ return False
+
+
+@mcp.tool()
+@inject_preferences
+def create_rich_email_draft(
+ account: str,
+ subject: str = "",
+ to: Optional[str] = None,
+ text_body: Optional[str] = None,
+ html_body: Optional[str] = None,
+ cc: Optional[str] = None,
+ bcc: Optional[str] = None,
+ output_path: Optional[str] = None,
+ open_in_mail: bool = True,
+ save_as_draft: bool = False,
+) -> str:
+ """
+ Create a rich-text email draft by generating an unsent `.eml` message and optionally opening it in Mail.
+
+ This is the preferred path for HTML or richly formatted emails because Mail reliably renders `.eml`
+ content, while setting raw HTML through AppleScript often stores the literal markup instead.
+
+ Args:
+ account: Account name to use for the sender identity (e.g., "Work", "Oracle")
+ subject: Subject line for the draft (optional; defaults to empty)
+ to: Optional recipient email address(es), comma-separated for multiple
+ text_body: Optional plain-text body. If omitted but html_body is provided, a fallback plain body is generated.
+ html_body: Optional HTML body. If omitted but text_body is provided, a basic HTML wrapper is generated.
+ cc: Optional CC recipients, comma-separated for multiple
+ bcc: Optional BCC recipients, comma-separated for multiple
+ output_path: Optional path for the generated `.eml` file
+ open_in_mail: If True, open the generated `.eml` in Mail (default: True)
+ save_as_draft: If True, ask Mail to save the opened compose window into Drafts (default: False)
+
+ Returns:
+ Confirmation with the generated `.eml` path, missing details, and Mail-open/save status
+ """
+ if not account or not account.strip():
+ return "Error: 'account' is required"
+
+ recipients_to = _split_addresses(to)
+ recipients_cc = _split_addresses(cc)
+ recipients_bcc = _split_addresses(bcc)
+ plain_body, rich_body, body_missing = _prepare_rich_bodies(
+ subject, text_body, html_body
+ )
+
+ missing_details = []
+ if not subject or not subject.strip():
+ missing_details.append("subject")
+ if not recipients_to:
+ missing_details.append("to")
+ missing_details.extend(body_missing)
+
+ sender_address = _resolve_sender_address(account)
+ message = EmailMessage()
+ if subject:
+ message["Subject"] = subject
+ if sender_address:
+ message["From"] = sender_address
+ if recipients_to:
+ message["To"] = ", ".join(recipients_to)
+ if recipients_cc:
+ message["Cc"] = ", ".join(recipients_cc)
+ if recipients_bcc:
+ message["Bcc"] = ", ".join(recipients_bcc)
+ message["X-Unsent"] = "1"
+ message.set_content(plain_body)
+ message.add_alternative(rich_body, subtype="html")
+
+ draft_path = (
+ Path(output_path).expanduser()
+ if output_path
+ else _default_rich_draft_path(subject)
+ )
+ draft_path.parent.mkdir(parents=True, exist_ok=True)
+ draft_path.write_bytes(bytes(message))
+
+ opened = False
+ saved = False
+ if open_in_mail:
+ subprocess.run(["open", "-a", "Mail", str(draft_path)], check=True)
+ opened = True
+ if save_as_draft:
+ saved = _save_open_message_as_draft(subject)
+
+ output_lines = ["RICH EMAIL DRAFT", "", "✓ Rich draft prepared successfully!", ""]
+ output_lines.append("Account: " + account)
+ output_lines.append("Subject: " + (subject if subject else "[empty]"))
+ output_lines.append("EML path: " + str(draft_path))
+ output_lines.append("Opened in Mail: " + ("yes" if opened else "no"))
+ if open_in_mail:
+ output_lines.append("Saved in Drafts: " + ("yes" if saved else "no"))
+ if sender_address:
+ output_lines.append("From: " + sender_address)
+ if recipients_to:
+ output_lines.append("To: " + ", ".join(recipients_to))
+ if recipients_cc:
+ output_lines.append("CC: " + ", ".join(recipients_cc))
+ if recipients_bcc:
+ output_lines.append("BCC: " + ", ".join(recipients_bcc))
+ output_lines.append(
+ "Missing details: "
+ + (", ".join(missing_details) if missing_details else "none")
+ )
+ output_lines.append(
+ "Note: Prefer this `.eml` workflow for HTML email drafts; Mail renders it more reliably than raw HTML injected via AppleScript content."
+ )
+ return "\n".join(output_lines)
+
+
+def _send_html_email(
+ account: str,
+ to: str,
+ subject: str,
+ body_plain: str,
+ body_html: str,
+ cc: Optional[str] = None,
+ bcc: Optional[str] = None,
+ attachments_script: str = "",
+ mode: str = "send",
+) -> str:
+ """Send an HTML-formatted email via NSPasteboard clipboard injection.
+
+ Uses AppleScriptObjC to place HTML on the clipboard with the proper
+ pasteboard type, creates a compose window, tabs into the body, and
+ pastes. Then sends, saves as draft, or leaves open for review.
+ """
+ safe_account = escape_applescript(account)
+ escaped_subject = escape_applescript(subject)
+
+ # Build recipient scripts
+ to_lines = ""
+ for addr in [a.strip() for a in to.split(",") if a.strip()]:
+ to_lines += f'make new to recipient at end of to recipients with properties {{address:"{escape_applescript(addr)}"}}\n'
+
+ cc_lines = ""
+ if cc:
+ for addr in [a.strip() for a in cc.split(",") if a.strip()]:
+ cc_lines += f'make new cc recipient at end of cc recipients with properties {{address:"{escape_applescript(addr)}"}}\n'
+
+ bcc_lines = ""
+ if bcc:
+ for addr in [a.strip() for a in bcc.split(",") if a.strip()]:
+ bcc_lines += f'make new bcc recipient at end of bcc recipients with properties {{address:"{escape_applescript(addr)}"}}\n'
+
+ # Mode-specific behaviour after paste
+ if mode == "send":
+ post_paste_script = """
+ -- Send via keyboard shortcut
+ keystroke "d" using {command down, shift down}
+ """
+ success_text = "Email sent successfully (HTML)"
+ elif mode == "draft":
+ post_paste_script = """
+ -- Save as draft: Cmd+S then close
+ keystroke "s" using command down
+ delay 0.5
+ """
+ success_text = "Email saved as draft (HTML)"
+ else: # open
+ post_paste_script = "-- Leaving open for review"
+ success_text = (
+ "Email opened in Mail for review (HTML). Edit and send when ready."
+ )
+
+ # Write HTML to temp file so the AppleScript can read it without
+ # worrying about escaping quotes/special chars in the HTML string.
+ tmp = tempfile.NamedTemporaryFile(
+ mode="w",
+ suffix=".html",
+ prefix="mail_html_",
+ delete=False,
+ encoding="utf-8",
+ )
+ tmp.write(body_html)
+ tmp.close()
+ html_temp_path = tmp.name
+
+ script = f'''
+use framework "Foundation"
+use framework "AppKit"
+use scripting additions
+
+-- Step 1: Read HTML from temp file and place on clipboard
+set htmlString to do shell script "cat '{html_temp_path}'"
+set pb to current application's NSPasteboard's generalPasteboard()
+
+-- Save current clipboard for restoration
+set oldClip to pb's stringForType:(current application's NSPasteboardTypeString)
+
+pb's clearContents()
+set htmlData to (current application's NSString's stringWithString:htmlString)'s dataUsingEncoding:(current application's NSUTF8StringEncoding)
+pb's setData:htmlData forType:(current application's NSPasteboardTypeHTML)
+
+-- Step 2: Create compose window (empty body so signature doesn't interfere)
+tell application "Mail"
+ set newMsg to make new outgoing message with properties {{subject:"{escaped_subject}", content:"", visible:true}}
+ set emailAddrs to email addresses of account "{safe_account}"
+ set senderAddress to item 1 of emailAddrs
+ set sender of newMsg to senderAddress
+ tell newMsg
+ {to_lines}
+ {cc_lines}
+ {bcc_lines}
+ {attachments_script}
+ end tell
+ activate
+end tell
+
+-- Step 3: Wait for compose window to render
+delay 2.5
+
+-- Step 4: Tab from header fields into body, then paste
+tell application "System Events"
+ set frontmost of process "Mail" to true
+ delay 0.5
+ tell process "Mail"
+ -- Tab through: To -> Cc -> Bcc -> Subject -> Body
+ -- 7 tabs covers all combinations of visible/hidden CC/BCC fields
+ repeat 7 times
+ key code 48
+ delay 0.1
+ end repeat
+ delay 0.3
+
+ -- Paste HTML into body (no select-all to avoid wiping attachments)
+ keystroke "v" using command down
+ delay 0.5
+
+ {post_paste_script}
+ end tell
+end tell
+
+-- Step 5: Clean up temp file
+do shell script "rm -f '{html_temp_path}'"
+
+-- Step 6: Restore clipboard
+if oldClip is not missing value then
+ pb's clearContents()
+ pb's setString:oldClip forType:(current application's NSPasteboardTypeString)
+end if
+
+return "{success_text}"
+'''
+
+ try:
+ result = subprocess.run(
+ ["osascript", "-"],
+ input=script.encode("utf-8"),
+ capture_output=True,
+ timeout=30,
+ )
+ if result.returncode != 0:
+ stderr = result.stderr.decode("utf-8", errors="replace").strip()
+ return f"Error sending HTML email: {stderr}"
+ output = result.stdout.decode("utf-8", errors="replace").strip()
+ # Build confirmation message
+ confirm = f"{output}\n\nFrom: {account}\nTo: {to}\nSubject: {subject}"
+ if cc:
+ confirm += f"\nCC: {cc}"
+ if bcc:
+ confirm += f"\nBCC: {bcc}"
+ return confirm
+ except subprocess.TimeoutExpired:
+ return "Error: HTML email script timed out"
+ finally:
+ if os.path.exists(html_temp_path):
+ os.unlink(html_temp_path)
+
+
+def _validate_attachment_paths(attachments: str) -> Tuple[List[str], Optional[str]]:
+ """Validate and resolve attachment file paths.
+
+ Splits comma-separated paths, expands tildes, resolves symlinks,
+ and enforces security constraints (home-dir-only, no sensitive dirs,
+ file must exist).
+
+ Returns:
+ A tuple of (resolved_paths, error_message).
+ If error_message is not None, resolved_paths should be ignored.
+ """
+ home_dir = os.path.expanduser("~")
+ sensitive_dirs = [
+ os.path.join(home_dir, ".ssh"),
+ os.path.join(home_dir, ".gnupg"),
+ os.path.join(home_dir, ".config"),
+ os.path.join(home_dir, ".aws"),
+ os.path.join(home_dir, ".claude"),
+ os.path.join(home_dir, "Library", "LaunchAgents"),
+ os.path.join(home_dir, "Library", "LaunchDaemons"),
+ os.path.join(home_dir, "Library", "Keychains"),
+ ]
+
+ resolved_paths: List[str] = []
+ raw_paths = [p.strip() for p in attachments.split(",")]
+
+ for raw_path in raw_paths:
+ if not raw_path:
+ continue
+
+ # Expand tilde and resolve symlinks
+ expanded = os.path.expanduser(raw_path)
+ resolved = os.path.realpath(expanded)
+
+ # Must be under the user's home directory
+ if not resolved.startswith(home_dir + os.sep) and resolved != home_dir:
+ return (
+ [],
+ f"Error: Attachment path must be under your home directory ({home_dir}). Got: {resolved}",
+ )
+
+ # Block sensitive directories
+ for sensitive_dir in sensitive_dirs:
+ if resolved.startswith(sensitive_dir + os.sep) or resolved == sensitive_dir:
+ return (
+ [],
+ f"Error: Cannot attach files from sensitive directory: {sensitive_dir}",
+ )
+
+ # File must exist
+ if not os.path.isfile(resolved):
+ return [], f"Error: Attachment file does not exist: {resolved}"
+
+ resolved_paths.append(resolved)
+
+ if not resolved_paths:
+ return [], "Error: No valid attachment paths provided."
+
+ return resolved_paths, None
@mcp.tool()
@@ -14,7 +470,11 @@ def reply_to_email(
reply_body: str,
reply_to_all: bool = False,
cc: Optional[str] = None,
- bcc: Optional[str] = None
+ bcc: Optional[str] = None,
+ send: bool = True,
+ mode: Optional[str] = None,
+ attachments: Optional[str] = None,
+ body_html: Optional[str] = None,
) -> str:
"""
Reply to an email matching a subject keyword.
@@ -26,26 +486,67 @@ def reply_to_email(
reply_to_all: If True, reply to all recipients; if False, reply only to sender (default: False)
cc: Optional CC recipients, comma-separated for multiple
bcc: Optional BCC recipients, comma-separated for multiple
+ send: If True (default), send immediately; if False, save as draft. Ignored if mode is set.
+ mode: Delivery mode — "send" (send immediately), "draft" (save silently), or "open" (open compose window for review). Overrides send parameter when set.
+ attachments: Optional file paths to attach, comma-separated for multiple (e.g., "/path/to/file1.png,/path/to/file2.pdf")
+ body_html: Optional HTML body for rich formatting (bold, headings, links, colors). When provided, the reply is pasted as HTML. The plain 'reply_body' field is still required as fallback text.
Returns:
- Confirmation message with details of the reply sent
+ Confirmation message with details of the reply sent, saved draft, or opened draft
"""
# Escape all user inputs for AppleScript
safe_account = escape_applescript(account)
safe_subject_keyword = escape_applescript(subject_keyword)
- escaped_body = escape_applescript(reply_body)
+
+ # Write reply body to a temp file to avoid AppleScript string escaping
+ # issues with special characters (em dashes, curly quotes, colons, etc.)
+ body_tmp = tempfile.NamedTemporaryFile(
+ mode="w",
+ suffix=".txt",
+ prefix="mail_reply_",
+ delete=False,
+ encoding="utf-8",
+ )
+ body_tmp.write(reply_body)
+ body_tmp.close()
+ body_temp_path = body_tmp.name
+
+ # If body_html provided, write it to a temp file for the AppleScript to read.
+ # If plain text only, wrap it in basic HTML so the clipboard paste renders
+ # properly in Mail's HTML compose view (preserving line breaks and gap).
+ html_temp_path = None
+ # Append an empty paragraph to create a visible gap before the quoted original.
+ # Mail strips trailing tags, so we use a
with instead.
+ gap_html = "
"
+ if body_html:
+ html_content = body_html + gap_html
+ else:
+ # Wrap plain text in HTML, converting newlines to
+ escaped_plain = html_escape(reply_body)
+ escaped_plain = escaped_plain.replace("\n", " ")
+ html_content = f"
{escaped_plain}
{gap_html}"
+ html_tmp = tempfile.NamedTemporaryFile(
+ mode="w",
+ suffix=".html",
+ prefix="mail_reply_html_",
+ delete=False,
+ encoding="utf-8",
+ )
+ html_tmp.write(html_content)
+ html_tmp.close()
+ html_temp_path = html_tmp.name
# Build the reply command based on reply_to_all flag
if reply_to_all:
- reply_command = 'set replyMessage to reply foundMessage with opening window reply to all'
+ reply_command = "set replyMessage to reply foundMessage with opening window and reply to all"
else:
- reply_command = 'set replyMessage to reply foundMessage with opening window'
+ reply_command = "set replyMessage to reply foundMessage with opening window"
# Build CC recipients if provided
- cc_script = ''
+ cc_script = ""
if cc:
- cc_addresses = [addr.strip() for addr in cc.split(',')]
+ cc_addresses = [addr.strip() for addr in cc.split(",")]
for addr in cc_addresses:
safe_addr = escape_applescript(addr)
cc_script += f'''
@@ -53,97 +554,219 @@ def reply_to_email(
'''
# Build BCC recipients if provided
- bcc_script = ''
+ bcc_script = ""
if bcc:
- bcc_addresses = [addr.strip() for addr in bcc.split(',')]
+ bcc_addresses = [addr.strip() for addr in bcc.split(",")]
for addr in bcc_addresses:
safe_addr = escape_applescript(addr)
bcc_script += f'''
make new bcc recipient at end of bcc recipients of replyMessage with properties {{address:"{safe_addr}"}}
'''
+ # Build attachment script if provided
+ attachment_script = ""
+ attachment_info = ""
+ if attachments:
+ validated_paths, error = _validate_attachment_paths(attachments)
+ if error:
+ return error
+ for path in validated_paths:
+ safe_path = escape_applescript(path)
+ attachment_script += f'''
+ set theFile to POSIX file "{safe_path}"
+ make new attachment with properties {{file name:theFile}} at after the last paragraph
+ delay 1
+ '''
+ attachment_info += f" {path}\n"
+
safe_cc = escape_applescript(cc) if cc else ""
safe_bcc = escape_applescript(bcc) if bcc else ""
+ safe_attachment_info = (
+ escape_applescript(attachment_info) if attachment_info else ""
+ )
+
+ # Resolve delivery mode: mode parameter takes precedence over send boolean
+ if mode is not None:
+ if mode not in ("send", "draft", "open"):
+ return f"Error: Invalid mode '{mode}'. Use: send, draft, open"
+ effective_mode = mode
+ else:
+ effective_mode = "send" if send else "draft"
+
+ # Read body from temp file in AppleScript (avoids all string escaping issues)
+ read_body_script = f'set replyBodyText to do shell script "cat " & quoted form of "{body_temp_path}"'
+
+ # Determine behavior per mode
+ # All modes use HTML clipboard paste (via NSPasteboard) to insert the reply body.
+ # This preserves Mail.app's native quoted original in the HTML layer.
+ # (setting `content` via AppleScript overwrites the HTML layer entirely,
+ # destroying the email thread history.)
+
+ if effective_mode == "send":
+ header_text = "SENDING REPLY"
+ post_paste_action = """
+ delay 0.5
+ tell application "Mail"
+ send replyMessage
+ end tell"""
+ success_text = "Reply sent successfully!"
+ elif effective_mode == "open":
+ header_text = "OPENING REPLY FOR REVIEW"
+ post_paste_action = ""
+ success_text = "Reply opened in Mail for review. Edit and send when ready."
+ else: # draft
+ header_text = "SAVING REPLY AS DRAFT"
+ post_paste_action = """
+ delay 0.5
+ tell application "Mail"
+ close window 1 saving yes
+ end tell"""
+ success_text = "Reply saved as draft!"
+
+ cleanup_script = f'do shell script "rm -f " & quoted form of "{body_temp_path}"'
+ html_cleanup_script = f'do shell script "rm -f \'{html_temp_path}\'"'
script = f'''
- tell application "Mail"
- set outputText to "SENDING REPLY" & return & return
+use framework "Foundation"
+use framework "AppKit"
+use scripting additions
+
+-- Step 1: Place reply body HTML on clipboard via NSPasteboard
+set htmlString to do shell script "cat '{html_temp_path}'"
+set pb to current application's NSPasteboard's generalPasteboard()
+set oldClip to pb's stringForType:(current application's NSPasteboardTypeString)
+pb's clearContents()
+set htmlData to (current application's NSString's stringWithString:htmlString)'s dataUsingEncoding:(current application's NSUTF8StringEncoding)
+pb's setData:htmlData forType:(current application's NSPasteboardTypeHTML)
+
+-- Step 2: Find the email and create reply
+tell application "Mail"
+ set outputText to "{header_text}" & return & return
+
+ try
+ -- Read reply body from temp file (for output text only)
+ {read_body_script}
+
+ set targetAccount to account "{safe_account}"
+ {inbox_mailbox_script("inboxMailbox", "targetAccount")}
+ set inboxMessages to every message of inboxMailbox
+ set foundMessage to missing value
+
+ -- Find the first matching message
+ repeat with aMessage in inboxMessages
+ try
+ set messageSubject to subject of aMessage
- try
- set targetAccount to account "{safe_account}"
- {inbox_mailbox_script("inboxMailbox", "targetAccount")}
- set inboxMessages to every message of inboxMailbox
- set foundMessage to missing value
-
- -- Find the first matching message
- repeat with aMessage in inboxMessages
- try
- set messageSubject to subject of aMessage
-
- if messageSubject contains "{safe_subject_keyword}" then
- set foundMessage to aMessage
- exit repeat
- end if
- end try
- end repeat
-
- if foundMessage is not missing value then
- set messageSubject to subject of foundMessage
- set messageSender to sender of foundMessage
- set messageDate to date received of foundMessage
-
- -- Create reply
- {reply_command}
-
- -- Ensure the reply is from the correct account
- set emailAddrs to email addresses of targetAccount
- set senderAddress to item 1 of emailAddrs
- set sender of replyMessage to senderAddress
+ if messageSubject contains "{safe_subject_keyword}" then
+ set foundMessage to aMessage
+ exit repeat
+ end if
+ end try
+ end repeat
- -- Set reply content
- set content of replyMessage to "{escaped_body}"
+ if foundMessage is not missing value then
+ set messageSubject to subject of foundMessage
+ set messageSender to sender of foundMessage
+ set messageDate to date received of foundMessage
- -- Add CC/BCC recipients
- {cc_script}
- {bcc_script}
+ -- Create reply
+ {reply_command}
+ delay 0.5
+
+ -- Ensure the reply is from the correct account
+ set emailAddrs to email addresses of targetAccount
+ set senderAddress to item 1 of emailAddrs
+ set sender of replyMessage to senderAddress
+
+ -- Add CC/BCC recipients
+ {cc_script}
+ {bcc_script}
- -- Send the reply
- send replyMessage
+ -- Add attachments
+ {attachment_script}
- set outputText to outputText & "✓ Reply sent successfully!" & return & return
- set outputText to outputText & "Original email:" & return
- set outputText to outputText & " Subject: " & messageSubject & return
- set outputText to outputText & " From: " & messageSender & return
- set outputText to outputText & " Date: " & (messageDate as string) & return & return
- set outputText to outputText & "Reply body:" & return
- set outputText to outputText & " " & "{escaped_body}" & return
+ -- Paste reply body (HTML already on clipboard from Step 1)
+ set visible of replyMessage to true
+ activate
+ delay 1.5
+
+ tell application "System Events"
+ tell process "Mail"
+ keystroke "v" using command down
+ end tell
+ end tell
+ delay 0.5
+
+ {post_paste_action}
+
+ set outputText to outputText & "{success_text}" & return
+ set outputText to outputText & "To: " & messageSender & return
+ set outputText to outputText & "Subject: " & messageSubject & return
'''
if cc:
- script += f'''
+ script += f"""
set outputText to outputText & "CC: {safe_cc}" & return
- '''
+ """
if bcc:
- script += f'''
+ script += f"""
set outputText to outputText & "BCC: {safe_bcc}" & return
+ """
+
+ if attachments:
+ script += f'''
+ set outputText to outputText & "Attachments:" & return & "{safe_attachment_info}" & return
'''
- script += f'''
+ script += f"""
else
- set outputText to outputText & "⚠ No email found matching: {safe_subject_keyword}" & return
+ set outputText to outputText & "No email found matching: {safe_subject_keyword}" & return
end if
+ -- Clean up temp files
+ {cleanup_script}
+ {html_cleanup_script}
+
on error errMsg
+ -- Clean up temp files even on error
+ try
+ {cleanup_script}
+ {html_cleanup_script}
+ end try
return "Error: " & errMsg & return & "Please check that the account name is correct and the email exists."
end try
return outputText
end tell
- '''
- result = run_applescript(script)
- return result
+ -- Restore clipboard
+ if oldClip is not missing value then
+ pb's clearContents()
+ pb's setString:oldClip forType:(current application's NSPasteboardTypeString)
+ end if
+ """
+
+ try:
+ # Use osascript directly for AppleScriptObjC (use framework) support
+ result = subprocess.run(
+ ["osascript", "-"],
+ input=script.encode("utf-8"),
+ capture_output=True,
+ timeout=30,
+ )
+ if result.returncode != 0:
+ stderr = result.stderr.decode("utf-8", errors="replace").strip()
+ return f"Error in reply: {stderr}"
+ return result.stdout.decode("utf-8", errors="replace").strip()
+ except subprocess.TimeoutExpired:
+ return "Error: Reply script timed out"
+ finally:
+ # Belt-and-suspenders cleanup in case AppleScript didn't run
+ if os.path.exists(body_temp_path):
+ os.unlink(body_temp_path)
+ if html_temp_path and os.path.exists(html_temp_path):
+ os.unlink(html_temp_path)
@mcp.tool()
@@ -154,7 +777,10 @@ def compose_email(
subject: str,
body: str,
cc: Optional[str] = None,
- bcc: Optional[str] = None
+ bcc: Optional[str] = None,
+ attachments: Optional[str] = None,
+ mode: str = "send",
+ body_html: Optional[str] = None,
) -> str:
"""
Compose and send a new email from a specific account.
@@ -163,22 +789,59 @@ def compose_email(
account: Account name to send from (e.g., "Gmail", "Work", "Personal")
to: Recipient email address(es), comma-separated for multiple
subject: Email subject line
- body: Email body text
+ body: Email body text (used as plain-text fallback when body_html is provided)
cc: Optional CC recipients, comma-separated for multiple
bcc: Optional BCC recipients, comma-separated for multiple
+ attachments: Optional file paths to attach, comma-separated for multiple (e.g., "/path/to/file1.png,/path/to/file2.pdf")
+ mode: Delivery mode — "send" (send immediately, default), "draft" (save silently to Drafts), or "open" (open compose window for review before sending)
+ body_html: Optional HTML body for rich formatting (bold, headings, links, colors). When provided, the email is sent as HTML. The plain 'body' field is still required as fallback text.
Returns:
- Confirmation message with details of the sent email
+ Confirmation message with details of the email
"""
- # Escape all user inputs for AppleScript
+ # Validate mode
+ if mode not in ("send", "draft", "open"):
+ return f"Error: Invalid mode '{mode}'. Use: send, draft, open"
+
+ # Validate and resolve attachments early
+ attachment_script = ""
+ attachment_info = ""
+ if attachments:
+ validated_paths, error = _validate_attachment_paths(attachments)
+ if error:
+ return error
+ for path in validated_paths:
+ safe_path = escape_applescript(path)
+ attachment_script += f'''
+ set theFile to POSIX file "{safe_path}"
+ make new attachment with properties {{file name:theFile}} at after the last paragraph
+ delay 1
+ '''
+ attachment_info += f" {path}\n"
+
+ # --- HTML path: use NSPasteboard clipboard injection ---
+ if body_html:
+ return _send_html_email(
+ account=account,
+ to=to,
+ subject=subject,
+ body_plain=body,
+ body_html=body_html,
+ cc=cc,
+ bcc=bcc,
+ attachments_script=attachment_script,
+ mode=mode,
+ )
+
+ # --- Plain-text path: existing AppleScript approach ---
safe_account = escape_applescript(account)
escaped_subject = escape_applescript(subject)
escaped_body = escape_applescript(body)
# Build TO recipients (split comma-separated addresses)
- to_script = ''
- to_addresses = [addr.strip() for addr in to.split(',')]
+ to_script = ""
+ to_addresses = [addr.strip() for addr in to.split(",")]
for addr in to_addresses:
safe_addr = escape_applescript(addr)
to_script += f'''
@@ -186,9 +849,9 @@ def compose_email(
'''
# Build CC recipients if provided
- cc_script = ''
+ cc_script = ""
if cc:
- cc_addresses = [addr.strip() for addr in cc.split(',')]
+ cc_addresses = [addr.strip() for addr in cc.split(",")]
for addr in cc_addresses:
safe_addr = escape_applescript(addr)
cc_script += f'''
@@ -196,9 +859,9 @@ def compose_email(
'''
# Build BCC recipients if provided
- bcc_script = ''
+ bcc_script = ""
if bcc:
- bcc_addresses = [addr.strip() for addr in bcc.split(',')]
+ bcc_addresses = [addr.strip() for addr in bcc.split(",")]
for addr in bcc_addresses:
safe_addr = escape_applescript(addr)
bcc_script += f'''
@@ -208,16 +871,36 @@ def compose_email(
safe_to = escape_applescript(to)
safe_cc = escape_applescript(cc) if cc else ""
safe_bcc = escape_applescript(bcc) if bcc else ""
+ safe_attachment_info = (
+ escape_applescript(attachment_info) if attachment_info else ""
+ )
+
+ # Determine behavior per mode
+ if mode == "send":
+ header_text = "COMPOSING EMAIL"
+ visible = "false"
+ send_command = "send newMessage"
+ success_text = "✓ Email sent successfully!"
+ elif mode == "open":
+ header_text = "OPENING EMAIL FOR REVIEW"
+ visible = "true"
+ send_command = "activate"
+ success_text = "✓ Email opened in Mail for review. Edit and send when ready."
+ else: # draft
+ header_text = "SAVING EMAIL AS DRAFT"
+ visible = "false"
+ send_command = "close window 1 saving yes"
+ success_text = "✓ Email saved as draft!"
script = f'''
tell application "Mail"
- set outputText to "COMPOSING EMAIL" & return & return
+ set outputText to "{header_text}" & return & return
try
set targetAccount to account "{safe_account}"
-- Create new outgoing message
- set newMessage to make new outgoing message with properties {{subject:"{escaped_subject}", content:"{escaped_body}", visible:false}}
+ set newMessage to make new outgoing message with properties {{subject:"{escaped_subject}", content:"{escaped_body}", visible:{visible}}}
-- Set the sender account
set emailAddrs to email addresses of targetAccount
@@ -231,27 +914,35 @@ def compose_email(
{bcc_script}
end tell
- -- Send the message
- send newMessage
+ -- Add attachments
+ tell newMessage
+ {attachment_script}
+ end tell
+
+ -- Send, save as draft, or leave open for review
+ {send_command}
- set outputText to outputText & "✓ Email sent successfully!" & return & return
- set outputText to outputText & "From: " & name of targetAccount & return
+ set outputText to outputText & "{success_text}" & return
set outputText to outputText & "To: {safe_to}" & return
+ set outputText to outputText & "Subject: {escaped_subject}" & return
'''
if cc:
- script += f'''
+ script += f"""
set outputText to outputText & "CC: {safe_cc}" & return
- '''
+ """
if bcc:
- script += f'''
+ script += f"""
set outputText to outputText & "BCC: {safe_bcc}" & return
+ """
+
+ if attachments:
+ script += f'''
+ set outputText to outputText & "Attachments:" & return & "{safe_attachment_info}" & return
'''
script += f'''
- set outputText to outputText & "Subject: {escaped_subject}" & return
- set outputText to outputText & "Body: " & "{escaped_body}" & return
on error errMsg
return "Error: " & errMsg & return & "Please check that the account name and email addresses are correct."
@@ -274,7 +965,7 @@ def forward_email(
message: Optional[str] = None,
mailbox: str = "INBOX",
cc: Optional[str] = None,
- bcc: Optional[str] = None
+ bcc: Optional[str] = None,
) -> str:
"""
Forward an email to one or more recipients.
@@ -300,9 +991,9 @@ def forward_email(
escaped_message = escape_applescript(message) if message else ""
# Build CC recipients if provided
- cc_script = ''
+ cc_script = ""
if cc:
- cc_addresses = [addr.strip() for addr in cc.split(',')]
+ cc_addresses = [addr.strip() for addr in cc.split(",")]
for addr in cc_addresses:
safe_addr = escape_applescript(addr)
cc_script += f'''
@@ -310,9 +1001,9 @@ def forward_email(
'''
# Build BCC recipients if provided
- bcc_script = ''
+ bcc_script = ""
if bcc:
- bcc_addresses = [addr.strip() for addr in bcc.split(',')]
+ bcc_addresses = [addr.strip() for addr in bcc.split(",")]
for addr in bcc_addresses:
safe_addr = escape_applescript(addr)
bcc_script += f'''
@@ -323,107 +1014,178 @@ def forward_email(
safe_bcc = escape_applescript(bcc) if bcc else ""
# Build TO recipients (split comma-separated)
- to_script = ''
- to_addresses = [addr.strip() for addr in to.split(',')]
+ to_script = ""
+ to_addresses = [addr.strip() for addr in to.split(",")]
for addr in to_addresses:
safe_addr = escape_applescript(addr)
to_script += f'''
make new to recipient at end of to recipients of forwardMessage with properties {{address:"{safe_addr}"}}
'''
- script = f'''
- tell application "Mail"
- set outputText to "FORWARDING EMAIL" & return & return
+ # If an optional message is provided, write it as HTML to a temp file
+ # for NSPasteboard clipboard injection (preserves forwarded content).
+ fwd_html_temp_path = None
+ fwd_html_paste_script = ""
+ fwd_html_cleanup_script = ""
+ if message:
+ escaped_plain = html_escape(message)
+ escaped_plain = escaped_plain.replace("\n", " ")
+ fwd_html_content = f"{escaped_plain}
"
+ fwd_html_tmp = tempfile.NamedTemporaryFile(
+ mode="w",
+ suffix=".html",
+ prefix="mail_fwd_html_",
+ delete=False,
+ encoding="utf-8",
+ )
+ fwd_html_tmp.write(fwd_html_content)
+ fwd_html_tmp.close()
+ fwd_html_temp_path = fwd_html_tmp.name
+ fwd_html_cleanup_script = f'do shell script "rm -f \'{fwd_html_temp_path}\'"'
+ fwd_html_paste_script = f"""
+ set visible of forwardMessage to true
+ activate
+ delay 1.5
+
+ set htmlString to do shell script "cat '{fwd_html_temp_path}'"
+ set pb to current application's NSPasteboard's generalPasteboard()
+ set oldClip to pb's stringForType:(current application's NSPasteboardTypeString)
+ pb's clearContents()
+ set htmlData to (current application's NSString's stringWithString:htmlString)'s dataUsingEncoding:(current application's NSUTF8StringEncoding)
+ pb's setData:htmlData forType:(current application's NSPasteboardTypeHTML)
+
+ tell application "System Events"
+ tell process "Mail"
+ keystroke "v" using command down
+ end tell
+ end tell
+ delay 0.5
+ if oldClip is not missing value then
+ pb's clearContents()
+ pb's setString:oldClip forType:(current application's NSPasteboardTypeString)
+ end if
+ """
+
+ use_frameworks = ""
+ if message:
+ use_frameworks = """use framework "Foundation"
+use framework "AppKit"
+use scripting additions
+"""
+
+ script = f'''{use_frameworks}
+tell application "Mail"
+ set outputText to "FORWARDING EMAIL" & return & return
+
+ try
+ set targetAccount to account "{safe_account}"
+ -- Try to get mailbox
try
- set targetAccount to account "{safe_account}"
- -- Try to get mailbox
+ set targetMailbox to mailbox "{safe_mailbox}" of targetAccount
+ on error
+ if "{safe_mailbox}" is "INBOX" then
+ set targetMailbox to mailbox "Inbox" of targetAccount
+ else
+ error "Mailbox not found: {safe_mailbox}"
+ end if
+ end try
+
+ set mailboxMessages to every message of targetMailbox
+ set foundMessage to missing value
+
+ -- Find the first matching message
+ repeat with aMessage in mailboxMessages
try
- set targetMailbox to mailbox "{safe_mailbox}" of targetAccount
- on error
- if "{safe_mailbox}" is "INBOX" then
- set targetMailbox to mailbox "Inbox" of targetAccount
- else
- error "Mailbox not found: {safe_mailbox}"
+ set messageSubject to subject of aMessage
+
+ if messageSubject contains "{safe_subject_keyword}" then
+ set foundMessage to aMessage
+ exit repeat
end if
end try
+ end repeat
- set mailboxMessages to every message of targetMailbox
- set foundMessage to missing value
+ if foundMessage is not missing value then
+ set messageSubject to subject of foundMessage
+ set messageSender to sender of foundMessage
+ set messageDate to date received of foundMessage
- -- Find the first matching message
- repeat with aMessage in mailboxMessages
- try
- set messageSubject to subject of aMessage
+ -- Create forward
+ set forwardMessage to forward foundMessage with opening window
- if messageSubject contains "{safe_subject_keyword}" then
- set foundMessage to aMessage
- exit repeat
- end if
- end try
- end repeat
+ -- Set sender account
+ set emailAddrs to email addresses of targetAccount
+ set senderAddress to item 1 of emailAddrs
+ set sender of forwardMessage to senderAddress
- if foundMessage is not missing value then
- set messageSubject to subject of foundMessage
- set messageSender to sender of foundMessage
- set messageDate to date received of foundMessage
+ -- Add recipients
+ {to_script}
- -- Create forward
- set forwardMessage to forward foundMessage with opening window
+ -- Add CC/BCC recipients
+ {cc_script}
+ {bcc_script}
- -- Set sender account
- set emailAddrs to email addresses of targetAccount
- set senderAddress to item 1 of emailAddrs
- set sender of forwardMessage to senderAddress
+ -- Add optional message via HTML clipboard paste (preserves forwarded content)
+ {fwd_html_paste_script}
- -- Add recipients
- {to_script}
+ -- Send the forward
+ send forwardMessage
- -- Add CC/BCC recipients
- {cc_script}
- {bcc_script}
-
- -- Add optional message
- if "{escaped_message}" is not "" then
- set content of forwardMessage to "{escaped_message}" & return & return & content of forwardMessage
- end if
-
- -- Send the forward
- send forwardMessage
+ -- Clean up temp files
+ {fwd_html_cleanup_script}
- set outputText to outputText & "✓ Email forwarded successfully!" & return & return
- set outputText to outputText & "Original email:" & return
- set outputText to outputText & " Subject: " & messageSubject & return
- set outputText to outputText & " From: " & messageSender & return
- set outputText to outputText & " Date: " & (messageDate as string) & return & return
- set outputText to outputText & "Forwarded to: {safe_to}" & return
+ set outputText to outputText & "Email forwarded successfully." & return
+ set outputText to outputText & "To: {safe_to}" & return
+ set outputText to outputText & "Subject: " & messageSubject & return
'''
if cc:
- script += f'''
+ script += f"""
set outputText to outputText & "CC: {safe_cc}" & return
- '''
+ """
if bcc:
- script += f'''
+ script += f"""
set outputText to outputText & "BCC: {safe_bcc}" & return
- '''
+ """
- script += f'''
+ script += f"""
else
set outputText to outputText & "⚠ No email found matching: {safe_subject_keyword}" & return
end if
on error errMsg
+ try
+ {fwd_html_cleanup_script}
+ end try
return "Error: " & errMsg
end try
return outputText
end tell
- '''
+ """
- result = run_applescript(script)
- return result
+ try:
+ if message:
+ # Use osascript directly for AppleScriptObjC (use framework) support
+ result = subprocess.run(
+ ["osascript", "-"],
+ input=script.encode("utf-8"),
+ capture_output=True,
+ timeout=30,
+ )
+ if result.returncode != 0:
+ stderr = result.stderr.decode("utf-8", errors="replace").strip()
+ return f"Error forwarding email: {stderr}"
+ return result.stdout.decode("utf-8", errors="replace").strip()
+ else:
+ return run_applescript(script)
+ except subprocess.TimeoutExpired:
+ return "Error: Forward script timed out"
+ finally:
+ if fwd_html_temp_path and os.path.exists(fwd_html_temp_path):
+ os.unlink(fwd_html_temp_path)
@mcp.tool()
@@ -436,20 +1198,20 @@ def manage_drafts(
body: Optional[str] = None,
cc: Optional[str] = None,
bcc: Optional[str] = None,
- draft_subject: Optional[str] = None
+ draft_subject: Optional[str] = None,
) -> str:
"""
- Manage draft emails - list, create, send, or delete drafts.
+ Manage draft emails - list, create, send, open, or delete drafts.
Args:
account: Account name (e.g., "Gmail", "Work")
- action: Action to perform: "list", "create", "send", "delete"
+ action: Action to perform: "list", "create", "send", "open", "delete". Use "open" to open a draft in a visible compose window for review before sending.
subject: Email subject (required for create)
to: Recipient email(s) for create (comma-separated)
body: Email body (required for create)
cc: Optional CC recipients for create
bcc: Optional BCC recipients for create
- draft_subject: Subject keyword to find draft (required for send/delete)
+ draft_subject: Subject keyword to find draft (required for send/open/delete)
Returns:
Formatted output based on action
@@ -498,8 +1260,8 @@ def manage_drafts(
safe_to = escape_applescript(to)
# Build TO recipients (split comma-separated)
- to_script = ''
- to_addresses = [addr.strip() for addr in to.split(',')]
+ to_script = ""
+ to_addresses = [addr.strip() for addr in to.split(",")]
for addr in to_addresses:
safe_addr = escape_applescript(addr)
to_script += f'''
@@ -507,9 +1269,9 @@ def manage_drafts(
'''
# Build CC recipients if provided
- cc_script = ''
+ cc_script = ""
if cc:
- cc_addresses = [addr.strip() for addr in cc.split(',')]
+ cc_addresses = [addr.strip() for addr in cc.split(",")]
for addr in cc_addresses:
safe_addr = escape_applescript(addr)
cc_script += f'''
@@ -517,9 +1279,9 @@ def manage_drafts(
'''
# Build BCC recipients if provided
- bcc_script = ''
+ bcc_script = ""
if bcc:
- bcc_addresses = [addr.strip() for addr in bcc.split(',')]
+ bcc_addresses = [addr.strip() for addr in bcc.split(",")]
for addr in bcc_addresses:
safe_addr = escape_applescript(addr)
bcc_script += f'''
@@ -564,6 +1326,8 @@ def manage_drafts(
'''
elif action == "send":
+ if READ_ONLY:
+ return "Error: Sending drafts is disabled in read-only mode."
if not draft_subject:
return "Error: 'draft_subject' is required for sending drafts"
@@ -612,6 +1376,57 @@ def manage_drafts(
end tell
'''
+ elif action == "open":
+ if not draft_subject:
+ return "Error: 'draft_subject' is required for opening drafts"
+
+ safe_draft_subject = escape_applescript(draft_subject)
+
+ script = f'''
+ tell application "Mail"
+ set outputText to "OPENING DRAFT FOR REVIEW" & return & return
+
+ try
+ set targetAccount to account "{safe_account}"
+ set draftsMailbox to mailbox "Drafts" of targetAccount
+ set draftMessages to every message of draftsMailbox
+ set foundDraft to missing value
+
+ -- Find the draft
+ repeat with aDraft in draftMessages
+ try
+ set draftSubject to subject of aDraft
+
+ if draftSubject contains "{safe_draft_subject}" then
+ set foundDraft to aDraft
+ exit repeat
+ end if
+ end try
+ end repeat
+
+ if foundDraft is not missing value then
+ set draftSubject to subject of foundDraft
+
+ -- Open the draft in a visible compose window
+ set draftWindow to open foundDraft
+ activate
+
+ set outputText to outputText & "✓ Draft opened in Mail for review!" & return
+ set outputText to outputText & "Subject: " & draftSubject & return
+ set outputText to outputText & return & "Edit and send when ready." & return
+
+ else
+ set outputText to outputText & "⚠ No draft found matching: {safe_draft_subject}" & return
+ end if
+
+ on error errMsg
+ return "Error: " & errMsg
+ end try
+
+ return outputText
+ end tell
+ '''
+
elif action == "delete":
if not draft_subject:
return "Error: 'draft_subject' is required for deleting drafts"
@@ -662,7 +1477,9 @@ def manage_drafts(
'''
else:
- return f"Error: Invalid action '{action}'. Use: list, create, send, delete"
+ return (
+ f"Error: Invalid action '{action}'. Use: list, create, send, open, delete"
+ )
result = run_applescript(script)
return result
diff --git a/apple_mail_mcp/tools/inbox.py b/apple_mail_mcp/tools/inbox.py
index 077ef16..cd06517 100644
--- a/apple_mail_mcp/tools/inbox.py
+++ b/apple_mail_mcp/tools/inbox.py
@@ -1,9 +1,38 @@
"""Inbox tools: listing, counting, and overview."""
+import json
from typing import Optional, List, Dict, Any
from apple_mail_mcp.server import mcp
-from apple_mail_mcp.core import inject_preferences, escape_applescript, run_applescript, inbox_mailbox_script
+from apple_mail_mcp.core import (
+ inject_preferences,
+ escape_applescript,
+ run_applescript,
+ inbox_mailbox_script,
+ content_preview_script,
+)
+
+
+def _parse_pipe_delimited_emails(raw: str) -> List[Dict[str, Any]]:
+ """Parse '|||'-delimited AppleScript output into a list of email dicts."""
+ emails = []
+ if not raw:
+ return emails
+ for line in raw.split("\n"):
+ if "|||" not in line:
+ continue
+ parts = line.split("|||")
+ if len(parts) >= 5:
+ emails.append(
+ {
+ "subject": parts[0].strip(),
+ "sender": parts[1].strip(),
+ "date": parts[2].strip(),
+ "is_read": parts[3].strip().lower() == "true",
+ "account": parts[4].strip(),
+ }
+ )
+ return emails
@mcp.tool()
@@ -11,21 +40,31 @@
def list_inbox_emails(
account: Optional[str] = None,
max_emails: int = 0,
- include_read: bool = True
+ include_read: bool = True,
+ include_content: bool = False,
+ output_format: str = "text",
) -> str:
"""
List all emails from inbox across all accounts or a specific account.
+ Replaces the former get_recent_emails tool — use account + max_emails to
+ get recent emails from a single account.
+
Args:
account: Optional account name to filter (e.g., "Gmail", "Work"). If None, shows all accounts.
max_emails: Maximum number of emails to return per account (0 = all)
include_read: Whether to include read emails (default: True)
+ include_content: Whether to include a content preview for each email (slower, default: False)
+ output_format: "text" (default, human-readable) or "json" (structured list of email dicts)
Returns:
Formatted list of emails with subject, sender, date, and read status
"""
- script = f'''
+ if output_format == "json":
+ return _list_inbox_emails_json(account, max_emails, include_read, include_content)
+
+ script = f"""
tell application "Mail"
set outputText to "INBOX EMAILS - ALL ACCOUNTS" & return & return
set totalCount to 0
@@ -70,6 +109,9 @@ def list_inbox_emails(
set outputText to outputText & readIndicator & " " & messageSubject & return
set outputText to outputText & " From: " & messageSender & return
set outputText to outputText & " Date: " & (messageDate as string) & return
+
+ {content_preview_script(200) if include_content else ""}
+
set outputText to outputText & return
set totalCount to totalCount + 1
@@ -89,55 +131,192 @@ def list_inbox_emails(
return outputText
end tell
- '''
+ """
result = run_applescript(script)
return result
+def _list_inbox_emails_json(
+ account: Optional[str],
+ max_emails: int,
+ include_read: bool,
+ include_content: bool = False,
+) -> str:
+ """Return inbox emails as a JSON string."""
+ escaped_account = escape_applescript(account) if account else None
+ account_filter = f'if accountName is "{escaped_account}" then' if account else ""
+ account_filter_end = "end if" if account else ""
+
+ script = f"""
+ tell application "Mail"
+ set resultLines to {{}}
+ set allAccounts to every account
+ repeat with anAccount in allAccounts
+ set accountName to name of anAccount
+ {account_filter}
+ try
+ {inbox_mailbox_script("inboxMailbox", "anAccount")}
+ set inboxMessages to every message of inboxMailbox
+ set currentIndex to 0
+ repeat with aMessage in inboxMessages
+ set currentIndex to currentIndex + 1
+ if {max_emails} > 0 and currentIndex > {max_emails} then exit repeat
+ try
+ set messageSubject to subject of aMessage
+ set messageSender to sender of aMessage
+ set messageDate to date received of aMessage
+ set messageRead to read status of aMessage
+ set shouldInclude to true
+ if not {str(include_read).lower()} and messageRead then
+ set shouldInclude to false
+ end if
+ if shouldInclude then
+ set end of resultLines to messageSubject & "|||" & messageSender & "|||" & (messageDate as string) & "|||" & messageRead & "|||" & accountName
+ end if
+ end try
+ end repeat
+ end try
+ {account_filter_end}
+ end repeat
+ set AppleScript's text item delimiters to linefeed
+ return resultLines as string
+ end tell
+ """
+ raw = run_applescript(script)
+ emails = _parse_pipe_delimited_emails(raw)
+ return json.dumps(emails, indent=2)
+
+
@mcp.tool()
@inject_preferences
-def get_unread_count() -> Dict[str, int]:
+def get_mailbox_unread_counts(
+ account: Optional[str] = None,
+ include_zero: bool = False,
+ summary_only: bool = False,
+) -> Dict[str, Any]:
"""
- Get the count of unread emails for each account.
+ Get unread counts per mailbox for one account or all accounts.
+
+ When summary_only=True, returns only per-account inbox unread totals
+ (replaces the former get_unread_count tool).
+
+ Args:
+ account: Optional account name filter
+ include_zero: Whether to include mailboxes with zero unread messages
+ summary_only: If True, return only per-account inbox unread totals
+ (flat dict of account name -> unread count)
Returns:
- Dictionary mapping account names to unread email counts
+ If summary_only=False: nested dict keyed by account name then mailbox path
+ If summary_only=True: flat dict mapping account names to inbox unread counts
"""
+ escaped_account = escape_applescript(account) if account else None
+
+ # Fast path: summary_only returns just per-account inbox unread totals
+ if summary_only:
+ script = f"""
+ tell application "Mail"
+ set resultList to {{}}
+ set allAccounts to every account
+
+ repeat with anAccount in allAccounts
+ set accountName to name of anAccount
+
+ try
+ {inbox_mailbox_script("inboxMailbox", "anAccount")}
+ set unreadCount to unread count of inboxMailbox
+ set end of resultList to accountName & ":" & unreadCount
+ on error
+ set end of resultList to accountName & ":ERROR"
+ end try
+ end repeat
+
+ set AppleScript's text item delimiters to "|"
+ return resultList as string
+ end tell
+ """
+ result = run_applescript(script)
+ counts: Dict[str, int] = {}
+ for item in result.split("|"):
+ if ":" in item:
+ acct_name, count_str = item.split(":", 1)
+ if count_str != "ERROR":
+ counts[acct_name] = int(count_str)
+ else:
+ counts[acct_name] = -1
+ return counts
+
+ account_filter = (
+ f'''
+ if accountName is not "{escaped_account}" then
+ set shouldIncludeAccount to false
+ end if
+ '''
+ if account
+ else ""
+ )
- script = '''
+ script = f"""
tell application "Mail"
- set resultList to {}
+ set resultList to {{}}
set allAccounts to every account
repeat with anAccount in allAccounts
set accountName to name of anAccount
+ set shouldIncludeAccount to true
+ {account_filter}
- try
- {inbox_mailbox_script("inboxMailbox", "anAccount")}
- set unreadCount to unread count of inboxMailbox
- set end of resultList to accountName & ":" & unreadCount
- on error
- set end of resultList to accountName & ":ERROR"
- end try
+ if shouldIncludeAccount then
+ try
+ set accountMailboxes to every mailbox of anAccount
+
+ repeat with aMailbox in accountMailboxes
+ try
+ set mailboxName to name of aMailbox
+ set unreadCount to unread count of aMailbox
+ if {str(include_zero).lower()} or unreadCount > 0 then
+ set end of resultList to accountName & "|||" & mailboxName & "|||" & unreadCount
+ end if
+
+ try
+ set subMailboxes to every mailbox of aMailbox
+ repeat with subBox in subMailboxes
+ set subName to name of subBox
+ set subUnread to unread count of subBox
+ if {str(include_zero).lower()} or subUnread > 0 then
+ set end of resultList to accountName & "|||" & mailboxName & "/" & subName & "|||" & subUnread
+ end if
+ end repeat
+ end try
+ end try
+ end repeat
+ end try
+ end if
end repeat
- set AppleScript's text item delimiters to "|"
- return resultList as string
+ if (count of resultList) is 0 then
+ return ""
+ end if
+
+ set AppleScript's text item delimiters to linefeed
+ set outputText to resultList as string
+ set AppleScript's text item delimiters to ""
+ return outputText
end tell
- '''
+ """
result = run_applescript(script)
+ counts: Dict[str, Dict[str, int]] = {}
+ if not result:
+ return counts
- # Parse the result
- counts = {}
- for item in result.split('|'):
- if ':' in item:
- account, count = item.split(':', 1)
- if count != "ERROR":
- counts[account] = int(count)
- else:
- counts[account] = -1 # Error indicator
+ for line in result.splitlines():
+ parts = line.split("|||", 2)
+ if len(parts) != 3:
+ continue
+ account_name, mailbox_name, unread_value = parts
+ counts.setdefault(account_name, {})[mailbox_name] = int(unread_value)
return counts
@@ -152,7 +331,7 @@ def list_accounts() -> List[str]:
List of account names
"""
- script = '''
+ script = """
tell application "Mail"
set accountNames to {}
set allAccounts to every account
@@ -165,113 +344,15 @@ def list_accounts() -> List[str]:
set AppleScript's text item delimiters to "|"
return accountNames as string
end tell
- '''
-
- result = run_applescript(script)
- return result.split('|') if result else []
-
-
-@mcp.tool()
-@inject_preferences
-def get_recent_emails(
- account: str,
- count: int = 10,
- include_content: bool = False
-) -> str:
- """
- Get the most recent emails from a specific account.
-
- Args:
- account: Account name (e.g., "Gmail", "Work")
- count: Number of recent emails to retrieve (default: 10)
- include_content: Whether to include content preview (slower, default: False)
-
- Returns:
- Formatted list of recent emails
"""
- # Escape user inputs for AppleScript
- escaped_account = escape_applescript(account)
-
- content_script = '''
- try
- set msgContent to content of aMessage
- set AppleScript's text item delimiters to {{return, linefeed}}
- set contentParts to text items of msgContent
- set AppleScript's text item delimiters to " "
- set cleanText to contentParts as string
- set AppleScript's text item delimiters to ""
-
- if length of cleanText > 200 then
- set contentPreview to text 1 thru 200 of cleanText & "..."
- else
- set contentPreview to cleanText
- end if
-
- set outputText to outputText & " Preview: " & contentPreview & return
- on error
- set outputText to outputText & " Preview: [Not available]" & return
- end try
- ''' if include_content else ''
-
- script = f'''
- tell application "Mail"
- set outputText to "RECENT EMAILS - {escaped_account}" & return & return
-
- try
- set targetAccount to account "{escaped_account}"
- {inbox_mailbox_script("inboxMailbox", "targetAccount")}
- set inboxMessages to every message of inboxMailbox
-
- set currentIndex to 0
- repeat with aMessage in inboxMessages
- set currentIndex to currentIndex + 1
- if currentIndex > {count} then exit repeat
-
- try
- set messageSubject to subject of aMessage
- set messageSender to sender of aMessage
- set messageDate to date received of aMessage
- set messageRead to read status of aMessage
-
- if messageRead then
- set readIndicator to "✓"
- else
- set readIndicator to "✉"
- end if
-
- set outputText to outputText & readIndicator & " " & messageSubject & return
- set outputText to outputText & " From: " & messageSender & return
- set outputText to outputText & " Date: " & (messageDate as string) & return
-
- {content_script}
-
- set outputText to outputText & return
- end try
- end repeat
-
- set outputText to outputText & "========================================" & return
- set outputText to outputText & "Showing " & (currentIndex - 1) & " email(s)" & return
- set outputText to outputText & "========================================" & return
-
- on error errMsg
- return "Error: " & errMsg
- end try
-
- return outputText
- end tell
- '''
-
result = run_applescript(script)
- return result
+ return result.split("|") if result else []
@mcp.tool()
@inject_preferences
-def list_mailboxes(
- account: Optional[str] = None,
- include_counts: bool = True
-) -> str:
+def list_mailboxes(account: Optional[str] = None, include_counts: bool = True) -> str:
"""
List all mailboxes (folders) for a specific account or all accounts.
@@ -284,7 +365,8 @@ def list_mailboxes(
For nested mailboxes, shows both indented format and path format (e.g., "Projects/Amplify Impact")
"""
- count_script = '''
+ count_script = (
+ """
try
set msgCount to count of messages of aMailbox
set unreadCount to unread count of aMailbox
@@ -292,18 +374,25 @@ def list_mailboxes(
on error
set outputText to outputText & " (count unavailable)"
end try
- ''' if include_counts else ''
+ """
+ if include_counts
+ else ""
+ )
# Escape user inputs for AppleScript
escaped_account = escape_applescript(account) if account else None
- account_filter = f'''
+ account_filter = (
+ f'''
if accountName is "{escaped_account}" then
- ''' if account else ''
+ '''
+ if account
+ else ""
+ )
- account_filter_end = 'end if' if account else ''
+ account_filter_end = "end if" if account else ""
- script = f'''
+ script = f"""
tell application "Mail"
set outputText to "MAILBOXES" & return & return
set allAccounts to every account
@@ -334,7 +423,7 @@ def list_mailboxes(
set subName to name of subBox
set outputText to outputText & " └─ " & subName & " [Path: " & mailboxName & "/" & subName & "]"
- {count_script.replace('aMailbox', 'subBox') if include_counts else ''}
+ {count_script.replace("aMailbox", "subBox") if include_counts else ""}
set outputText to outputText & return
end repeat
@@ -350,7 +439,7 @@ def list_mailboxes(
return outputText
end tell
- '''
+ """
result = run_applescript(script)
return result
@@ -372,7 +461,7 @@ def get_inbox_overview() -> str:
to suggest relevant actions based on the current state.
"""
- script = f'''
+ script = f"""
tell application "Mail"
set outputText to "╔══════════════════════════════════════════╗" & return
set outputText to outputText & "║ EMAIL INBOX OVERVIEW ║" & return
@@ -537,7 +626,7 @@ def get_inbox_overview() -> str:
return outputText
end tell
- '''
+ """
result = run_applescript(script)
return result
diff --git a/apple_mail_mcp/tools/manage.py b/apple_mail_mcp/tools/manage.py
index 01b7521..921a437 100644
--- a/apple_mail_mcp/tools/manage.py
+++ b/apple_mail_mcp/tools/manage.py
@@ -1,123 +1,181 @@
"""Management tools: moving, status updates, trash, and attachments."""
import os
-from typing import Optional
+from typing import Optional, List
from apple_mail_mcp.server import mcp
-from apple_mail_mcp.core import inject_preferences, escape_applescript, run_applescript, inbox_mailbox_script
+from apple_mail_mcp.core import (
+ contains_any_condition,
+ equals_any_numeric_condition,
+ inject_preferences,
+ escape_applescript,
+ normalize_message_ids,
+ normalize_search_terms,
+ run_applescript,
+ inbox_mailbox_script,
+ build_mailbox_ref,
+ build_filter_condition,
+)
@mcp.tool()
@inject_preferences
def move_email(
account: str,
- subject_keyword: str,
to_mailbox: str,
+ subject_keyword: Optional[str] = None,
from_mailbox: str = "INBOX",
- max_moves: int = 1
+ max_moves: int = 50,
+ subject_keywords: Optional[List[str]] = None,
+ sender: Optional[str] = None,
+ older_than_days: Optional[int] = None,
+ dry_run: bool = False,
+ only_read: bool = False,
) -> str:
"""
- Move email(s) matching a subject keyword from one mailbox to another.
+ Move email(s) matching filters from one mailbox to another.
+
+ Supports subject, sender, and date filters. Use dry_run=True to preview
+ matches without moving. Set only_read=True to skip unread emails (useful
+ for archiving). For archiving to "Archive", just set to_mailbox="Archive".
Args:
account: Account name (e.g., "Gmail", "Work")
- subject_keyword: Keyword to search for in email subjects
to_mailbox: Destination mailbox name. For nested mailboxes, use "/" separator (e.g., "Projects/Amplify Impact")
+ subject_keyword: Optional keyword to search for in email subjects
from_mailbox: Source mailbox name (default: "INBOX")
- max_moves: Maximum number of emails to move (default: 1, safety limit)
+ max_moves: Maximum number of emails to move (default: 50, safety limit)
+ subject_keywords: Optional list of keywords to match in subjects; matches any keyword
+ sender: Optional sender to filter emails by
+ older_than_days: Optional age filter - only move emails older than N days
+ dry_run: If True, preview what would be moved without acting (default: False)
+ only_read: If True, only move emails that have been read (default: False)
Returns:
Confirmation message with details of moved emails
"""
- # Escape all user inputs for AppleScript
- safe_account = escape_applescript(account)
- safe_subject_keyword = escape_applescript(subject_keyword)
- safe_from_mailbox = escape_applescript(from_mailbox)
- safe_to_mailbox = escape_applescript(to_mailbox)
+ subject_terms = normalize_search_terms(subject_keyword, subject_keywords)
+ if not subject_terms and not sender and not older_than_days:
+ return (
+ "Error: At least one filter is required (subject_keyword, sender, "
+ "or older_than_days). This prevents accidentally moving everything."
+ )
- # Parse nested mailbox path
- mailbox_parts = to_mailbox.split('/')
-
- # Build the nested mailbox reference
+ safe_account = escape_applescript(account)
+ safe_from = escape_applescript(from_mailbox)
+ safe_to = escape_applescript(to_mailbox)
+
+ # Build filter condition for the loop body (uses local vars)
+ condition_str = build_filter_condition(
+ subject=subject_keyword if not subject_keywords else None,
+ sender=sender,
+ )
+ # For multi-keyword subject matching, override the subject part
+ if subject_terms:
+ subj_cond = " or ".join(
+ f'messageSubject contains "{escape_applescript(t)}"' for t in subject_terms
+ )
+ subj_cond = f"({subj_cond})"
+ if sender:
+ condition_str = f'{subj_cond} and messageSender contains "{escape_applescript(sender)}"'
+ else:
+ condition_str = subj_cond
+
+ if only_read:
+ read_cond = "messageRead is true"
+ condition_str = (
+ f"{condition_str} and {read_cond}" if condition_str != "true" else read_cond
+ )
+
+ # Date filter
+ date_setup = ""
+ date_cond = ""
+ if older_than_days and older_than_days > 0:
+ date_setup = f"set cutoffDate to (current date) - ({older_than_days} * days)"
+ date_cond = " and messageDate < cutoffDate"
+
+ # Build nested mailbox reference for destination
+ mailbox_parts = to_mailbox.split("/")
if len(mailbox_parts) > 1:
- # Nested mailbox
- dest_mailbox_script = f'mailbox "{escape_applescript(mailbox_parts[-1])}" of '
+ dest_ref = f'mailbox "{escape_applescript(mailbox_parts[-1])}" of '
for i in range(len(mailbox_parts) - 2, -1, -1):
- dest_mailbox_script += f'mailbox "{escape_applescript(mailbox_parts[i])}" of '
- dest_mailbox_script += 'targetAccount'
+ dest_ref += f'mailbox "{escape_applescript(mailbox_parts[i])}" of '
+ dest_ref += "targetAccount"
else:
- dest_mailbox_script = f'mailbox "{safe_to_mailbox}" of targetAccount'
+ dest_ref = f'mailbox "{safe_to}" of targetAccount'
+
+ if dry_run:
+ mode_label = "DRY RUN - PREVIEW MOVE"
+ move_action = ""
+ result_prefix = "Would move"
+ else:
+ mode_label = "MOVING EMAILS"
+ move_action = "move aMessage to destMailbox"
+ result_prefix = "Moved"
+
+ dest_setup = "" if dry_run else f"""
+ set destMailbox to {dest_ref}"""
script = f'''
tell application "Mail"
- set outputText to "MOVING EMAILS" & return & return
- set movedCount to 0
+ with timeout of 300 seconds
+ set outputText to "{mode_label}: {safe_from} -> {safe_to}" & return & return
+ set moveCount to 0
- try
- set targetAccount to account "{safe_account}"
- -- Try to get source mailbox (handle both "INBOX"/"Inbox" variations)
try
- set sourceMailbox to mailbox "{safe_from_mailbox}" of targetAccount
- on error
- if "{safe_from_mailbox}" is "INBOX" then
- set sourceMailbox to mailbox "Inbox" of targetAccount
- else
- error "Source mailbox not found"
- end if
- end try
-
- -- Get destination mailbox (handles nested mailboxes)
- set destMailbox to {dest_mailbox_script}
- set sourceMessages to every message of sourceMailbox
+ set targetAccount to account "{safe_account}"
+ {build_mailbox_ref(from_mailbox, var_name="sourceMailbox")}
+ {dest_setup}
+ {date_setup}
- repeat with aMessage in sourceMessages
- if movedCount >= {max_moves} then exit repeat
+ set mailboxMessages to every message of sourceMailbox
- try
- set messageSubject to subject of aMessage
+ repeat with aMessage in mailboxMessages
+ if moveCount >= {max_moves} then exit repeat
- -- Check if subject contains keyword (case insensitive)
- if messageSubject contains "{safe_subject_keyword}" then
+ try
+ set messageSubject to subject of aMessage
set messageSender to sender of aMessage
set messageDate to date received of aMessage
+ set messageRead to read status of aMessage
- -- Move the message
- move aMessage to destMailbox
+ if {condition_str}{date_cond} then
+ {move_action}
- set outputText to outputText & "✓ Moved: " & messageSubject & return
- set outputText to outputText & " From: " & messageSender & return
- set outputText to outputText & " Date: " & (messageDate as string) & return
- set outputText to outputText & " {safe_from_mailbox} → {safe_to_mailbox}" & return & return
+ set outputText to outputText & "{result_prefix}: " & messageSubject & return
+ set outputText to outputText & " From: " & messageSender & return
+ set outputText to outputText & " Date: " & (messageDate as string) & return & return
- set movedCount to movedCount + 1
- end if
- end try
- end repeat
+ set moveCount to moveCount + 1
+ end if
+ end try
+ end repeat
- set outputText to outputText & "========================================" & return
- set outputText to outputText & "TOTAL MOVED: " & movedCount & " email(s)" & return
- set outputText to outputText & "========================================" & return
+ set outputText to outputText & "========================================" & return
+ set outputText to outputText & "TOTAL: " & moveCount & " email(s) {result_prefix.lower()}" & return
+ if moveCount >= {max_moves} then
+ set outputText to outputText & "(max_moves limit reached)" & return
+ end if
+ set outputText to outputText & "========================================" & return
- on error errMsg
- return "Error: " & errMsg & return & "Please check that account and mailbox names are correct. For nested mailboxes, use '/' separator (e.g., 'Projects/Amplify Impact')."
- end try
+ on error errMsg
+ return "Error: " & errMsg & return & "Check that account and mailbox names are correct. For nested mailboxes, use '/' separator."
+ end try
- return outputText
+ return outputText
+ end timeout
end tell
'''
- result = run_applescript(script)
+ result = run_applescript(script, timeout=300)
return result
@mcp.tool()
@inject_preferences
def save_email_attachment(
- account: str,
- subject_keyword: str,
- attachment_name: str,
- save_path: str
+ account: str, subject_keyword: str, attachment_name: str, save_path: str
) -> str:
"""
Save a specific attachment from an email to disk.
@@ -135,6 +193,34 @@ def save_email_attachment(
# Expand tilde in save_path (POSIX file in AppleScript does not expand ~)
expanded_path = os.path.expanduser(save_path)
+ # Path validation: resolve to absolute path and enforce safety constraints
+ resolved_path = os.path.realpath(expanded_path)
+ home_dir = os.path.expanduser("~")
+
+ # Must be under the user's home directory
+ if not resolved_path.startswith(home_dir + os.sep) and resolved_path != home_dir:
+ return f"Error: Save path must be under your home directory ({home_dir}). Got: {resolved_path}"
+
+ # Block sensitive directories
+ sensitive_dirs = [
+ os.path.join(home_dir, ".ssh"),
+ os.path.join(home_dir, ".gnupg"),
+ os.path.join(home_dir, ".config"),
+ os.path.join(home_dir, ".aws"),
+ os.path.join(home_dir, ".claude"),
+ os.path.join(home_dir, "Library", "LaunchAgents"),
+ os.path.join(home_dir, "Library", "LaunchDaemons"),
+ os.path.join(home_dir, "Library", "Keychains"),
+ ]
+ for sensitive_dir in sensitive_dirs:
+ if (
+ resolved_path.startswith(sensitive_dir + os.sep)
+ or resolved_path == sensitive_dir
+ ):
+ return f"Error: Cannot save attachments to sensitive directory: {sensitive_dir}"
+
+ expanded_path = resolved_path
+
# Escape for AppleScript
escaped_account = escape_applescript(account)
escaped_keyword = escape_applescript(subject_keyword)
@@ -205,108 +291,209 @@ def update_email_status(
account: str,
action: str,
subject_keyword: Optional[str] = None,
+ subject_keywords: Optional[List[str]] = None,
sender: Optional[str] = None,
mailbox: str = "INBOX",
- max_updates: int = 10
+ max_updates: int = 10,
+ apply_to_all: bool = False,
+ message_ids: Optional[List[str]] = None,
+ older_than_days: Optional[int] = None,
) -> str:
"""
Update email status - mark as read/unread or flag/unflag emails.
+ When message_ids is provided, uses exact ID matching (ignores other filters).
+ Otherwise filters by subject, sender, and/or age.
+
Args:
account: Account name (e.g., "Gmail", "Work")
action: Action to perform: "mark_read", "mark_unread", "flag", "unflag"
subject_keyword: Optional keyword to filter emails by subject
+ subject_keywords: Optional list of subject keywords; matches any keyword
sender: Optional sender to filter emails by
mailbox: Mailbox to search in (default: "INBOX")
max_updates: Maximum number of emails to update (safety limit, default: 10)
+ apply_to_all: Must be True to allow updates without any filter
+ message_ids: Optional list of exact Mail message ids for precise targeting
+ older_than_days: Optional age filter - only update emails older than N days
Returns:
Confirmation message with details of updated emails
"""
- # Escape all user inputs for AppleScript
safe_account = escape_applescript(account)
- safe_mailbox = escape_applescript(mailbox)
-
- # Build search condition
- conditions = []
- if subject_keyword:
- conditions.append(f'messageSubject contains "{escape_applescript(subject_keyword)}"')
- if sender:
- conditions.append(f'messageSender contains "{escape_applescript(sender)}"')
-
- condition_str = ' and '.join(conditions) if conditions else 'true'
- # Build action script
+ # Build action scripts
if action == "mark_read":
- action_script = 'set read status of aMessage to true'
+ bulk_action_script = "set read status of targetMessages to true"
+ single_action_script = "set read status of aMessage to true"
action_label = "Marked as read"
elif action == "mark_unread":
- action_script = 'set read status of aMessage to false'
+ bulk_action_script = "set read status of targetMessages to false"
+ single_action_script = "set read status of aMessage to false"
action_label = "Marked as unread"
elif action == "flag":
- action_script = 'set flagged status of aMessage to true'
+ bulk_action_script = "set flagged status of targetMessages to true"
+ single_action_script = "set flagged status of aMessage to true"
action_label = "Flagged"
elif action == "unflag":
- action_script = 'set flagged status of aMessage to false'
+ bulk_action_script = "set flagged status of targetMessages to false"
+ single_action_script = "set flagged status of aMessage to false"
action_label = "Unflagged"
else:
return f"Error: Invalid action '{action}'. Use: mark_read, mark_unread, flag, unflag"
+ # --- ID-based path (fast, ignores other filters) ---
+ if message_ids is not None:
+ normalized_ids = normalize_message_ids(message_ids)
+ if not normalized_ids:
+ return "Error: 'message_ids' must contain one or more numeric Mail ids"
+
+ id_condition = equals_any_numeric_condition("id", normalized_ids)
+
+ script = f'''
+ tell application "Mail"
+ with timeout of 300 seconds
+ set outputText to "UPDATING EMAIL STATUS BY IDS: {action_label}" & return & return
+ set updateCount to 0
+
+ try
+ set targetAccount to account "{safe_account}"
+ {build_mailbox_ref(mailbox, var_name="targetMailbox")}
+
+ set targetMessages to every message of targetMailbox whose {id_condition}
+ set requestedCount to {len(normalized_ids)}
+
+ if (count of targetMessages) > 0 then
+ try
+ {bulk_action_script}
+ on error
+ repeat with aMessage in targetMessages
+ {single_action_script}
+ end repeat
+ end try
+
+ repeat with aMessage in targetMessages
+ try
+ set messageSubject to subject of aMessage
+ set messageSender to sender of aMessage
+ set messageDate to date received of aMessage
+
+ set outputText to outputText & "- {action_label}: " & messageSubject & return
+ set outputText to outputText & " From: " & messageSender & return
+ set outputText to outputText & " Date: " & (messageDate as string) & return & return
+ set updateCount to updateCount + 1
+ end try
+ end repeat
+ end if
+
+ set outputText to outputText & "========================================" & return
+ set outputText to outputText & "REQUESTED IDS: " & requestedCount & return
+ set outputText to outputText & "TOTAL UPDATED: " & updateCount & " email(s)" & return
+ set outputText to outputText & "========================================" & return
+
+ on error errMsg
+ return "Error: " & errMsg
+ end try
+
+ return outputText
+ end timeout
+ end tell
+ '''
+
+ return run_applescript(script, timeout=300)
+
+ # --- Filter-based path ---
+ subject_terms = normalize_search_terms(subject_keyword, subject_keywords)
+
+ # Safety check: require at least one filter or explicit apply_to_all
+ has_filter = bool(subject_terms) or bool(sender) or (
+ older_than_days is not None and older_than_days > 0
+ )
+ if not has_filter and not apply_to_all:
+ return (
+ "Error: No filter provided. Provide subject_keyword, sender, or older_than_days "
+ "to filter emails, or set apply_to_all=True to update all messages in the mailbox."
+ )
+
+ # Pre-filter conditions (skip no-op updates)
+ if action == "mark_read":
+ conditions = ["read status is false"]
+ elif action == "mark_unread":
+ conditions = ["read status is true"]
+ elif action == "flag":
+ conditions = ["flagged status is false"]
+ else: # unflag
+ conditions = ["flagged status is true"]
+
+ if subject_terms:
+ conditions.append(contains_any_condition("subject", subject_terms))
+ if sender:
+ conditions.append(f'sender contains "{escape_applescript(sender)}"')
+
+ search_condition = " and ".join(conditions)
+
+ # Date filter
+ date_setup = ""
+ date_check_start = ""
+ date_check_end = ""
+ if older_than_days and older_than_days > 0:
+ date_setup = f"set cutoffDate to (current date) - ({older_than_days} * days)"
+ date_check_start = "if (date received of aMessage) < cutoffDate then"
+ date_check_end = "end if"
+
script = f'''
tell application "Mail"
- set outputText to "UPDATING EMAIL STATUS: {action_label}" & return & return
- set updateCount to 0
+ with timeout of 300 seconds
+ set outputText to "UPDATING EMAIL STATUS: {action_label}" & return & return
+ set updateCount to 0
- try
- set targetAccount to account "{safe_account}"
- -- Try to get mailbox
try
- set targetMailbox to mailbox "{safe_mailbox}" of targetAccount
- on error
- if "{safe_mailbox}" is "INBOX" then
- set targetMailbox to mailbox "Inbox" of targetAccount
- else
- error "Mailbox not found: {safe_mailbox}"
- end if
- end try
-
- set mailboxMessages to every message of targetMailbox
-
- repeat with aMessage in mailboxMessages
- if updateCount >= {max_updates} then exit repeat
+ set targetAccount to account "{safe_account}"
+ {build_mailbox_ref(mailbox, var_name="targetMailbox")}
+ {date_setup}
- try
- set messageSubject to subject of aMessage
- set messageSender to sender of aMessage
- set messageDate to date received of aMessage
+ set matchingMessages to every message of targetMailbox whose {search_condition}
+ set matchingCount to count of matchingMessages
- -- Apply filter conditions
- if {condition_str} then
- {action_script}
+ if matchingCount is 0 then
+ set targetMessages to {{}}
+ else if matchingCount > {max_updates} then
+ set targetMessages to items 1 thru {max_updates} of matchingMessages
+ else
+ set targetMessages to matchingMessages
+ end if
- set outputText to outputText & "✓ {action_label}: " & messageSubject & return
- set outputText to outputText & " From: " & messageSender & return
- set outputText to outputText & " Date: " & (messageDate as string) & return & return
+ repeat with aMessage in targetMessages
+ try
+ {date_check_start}
+ {single_action_script}
+ set messageSubject to subject of aMessage
+ set messageSender to sender of aMessage
+ set messageDate to date received of aMessage
- set updateCount to updateCount + 1
- end if
- end try
- end repeat
+ set outputText to outputText & "- {action_label}: " & messageSubject & return
+ set outputText to outputText & " From: " & messageSender & return
+ set outputText to outputText & " Date: " & (messageDate as string) & return & return
+ set updateCount to updateCount + 1
+ {date_check_end}
+ end try
+ end repeat
- set outputText to outputText & "========================================" & return
- set outputText to outputText & "TOTAL UPDATED: " & updateCount & " email(s)" & return
- set outputText to outputText & "========================================" & return
+ set outputText to outputText & "========================================" & return
+ set outputText to outputText & "TOTAL UPDATED: " & updateCount & " email(s)" & return
+ set outputText to outputText & "========================================" & return
- on error errMsg
- return "Error: " & errMsg
- end try
+ on error errMsg
+ return "Error: " & errMsg
+ end try
- return outputText
+ return outputText
+ end timeout
end tell
'''
- result = run_applescript(script)
+ result = run_applescript(script, timeout=300)
return result
@@ -316,20 +503,33 @@ def manage_trash(
account: str,
action: str,
subject_keyword: Optional[str] = None,
+ subject_keywords: Optional[List[str]] = None,
sender: Optional[str] = None,
mailbox: str = "INBOX",
- max_deletes: int = 5
+ max_deletes: int = 5,
+ confirm_empty: bool = False,
+ apply_to_all: bool = False,
+ older_than_days: Optional[int] = None,
+ dry_run: bool = True,
) -> str:
"""
Manage trash operations - delete emails or empty trash.
+ When dry_run=True (default) and action is "move_to_trash", previews what
+ would be deleted without acting. Set dry_run=False to actually move to trash.
+
Args:
account: Account name (e.g., "Gmail", "Work")
action: Action to perform: "move_to_trash", "delete_permanent", "empty_trash"
subject_keyword: Optional keyword to filter emails (not used for empty_trash)
+ subject_keywords: Optional list of subject keywords; matches any keyword
sender: Optional sender to filter emails (not used for empty_trash)
mailbox: Source mailbox (default: "INBOX", not used for empty_trash or delete_permanent)
max_deletes: Maximum number of emails to delete (safety limit, default: 5)
+ confirm_empty: Must be True to execute "empty_trash" action (safety confirmation)
+ apply_to_all: Must be True to allow operations without subject_keyword or sender filter
+ older_than_days: Optional age filter - only affect emails older than N days
+ dry_run: If True (default), preview what would be affected without acting
Returns:
Confirmation message with details of deleted emails
@@ -338,8 +538,14 @@ def manage_trash(
# Escape all user inputs for AppleScript
safe_account = escape_applescript(account)
safe_mailbox = escape_applescript(mailbox)
+ subject_terms = normalize_search_terms(subject_keyword, subject_keywords)
if action == "empty_trash":
+ if not confirm_empty:
+ return (
+ "Error: empty_trash permanently deletes ALL messages in the trash. "
+ "Set confirm_empty=True to proceed."
+ )
script = f'''
tell application "Mail"
set outputText to "EMPTYING TRASH" & return & return
@@ -349,14 +555,20 @@ def manage_trash(
set trashMailbox to mailbox "Trash" of targetAccount
set trashMessages to every message of trashMailbox
set messageCount to count of trashMessages
+ set deleteCount to 0
- -- Delete all messages in trash
+ -- Delete messages in trash, respecting max_deletes
repeat with aMessage in trashMessages
+ if deleteCount >= {max_deletes} then exit repeat
delete aMessage
+ set deleteCount to deleteCount + 1
end repeat
set outputText to outputText & "✓ Emptied trash for account: {safe_account}" & return
- set outputText to outputText & " Deleted " & messageCount & " message(s)" & return
+ set outputText to outputText & " Deleted " & deleteCount & " of " & messageCount & " message(s)" & return
+ if deleteCount < messageCount then
+ set outputText to outputText & " (limited by max_deletes=" & {max_deletes} & ")" & return
+ end if
on error errMsg
return "Error: " & errMsg
@@ -366,118 +578,275 @@ def manage_trash(
end tell
'''
elif action == "delete_permanent":
+ # Safety check: require at least one filter or explicit apply_to_all
+ if not subject_terms and not sender and not apply_to_all:
+ return (
+ "Error: No filter provided. Provide subject_keyword or sender to filter emails, "
+ "or set apply_to_all=True to delete all matching messages."
+ )
+
# Build search condition with escaped inputs
conditions = []
- if subject_keyword:
- conditions.append(f'messageSubject contains "{escape_applescript(subject_keyword)}"')
+ if subject_terms:
+ conditions.append(contains_any_condition("subject", subject_terms))
if sender:
- conditions.append(f'messageSender contains "{escape_applescript(sender)}"')
+ conditions.append(f'sender contains "{escape_applescript(sender)}"')
- condition_str = ' and '.join(conditions) if conditions else 'true'
+ if conditions:
+ matching_messages_script = f"set matchingMessages to every message of trashMailbox whose {' and '.join(conditions)}"
+ else:
+ matching_messages_script = (
+ "set matchingMessages to every message of trashMailbox"
+ )
script = f'''
tell application "Mail"
- set outputText to "PERMANENTLY DELETING EMAILS" & return & return
- set deleteCount to 0
-
- try
- set targetAccount to account "{safe_account}"
- set trashMailbox to mailbox "Trash" of targetAccount
- set trashMessages to every message of trashMailbox
+ with timeout of 300 seconds
+ set outputText to "PERMANENTLY DELETING EMAILS" & return & return
+ set deleteCount to 0
- repeat with aMessage in trashMessages
- if deleteCount >= {max_deletes} then exit repeat
+ try
+ set targetAccount to account "{safe_account}"
+ set trashMailbox to mailbox "Trash" of targetAccount
+ {matching_messages_script}
+ set matchingCount to count of matchingMessages
+
+ if matchingCount is 0 then
+ set targetMessages to {{}}
+ else if matchingCount > {max_deletes} then
+ set targetMessages to items 1 thru {max_deletes} of matchingMessages
+ else
+ set targetMessages to matchingMessages
+ end if
- try
- set messageSubject to subject of aMessage
- set messageSender to sender of aMessage
+ repeat with aMessage in targetMessages
+ try
+ set messageSubject to subject of aMessage
+ set messageSender to sender of aMessage
- -- Apply filter conditions
- if {condition_str} then
set outputText to outputText & "✓ Permanently deleted: " & messageSubject & return
set outputText to outputText & " From: " & messageSender & return & return
delete aMessage
set deleteCount to deleteCount + 1
- end if
- end try
- end repeat
+ end try
+ end repeat
- set outputText to outputText & "========================================" & return
- set outputText to outputText & "TOTAL DELETED: " & deleteCount & " email(s)" & return
- set outputText to outputText & "========================================" & return
+ set outputText to outputText & "========================================" & return
+ set outputText to outputText & "TOTAL DELETED: " & deleteCount & " email(s)" & return
+ set outputText to outputText & "========================================" & return
- on error errMsg
- return "Error: " & errMsg
- end try
+ on error errMsg
+ return "Error: " & errMsg
+ end try
- return outputText
+ return outputText
+ end timeout
end tell
'''
else: # move_to_trash
+ # Safety check: require at least one filter or explicit apply_to_all
+ has_filter = bool(subject_terms) or bool(sender) or (
+ older_than_days is not None and older_than_days > 0
+ )
+ if not has_filter and not apply_to_all:
+ return (
+ "Error: No filter provided. Provide subject_keyword, sender, or older_than_days "
+ "to filter emails, or set apply_to_all=True to move all messages to trash."
+ )
+
# Build search condition with escaped inputs
conditions = []
- if subject_keyword:
- conditions.append(f'messageSubject contains "{escape_applescript(subject_keyword)}"')
+ if subject_terms:
+ conditions.append(contains_any_condition("subject", subject_terms))
if sender:
- conditions.append(f'messageSender contains "{escape_applescript(sender)}"')
-
- condition_str = ' and '.join(conditions) if conditions else 'true'
+ conditions.append(f'sender contains "{escape_applescript(sender)}"')
+
+ if conditions:
+ matching_messages_script = f"set matchingMessages to every message of sourceMailbox whose {' and '.join(conditions)}"
+ else:
+ matching_messages_script = (
+ "set matchingMessages to every message of sourceMailbox"
+ )
+
+ # Date filter
+ date_setup = ""
+ date_check_start = ""
+ date_check_end = ""
+ if older_than_days and older_than_days > 0:
+ date_setup = f"set cutoffDate to (current date) - ({older_than_days} * days)"
+ date_check_start = "if (date received of aMessage) < cutoffDate then"
+ date_check_end = "end if"
+
+ if dry_run:
+ mode_label = "DRY RUN - PREVIEW TRASH"
+ move_script = ""
+ result_verb = "Would trash"
+ else:
+ mode_label = "MOVING EMAILS TO TRASH"
+ move_script = "move aMessage to trashMailbox"
+ result_verb = "Moved to trash"
+
+ trash_setup = "" if dry_run else """
+ set trashMailbox to mailbox "Trash" of targetAccount"""
script = f'''
tell application "Mail"
- set outputText to "MOVING EMAILS TO TRASH" & return & return
- set deleteCount to 0
+ with timeout of 300 seconds
+ set outputText to "{mode_label}" & return & return
+ set deleteCount to 0
- try
- set targetAccount to account "{safe_account}"
- -- Get source mailbox
try
- set sourceMailbox to mailbox "{safe_mailbox}" of targetAccount
- on error
- if "{safe_mailbox}" is "INBOX" then
- set sourceMailbox to mailbox "Inbox" of targetAccount
+ set targetAccount to account "{safe_account}"
+ {build_mailbox_ref(mailbox, var_name="sourceMailbox")}
+ {trash_setup}
+ {date_setup}
+
+ {matching_messages_script}
+ set matchingCount to count of matchingMessages
+
+ if matchingCount is 0 then
+ set targetMessages to {{}}
+ else if matchingCount > {max_deletes} then
+ set targetMessages to items 1 thru {max_deletes} of matchingMessages
else
- error "Mailbox not found: {safe_mailbox}"
+ set targetMessages to matchingMessages
end if
+
+ repeat with aMessage in targetMessages
+ try
+ set messageSubject to subject of aMessage
+ set messageSender to sender of aMessage
+ set messageDate to date received of aMessage
+
+ {date_check_start}
+ {move_script}
+ set deleteCount to deleteCount + 1
+
+ set outputText to outputText & "{result_verb}: " & messageSubject & return
+ set outputText to outputText & " From: " & messageSender & return
+ set outputText to outputText & " Date: " & (messageDate as string) & return & return
+ {date_check_end}
+ end try
+ end repeat
+
+ set outputText to outputText & "========================================" & return
+ set outputText to outputText & "TOTAL: " & deleteCount & " email(s) {result_verb.lower()}" & return
+ set outputText to outputText & "========================================" & return
+
+ on error errMsg
+ return "Error: " & errMsg
end try
- -- Get trash mailbox
- set trashMailbox to mailbox "Trash" of targetAccount
- set sourceMessages to every message of sourceMailbox
+ return outputText
+ end timeout
+ end tell
+ '''
- repeat with aMessage in sourceMessages
- if deleteCount >= {max_deletes} then exit repeat
+ result = run_applescript(script, timeout=300)
+ return result
- try
- set messageSubject to subject of aMessage
- set messageSender to sender of aMessage
- set messageDate to date received of aMessage
- -- Apply filter conditions
- if {condition_str} then
- move aMessage to trashMailbox
+import re
- set outputText to outputText & "✓ Moved to trash: " & messageSubject & return
- set outputText to outputText & " From: " & messageSender & return
- set outputText to outputText & " Date: " & (messageDate as string) & return & return
+# Characters that could break AppleScript strings or mailbox names
+_INVALID_MAILBOX_CHARS = re.compile(r"[\\\"<>|?*:\x00-\x1f]")
- set deleteCount to deleteCount + 1
- end if
- end try
- end repeat
- set outputText to outputText & "========================================" & return
- set outputText to outputText & "TOTAL MOVED TO TRASH: " & deleteCount & " email(s)" & return
- set outputText to outputText & "========================================" & return
+@mcp.tool()
+@inject_preferences
+def create_mailbox(
+ account: str,
+ name: str,
+ parent_mailbox: Optional[str] = None,
+) -> str:
+ """
+ Create a new mailbox (folder) in the specified account.
- on error errMsg
- return "Error: " & errMsg
+ Supports nested paths via the parent_mailbox parameter (e.g.,
+ parent_mailbox="Projects" + name="2024" creates Projects/2024).
+ You can also pass a full slash-separated path as *name*
+ (e.g., "Projects/2024/ClientName") and omit parent_mailbox.
+
+ Args:
+ account: Account name (e.g., "Gmail", "Work")
+ name: Name for the new mailbox. May contain "/" to create a
+ nested path in one call (each segment is created if needed).
+ parent_mailbox: Optional existing parent folder for nesting.
+
+ Returns:
+ Confirmation with the new mailbox path.
+ """
+ # Validate name
+ if not name or not name.strip():
+ return "Error: Mailbox name cannot be empty."
+
+ # Split name into segments (support "A/B/C" shorthand)
+ segments = [s.strip() for s in name.split("/") if s.strip()]
+ if not segments:
+ return "Error: Mailbox name cannot be empty."
+
+ for seg in segments:
+ if _INVALID_MAILBOX_CHARS.search(seg):
+ return (
+ f"Error: Invalid characters in mailbox name segment '{seg}'. "
+ 'Avoid \\ " < > | ? * : and control characters.'
+ )
+
+ safe_account = escape_applescript(account)
+
+ # If parent_mailbox is given, prepend its segments
+ if parent_mailbox:
+ parent_segments = [s.strip() for s in parent_mailbox.split("/") if s.strip()]
+ segments = parent_segments + segments
+
+ # Build AppleScript to create each level one at a time
+ create_blocks = ""
+ for depth in range(len(segments)):
+ seg = escape_applescript(segments[depth])
+ if depth == 0:
+ create_blocks += f'''
+ try
+ set parentRef to mailbox "{seg}" of targetAccount
+ on error
+ make new mailbox at targetAccount with properties {{name:"{seg}"}}
+ set parentRef to mailbox "{seg}" of targetAccount
+ end try
+'''
+ else:
+ create_blocks += f'''
+ try
+ set parentRef to mailbox "{seg}" of parentRef
+ on error
+ make new mailbox at parentRef with properties {{name:"{seg}"}}
+ set parentRef to mailbox "{seg}" of parentRef
end try
+'''
+
+ full_path = "/".join(segments)
+ safe_path = escape_applescript(full_path)
+
+ script = f'''
+ tell application "Mail"
+ set outputText to "CREATING MAILBOX" & return & return
+
+ try
+ set targetAccount to account "{safe_account}"
+
+ {create_blocks}
+
+ set outputText to outputText & "OK Mailbox created successfully!" & return & return
+ set outputText to outputText & "Account: {safe_account}" & return
+ set outputText to outputText & "Path: {safe_path}" & return
+
+ on error errMsg
+ return "Error: " & errMsg
+ end try
+
+ return outputText
+ end tell
+ '''
+
+ return run_applescript(script)
- return outputText
- end tell
- '''
- result = run_applescript(script)
- return result
diff --git a/apple_mail_mcp/tools/search.py b/apple_mail_mcp/tools/search.py
index 085232a..bebf0cd 100644
--- a/apple_mail_mcp/tools/search.py
+++ b/apple_mail_mcp/tools/search.py
@@ -1,962 +1,593 @@
"""Search tools: finding and filtering emails."""
+import json
+import re
+from datetime import datetime
from typing import Optional, List, Dict, Any
+from urllib.parse import quote
from apple_mail_mcp.server import mcp
-from apple_mail_mcp.core import inject_preferences, escape_applescript, run_applescript, LOWERCASE_HANDLER
-
-
-@mcp.tool()
-@inject_preferences
-def get_email_with_content(
- account: str,
- subject_keyword: str,
- max_results: int = 5,
- max_content_length: int = 300,
- mailbox: str = "INBOX"
+from apple_mail_mcp.core import (
+ contains_any_condition,
+ inject_preferences,
+ escape_applescript,
+ normalize_search_terms,
+ run_applescript,
+ LOWERCASE_HANDLER,
+)
+
+
+MONTH_NAMES = [
+ "January",
+ "February",
+ "March",
+ "April",
+ "May",
+ "June",
+ "July",
+ "August",
+ "September",
+ "October",
+ "November",
+ "December",
+]
+
+
+def _build_applescript_date(
+ var_name: str, date_value: Optional[str], end_of_day: bool = False
) -> str:
+ """Build AppleScript to create a date from an ISO day string."""
+ if not date_value:
+ return ""
+
+ try:
+ parsed_date = datetime.strptime(date_value, "%Y-%m-%d")
+ except ValueError:
+ raise ValueError(f"Invalid date '{date_value}'. Use YYYY-MM-DD")
+
+ month_name = MONTH_NAMES[parsed_date.month - 1]
+ seconds = 86399 if end_of_day else 0
+ return f"""
+ set {var_name} to current date
+ set year of {var_name} to {parsed_date.year}
+ set month of {var_name} to {month_name}
+ set day of {var_name} to {parsed_date.day}
+ set time of {var_name} to {seconds}
"""
- Search for emails by subject keyword and return with full content preview.
- Args:
- account: Account name to search in (e.g., "Gmail", "Work")
- subject_keyword: Keyword to search for in email subjects
- max_results: Maximum number of matching emails to return (default: 5)
- max_content_length: Maximum content length in characters (default: 300, 0 = unlimited)
- mailbox: Mailbox to search (default: "INBOX", use "All" for all mailboxes)
-
- Returns:
- Detailed email information including content preview
- """
- # Escape user inputs for AppleScript
- escaped_keyword = escape_applescript(subject_keyword)
- escaped_account = escape_applescript(account)
- escaped_mailbox = escape_applescript(mailbox)
-
- # Build mailbox selection logic
- if mailbox == "All":
- mailbox_script = '''
- set allMailboxes to every mailbox of targetAccount
- set searchMailboxes to allMailboxes
- '''
- search_location = "all mailboxes"
+def _parse_search_records(output: str) -> List[Dict[str, Any]]:
+ """Parse structured search output into dict records."""
+ if not output:
+ return []
+
+ records = []
+ for line in output.splitlines():
+ parts = line.split("|||", 8)
+ if len(parts) < 8:
+ continue
+
+ internet_message_id = parts[1].strip()
+ record = {
+ "message_id": parts[0].strip(),
+ "internet_message_id": internet_message_id,
+ "subject": parts[2].strip(),
+ "sender": parts[3].strip(),
+ "mailbox": parts[4].strip(),
+ "account": parts[5].strip(),
+ "is_read": parts[6].strip().lower() == "true",
+ "received_date": parts[7].strip(),
+ }
+ if internet_message_id:
+ record["mail_link"] = "message:" + quote(internet_message_id, safe="")
+ if len(parts) > 8 and parts[8].strip():
+ record["content_preview"] = parts[8].strip()
+ records.append(record)
+
+ return records
+
+
+def _sort_search_records(
+ records: List[Dict[str, Any]], sort: str
+) -> List[Dict[str, Any]]:
+ """Sort records by received date."""
+ reverse = sort == "date_desc"
+ return sorted(
+ records, key=lambda item: item.get("received_date", ""), reverse=reverse
+ )
+
+
+def _format_search_records_text(
+ records: List[Dict[str, Any]],
+ subject_only: bool = False,
+) -> str:
+ """Format search records as human-readable text."""
+ lines = []
+
+ if subject_only:
+ lines.append("SUBJECT SEARCH RESULTS")
+ lines.append("")
+ for item in records:
+ lines.append(f"- {item['subject']}")
else:
- mailbox_script = f'''
- try
- set searchMailbox to mailbox "{escaped_mailbox}" of targetAccount
- on error
- if "{escaped_mailbox}" is "INBOX" then
- set searchMailbox to mailbox "Inbox" of targetAccount
- else
- error "Mailbox not found: {escaped_mailbox}"
- end if
- end try
- set searchMailboxes to {{searchMailbox}}
- '''
- search_location = mailbox
-
- script = f'''
- {LOWERCASE_HANDLER}
-
- tell application "Mail"
- set outputText to "SEARCH RESULTS FOR: {escaped_keyword}" & return
- set outputText to outputText & "Searching in: {search_location}" & return & return
- set resultCount to 0
-
- try
- set targetAccount to account "{escaped_account}"
- {mailbox_script}
-
- repeat with currentMailbox in searchMailboxes
- set mailboxMessages to every message of currentMailbox
- set mailboxName to name of currentMailbox
-
- repeat with aMessage in mailboxMessages
- if resultCount >= {max_results} then exit repeat
-
- try
- set messageSubject to subject of aMessage
-
- -- Convert to lowercase for case-insensitive matching
- set lowerSubject to my lowercase(messageSubject)
- set lowerKeyword to my lowercase("{escaped_keyword}")
-
- -- Check if subject contains keyword (case insensitive)
- if lowerSubject contains lowerKeyword then
- set messageSender to sender of aMessage
- set messageDate to date received of aMessage
- set messageRead to read status of aMessage
-
- if messageRead then
- set readIndicator to "\u2713"
- else
- set readIndicator to "\u2709"
- end if
-
- set outputText to outputText & readIndicator & " " & messageSubject & return
- set outputText to outputText & " From: " & messageSender & return
- set outputText to outputText & " Date: " & (messageDate as string) & return
- set outputText to outputText & " Mailbox: " & mailboxName & return
-
- -- Get content preview
- try
- set msgContent to content of aMessage
- set AppleScript's text item delimiters to {{return, linefeed}}
- set contentParts to text items of msgContent
- set AppleScript's text item delimiters to " "
- set cleanText to contentParts as string
- set AppleScript's text item delimiters to ""
-
- -- Handle content length limit (0 = unlimited)
- if {max_content_length} > 0 and length of cleanText > {max_content_length} then
- set contentPreview to text 1 thru {max_content_length} of cleanText & "..."
- else
- set contentPreview to cleanText
- end if
-
- set outputText to outputText & " Content: " & contentPreview & return
- on error
- set outputText to outputText & " Content: [Not available]" & return
- end try
-
- set outputText to outputText & return
- set resultCount to resultCount + 1
- end if
- end try
- end repeat
- end repeat
-
- set outputText to outputText & "========================================" & return
- set outputText to outputText & "FOUND: " & resultCount & " matching email(s)" & return
- set outputText to outputText & "========================================" & return
-
- on error errMsg
- return "Error: " & errMsg
- end try
-
- return outputText
- end tell
- '''
-
- result = run_applescript(script)
- return result
-
-
-@mcp.tool()
-@inject_preferences
-def search_emails(
- account: str,
+ lines.append("SEARCH RESULTS")
+ lines.append("")
+ for item in records:
+ indicator = "\u2713" if item["is_read"] else "\u2709"
+ lines.append(f"{indicator} {item['subject']}")
+ lines.append(f" From: {item['sender']}")
+ lines.append(f" Date: {item['received_date']}")
+ lines.append(f" Mailbox: {item['mailbox']}")
+ if item.get("content_preview"):
+ lines.append(f" Content: {item['content_preview']}")
+ lines.append("")
+
+ lines.append("========================================")
+ lines.append(f"FOUND: {len(records)} matching email(s)")
+ lines.append("========================================")
+ return "\n".join(lines)
+
+
+def _build_search_response(
+ records: List[Dict[str, Any]],
+ offset: int,
+ limit: int,
+ sort: str,
+ output_format: str,
+ subject_only: bool = False,
+) -> str:
+ """Return either JSON or text for search results."""
+ sorted_records = _sort_search_records(records, sort)
+ has_more = len(sorted_records) > limit
+ items = sorted_records[:limit]
+ next_offset = offset + len(items) if has_more else None
+
+ if output_format == "json":
+ return json.dumps(
+ {
+ "items": items,
+ "offset": offset,
+ "limit": limit,
+ "returned": len(items),
+ "has_more": has_more,
+ "next_offset": next_offset,
+ "sort": sort,
+ }
+ )
+
+ return _format_search_records_text(items, subject_only=subject_only)
+
+
+def _search_mail_records(
+ account: Optional[str] = None,
mailbox: str = "INBOX",
- subject_keyword: Optional[str] = None,
+ subject_terms: Optional[List[str]] = None,
sender: Optional[str] = None,
has_attachments: Optional[bool] = None,
read_status: str = "all",
date_from: Optional[str] = None,
date_to: Optional[str] = None,
include_content: bool = False,
- max_results: int = 20
-) -> str:
- """
- Unified search tool - search emails with advanced filtering across any mailbox.
-
- Args:
- account: Account name to search in (e.g., "Gmail", "Work")
- mailbox: Mailbox to search (default: "INBOX", use "All" for all mailboxes, or specific folder name)
- subject_keyword: Optional keyword to search in subject
- sender: Optional sender email or name to filter by
- has_attachments: Optional filter for emails with attachments (True/False/None)
- read_status: Filter by read status: "all", "read", "unread" (default: "all")
- date_from: Optional start date filter (format: "YYYY-MM-DD")
- date_to: Optional end date filter (format: "YYYY-MM-DD")
- include_content: Whether to include email content preview (slower)
- max_results: Maximum number of results to return (default: 20)
-
- Returns:
- Formatted list of matching emails with all requested details
+ content_length: int = 300,
+ offset: int = 0,
+ limit: int = 100,
+ sort: str = "date_desc",
+ body_text: Optional[str] = None,
+) -> List[Dict[str, Any]]:
+ """Return structured search records from Apple Mail.
+
+ When account is None, iterates all accounts.
+ When body_text is provided, uses per-message iteration with case-insensitive
+ content matching (slower than subject/sender-only searches).
"""
+ if offset < 0:
+ raise ValueError("offset must be >= 0")
+ if limit <= 0:
+ return []
+ if sort not in {"date_desc", "date_asc"}:
+ raise ValueError("Invalid sort. Use: date_desc, date_asc")
+ if read_status not in {"all", "read", "unread"}:
+ raise ValueError("Invalid read_status. Use: all, read, unread")
- # Escape user inputs for AppleScript
- escaped_account = escape_applescript(account)
- escaped_mailbox = escape_applescript(mailbox)
- escaped_subject = escape_applescript(subject_keyword) if subject_keyword else None
escaped_sender = escape_applescript(sender) if sender else None
- # Build AppleScript search conditions
- conditions = []
-
- if subject_keyword:
- conditions.append(f'messageSubject contains "{escaped_subject}"')
-
- if sender:
- conditions.append(f'messageSender contains "{escaped_sender}"')
-
- if has_attachments is not None:
- if has_attachments:
- conditions.append('(count of mail attachments of aMessage) > 0')
- else:
- conditions.append('(count of mail attachments of aMessage) = 0')
-
- if read_status == "read":
- conditions.append('messageRead is true')
- elif read_status == "unread":
- conditions.append('messageRead is false')
-
- # Combine conditions with AND logic
- condition_str = ' and '.join(conditions) if conditions else 'true'
-
- # Handle content preview
- content_script = '''
- try
- set msgContent to content of aMessage
- set AppleScript's text item delimiters to {{return, linefeed}}
- set contentParts to text items of msgContent
- set AppleScript's text item delimiters to " "
- set cleanText to contentParts as string
- set AppleScript's text item delimiters to ""
-
- if length of cleanText > 300 then
- set contentPreview to text 1 thru 300 of cleanText & "..."
- else
- set contentPreview to cleanText
- end if
-
- set outputText to outputText & " Content: " & contentPreview & return
- on error
- set outputText to outputText & " Content: [Not available]" & return
- end try
- ''' if include_content else ''
-
- # Build mailbox selection logic
- if mailbox == "All":
- mailbox_script = '''
- set allMailboxes to every mailbox of targetAccount
- set searchMailboxes to allMailboxes
- '''
+ # When body_text is provided, we must iterate per-message (can't use whose clause)
+ use_body_search = body_text is not None
+
+ # Build whose-clause filter conditions (only used when NOT doing body search)
+ filter_conditions = []
+ if not use_body_search:
+ if subject_terms:
+ filter_conditions.append(contains_any_condition("subject", subject_terms))
+ if sender:
+ filter_conditions.append(f'sender contains "{escaped_sender}"')
+ if has_attachments is not None:
+ if has_attachments:
+ filter_conditions.append("(count of mail attachments) > 0")
+ else:
+ filter_conditions.append("(count of mail attachments) = 0")
+ if read_status == "read":
+ filter_conditions.append("read status is true")
+ elif read_status == "unread":
+ filter_conditions.append("read status is false")
+ if date_from:
+ filter_conditions.append("date received >= fromDate")
+ if date_to:
+ filter_conditions.append("date received <= toDate")
+
+ if filter_conditions:
+ matching_messages_script = f"set matchingMessages to every message of currentMailbox whose {' and '.join(filter_conditions)}"
else:
- mailbox_script = f'''
- try
- set searchMailbox to mailbox "{escaped_mailbox}" of targetAccount
- on error
- if "{escaped_mailbox}" is "INBOX" then
- set searchMailbox to mailbox "Inbox" of targetAccount
- else
- error "Mailbox not found: {escaped_mailbox}"
- end if
- end try
- set searchMailboxes to {{searchMailbox}}
- '''
-
- script = f'''
- tell application "Mail"
- set outputText to "SEARCH RESULTS" & return & return
- set outputText to outputText & "Searching in: {escaped_mailbox}" & return
- set outputText to outputText & "Account: {escaped_account}" & return & return
- set resultCount to 0
+ matching_messages_script = (
+ "set matchingMessages to every message of currentMailbox"
+ )
- try
- set targetAccount to account "{escaped_account}"
- {mailbox_script}
-
- repeat with currentMailbox in searchMailboxes
- -- Wrap in try block to handle mailboxes that throw errors (smart mailboxes, etc.)
- try
- set mailboxName to name of currentMailbox
-
- -- Skip system folders when searching to reduce noise and avoid errors
- set skipFolders to {{"Trash", "Junk", "Junk Email", "Deleted Items", "Sent", "Sent Items", "Sent Messages", "Drafts", "Spam", "Deleted Messages"}}
- set shouldSkip to false
- repeat with skipFolder in skipFolders
- if mailboxName is skipFolder then
- set shouldSkip to true
- exit repeat
- end if
- end repeat
-
- if not shouldSkip then
- set mailboxMessages to every message of currentMailbox
-
- repeat with aMessage in mailboxMessages
- if resultCount >= {max_results} then exit repeat
-
- try
- set messageSubject to subject of aMessage
- set messageSender to sender of aMessage
- set messageDate to date received of aMessage
- set messageRead to read status of aMessage
-
- -- Apply search conditions
- if {condition_str} then
- set readIndicator to "\u2709"
- if messageRead then
- set readIndicator to "\u2713"
- end if
-
- set outputText to outputText & readIndicator & " " & messageSubject & return
- set outputText to outputText & " From: " & messageSender & return
- set outputText to outputText & " Date: " & (messageDate as string) & return
- set outputText to outputText & " Mailbox: " & mailboxName & return
-
- {content_script}
-
- set outputText to outputText & return
- set resultCount to resultCount + 1
- end if
- end try
+ if mailbox == "All":
+ mailbox_script = """
+ set searchMailboxes to every mailbox of targetAccount
+ """
+ skip_script = """
+ set skipFolders to {"Trash", "Junk", "Junk Email", "Deleted Items", "Sent", "Sent Items", "Sent Messages", "Drafts", "Spam", "Deleted Messages"}
+ repeat with skipFolder in skipFolders
+ if mailboxName is skipFolder then
+ set shouldSkip to true
+ exit repeat
+ end if
end repeat
- end if
- on error
- -- Skip mailboxes that throw errors (smart mailboxes, missing values, etc.)
- end try
- end repeat
-
- set outputText to outputText & "========================================" & return
- set outputText to outputText & "FOUND: " & resultCount & " matching email(s)" & return
- set outputText to outputText & "========================================" & return
-
- on error errMsg
- return "Error: " & errMsg
- end try
-
- return outputText
- end tell
- '''
-
- result = run_applescript(script)
- return result
-
-
-@mcp.tool()
-@inject_preferences
-def search_by_sender(
- sender: str,
- account: Optional[str] = None,
- days_back: int = 30,
- max_results: int = 20,
- include_content: bool = True,
- max_content_length: int = 500,
- mailbox: str = "INBOX"
-) -> str:
- """
- Find all emails from a specific sender across one or all accounts.
- Perfect for tracking newsletters, contacts, or communications from specific people/organizations.
-
- Args:
- sender: Sender name or email to search for (partial match, e.g., "alphasignal" or "john@")
- account: Optional account name. If None, searches all accounts.
- days_back: Only search emails from the last N days (default: 30, 0 = all time)
- max_results: Maximum number of emails to return (default: 20)
- include_content: Whether to include email content preview (default: True)
- max_content_length: Maximum length of content preview (default: 500)
- mailbox: Mailbox to search (default: "INBOX", use "All" for all mailboxes)
-
- Returns:
- Formatted list of emails from the sender, sorted by date (newest first)
- """
-
- # Build date filter if days_back > 0
- date_filter_script = ""
- date_check = ""
- if days_back > 0:
- date_filter_script = f'''
- set targetDate to (current date) - ({days_back} * days)
- '''
- date_check = "and messageDate > targetDate"
-
- # Build content preview script
- content_script = ""
- if include_content:
- content_script = f'''
- try
- set msgContent to content of aMessage
- set AppleScript's text item delimiters to {{return, linefeed}}
- set contentParts to text items of msgContent
- set AppleScript's text item delimiters to " "
- set cleanText to contentParts as string
- set AppleScript's text item delimiters to ""
-
- if {max_content_length} > 0 and length of cleanText > {max_content_length} then
- set contentPreview to text 1 thru {max_content_length} of cleanText & "..."
- else
- set contentPreview to cleanText
- end if
-
- set outputText to outputText & " Content: " & contentPreview & return
- on error
- set outputText to outputText & " Content: [Not available]" & return
- end try
- '''
-
- # Escape user inputs for AppleScript
- escaped_sender = escape_applescript(sender)
- escaped_mailbox = escape_applescript(mailbox)
- search_all_mailboxes = mailbox == "All"
-
- # Build mailbox selection: INBOX-only (fast) vs all mailboxes
- if search_all_mailboxes:
- mailbox_loop_start = '''
- set accountMailboxes to every mailbox of anAccount
- repeat with aMailbox in accountMailboxes
- set mailboxName to name of aMailbox
- -- Skip system and aggregate folders to avoid scanning huge mailboxes
- if mailboxName is not in {"Trash", "Junk", "Junk Email", "Deleted Items", "Deleted Messages", "Spam", "Drafts", "Sent", "Sent Items", "Sent Messages", "Sent Mail", "All Mail", "Bin"} then
- '''
- mailbox_loop_end = f'''
- if resultCount >= {max_results} then exit repeat
- end if
- end repeat
- '''
+ """
else:
- mailbox_loop_start = f'''
- -- Fast path: only search the target mailbox
+ escaped_mailbox = escape_applescript(mailbox)
+ mailbox_script = f'''
try
- set aMailbox to mailbox "{escaped_mailbox}" of anAccount
+ set searchMailbox to mailbox "{escaped_mailbox}" of targetAccount
on error
if "{escaped_mailbox}" is "INBOX" then
- set aMailbox to mailbox "Inbox" of anAccount
+ set searchMailbox to mailbox "Inbox" of targetAccount
else
error "Mailbox not found: {escaped_mailbox}"
end if
end try
- set mailboxName to name of aMailbox
- if true then
- '''
- mailbox_loop_end = '''
- end if
+ set searchMailboxes to {{searchMailbox}}
'''
+ skip_script = ""
- # Build account iteration: direct access (fast) vs all accounts
+ date_setup = _build_applescript_date("fromDate", date_from)
+ date_setup += _build_applescript_date("toDate", date_to, end_of_day=True)
+
+ # Build account iteration
if account:
escaped_account = escape_applescript(account)
- account_loop_start = f'''
- set anAccount to account "{escaped_account}"
- set accountName to name of anAccount
- repeat 1 times
- '''
- account_loop_end = '''
- end repeat
+ account_setup = f'''
+ set searchAccounts to {{account "{escaped_account}"}}
'''
else:
- account_loop_start = f'''
- set allAccounts to every account
- repeat with anAccount in allAccounts
- set accountName to name of anAccount
- '''
- account_loop_end = f'''
- if resultCount >= {max_results} then exit repeat
- end repeat
+ account_setup = """
+ set searchAccounts to every account
+ """
+
+ # Build body search per-message filter block
+ if use_body_search:
+ escaped_body = escape_applescript(body_text.lower()) if body_text else ""
+ # Build per-message conditions for subject, sender, read_status, dates, attachments
+ per_msg_conditions = []
+ if subject_terms:
+ # Case-insensitive subject check
+ subject_checks = " or ".join(
+ f'lowerSubject contains "{escape_applescript(t.lower())}"'
+ for t in subject_terms
+ )
+ per_msg_conditions.append(f"({subject_checks})")
+ if sender:
+ per_msg_conditions.append(f'lowerSender contains "{escape_applescript(sender.lower())}"')
+ if read_status == "read":
+ per_msg_conditions.append("messageRead is true")
+ elif read_status == "unread":
+ per_msg_conditions.append("messageRead is false")
+ if date_from:
+ per_msg_conditions.append("messageDate >= fromDate")
+ if date_to:
+ per_msg_conditions.append("messageDate <= toDate")
+ if has_attachments is True:
+ per_msg_conditions.append("(count of mail attachments of aMessage) > 0")
+ elif has_attachments is False:
+ per_msg_conditions.append("(count of mail attachments of aMessage) = 0")
+
+ # Body text condition is always present in body search mode
+ per_msg_conditions.append(f'lowerContent contains "{escaped_body}"')
+
+ combined_condition = " and ".join(per_msg_conditions)
+
+ body_search_loop = f'''
+ set matchingMessages to {{}}
+ set allMessages to every message of currentMailbox
+ repeat with aMessage in allMessages
+ if collectLimit <= 0 then exit repeat
+ try
+ set messageSubject to subject of aMessage
+ set messageSender to sender of aMessage
+ set messageRead to read status of aMessage
+ set messageDate to date received of aMessage
+ set lowerSubject to my lowercase(messageSubject)
+ set lowerSender to my lowercase(messageSender)
+ set msgContent to ""
+ try
+ set msgContent to content of aMessage
+ end try
+ set lowerContent to my lowercase(msgContent)
+ if {combined_condition} then
+ set end of matchingMessages to aMessage
+ end if
+ end try
+ end repeat
'''
+ else:
+ body_search_loop = ""
- script = f'''
- {LOWERCASE_HANDLER}
+ # Choose the message collection strategy
+ if use_body_search:
+ message_collection = body_search_loop
+ else:
+ message_collection = f" {matching_messages_script}"
- tell application "Mail"
- set outputText to "EMAILS FROM SENDER: {escaped_sender}" & return
- set outputText to outputText & "\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501" & return & return
- set resultCount to 0
+ lowercase_handler = LOWERCASE_HANDLER if use_body_search else ""
- {date_filter_script}
+ script = f'''
+ {lowercase_handler}
- {account_loop_start}
+ on sanitize_field(value)
+ try
+ set valueText to value as string
+ on error
+ set valueText to ""
+ end try
- try
- {mailbox_loop_start}
+ set AppleScript's text item delimiters to {{return, linefeed, tab}}
+ set valueParts to text items of valueText
+ set AppleScript's text item delimiters to " "
+ set valueText to valueParts as string
+ set AppleScript's text item delimiters to "|||"
+ set valueParts to text items of valueText
+ set AppleScript's text item delimiters to " | "
+ set valueText to valueParts as string
+ set AppleScript's text item delimiters to ""
+ return valueText
+ end sanitize_field
+
+ on pad2(numberValue)
+ if numberValue < 10 then
+ return "0" & (numberValue as string)
+ end if
+ return numberValue as string
+ end pad2
+
+ on month_number(monthValue)
+ set monthValues to {{January, February, March, April, May, June, July, August, September, October, November, December}}
+ repeat with monthIndex from 1 to 12
+ if item monthIndex of monthValues is monthValue then
+ return monthIndex
+ end if
+ end repeat
+ return 0
+ end month_number
+
+ on iso_datetime(dateValue)
+ set yearValue to year of dateValue as integer
+ set monthValue to my month_number(month of dateValue)
+ set dayValue to day of dateValue as integer
+ set hourValue to hours of dateValue
+ set minuteValue to minutes of dateValue
+ set secondValue to seconds of dateValue
+ return (yearValue as string) & "-" & my pad2(monthValue) & "-" & my pad2(dayValue) & "T" & my pad2(hourValue) & ":" & my pad2(minuteValue) & ":" & my pad2(secondValue)
+ end iso_datetime
- set mailboxMessages to every message of aMailbox
+ tell application "Mail"
+ with timeout of 180 seconds
+ try
+ set recordLines to {{}}
+ set offsetRemaining to {offset}
+ set collectLimit to {limit + 1}
+ {date_setup}
+ {account_setup}
- repeat with aMessage in mailboxMessages
- if resultCount >= {max_results} then exit repeat
+ repeat with targetAccount in searchAccounts
+ if collectLimit <= 0 then exit repeat
+ set accountName to my sanitize_field(name of targetAccount)
+ {mailbox_script}
- try
- set messageSender to sender of aMessage
- set messageDate to date received of aMessage
+ repeat with currentMailbox in searchMailboxes
+ if collectLimit <= 0 then exit repeat
- -- Case-insensitive sender match
- set lowerSender to my lowercase(messageSender)
- set lowerSearch to my lowercase("{escaped_sender}")
+ try
+ set mailboxName to my sanitize_field(name of currentMailbox)
+ set shouldSkip to false
+ {skip_script}
- if lowerSender contains lowerSearch {date_check} then
- set messageSubject to subject of aMessage
- set messageRead to read status of aMessage
+ if not shouldSkip then
+ {message_collection}
+ set matchingCount to count of matchingMessages
- if messageRead then
- set readIndicator to "\u2713"
+ if offsetRemaining >= matchingCount then
+ set offsetRemaining to offsetRemaining - matchingCount
+ else
+ set startIndex to offsetRemaining + 1
+ set availableCount to matchingCount - offsetRemaining
+ if availableCount > collectLimit then
+ set endIndex to startIndex + collectLimit - 1
else
- set readIndicator to "\u2709"
+ set endIndex to startIndex + availableCount - 1
end if
- set outputText to outputText & readIndicator & " " & messageSubject & return
- set outputText to outputText & " From: " & messageSender & return
- set outputText to outputText & " Date: " & (messageDate as string) & return
- set outputText to outputText & " Account: " & accountName & return
- set outputText to outputText & " Mailbox: " & mailboxName & return
-
- {content_script}
+ if endIndex >= startIndex then
+ set targetMessages to items startIndex thru endIndex of matchingMessages
+
+ repeat with aMessage in targetMessages
+ try
+ set messageId to my sanitize_field(id of aMessage)
+ set internetMessageId to ""
+ try
+ set internetMessageId to my sanitize_field(message id of aMessage)
+ end try
+ set messageSubject to my sanitize_field(subject of aMessage)
+ set messageSender to my sanitize_field(sender of aMessage)
+ set messageRead to read status of aMessage
+ set messageDate to date received of aMessage
+ set receivedAt to my iso_datetime(messageDate)
+ set contentPreview to ""
+
+ if {str(include_content).lower()} then
+ try
+ set msgContent to content of aMessage
+ set AppleScript's text item delimiters to {{return, linefeed, tab}}
+ set contentParts to text items of msgContent
+ set AppleScript's text item delimiters to " "
+ set cleanText to contentParts as string
+ set AppleScript's text item delimiters to ""
+ if {content_length} > 0 and length of cleanText > {content_length} then
+ set contentPreview to my sanitize_field(text 1 thru {content_length} of cleanText & "...")
+ else
+ set contentPreview to my sanitize_field(cleanText)
+ end if
+ on error
+ set contentPreview to ""
+ end try
+ end if
+
+ set readValue to "false"
+ if messageRead then
+ set readValue to "true"
+ end if
+
+ set recordLine to messageId & "|||" & internetMessageId & "|||" & messageSubject & "|||" & messageSender & "|||" & mailboxName & "|||" & accountName & "|||" & readValue & "|||" & receivedAt & "|||" & contentPreview
+ set end of recordLines to recordLine
+ set collectLimit to collectLimit - 1
+ if collectLimit <= 0 then exit repeat
+ end try
+ end repeat
+ end if
- set outputText to outputText & return
- set resultCount to resultCount + 1
+ set offsetRemaining to 0
end if
- end try
- end repeat
+ end if
+ on error
+ -- Skip mailboxes that cannot be searched
+ end try
+ end repeat
+ end repeat
- {mailbox_loop_end}
+ if (count of recordLines) is 0 then
+ return ""
+ end if
+ set AppleScript's text item delimiters to linefeed
+ set outputText to recordLines as string
+ set AppleScript's text item delimiters to ""
+ return outputText
on error errMsg
- set outputText to outputText & "\u26a0 Error accessing mailboxes for " & accountName & ": " & errMsg & return
+ return "ERROR|||" & errMsg
end try
-
- {account_loop_end}
-
- set outputText to outputText & "========================================" & return
- set outputText to outputText & "FOUND: " & resultCount & " email(s) from sender" & return
- if {days_back} > 0 then
- set outputText to outputText & "Time range: Last {days_back} days" & return
- end if
- set outputText to outputText & "========================================" & return
-
- return outputText
+ end timeout
end tell
'''
- result = run_applescript(script)
- return result
-
+ result = run_applescript(script, timeout=180)
+ if result.startswith("ERROR|||"):
+ raise ValueError(result.split("|||", 1)[1])
-@mcp.tool()
-@inject_preferences
-def search_email_content(
- account: str,
- search_text: str,
- mailbox: str = "INBOX",
- search_subject: bool = True,
- search_body: bool = True,
- max_results: int = 10,
- max_content_length: int = 600
-) -> str:
- """
- Search email body content (and optionally subject).
- This is slower than subject-only search but finds more relevant results.
-
- Args:
- account: Account name to search in
- search_text: Text to search for in email content
- mailbox: Mailbox to search (default: "INBOX")
- search_subject: Also search in subject line (default: True)
- search_body: Search in email body (default: True)
- max_results: Maximum results to return (default: 10, keep low as this is slow)
- max_content_length: Max content preview length (default: 600)
-
- Returns:
- Emails where the search text appears in body and/or subject
- """
- escaped_search = escape_applescript(search_text).lower()
- escaped_account = escape_applescript(account)
- escaped_mailbox = escape_applescript(mailbox)
- search_conditions = []
- if search_subject:
- search_conditions.append(f'lowerSubject contains "{escaped_search}"')
- if search_body:
- search_conditions.append(f'lowerContent contains "{escaped_search}"')
- search_condition = ' or '.join(search_conditions) if search_conditions else 'false'
-
- script = f'''
- {LOWERCASE_HANDLER}
-
- tell application "Mail"
- set outputText to "\U0001f50e CONTENT SEARCH: {escaped_search}" & return
- set outputText to outputText & "\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501" & return
- set outputText to outputText & "\u26a0 Note: Body search is slower - searching {max_results} results max" & return & return
- set resultCount to 0
- try
- set targetAccount to account "{escaped_account}"
- try
- set targetMailbox to mailbox "{escaped_mailbox}" of targetAccount
- on error
- if "{escaped_mailbox}" is "INBOX" then
- set targetMailbox to mailbox "Inbox" of targetAccount
- else
- error "Mailbox not found: {escaped_mailbox}"
- end if
- end try
- set mailboxMessages to every message of targetMailbox
- repeat with aMessage in mailboxMessages
- if resultCount >= {max_results} then exit repeat
- try
- set messageSubject to subject of aMessage
- set msgContent to ""
- try
- set msgContent to content of aMessage
- end try
- set lowerSubject to my lowercase(messageSubject)
- set lowerContent to my lowercase(msgContent)
- if {search_condition} then
- set messageSender to sender of aMessage
- set messageDate to date received of aMessage
- set messageRead to read status of aMessage
- if messageRead then
- set readIndicator to "\u2713"
- else
- set readIndicator to "\u2709"
- end if
- set outputText to outputText & readIndicator & " " & messageSubject & return
- set outputText to outputText & " From: " & messageSender & return
- set outputText to outputText & " Date: " & (messageDate as string) & return
- set outputText to outputText & " Mailbox: {escaped_mailbox}" & return
- try
- set AppleScript's text item delimiters to {{return, linefeed}}
- set contentParts to text items of msgContent
- set AppleScript's text item delimiters to " "
- set cleanText to contentParts as string
- set AppleScript's text item delimiters to ""
- if length of cleanText > {max_content_length} then
- set contentPreview to text 1 thru {max_content_length} of cleanText & "..."
- else
- set contentPreview to cleanText
- end if
- set outputText to outputText & " Content: " & contentPreview & return
- on error
- set outputText to outputText & " Content: [Not available]" & return
- end try
- set outputText to outputText & return
- set resultCount to resultCount + 1
- end if
- end try
- end repeat
- set outputText to outputText & "========================================" & return
- set outputText to outputText & "FOUND: " & resultCount & " email(s) matching \\"{escaped_search}\\"" & return
- set outputText to outputText & "========================================" & return
- on error errMsg
- return "Error: " & errMsg
- end try
- return outputText
- end tell
- '''
- result = run_applescript(script)
- return result
+ return _parse_search_records(result)
@mcp.tool()
@inject_preferences
-def get_newsletters(
+def search_emails(
account: Optional[str] = None,
- days_back: int = 7,
- max_results: int = 25,
- include_content: bool = True,
- max_content_length: int = 500
+ mailbox: str = "INBOX",
+ subject_keyword: Optional[str] = None,
+ subject_keywords: Optional[List[str]] = None,
+ sender: Optional[str] = None,
+ has_attachments: Optional[bool] = None,
+ read_status: str = "all",
+ date_from: Optional[str] = None,
+ date_to: Optional[str] = None,
+ include_content: bool = False,
+ max_content_length: int = 500,
+ body_text: Optional[str] = None,
+ max_results: Optional[int] = 20,
+ output_format: str = "text",
+ offset: int = 0,
+ limit: Optional[int] = None,
+ sort: str = "date_desc",
) -> str:
"""
- Find newsletter and digest emails by detecting common patterns.
- Automatically identifies emails from newsletter services and digest senders.
-
- Args:
- account: Account to search. If None, searches all accounts.
- days_back: Only search last N days (default: 7)
- max_results: Maximum newsletters to return (default: 25)
- include_content: Include content preview (default: True)
- max_content_length: Max preview length (default: 500)
-
- Returns:
- List of detected newsletter emails sorted by date
- """
- # Escape user inputs for AppleScript
- escaped_account = escape_applescript(account) if account else None
-
- content_script = ""
- if include_content:
- content_script = f'''
- try
- set msgContent to content of aMessage
- set AppleScript's text item delimiters to {{return, linefeed}}
- set contentParts to text items of msgContent
- set AppleScript's text item delimiters to " "
- set cleanText to contentParts as string
- set AppleScript's text item delimiters to ""
- if length of cleanText > {max_content_length} then
- set contentPreview to text 1 thru {max_content_length} of cleanText & "..."
- else
- set contentPreview to cleanText
- end if
- set outputText to outputText & " Content: " & contentPreview & return
- on error
- set outputText to outputText & " Content: [Not available]" & return
- end try
- '''
-
- account_filter_start = ""
- account_filter_end = ""
- if account:
- account_filter_start = f'if accountName is "{escaped_account}" then'
- account_filter_end = "end if"
+ Unified search tool with JSON output, pagination, and real date filtering.
- date_filter = ""
- date_check = ""
- if days_back > 0:
- date_filter = f'set cutoffDate to (current date) - ({days_back} * days)'
- date_check = " and messageDate > cutoffDate"
-
- script = f'''
- {LOWERCASE_HANDLER}
-
- tell application "Mail"
- set outputText to "\U0001f4f0 NEWSLETTER DETECTION" & return
- set outputText to outputText & "\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501" & return & return
- set resultCount to 0
- {date_filter}
- set allAccounts to every account
- repeat with anAccount in allAccounts
- set accountName to name of anAccount
- {account_filter_start}
- try
- set accountMailboxes to every mailbox of anAccount
- repeat with aMailbox in accountMailboxes
- try
- set mailboxName to name of aMailbox
- if mailboxName is "INBOX" or mailboxName is "Inbox" then
- set mailboxMessages to every message of aMailbox
- repeat with aMessage in mailboxMessages
- if resultCount >= {max_results} then exit repeat
- try
- set messageSender to sender of aMessage
- set messageDate to date received of aMessage
- set lowerSender to my lowercase(messageSender)
- set isNewsletter to false
- if lowerSender contains "substack.com" or lowerSender contains "beehiiv.com" or lowerSender contains "mailchimp" or lowerSender contains "sendgrid" or lowerSender contains "convertkit" or lowerSender contains "buttondown" or lowerSender contains "ghost.io" or lowerSender contains "revue.co" or lowerSender contains "mailgun" then
- set isNewsletter to true
- end if
- if lowerSender contains "newsletter" or lowerSender contains "digest" or lowerSender contains "weekly" or lowerSender contains "daily" or lowerSender contains "bulletin" or lowerSender contains "briefing" or lowerSender contains "news@" or lowerSender contains "updates@" then
- set isNewsletter to true
- end if
- if isNewsletter{date_check} then
- set messageSubject to subject of aMessage
- set messageRead to read status of aMessage
- if messageRead then
- set readIndicator to "\u2713"
- else
- set readIndicator to "\u2709"
- end if
- set outputText to outputText & readIndicator & " " & messageSubject & return
- set outputText to outputText & " From: " & messageSender & return
- set outputText to outputText & " Date: " & (messageDate as string) & return
- set outputText to outputText & " Account: " & accountName & return
- {content_script}
- set outputText to outputText & return
- set resultCount to resultCount + 1
- end if
- end try
- end repeat
- end if
- end try
- if resultCount >= {max_results} then exit repeat
- end repeat
- end try
- {account_filter_end}
- if resultCount >= {max_results} then exit repeat
- end repeat
- set outputText to outputText & "========================================" & return
- set outputText to outputText & "FOUND: " & resultCount & " newsletter(s)" & return
- set outputText to outputText & "========================================" & return
- return outputText
- end tell
- '''
- result = run_applescript(script)
- return result
-
-
-@mcp.tool()
-@inject_preferences
-def get_recent_from_sender(
- sender: str,
- account: Optional[str] = None,
- time_range: str = "week",
- max_results: int = 15,
- include_content: bool = True,
- max_content_length: int = 400,
- mailbox: str = "INBOX"
-) -> str:
- """
- Get recent emails from a specific sender with simple, human-friendly time filters.
+ Consolidates subject search, sender search, body content search, and
+ cross-account search into a single tool.
Args:
- sender: Sender name or email to search for (partial match)
- account: Optional account. If None, searches all accounts.
- time_range: Human-friendly time filter:
- - "today" = last 24 hours
- - "yesterday" = yesterday only
- - "week" = last 7 days (default)
- - "month" = last 30 days
- - "all" = no time filter
- max_results: Maximum emails to return (default: 15)
- include_content: Include content preview (default: True)
- max_content_length: Max preview length (default: 400)
- mailbox: Mailbox to search (default: "INBOX", use "All" for all mailboxes)
+ account: Account name to search in (e.g., "Gmail", "Work").
+ If None, searches ALL accounts (slower).
+ mailbox: Mailbox to search (default: "INBOX", use "All" for all mailboxes, or specific folder name)
+ subject_keyword: Optional keyword to search in subject
+ subject_keywords: Optional list of subject keywords; matches any keyword
+ sender: Optional sender email or name to filter by
+ has_attachments: Optional filter for emails with attachments (True/False/None)
+ read_status: Filter by read status: "all", "read", "unread" (default: "all")
+ date_from: Optional start date filter (format: "YYYY-MM-DD")
+ date_to: Optional end date filter (format: "YYYY-MM-DD")
+ include_content: Whether to include email content preview (slower)
+ max_content_length: Maximum content length in characters when include_content=True (default: 500, 0 = unlimited)
+ body_text: Optional text to search for in email body content (case-insensitive).
+ WARNING: body search is significantly slower as it reads each message body.
+ max_results: Backward-compatible alias for limit
+ output_format: Output format: "text" or "json" (default: "text")
+ offset: Number of matching results to skip before returning data
+ limit: Maximum number of results to return per page
+ sort: Result sort order: "date_desc" or "date_asc"
Returns:
- Recent emails from the specified sender within the time range
+ Formatted list of matching emails or JSON payload with stable message metadata
"""
- time_ranges = {"today": 1, "yesterday": 2, "week": 7, "month": 30, "all": 0}
- days_back = time_ranges.get(time_range.lower(), 7)
- is_yesterday = time_range.lower() == "yesterday"
-
- content_script = ""
- if include_content:
- content_script = f'''
- try
- set msgContent to content of aMessage
- set AppleScript's text item delimiters to {{return, linefeed}}
- set contentParts to text items of msgContent
- set AppleScript's text item delimiters to " "
- set cleanText to contentParts as string
- set AppleScript's text item delimiters to ""
- if length of cleanText > {max_content_length} then
- set contentPreview to text 1 thru {max_content_length} of cleanText & "..."
- else
- set contentPreview to cleanText
- end if
- set outputText to outputText & " Content: " & contentPreview & return
- on error
- set outputText to outputText & " Content: [Not available]" & return
- end try
- '''
-
- # Escape user inputs for AppleScript
- escaped_sender = escape_applescript(sender)
- escaped_mailbox = escape_applescript(mailbox)
- search_all_mailboxes = mailbox == "All"
-
- date_filter = ""
- date_check = ""
- if days_back > 0:
- date_filter = f'set cutoffDate to (current date) - ({days_back} * days)'
- if is_yesterday:
- date_filter += '''
- set todayStart to (current date) - (time of (current date))
- set yesterdayStart to todayStart - (1 * days)
- '''
- date_check = " and messageDate >= yesterdayStart and messageDate < todayStart"
- else:
- date_check = " and messageDate > cutoffDate"
-
- # Build mailbox selection: INBOX-only (fast) vs all mailboxes
- if search_all_mailboxes:
- mailbox_loop_start = '''
- set accountMailboxes to every mailbox of anAccount
- repeat with aMailbox in accountMailboxes
- try
- set mailboxName to name of aMailbox
- if mailboxName is not in {"Trash", "Junk", "Junk Email", "Deleted Items", "Deleted Messages", "Spam", "Drafts", "Sent", "Sent Items", "Sent Messages", "Sent Mail", "All Mail", "Bin"} then
- '''
- mailbox_loop_end = f'''
- end if
- end try
- if resultCount >= {max_results} then exit repeat
- end repeat
- '''
- else:
- mailbox_loop_start = f'''
- -- Fast path: only search the target mailbox
- try
- set aMailbox to mailbox "{escaped_mailbox}" of anAccount
- on error
- if "{escaped_mailbox}" is "INBOX" then
- set aMailbox to mailbox "Inbox" of anAccount
- else
- error "Mailbox not found: {escaped_mailbox}"
- end if
- end try
- set mailboxName to name of aMailbox
- if true then
- '''
- mailbox_loop_end = '''
- end if
- '''
-
- # Build account iteration: direct access (fast) vs all accounts
- if account:
- escaped_account = escape_applescript(account)
- account_loop_start = f'''
- set anAccount to account "{escaped_account}"
- set accountName to name of anAccount
- repeat 1 times
- '''
- account_loop_end = '''
- end repeat
- '''
- else:
- account_loop_start = f'''
- set allAccounts to every account
- repeat with anAccount in allAccounts
- set accountName to name of anAccount
- '''
- account_loop_end = f'''
- if resultCount >= {max_results} then exit repeat
- end repeat
- '''
-
- script = f'''
- {LOWERCASE_HANDLER}
-
- tell application "Mail"
- set outputText to "\U0001f4e7 EMAILS FROM: {escaped_sender}" & return
- set outputText to outputText & "\u23f0 Time range: {time_range}" & return
- set outputText to outputText & "\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501" & return & return
- set resultCount to 0
- {date_filter}
-
- {account_loop_start}
-
- try
- {mailbox_loop_start}
-
- set mailboxMessages to every message of aMailbox
- repeat with aMessage in mailboxMessages
- if resultCount >= {max_results} then exit repeat
- try
- set messageSender to sender of aMessage
- set messageDate to date received of aMessage
- set lowerSender to my lowercase(messageSender)
- set lowerSearch to my lowercase("{escaped_sender}")
- if lowerSender contains lowerSearch{date_check} then
- set messageSubject to subject of aMessage
- set messageRead to read status of aMessage
- if messageRead then
- set readIndicator to "\u2713"
- else
- set readIndicator to "\u2709"
- end if
- set outputText to outputText & readIndicator & " " & messageSubject & return
- set outputText to outputText & " From: " & messageSender & return
- set outputText to outputText & " Date: " & (messageDate as string) & return
- set outputText to outputText & " Account: " & accountName & return
- {content_script}
- set outputText to outputText & return
- set resultCount to resultCount + 1
- end if
- end try
- end repeat
-
- {mailbox_loop_end}
-
- end try
-
- {account_loop_end}
-
- set outputText to outputText & "========================================" & return
- set outputText to outputText & "FOUND: " & resultCount & " email(s) from sender" & return
- set outputText to outputText & "========================================" & return
- return outputText
- end tell
- '''
- result = run_applescript(script)
- return result
+ if output_format not in {"text", "json"}:
+ return "Error: Invalid output_format. Use: text, json"
+
+ if limit is None:
+ limit = max_results if max_results is not None else 100
+
+ subject_terms = normalize_search_terms(subject_keyword, subject_keywords)
+
+ try:
+ records = _search_mail_records(
+ account=account,
+ mailbox=mailbox,
+ subject_terms=subject_terms,
+ sender=sender,
+ has_attachments=has_attachments,
+ read_status=read_status,
+ date_from=date_from,
+ date_to=date_to,
+ include_content=include_content,
+ content_length=max_content_length,
+ offset=offset,
+ limit=limit,
+ sort=sort,
+ body_text=body_text,
+ )
+ return _build_search_response(
+ records,
+ offset=offset,
+ limit=limit,
+ sort=sort,
+ output_format=output_format,
+ subject_only=False,
+ )
+ except ValueError as exc:
+ return f"Error: {exc}"
@mcp.tool()
@inject_preferences
def get_email_thread(
- account: str,
- subject_keyword: str,
- mailbox: str = "INBOX",
- max_messages: int = 50
+ account: str, subject_keyword: str, mailbox: str = "INBOX", max_messages: int = 50
) -> str:
"""
Get an email conversation thread - all messages with the same or similar subject.
@@ -976,10 +607,10 @@ def get_email_thread(
escaped_mailbox = escape_applescript(mailbox)
# For thread detection, we'll strip common prefixes
- thread_keywords = ['Re:', 'Fwd:', 'FW:', 'RE:', 'Fw:']
+ thread_keywords = ["Re:", "Fwd:", "FW:", "RE:", "Fw:"]
cleaned_keyword = subject_keyword
for prefix in thread_keywords:
- cleaned_keyword = cleaned_keyword.replace(prefix, '').strip()
+ cleaned_keyword = cleaned_keyword.replace(prefix, "").strip()
escaped_keyword = escape_applescript(cleaned_keyword)
mailbox_script = f'''
@@ -1028,8 +659,15 @@ def get_email_thread(
if cleanSubject starts with "Re: " then
set cleanSubject to text 5 thru -1 of cleanSubject
end if
- if cleanSubject starts with "Fwd: " or cleanSubject starts with "FW: " then
+ if cleanSubject starts with "RE: " then
+ set cleanSubject to text 5 thru -1 of cleanSubject
+ end if
+ if cleanSubject starts with "Fwd: " then
set cleanSubject to text 6 thru -1 of cleanSubject
+ else if cleanSubject starts with "FW: " then
+ set cleanSubject to text 5 thru -1 of cleanSubject
+ else if cleanSubject starts with "Fw: " then
+ set cleanSubject to text 5 thru -1 of cleanSubject
end if
-- Check if this message is part of the thread
@@ -1095,220 +733,3 @@ def get_email_thread(
result = run_applescript(script)
return result
-
-
-@mcp.tool()
-@inject_preferences
-def search_all_accounts(
- subject_keyword: Optional[str] = None,
- sender: Optional[str] = None,
- days_back: int = 7,
- max_results: int = 30,
- include_content: bool = True,
- max_content_length: int = 400
-) -> str:
- """
- Search across ALL email accounts at once.
-
- Returns consolidated results sorted by date (newest first).
- Only searches INBOX mailboxes (skips Trash, Junk, Drafts, Sent).
-
- Args:
- subject_keyword: Optional keyword to search in subject
- sender: Optional sender email or name to filter by
- days_back: Number of days to look back (default: 7, 0 = all time)
- max_results: Maximum total results across all accounts (default: 30)
- include_content: Whether to include email content preview (default: True)
- max_content_length: Maximum content length in characters (default: 400)
-
- Returns:
- Formatted list of matching emails with account name for each
- """
- # Build date filter
- date_filter = ""
- if days_back > 0:
- date_filter = f'''
- set cutoffDate to (current date) - ({days_back} * days)
- if messageDate < cutoffDate then
- set skipMessage to true
- end if
- '''
-
- # Build subject filter
- subject_filter = ""
- if subject_keyword:
- escaped_keyword = escape_applescript(subject_keyword)
- subject_filter = f'''
- set lowerSubject to my lowercase(messageSubject)
- set lowerKeyword to my lowercase("{escaped_keyword}")
- if lowerSubject does not contain lowerKeyword then
- set skipMessage to true
- end if
- '''
-
- # Build sender filter
- sender_filter = ""
- if sender:
- escaped_sender = escape_applescript(sender)
- sender_filter = f'''
- set lowerSender to my lowercase(messageSender)
- set lowerSenderFilter to my lowercase("{escaped_sender}")
- if lowerSender does not contain lowerSenderFilter then
- set skipMessage to true
- end if
- '''
-
- # Build content retrieval
- content_retrieval = ""
- if include_content:
- content_retrieval = f'''
- try
- set messageContent to content of msg
- if length of messageContent > {max_content_length} then
- set messageContent to text 1 thru {max_content_length} of messageContent & "..."
- end if
- -- Clean up content for display
- set messageContent to my replaceText(messageContent, return, " ")
- set messageContent to my replaceText(messageContent, linefeed, " ")
- on error
- set messageContent to "(Content unavailable)"
- end try
- set emailRecord to emailRecord & "Content: " & messageContent & linefeed
- '''
-
- script = f'''
- {LOWERCASE_HANDLER}
-
- on replaceText(theText, searchStr, replaceStr)
- set AppleScript\'s text item delimiters to searchStr
- set theItems to text items of theText
- set AppleScript\'s text item delimiters to replaceStr
- set theText to theItems as text
- set AppleScript\'s text item delimiters to ""
- return theText
- end replaceText
-
- tell application "Mail"
- set allResults to {{}}
- set allAccounts to every account
-
- repeat with acct in allAccounts
- set acctName to name of acct
-
- -- Find INBOX mailbox
- set inboxMailbox to missing value
- try
- set inboxMailbox to mailbox "INBOX" of acct
- on error
- -- Try to find inbox by checking mailboxes
- repeat with mb in mailboxes of acct
- set mbName to name of mb
- if mbName is "INBOX" or mbName is "Inbox" then
- set inboxMailbox to mb
- exit repeat
- end if
- end repeat
- end try
-
- if inboxMailbox is not missing value then
- try
- set msgs to messages of inboxMailbox
-
- repeat with msg in msgs
- set skipMessage to false
-
- try
- set messageSubject to subject of msg
- set messageSender to sender of msg
- set messageDate to date received of msg
- set messageRead to read status of msg
- on error
- set skipMessage to true
- end try
-
- if not skipMessage then
- {date_filter}
- end if
-
- if not skipMessage then
- {subject_filter}
- end if
-
- if not skipMessage then
- {sender_filter}
- end if
-
- if not skipMessage then
- -- Build email record
- set emailRecord to ""
- set emailRecord to emailRecord & "Account: " & acctName & linefeed
- set emailRecord to emailRecord & "Subject: " & messageSubject & linefeed
- set emailRecord to emailRecord & "From: " & messageSender & linefeed
- set emailRecord to emailRecord & "Date: " & (messageDate as string) & linefeed
- if messageRead then
- set emailRecord to emailRecord & "Status: Read" & linefeed
- else
- set emailRecord to emailRecord & "Status: UNREAD" & linefeed
- end if
- {content_retrieval}
-
- -- Store with date for sorting
- set end of allResults to {{emailDate:messageDate, emailText:emailRecord}}
- end if
-
- -- Check if we have enough results
- if (count of allResults) >= {max_results} then
- exit repeat
- end if
- end repeat
- on error errMsg
- -- Skip this account if there\'s an error
- end try
- end if
-
- -- Check if we have enough results
- if (count of allResults) >= {max_results} then
- exit repeat
- end if
- end repeat
-
- -- Sort results by date (newest first)
- set sortedResults to my sortByDate(allResults)
-
- -- Build output
- set outputText to ""
- set emailCount to count of sortedResults
-
- if emailCount is 0 then
- return "No emails found matching your criteria across all accounts."
- end if
-
- set outputText to "=== Cross-Account Search Results ===" & linefeed
- set outputText to outputText & "Found " & emailCount & " email(s)" & linefeed
- set outputText to outputText & "---" & linefeed & linefeed
-
- repeat with emailItem in sortedResults
- set outputText to outputText & emailText of emailItem & linefeed & "---" & linefeed
- end repeat
-
- return outputText
- end tell
-
- on sortByDate(theList)
- -- Simple bubble sort by date (descending - newest first)
- set listLength to count of theList
- repeat with i from 1 to listLength - 1
- repeat with j from 1 to listLength - i
- if emailDate of item j of theList < emailDate of item (j + 1) of theList then
- set temp to item j of theList
- set item j of theList to item (j + 1) of theList
- set item (j + 1) of theList to temp
- end if
- end repeat
- end repeat
- return theList
- end sortByDate
- '''
-
- result = run_applescript(script)
- return result
diff --git a/apple_mail_mcp/tools/smart_inbox.py b/apple_mail_mcp/tools/smart_inbox.py
new file mode 100644
index 0000000..0bec7da
--- /dev/null
+++ b/apple_mail_mcp/tools/smart_inbox.py
@@ -0,0 +1,581 @@
+"""Smart inbox tools: follow-up tracking, actionable email detection, and sender analytics."""
+
+from typing import Optional
+
+from apple_mail_mcp.server import mcp
+from apple_mail_mcp.core import (
+ inject_preferences,
+ escape_applescript,
+ run_applescript,
+ inbox_mailbox_script,
+ date_cutoff_script,
+ LOWERCASE_HANDLER,
+)
+from apple_mail_mcp.constants import (
+ NEWSLETTER_PLATFORM_PATTERNS,
+ NEWSLETTER_KEYWORD_PATTERNS,
+ THREAD_PREFIXES,
+)
+
+
+def _strip_subject_prefixes_script() -> str:
+ """Return AppleScript handler to strip Re:/Fwd:/etc prefixes from a subject."""
+ # Build a list of prefixes to strip
+ prefix_checks = ""
+ for prefix in THREAD_PREFIXES:
+ escaped = escape_applescript(prefix)
+ prefix_checks += f'''
+ if baseSubj starts with "{escaped}" then
+ set baseSubj to text {len(prefix) + 1} thru -1 of baseSubj
+ -- trim leading space
+ repeat while baseSubj starts with " "
+ set baseSubj to text 2 thru -1 of baseSubj
+ end repeat
+ set didStrip to true
+ end if
+'''
+ return f'''
+ on stripPrefixes(subj)
+ set baseSubj to subj
+ set didStrip to true
+ repeat while didStrip
+ set didStrip to false
+ {prefix_checks}
+ end repeat
+ return baseSubj
+ end stripPrefixes
+'''
+
+
+def _newsletter_filter_condition(sender_var: str = "lowerSender") -> str:
+ """Return AppleScript condition that evaluates to true if email is a newsletter."""
+ platform_checks = " or ".join(
+ f'{sender_var} contains "{escape_applescript(p)}"'
+ for p in NEWSLETTER_PLATFORM_PATTERNS
+ )
+ keyword_checks = " or ".join(
+ f'{sender_var} contains "{escape_applescript(k)}"'
+ for k in NEWSLETTER_KEYWORD_PATTERNS
+ )
+ return f"({platform_checks} or {keyword_checks})"
+
+
+@mcp.tool()
+@inject_preferences
+def get_awaiting_reply(
+ account: str,
+ days_back: int = 7,
+ exclude_noreply: bool = True,
+ max_results: int = 20,
+) -> str:
+ """Find sent emails that haven't received a reply yet.
+
+ Scans the Sent mailbox for outgoing emails and cross-references with
+ the Inbox to see if a reply (matching subject) was received from the
+ same recipient. Useful for follow-up tracking.
+
+ Args:
+ account: Account name (e.g., "Gmail", "Work", "Personal")
+ days_back: How many days back to check sent emails (default: 7)
+ exclude_noreply: Skip emails sent to noreply/no-reply addresses (default: True)
+ max_results: Maximum results to return (default: 20)
+
+ Returns:
+ List of sent emails still awaiting a reply with subject, recipient, and date sent
+ """
+ escaped_account = escape_applescript(account)
+
+ noreply_filter = ""
+ if exclude_noreply:
+ noreply_filter = '''
+ set lowerRecip to my lowercase(recipAddr)
+ if lowerRecip contains "noreply" or lowerRecip contains "no-reply" or lowerRecip contains "do-not-reply" or lowerRecip contains "donotreply" then
+ set skipThis to true
+ end if
+'''
+
+ script = f'''
+ tell application "Mail"
+ set outputText to "EMAILS AWAITING REPLY" & return
+ set outputText to outputText & "Account: {escaped_account} | Last {days_back} days" & return
+ set outputText to outputText & "========================================" & return & return
+
+ {date_cutoff_script(days_back, "cutoffDate")}
+
+ try
+ set targetAccount to account "{escaped_account}"
+
+ -- Get Sent mailbox
+ set sentMailbox to missing value
+ try
+ set sentMailbox to mailbox "Sent Messages" of targetAccount
+ on error
+ try
+ set sentMailbox to mailbox "Sent" of targetAccount
+ on error
+ try
+ set sentMailbox to mailbox "Sent Items" of targetAccount
+ on error
+ return "Error: Could not find Sent mailbox for account {escaped_account}"
+ end try
+ end try
+ end try
+
+ -- Get Inbox mailbox
+ {inbox_mailbox_script("inboxMailbox", "targetAccount")}
+
+ -- Collect subjects from inbox for matching
+ set inboxSubjects to {{}}
+ set inboxSenders to {{}}
+ set inboxMessages to every message of inboxMailbox
+
+ repeat with aMessage in inboxMessages
+ try
+ set msgSubject to subject of aMessage
+ set msgSender to sender of aMessage
+ set baseSubject to my stripPrefixes(msgSubject)
+ set lowerBase to my lowercase(baseSubject)
+ set end of inboxSubjects to lowerBase
+ set end of inboxSenders to my lowercase(msgSender)
+ end try
+ end repeat
+
+ -- Now scan sent emails
+ set sentMessages to every message of sentMailbox
+ set resultCount to 0
+ set checkedCount to 0
+
+ repeat with aMessage in sentMessages
+ if resultCount >= {max_results} then exit repeat
+
+ try
+ set messageDate to date sent of aMessage
+ {"if messageDate < cutoffDate then exit repeat" if days_back > 0 else ""}
+
+ set messageSubject to subject of aMessage
+ set messageRecipients to every to recipient of aMessage
+
+ if (count of messageRecipients) > 0 then
+ set recipAddr to address of item 1 of messageRecipients
+ set recipName to ""
+ try
+ set recipName to name of item 1 of messageRecipients
+ end try
+
+ set skipThis to false
+ {noreply_filter}
+
+ if not skipThis then
+ -- Strip prefixes from sent subject and check inbox
+ set baseSubject to my stripPrefixes(messageSubject)
+ set lowerBase to my lowercase(baseSubject)
+ set lowerRecipAddr to my lowercase(recipAddr)
+
+ -- Check if there is a reply in inbox from this recipient about this subject
+ set foundReply to false
+ set idx to 1
+ repeat with inboxSubj in inboxSubjects
+ if inboxSubj contains lowerBase or lowerBase contains inboxSubj then
+ set inboxSender to item idx of inboxSenders
+ if inboxSender contains lowerRecipAddr then
+ set foundReply to true
+ exit repeat
+ end if
+ end if
+ set idx to idx + 1
+ end repeat
+
+ if not foundReply then
+ set resultCount to resultCount + 1
+ set displayRecip to recipAddr
+ if recipName is not "" then
+ set displayRecip to recipName & " <" & recipAddr & ">"
+ end if
+ set outputText to outputText & resultCount & ". " & messageSubject & return
+ set outputText to outputText & " To: " & displayRecip & return
+ set outputText to outputText & " Sent: " & (messageDate as string) & return & return
+ end if
+ end if
+ end if
+ end try
+ end repeat
+
+ set outputText to outputText & "========================================" & return
+ set outputText to outputText & "Found " & resultCount & " sent email(s) awaiting reply." & return
+
+ on error errMsg
+ return "Error: " & errMsg
+ end try
+
+ return outputText
+ end tell
+
+ {LOWERCASE_HANDLER}
+ {_strip_subject_prefixes_script()}
+ '''
+
+ return run_applescript(script)
+
+
+@mcp.tool()
+@inject_preferences
+def get_needs_response(
+ account: str,
+ mailbox: str = "INBOX",
+ days_back: int = 7,
+ max_results: int = 20,
+) -> str:
+ """Identify unread emails that likely need a response from you.
+
+ Filters out newsletters, automated emails, and noreply senders.
+ Prioritises direct emails (To: you) with question marks as likely
+ needing a reply.
+
+ Args:
+ account: Account name (e.g., "Gmail", "Work", "Personal")
+ mailbox: Mailbox to scan (default: "INBOX")
+ days_back: How many days back to look (default: 7)
+ max_results: Maximum results to return (default: 20)
+
+ Returns:
+ Ranked list of emails likely needing a response, with priority hints
+ """
+ escaped_account = escape_applescript(account)
+ escaped_mailbox = escape_applescript(mailbox)
+
+ newsletter_condition = _newsletter_filter_condition("lowerSender")
+
+ script = f'''
+ tell application "Mail"
+ set outputText to "EMAILS NEEDING RESPONSE" & return
+ set outputText to outputText & "Account: {escaped_account} | Mailbox: {escaped_mailbox} | Last {days_back} days" & return
+ set outputText to outputText & "========================================" & return & return
+
+ {date_cutoff_script(days_back, "cutoffDate")}
+
+ try
+ set targetAccount to account "{escaped_account}"
+
+ -- Get target mailbox
+ try
+ set targetMailbox to mailbox "{escaped_mailbox}" of targetAccount
+ on error
+ if "{escaped_mailbox}" is "INBOX" then
+ set targetMailbox to mailbox "Inbox" of targetAccount
+ else
+ error "Mailbox not found: {escaped_mailbox}"
+ end if
+ end try
+
+ -- Collect sent subjects for "already replied" detection
+ set sentSubjects to {{}}
+ set sentMailbox to missing value
+ try
+ set sentMailbox to mailbox "Sent Messages" of targetAccount
+ on error
+ try
+ set sentMailbox to mailbox "Sent" of targetAccount
+ on error
+ try
+ set sentMailbox to mailbox "Sent Items" of targetAccount
+ end try
+ end try
+ end try
+
+ if sentMailbox is not missing value then
+ set sentMessages to every message of sentMailbox
+ set sentIdx to 0
+ repeat with aMessage in sentMessages
+ set sentIdx to sentIdx + 1
+ if sentIdx > 200 then exit repeat
+ try
+ set sentSubj to subject of aMessage
+ set baseSent to my stripPrefixes(sentSubj)
+ set end of sentSubjects to my lowercase(baseSent)
+ end try
+ end repeat
+ end if
+
+ -- Scan target mailbox
+ set mailboxMessages to every message of targetMailbox
+ set highPriority to {{}}
+ set normalPriority to {{}}
+ set totalChecked to 0
+
+ repeat with aMessage in mailboxMessages
+ if (count of highPriority) + (count of normalPriority) >= {max_results} then exit repeat
+
+ try
+ set messageDate to date received of aMessage
+ {"if messageDate < cutoffDate then exit repeat" if days_back > 0 else ""}
+
+ -- Only look at unread emails
+ if not (read status of aMessage) then
+ set messageSender to sender of aMessage
+ set messageSubject to subject of aMessage
+ set lowerSender to my lowercase(messageSender)
+
+ -- Filter out newsletters and automated senders
+ set isNewsletter to {newsletter_condition}
+ set isAutomated to (lowerSender contains "noreply" or lowerSender contains "no-reply" or lowerSender contains "donotreply" or lowerSender contains "do-not-reply" or lowerSender contains "notifications@" or lowerSender contains "mailer-daemon" or lowerSender contains "postmaster@")
+
+ if not isNewsletter and not isAutomated then
+ -- Check if user already replied
+ set baseSubject to my stripPrefixes(messageSubject)
+ set lowerBase to my lowercase(baseSubject)
+ set alreadyReplied to false
+ repeat with sentSubj in sentSubjects
+ if sentSubj contains lowerBase or lowerBase contains sentSubj then
+ set alreadyReplied to true
+ exit repeat
+ end if
+ end repeat
+
+ if not alreadyReplied then
+ -- Determine priority
+ set hasQuestion to (messageSubject contains "?")
+ try
+ set msgContent to content of aMessage
+ if length of msgContent > 500 then
+ set msgContent to text 1 thru 500 of msgContent
+ end if
+ if msgContent contains "?" then set hasQuestion to true
+ end try
+
+ set isFlagged to false
+ try
+ set isFlagged to flagged status of aMessage
+ end try
+
+ set emailEntry to messageSubject & "|||" & messageSender & "|||" & (messageDate as string) & "|||"
+ if hasQuestion or isFlagged then
+ if hasQuestion and isFlagged then
+ set emailEntry to emailEntry & "HIGH (flagged + question)"
+ else if isFlagged then
+ set emailEntry to emailEntry & "HIGH (flagged)"
+ else
+ set emailEntry to emailEntry & "MEDIUM (contains question)"
+ end if
+ set end of highPriority to emailEntry
+ else
+ set emailEntry to emailEntry & "NORMAL"
+ set end of normalPriority to emailEntry
+ end if
+ end if
+ end if
+ end if
+ end try
+ end repeat
+
+ -- Format output: high priority first, then normal
+ set resultCount to 0
+ repeat with entry in highPriority
+ set resultCount to resultCount + 1
+ set AppleScript's text item delimiters to "|||"
+ set parts to text items of entry
+ set AppleScript's text item delimiters to ""
+ set outputText to outputText & resultCount & ". [" & item 4 of parts & "] " & item 1 of parts & return
+ set outputText to outputText & " From: " & item 2 of parts & return
+ set outputText to outputText & " Date: " & item 3 of parts & return & return
+ end repeat
+
+ repeat with entry in normalPriority
+ set resultCount to resultCount + 1
+ set AppleScript's text item delimiters to "|||"
+ set parts to text items of entry
+ set AppleScript's text item delimiters to ""
+ set outputText to outputText & resultCount & ". [" & item 4 of parts & "] " & item 1 of parts & return
+ set outputText to outputText & " From: " & item 2 of parts & return
+ set outputText to outputText & " Date: " & item 3 of parts & return & return
+ end repeat
+
+ set outputText to outputText & "========================================" & return
+ set outputText to outputText & "Found " & resultCount & " email(s) needing response." & return
+
+ on error errMsg
+ return "Error: " & errMsg
+ end try
+
+ return outputText
+ end tell
+
+ {LOWERCASE_HANDLER}
+ {_strip_subject_prefixes_script()}
+ '''
+
+ return run_applescript(script)
+
+
+@mcp.tool()
+@inject_preferences
+def get_top_senders(
+ account: str,
+ mailbox: str = "INBOX",
+ days_back: int = 30,
+ top_n: int = 10,
+ group_by_domain: bool = False,
+) -> str:
+ """Analyse a mailbox to find the most frequent senders.
+
+ Useful for identifying key contacts, high-volume senders to filter,
+ or newsletter sources to unsubscribe from.
+
+ Args:
+ account: Account name (e.g., "Gmail", "Work", "Personal")
+ mailbox: Mailbox to analyse (default: "INBOX")
+ days_back: How many days back to look (default: 30, 0 = all time)
+ top_n: Number of top senders to return (default: 10)
+ group_by_domain: Group results by domain instead of individual sender (default: False)
+
+ Returns:
+ Ranked list of senders (or domains) with email counts
+ """
+ escaped_account = escape_applescript(account)
+ escaped_mailbox = escape_applescript(mailbox)
+
+ date_cutoff = date_cutoff_script(days_back, "cutoffDate")
+ date_check = "if messageDate < cutoffDate then exit repeat" if days_back > 0 else ""
+
+ # Build the extraction key: either full sender or domain
+ if group_by_domain:
+ # Extract domain from email address
+ extract_key = '''
+ -- Extract domain from sender address
+ set senderKey to ""
+ set atPos to 0
+ set senderLen to length of messageSender
+ repeat with i from 1 to senderLen
+ if character i of messageSender is "@" then
+ set atPos to i
+ end if
+ end repeat
+ if atPos > 0 then
+ -- Find the closing > if present
+ set endPos to senderLen
+ repeat with i from atPos to senderLen
+ if character i of messageSender is ">" then
+ set endPos to i - 1
+ exit repeat
+ end if
+ end repeat
+ set senderKey to text (atPos + 1) thru endPos of messageSender
+ else
+ set senderKey to messageSender
+ end if
+'''
+ title_label = "TOP SENDER DOMAINS"
+ else:
+ extract_key = '''
+ set senderKey to messageSender
+'''
+ title_label = "TOP SENDERS"
+
+ script = f'''
+ tell application "Mail"
+ set outputText to "{title_label}" & return
+ set outputText to outputText & "Account: {escaped_account} | Mailbox: {escaped_mailbox} | Last {days_back} days" & return
+ set outputText to outputText & "========================================" & return & return
+
+ {date_cutoff}
+
+ try
+ set targetAccount to account "{escaped_account}"
+
+ -- Get target mailbox
+ try
+ set targetMailbox to mailbox "{escaped_mailbox}" of targetAccount
+ on error
+ if "{escaped_mailbox}" is "INBOX" then
+ set targetMailbox to mailbox "Inbox" of targetAccount
+ else
+ error "Mailbox not found: {escaped_mailbox}"
+ end if
+ end try
+
+ set mailboxMessages to every message of targetMailbox
+ set senderKeys to {{}}
+ set senderCounts to {{}}
+ set totalAnalysed to 0
+
+ repeat with aMessage in mailboxMessages
+ try
+ set messageDate to date received of aMessage
+ {date_check}
+
+ set messageSender to sender of aMessage
+ set totalAnalysed to totalAnalysed + 1
+
+ {extract_key}
+
+ -- Update count
+ set foundSender to false
+ set idx to 1
+ repeat with existingKey in senderKeys
+ if existingKey as string is senderKey then
+ set item idx of senderCounts to (item idx of senderCounts) + 1
+ set foundSender to true
+ exit repeat
+ end if
+ set idx to idx + 1
+ end repeat
+ if not foundSender then
+ set end of senderKeys to senderKey
+ set end of senderCounts to 1
+ end if
+ end try
+ end repeat
+
+ -- Sort by count (simple selection sort, we only need top N)
+ set topN to {top_n}
+ repeat with i from 1 to (count of senderCounts)
+ if i > topN then exit repeat
+ -- Find max from i to end
+ set maxIdx to i
+ set maxVal to item i of senderCounts
+ repeat with j from (i + 1) to (count of senderCounts)
+ if item j of senderCounts > maxVal then
+ set maxIdx to j
+ set maxVal to item j of senderCounts
+ end if
+ end repeat
+ -- Swap
+ if maxIdx is not i then
+ set tmpCount to item i of senderCounts
+ set item i of senderCounts to item maxIdx of senderCounts
+ set item maxIdx of senderCounts to tmpCount
+ set tmpKey to item i of senderKeys as string
+ set item i of senderKeys to (item maxIdx of senderKeys as string)
+ set item maxIdx of senderKeys to tmpKey
+ end if
+ end repeat
+
+ -- Format output
+ set displayCount to topN
+ if (count of senderKeys) < displayCount then
+ set displayCount to (count of senderKeys)
+ end if
+
+ repeat with i from 1 to displayCount
+ set senderKey to item i of senderKeys
+ set sCount to item i of senderCounts
+ set pctText to ""
+ if totalAnalysed > 0 then
+ set pct to round ((sCount / totalAnalysed) * 100)
+ set pctText to " (" & pct & "%)"
+ end if
+ set outputText to outputText & i & ". " & senderKey & ": " & sCount & " emails" & pctText & return
+ end repeat
+
+ set outputText to outputText & return & "========================================" & return
+ set outputText to outputText & "Total emails analysed: " & totalAnalysed & return
+ set outputText to outputText & "Unique senders: " & (count of senderKeys) & return
+
+ on error errMsg
+ return "Error: " & errMsg
+ end try
+
+ return outputText
+ end tell
+ '''
+
+ return run_applescript(script)
diff --git a/docs/superpowers/plans/2026-03-27-tool-consolidation.md b/docs/superpowers/plans/2026-03-27-tool-consolidation.md
new file mode 100644
index 0000000..57130a5
--- /dev/null
+++ b/docs/superpowers/plans/2026-03-27-tool-consolidation.md
@@ -0,0 +1,315 @@
+# Apple Mail MCP Tool Consolidation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Reduce 31 MCP tools to ~15 by merging overlapping tools while preserving all capabilities.
+
+**Architecture:** Each tool group (search, inbox, manage, bulk) gets consolidated into fewer tools with richer parameter sets. Removed tools' unique capabilities are absorbed into the surviving tool. Existing tests are updated to target the new signatures. No new dependencies.
+
+**Tech Stack:** Python, FastMCP, AppleScript
+
+---
+
+## File Structure
+
+| File | Current Tools | After Consolidation | Changes |
+|------|--------------|--------------------|---------|
+| `search.py` | 10 tools | 2 tools | Remove 8 tools, merge capabilities into `search_emails` |
+| `inbox.py` | 7 tools | 5 tools | Merge `get_recent_emails` into `list_inbox_emails`, merge `get_unread_count` into `get_mailbox_unread_counts` |
+| `manage.py` | 7 tools | 3 tools | Merge `update_email_status_by_ids` into `update_email_status`, merge `bulk_move_emails`+`archive_emails` into `move_email`, merge `delete_emails` into `manage_trash` |
+| `bulk.py` | 3 tools | 0 tools (DELETE FILE) | All capabilities merged into manage.py tools |
+| `smart_inbox.py` | 3 tools | 3 tools | No changes |
+| `analytics.py` | 4 tools | 4 tools | No changes |
+| `compose.py` | 5 tools | 5 tools | No changes (already fixed in PR #32) |
+| `tests/` | 3 files | 3 files | Update imports/assertions; remove bulk helpers test or redirect |
+
+**Final tool count: ~22 tools** (down from 31)
+
+---
+
+## Task 1: Consolidate Search Tools (10 → 2)
+
+**Files:**
+- Modify: `apple_mail_mcp/tools/search.py`
+- Test: `tests/test_mail_search_tools.py`
+
+**Merge map:**
+| Removed Tool | Unique Capability | Absorbed Into |
+|---|---|---|
+| `search_subjects` | Subject-only search | `search_emails` (already has `subject_keyword`) |
+| `get_email_with_content` | Content preview | `search_emails` (already has `include_content`) |
+| `search_by_sender` | Cross-account sender search | `search_emails` (make `account` optional) |
+| `search_email_content` | Body text search | `search_emails` (add `body_text` param) |
+| `get_recent_from_sender` | Time-range sender search | `search_emails` (already has `date_from`/`sender`) |
+| `search_all_accounts` | Cross-account search | `search_emails` (make `account` optional) |
+| `get_newsletters` | Newsletter detection | Remove entirely |
+| `group_emails_by_subject_regex` | Regex grouping | Remove entirely |
+
+**Surviving tools:** `search_emails`, `get_email_thread`
+
+### Step-by-step
+
+- [ ] **Step 1: Extend `search_emails` — make `account` optional for cross-account search**
+
+In `search_emails` (line 518), change `account: str` to `account: Optional[str] = None`. When `account` is None, iterate all accounts (reuse the pattern from `search_all_accounts` line 1461 and `search_by_sender` line 691 which already loop accounts).
+
+The `_search_mail_records` helper (line 161) already accepts `account` — update it to accept `Optional[str]` and loop all accounts when None.
+
+Update the docstring to document cross-account behavior.
+
+- [ ] **Step 2: Add `body_text` parameter to `search_emails`**
+
+Add `body_text: Optional[str] = None` parameter. When provided, add body content search to the AppleScript filter logic inside `_search_mail_records`. Use the lowercase comparison pattern from `search_email_content` (line 912-920):
+
+```python
+# In _search_mail_records, when body_text is provided:
+# 1. Include LOWERCASE_HANDLER in the script
+# 2. Extract content of each message
+# 3. Add condition: my lowercase(content of aMessage) contains "{escaped_body_text}"
+```
+
+This is slower than subject-only search, so document this in the docstring.
+
+- [ ] **Step 3: Add `max_content_length` parameter to `search_emails`**
+
+Add `max_content_length: int = 500` to `search_emails`. Pass through to `_search_mail_records`. This replaces the dedicated `get_email_with_content` tool's truncation behavior.
+
+- [ ] **Step 4: Remove the 8 consolidated tools**
+
+Delete these function definitions and their `@mcp.tool()` decorators from `search.py`:
+- `search_subjects` (line 408)
+- `get_email_with_content` (line 473)
+- `search_by_sender` (line 691)
+- `search_email_content` (line 887)
+- `get_recent_from_sender` (line 1127)
+- `search_all_accounts` (line 1461)
+- `get_newsletters` (line 1000)
+- `group_emails_by_subject_regex` (line 595)
+
+Also remove any helper functions that are now unused (e.g., `_newsletter_filter_condition` in smart_inbox.py if only used by `get_newsletters`).
+
+- [ ] **Step 5: Update tests**
+
+In `tests/test_mail_search_tools.py`, update any tests that reference removed tools. Ensure existing `search_emails` tests still pass. Add a test for `account=None` cross-account behavior.
+
+- [ ] **Step 6: Run tests and verify**
+
+```bash
+cd /Users/freyerpatrick/projects/apple-mail-mcp && python -m pytest tests/test_mail_search_tools.py -v
+```
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add apple_mail_mcp/tools/search.py tests/test_mail_search_tools.py
+git commit -m "refactor: consolidate 10 search tools into 2 (search_emails + get_email_thread)"
+```
+
+---
+
+## Task 2: Consolidate Inbox Tools (7 → 5)
+
+**Files:**
+- Modify: `apple_mail_mcp/tools/inbox.py`
+
+**Merge map:**
+| Removed Tool | Unique Capability | Absorbed Into |
+|---|---|---|
+| `get_recent_emails` | Account-specific recent N emails with content | `list_inbox_emails` (add `count` param, already has `include_read`) |
+| `get_unread_count` | Quick unread summary | `get_mailbox_unread_counts` (add `summary_only` param) |
+
+**Surviving tools:** `list_inbox_emails`, `get_mailbox_unread_counts`, `list_accounts`, `list_mailboxes`, `get_inbox_overview`
+
+### Step-by-step
+
+- [ ] **Step 1: Merge `get_recent_emails` into `list_inbox_emails`**
+
+Add parameters to `list_inbox_emails`:
+- `count: int = 0` (0 = all, N = most recent N — mirrors `get_recent_emails`'s `count` param)
+- `include_content: bool = False` (from `get_recent_emails`)
+
+When `count > 0`, limit the loop to `count` messages (add early exit). When `include_content=True`, include content preview in output (use `content_preview_script` from core.py).
+
+The existing `max_emails` param already does limiting — rename/unify: keep `max_emails` as the single limit param (default 0 = unlimited). Remove the separate `count` concept.
+
+- [ ] **Step 2: Merge `get_unread_count` into `get_mailbox_unread_counts`**
+
+Add `summary_only: bool = False` to `get_mailbox_unread_counts`. When True, return only per-account totals (matching `get_unread_count`'s output). When False, return the full per-mailbox breakdown.
+
+Also make `account` optional in `get_mailbox_unread_counts` (it already is — `Optional[str] = None`), matching `get_unread_count`'s all-accounts behavior.
+
+- [ ] **Step 3: Remove merged tools**
+
+Delete `get_recent_emails` (line 351) and `get_unread_count` (line 183) including their `@mcp.tool()` decorators and helper `_get_recent_emails_json`.
+
+- [ ] **Step 4: Update `get_inbox_overview` and `inbox_dashboard`**
+
+`get_inbox_overview` (inbox.py:584) calls no other tools directly (it has its own AppleScript). But `inbox_dashboard` (analytics.py:747) calls `get_unread_count()` — update it to call `get_mailbox_unread_counts(summary_only=True)` instead.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add apple_mail_mcp/tools/inbox.py apple_mail_mcp/tools/analytics.py
+git commit -m "refactor: consolidate inbox tools — merge get_recent_emails and get_unread_count"
+```
+
+---
+
+## Task 3: Consolidate Manage + Bulk Tools (10 → 5)
+
+**Files:**
+- Modify: `apple_mail_mcp/tools/manage.py`
+- Delete: `apple_mail_mcp/tools/bulk.py`
+- Modify: `apple_mail_mcp/tools/__init__.py` (if it imports bulk)
+- Modify: `tests/test_bulk_helpers.py`
+
+**Merge map:**
+| Removed Tool | Unique Capability | Absorbed Into |
+|---|---|---|
+| `update_email_status_by_ids` | ID-based updates | `update_email_status` (add `message_ids` param) |
+| `mark_emails` (bulk.py) | Batch mark with date filter | `update_email_status` (add `older_than_days` param) |
+| `bulk_move_emails` (bulk.py) | Batch move with filters + dry_run | `move_email` (add `sender`, `older_than_days`, `dry_run` params) |
+| `archive_emails` | Move to Archive with read-only + dry_run | `move_email` (archive is just `to_mailbox="Archive"`) |
+| `delete_emails` (bulk.py) | Soft-delete with dry_run | `manage_trash` (already has `move_to_trash` action + filters) |
+
+**Surviving tools:** `move_email`, `update_email_status`, `manage_trash`, `save_email_attachment`, `create_mailbox`
+
+### Step-by-step
+
+- [ ] **Step 1: Extend `update_email_status` with `message_ids` and `older_than_days`**
+
+Add parameters:
+- `message_ids: Optional[List[str]] = None` — when provided, use ID-based matching (from `update_email_status_by_ids` logic)
+- `older_than_days: Optional[int] = None` — date cutoff filter (from `mark_emails` logic)
+
+When `message_ids` is provided, use `equals_any_numeric_condition` for the filter (from current `update_email_status_by_ids`). When `older_than_days` is provided, add date cutoff using `build_date_filter` from core.py.
+
+- [ ] **Step 2: Extend `move_email` with bulk capabilities**
+
+Add parameters:
+- `sender: Optional[str] = None` — filter by sender
+- `older_than_days: Optional[int] = None` — date cutoff
+- `dry_run: bool = False` — preview without moving
+- `only_read: bool = False` — only move read emails (for archive use case)
+
+Increase `max_moves` default from 1 to 50 when any bulk filter is provided (sender/older_than_days).
+
+Reuse filter building from `bulk_move_emails` (bulk.py) and date handling from `archive_emails` (manage.py).
+
+- [ ] **Step 3: Extend `manage_trash` with dry_run and older_than_days**
+
+Add parameters:
+- `older_than_days: Optional[int] = None`
+- `dry_run: bool = True` (safe default, matching `delete_emails`)
+
+- [ ] **Step 4: Remove merged tools and delete bulk.py**
+
+Delete from manage.py:
+- `update_email_status_by_ids` (line 385)
+- `archive_emails` (line 824)
+
+Delete entire file:
+- `apple_mail_mcp/tools/bulk.py`
+
+Update `apple_mail_mcp/tools/__init__.py` to remove bulk import.
+
+- [ ] **Step 5: Move useful bulk.py helpers to core.py**
+
+The `_build_filter_conditions`, `_date_filter_script`, `_mailbox_fallback_script` helpers in bulk.py may be useful. Check if core.py already has equivalents (`build_filter_condition`, `build_date_filter`, `build_mailbox_ref`). If so, just delete the bulk versions. If bulk versions are better, move them to core.py.
+
+- [ ] **Step 6: Update tests**
+
+Move/update `tests/test_bulk_helpers.py`:
+- Tests for helpers that moved to core.py should be redirected
+- Tests for removed tools should be removed
+- Add tests for new `message_ids` param in `update_email_status`
+
+- [ ] **Step 7: Run all tests**
+
+```bash
+cd /Users/freyerpatrick/projects/apple-mail-mcp && python -m pytest tests/ -v
+```
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add -A
+git commit -m "refactor: consolidate manage + bulk tools — remove bulk.py, merge into manage.py"
+```
+
+---
+
+## Task 4: Trim Verbose MCP Tool Responses
+
+**Files:**
+- Modify: `apple_mail_mcp/tools/compose.py`
+
+The `compose_email`, `reply_to_email`, and `forward_email` tools return overly verbose confirmation messages that include the full email body echoed back. This wastes tokens and clutters the conversation.
+
+### Step-by-step
+
+- [ ] **Step 1: Slim down compose_email response**
+
+In `compose_email`, reduce the success output to just:
+```
+Email sent successfully.
+To:
+Subject:
+```
+
+Remove the echoed body text from the response.
+
+- [ ] **Step 2: Slim down reply_to_email response**
+
+In `reply_to_email`, reduce to:
+```
+Reply sent successfully.
+To:
+Subject: Re:
+```
+
+Remove echoed reply body and original email details.
+
+- [ ] **Step 3: Slim down forward_email response**
+
+In `forward_email`, reduce to:
+```
+Email forwarded successfully.
+To:
+Subject: Fwd:
+```
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add apple_mail_mcp/tools/compose.py
+git commit -m "refactor: trim verbose tool responses to reduce token waste"
+```
+
+---
+
+## Task 5: Final Cleanup and PR
+
+- [ ] **Step 1: Remove unused imports across all modified files**
+
+Scan for unused imports in search.py, inbox.py, manage.py, analytics.py after removing tools.
+
+- [ ] **Step 2: Run full test suite**
+
+```bash
+cd /Users/freyerpatrick/projects/apple-mail-mcp && python -m pytest tests/ -v
+```
+
+- [ ] **Step 3: Update README.md tool list if it documents individual tools**
+
+Check if README lists tools — if so, update to reflect consolidated set.
+
+- [ ] **Step 4: Merge PR #32 (reply fix) first, then create consolidation PR**
+
+```bash
+gh pr merge 32 --merge
+git checkout main && git pull
+git checkout -b refactor/consolidate-tools
+git cherry-pick
+gh pr create --title "refactor: consolidate 31 tools down to ~22" --body "..."
+```
diff --git a/requirements.txt b/requirements.txt
index f320a04..400e3c9 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,2 +1,2 @@
-fastmcp>=0.1.0
-mcp-ui-server>=0.1.0
+fastmcp==3.1.0
+mcp-ui-server==1.0.0
diff --git a/skill-email-management/SKILL.md b/skill-email-management/SKILL.md
index 2d6ff19..dd242c8 100644
--- a/skill-email-management/SKILL.md
+++ b/skill-email-management/SKILL.md
@@ -21,7 +21,7 @@ The Apple Mail MCP provides comprehensive email management capabilities:
- **Overview & Discovery**: `get_inbox_overview`, `list_accounts`, `list_mailboxes`
- **Reading & Searching**: `list_inbox_emails`, `get_recent_emails`, `get_email_with_content`, `search_emails`, `get_email_thread`, `search_by_sender`, `search_all_accounts`, `search_email_content`, `get_newsletters`
-- **Composing & Responding**: `compose_email`, `reply_to_email`, `forward_email`
+- **Composing & Responding**: `compose_email`, `reply_to_email`, `forward_email`, `create_rich_email_draft`
- **Organization**: `move_email`, `update_email_status` (read/unread, flag/unflag)
- **Drafts**: `manage_drafts` (list, create, send, delete)
- **Attachments**: `list_email_attachments`, `save_email_attachment`
@@ -39,8 +39,9 @@ The Apple Mail MCP provides comprehensive email management capabilities:
1. **Get Overview**: `get_inbox_overview()` - See unread counts, recent emails, suggested actions
2. **Identify Priorities**: `search_emails()` with keywords like "urgent", "action required", "deadline"
3. **Quick Responses**:
- - For immediate replies: `reply_to_email()`
- - For considered responses: `manage_drafts(action="create")`
+ - For immediate replies: `reply_to_email()`
+ - For considered responses: `manage_drafts(action="create")`
+ - For rich newsletters, status updates, or HTML-heavy drafts: `create_rich_email_draft()`
4. **Organize by Category**:
- Move project emails: `move_email(to_mailbox="Projects/[ProjectName]")`
- Archive processed: `move_email(to_mailbox="Archive")`
@@ -180,6 +181,25 @@ The Apple Mail MCP provides comprehensive email management capabilities:
- Review drafts weekly to avoid accumulation
- Use descriptive subjects for easy draft identification
+### 7a. Rich Text / HTML Draft Workflow
+
+**Goal**: Create a rendered Mail compose window for rich email content without Mail showing literal HTML.
+
+**When to use it**:
+- Weekly updates and leadership emails
+- Newsletters or formatted announcements
+- Any case where the assistant only has partial details but should prepare a polished draft quickly
+
+**Preferred workflow**:
+1. Build the HTML content first
+2. Use `create_rich_email_draft()` instead of `manage_drafts(action="create")`
+3. Pass as many details as you have now; missing `to`, `subject`, or `body` can be filled in later
+4. Let the tool generate and open an unsent `.eml` draft in Mail
+5. Optionally save that compose window into Drafts
+
+**Important note**:
+- Do not inject raw HTML into the normal AppleScript `content` field for rich messages; Mail commonly stores that as visible markup rather than rendered formatting.
+
### 8. Thread Management
**Goal**: Handle email conversations effectively
diff --git a/tests/test_bulk_helpers.py b/tests/test_bulk_helpers.py
new file mode 100644
index 0000000..bb851ea
--- /dev/null
+++ b/tests/test_bulk_helpers.py
@@ -0,0 +1,95 @@
+"""Tests for core.py helper functions (no Mail.app interaction)."""
+
+import sys
+import os
+
+# Ensure package is importable
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
+
+from apple_mail_mcp.core import (
+ escape_applescript,
+ build_filter_condition,
+ build_date_filter,
+ build_mailbox_ref,
+)
+
+
+def test_escape_applescript_quotes():
+ assert escape_applescript('hello "world"') == 'hello \\"world\\"'
+
+
+def test_escape_applescript_backslash():
+ assert escape_applescript("path\\to\\file") == "path\\\\to\\\\file"
+
+
+def test_build_filter_no_args():
+ assert build_filter_condition() == "true"
+
+
+def test_build_filter_subject_only():
+ result = build_filter_condition(subject="invoice")
+ assert 'messageSubject contains "invoice"' in result
+
+
+def test_build_filter_sender_only():
+ result = build_filter_condition(sender="alice@example.com")
+ assert 'messageSender contains "alice@example.com"' in result
+
+
+def test_build_filter_both():
+ result = build_filter_condition(subject="hello", sender="bob")
+ assert "and" in result
+ assert "messageSubject" in result
+ assert "messageSender" in result
+
+
+def test_build_filter_escapes_injection():
+ result = build_filter_condition(subject='"; do evil; "')
+ assert '\\"' in result
+ assert "do evil" in result # still present but escaped
+
+
+def test_date_filter_zero():
+ setup, cond = build_date_filter(0)
+ assert setup == ""
+ assert cond == ""
+
+
+def test_date_filter_positive():
+ setup, cond = build_date_filter(30)
+ assert "cutoffDate" in setup
+ assert "30" in setup
+ assert "cutoffDate" in cond
+
+
+def test_mailbox_ref_inbox():
+ script = build_mailbox_ref("INBOX")
+ assert '"INBOX"' in script
+ assert '"Inbox"' in script # fallback
+
+
+def test_mailbox_ref_custom():
+ script = build_mailbox_ref("Archive")
+ assert '"Archive"' in script
+
+
+def test_mailbox_ref_nested():
+ script = build_mailbox_ref("Projects/2024")
+ assert '"2024"' in script
+ assert '"Projects"' in script
+
+
+if __name__ == "__main__":
+ tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
+ passed = 0
+ failed = 0
+ for t in tests:
+ try:
+ t()
+ passed += 1
+ print(f" PASS {t.__name__}")
+ except AssertionError as e:
+ failed += 1
+ print(f" FAIL {t.__name__}: {e}")
+ print(f"\n{passed} passed, {failed} failed")
+ sys.exit(1 if failed else 0)
diff --git a/tests/test_compose_tools.py b/tests/test_compose_tools.py
new file mode 100644
index 0000000..f8bfc7a
--- /dev/null
+++ b/tests/test_compose_tools.py
@@ -0,0 +1,91 @@
+"""Tests for compose and rich draft helpers."""
+
+import tempfile
+import unittest
+from pathlib import Path
+from unittest.mock import patch
+
+from apple_mail_mcp.tools import compose as compose_tools
+
+
+class ComposeToolTests(unittest.TestCase):
+ def test_create_rich_email_draft_writes_multipart_eml(self):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ output_path = Path(tmpdir) / "weekly-update.eml"
+
+ with (
+ patch(
+ "apple_mail_mcp.tools.compose.run_applescript",
+ return_value="sender@example.com",
+ ),
+ patch("apple_mail_mcp.tools.compose.subprocess.run") as mock_run,
+ ):
+ result = compose_tools.create_rich_email_draft(
+ account="Work",
+ subject="Weekly Update",
+ to="team@example.com",
+ text_body="Plain fallback",
+ html_body="