Skip to content

Commit bd628d7

Browse files
imdinuclaude
andcommitted
v0.1.8: Bug fixes, benchmark refresh, reframed messaging
## Bug Fixes - Fix watcher crash on file add (broad exception catch in parse loop) - Fix attachment cache leak (_cleanup_old_attachments now called) - Fix attachment cache permissions (0o700 for sensitive content) - Fix empty search error messages (actionable errors + rebuild hint) - Fix misleading get_email timeout (context-aware error message) ## Benchmark Updates - Add rusty_apple_mail_mcp (Rust) as new competitor - Update patrickfreyer tool mapping (search_emails API change) - Fix message ID discovery for nested responses + string IDs - Add capability matrix overview chart (benchmark_overview.png) - Re-run all benchmarks with 7 competitors ## Messaging & Docs - Rename this_week filter to last_7_days (this_week kept as alias) - Reframe all descriptions: lead with reliability + body search, not speed multipliers (87x/700-3500x removed from marketing surfaces) - Restructure benchmarks page: capability matrix first, details second - Update PyPI keywords: add search, claude, llm; drop fts5, automation - Update docs homepage, README, pyproject, server.json, mkdocs.yml ## Tests - 297 tests pass (4 new watcher tests, 2 new filter tests) - 25/25 integration tests pass Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent be97ff6 commit bd628d7

27 files changed

+526
-137
lines changed

CHANGELOG.md

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,23 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8-
## [0.1.7] - Unreleased
8+
## [0.1.8] - Unreleased
9+
10+
### Fixed
11+
12+
- **Watcher crash on file add**`parse_emlx()` exceptions beyond `OSError`/`ValueError`/`UnicodeDecodeError` (e.g. malformed plist, missing headers) no longer kill the watcher thread. The watcher now skips unparseable files and continues processing.
13+
- **Attachment cache leak**`_cleanup_old_attachments()` is now called automatically when extracting attachments, preventing unbounded disk usage from cached files.
14+
- **Attachment cache permissions** — cache directory is now created with `0o700` permissions to protect sensitive email attachment content.
15+
- **Empty search error messages** — search index errors (corrupt DB, SQLite issues) now return actionable error messages instead of empty strings. Suggests `apple-mail-mcp rebuild` when the index is broken.
16+
- **Misleading get_email timeout message** — when `get_email` times out, the error now checks whether account/mailbox were already provided and gives context-appropriate advice instead of always saying "Provide account/mailbox".
17+
18+
### Changed
19+
20+
- **Renamed `this_week` filter to `last_7_days`** — the filter returns a rolling 7-day window, not the current calendar week. `this_week` is still accepted as an alias for backwards compatibility. (#49)
21+
- **Updated patrickfreyer benchmark config**`search_email_content` tool renamed to `search_emails` with new parameter names to match their latest API.
22+
- **Added `rusty_apple_mail_mcp` to benchmarks** — Rust-based competitor that reads Apple's Envelope Index directly (no JXA). First non-AppleScript competitor in the benchmark suite.
23+
24+
## [0.1.7] - 2026-03-11
925

1026
### Added
1127

@@ -100,7 +116,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
100116
- Disk-based sync for index building
101117
- Real-time file watcher for index updates
102118

103-
[0.1.7]: https://github.com/imdinu/apple-mail-mcp/compare/v0.1.6...HEAD
119+
[0.1.8]: https://github.com/imdinu/apple-mail-mcp/compare/v0.1.7...HEAD
120+
[0.1.7]: https://github.com/imdinu/apple-mail-mcp/compare/v0.1.6...v0.1.7
104121
[0.1.6]: https://github.com/imdinu/apple-mail-mcp/compare/v0.1.5...v0.1.6
105122
[0.1.5]: https://github.com/imdinu/apple-mail-mcp/compare/v0.1.4...v0.1.5
106123
[0.1.4]: https://github.com/imdinu/apple-mail-mcp/compare/v0.1.3...v0.1.4

CLAUDE.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
## Project Overview
44

5-
Fast MCP server for Apple Mail with disk-first email reads (~1-5ms via .emlx parsing), batch JXA property fetching for 87x faster multi-email performance, and an FTS5 search index for **700-3500x faster** body search (~2ms vs ~7s).
5+
The only Apple Mail MCP server with full-text email search. Reliable on large mailboxes (30K+) where other servers timeout. Disk-first email reads (~5ms via .emlx parsing), batch JXA property fetching, and an FTS5 search index for full-text body search (~20ms).
66

77
## Project Structure
88

@@ -33,7 +33,7 @@ src/apple_mail_mcp/
3333
|------|---------|----------------|
3434
| `list_accounts()` | List email accounts | - |
3535
| `list_mailboxes(account?)` | List mailboxes | account (optional) |
36-
| `get_emails(...)` | Unified listing | filter: all/unread/flagged/today/this_week |
36+
| `get_emails(...)` | Unified listing | filter: all/unread/flagged/today/last_7_days |
3737
| `get_email(id)` | Full email content + attachments | message_id |
3838
| `search(query, ...)` | Unified search | scope: all/subject/sender/body/attachments |
3939
| `get_attachment(id, filename)` | Extract attachment content | message_id, filename |
@@ -45,7 +45,7 @@ get_emails() # All emails (default)
4545
get_emails(filter="unread") # Unread only
4646
get_emails(filter="flagged") # Flagged only
4747
get_emails(filter="today") # Received today
48-
get_emails(filter="this_week") # Last 7 days
48+
get_emails(filter="last_7_days") # Last 7 days
4949
```
5050

5151
### search() Scopes
@@ -280,7 +280,7 @@ With the consolidated API, extend `get_emails()` filters or `search()` scopes:
280280
@mcp.tool
281281
async def get_emails(
282282
...
283-
filter: Literal["all", "unread", "flagged", "today", "this_week", "starred"] = "all",
283+
filter: Literal["all", "unread", "flagged", "today", "last_7_days", "starred"] = "all",
284284
...
285285
):
286286
...
@@ -311,7 +311,7 @@ JSON.stringify({{success: true, id: {message_id}}});
311311
MailCore.today() // Date
312312

313313
// Get N days ago at midnight
314-
MailCore.daysAgo(7) // Date (for "this_week" filter)
314+
MailCore.daysAgo(7) // Date (for "last_7_days" filter)
315315

316316
// Format for JSON
317317
MailCore.formatDate(date) // ISO string or null

README.md

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
[![Code style: ruff](https://img.shields.io/badge/code%20style-ruff-000000.svg)](https://github.com/astral-sh/ruff)
1010
[![CI](https://github.com/imdinu/apple-mail-mcp/actions/workflows/lint.yml/badge.svg)](https://github.com/imdinu/apple-mail-mcp/actions/workflows/lint.yml)
1111

12-
A fast MCP server for Apple Mail — disk-first email reading, **87x faster** batch fetching via JXA, and an FTS5 search index for **700–3500x faster** body search (~2ms vs ~7s).
12+
The only Apple Mail MCP server with **full-text email search**. Reliable on large mailboxes where other servers timeout — with 6 tools for reading, searching, and extracting email content.
1313

1414
**[Read the docs](https://imdinu.github.io/apple-mail-mcp/)** for the full guide.
1515

@@ -46,22 +46,21 @@ apple-mail-mcp index --verbose
4646
|------|---------|
4747
| `list_accounts()` | List email accounts |
4848
| `list_mailboxes(account?)` | List mailboxes |
49-
| `get_emails(filter?, limit?)` | Get emails — all, unread, flagged, today, this_week |
49+
| `get_emails(filter?, limit?)` | Get emails — all, unread, flagged, today, last_7_days |
5050
| `get_email(message_id)` | Get single email with full content + attachments |
5151
| `search(query, scope?)` | Search — all, subject, sender, body, attachments |
52-
| `get_attachment(message_id, filename)` | Extract attachment content (base64) |
52+
| `get_attachment(message_id, filename)` | Extract attachment to disk, or extract links |
5353

5454
## Performance
5555

56-
| Scenario | Apple Mail MCP | Best alternative | Speedup |
57-
|----------|---------------|-----------------|---------|
58-
| Fetch single email | 6ms | unsupported | **Only one** (disk-first) |
59-
| Fetch 50 emails | 301ms | 13,800ms+ | **46x faster** |
60-
| Search (subject) | 10ms | 148ms | **15x faster** |
61-
| Search (body) | 22ms | unsupported | **Only one** |
62-
| List accounts | 118ms | 134ms | Fastest |
56+
Tested against [6 other Apple Mail MCP servers](https://imdinu.github.io/apple-mail-mcp/benchmarks/) on a 30K+ email mailbox:
6357

64-
> Benchmarked against [5 other Apple Mail MCP servers](https://imdinu.github.io/apple-mail-mcp/benchmarks/) at the MCP protocol level.
58+
- **Only server** that completes all operations without timing out
59+
- **Only server** with full-text body search (FTS5 index, ~20ms)
60+
- **5ms** single email fetch via disk-first `.emlx` reading
61+
- **7–9ms** subject search via FTS5 (vs 230ms+ for AppleScript-based servers)
62+
63+
![Capability Matrix](benchmark_overview.png)
6564

6665
## Configuration
6766

@@ -97,7 +96,7 @@ If you used [supermemoryai/apple-mcp](https://github.com/supermemoryai/apple-mcp
9796
| `search_emails` | `search(query, scope?)` — 5 scopes: all, subject, sender, body, attachments |
9897
| `send_email` | Not yet supported (planned) |
9998

100-
**What's different:** available on PyPI (`pipx install apple-mail-mcp`), disk-first single-email reads (~1-5ms via .emlx parsing), 87x faster batch fetching via JXA, FTS5 search index for ~2ms body search, and disk-based sync that avoids JXA timeouts and false-success responses.
99+
**What's different:** available on PyPI (`pipx install apple-mail-mcp`), full-text body search via FTS5 (~20ms), disk-first single-email reads (~5ms), reliable on large mailboxes (30K+) where AppleScript-based servers timeout.
101100

102101
## Development
103102

benchmarks/charts.py

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,164 @@ def generate_chart(
169169
return png_path
170170

171171

172+
# ─── Overview (capability matrix) ────────────────────────────
173+
174+
# Friendly display names for the overview chart
175+
COMPETITOR_LABELS = {
176+
"imdinu": "apple-mail-mcp (ours)",
177+
"rusty": "rusty (Rust)",
178+
"patrickfreyer": "patrickfreyer",
179+
"dhravya": "dhravya (archived)",
180+
"smorgan": "s-morgan-jeffries",
181+
"attilagyorffy": "attilagyorffy (Go)",
182+
"che-apple-mail": "che (Swift)",
183+
}
184+
185+
# Display order: us first, then by interest
186+
COMPETITOR_ORDER = [
187+
"imdinu",
188+
"rusty",
189+
"patrickfreyer",
190+
"attilagyorffy",
191+
"smorgan",
192+
"dhravya",
193+
]
194+
195+
SCENARIO_SHORT = {
196+
"cold_start": "Cold Start",
197+
"list_accounts": "List\nAccounts",
198+
"get_emails": "Fetch 50\nEmails",
199+
"get_email": "Fetch\nSingle Email",
200+
"search_subject": "Search\nSubject",
201+
"search_body": "Search\nBody",
202+
}
203+
204+
# Color codes: 0 = success, 1 = timeout/error, 2 = not supported
205+
COLOR_MAP = {0: "#22c55e", 1: "#ef4444", 2: "#d1d5db"}
206+
207+
208+
def _classify_result(
209+
results: list[dict], competitor: str, scenario: str
210+
) -> tuple[int, str]:
211+
"""Classify a benchmark result as (code, label).
212+
213+
Returns (0, "Xms"), (1, "TIMEOUT"), (1, "ERROR"), or (2, "—").
214+
"""
215+
matches = [
216+
r
217+
for r in results
218+
if r["competitor"] == competitor and r["scenario"] == scenario
219+
]
220+
if not matches:
221+
return 2, "—"
222+
r = matches[0]
223+
if r["success"]:
224+
return 0, f"{r['median_ms']:.0f}ms"
225+
err = r.get("error", "")
226+
if "Not supported" in err:
227+
return 2, "—"
228+
if "No such file" in err:
229+
return 2, "N/A"
230+
if "timeout" in err.lower() or "too slow" in err.lower():
231+
return 1, "TIMEOUT"
232+
return 1, "ERROR"
233+
234+
235+
def generate_overview_chart(
236+
results: list[dict],
237+
output_dir: Path,
238+
) -> Path:
239+
"""Generate the capability matrix overview chart."""
240+
scenarios = list(SCENARIO_SHORT.keys())
241+
# Filter to competitors present in results
242+
present = {r["competitor"] for r in results}
243+
competitors = [c for c in COMPETITOR_ORDER if c in present]
244+
245+
# Build grid (rows = competitors, cols = scenarios)
246+
# Reverse so "ours" appears at top in the chart
247+
z_values: list[list[int]] = []
248+
annotations: list[list[str]] = []
249+
for comp in reversed(competitors):
250+
row_z = []
251+
row_a = []
252+
for sc in scenarios:
253+
code, label = _classify_result(results, comp, sc)
254+
row_z.append(code)
255+
row_a.append(label)
256+
z_values.append(row_z)
257+
annotations.append(row_a)
258+
259+
y_labels = [
260+
COMPETITOR_LABELS.get(c, c) for c in reversed(competitors)
261+
]
262+
x_labels = [SCENARIO_SHORT[s] for s in scenarios]
263+
264+
# Custom colorscale: 0=green, 0.5=red, 1=gray
265+
colorscale = [
266+
[0.0, COLOR_MAP[0]],
267+
[0.33, COLOR_MAP[0]],
268+
[0.34, COLOR_MAP[1]],
269+
[0.66, COLOR_MAP[1]],
270+
[0.67, COLOR_MAP[2]],
271+
[1.0, COLOR_MAP[2]],
272+
]
273+
274+
fig = go.Figure(
275+
data=go.Heatmap(
276+
z=z_values,
277+
x=x_labels,
278+
y=y_labels,
279+
colorscale=colorscale,
280+
showscale=False,
281+
zmin=0,
282+
zmax=2,
283+
xgap=3,
284+
ygap=3,
285+
)
286+
)
287+
288+
# Add text annotations
289+
for i, row in enumerate(annotations):
290+
for j, text in enumerate(row):
291+
font_color = "#ffffff" if z_values[i][j] < 2 else "#6b7280"
292+
fig.add_annotation(
293+
x=x_labels[j],
294+
y=y_labels[i],
295+
text=f"<b>{text}</b>",
296+
showarrow=False,
297+
font=dict(size=13, color=font_color),
298+
)
299+
300+
fig.update_layout(
301+
title=dict(
302+
text="Apple Mail MCP Servers — Capability Matrix (30K+ mailbox)",
303+
font=dict(size=16, color="#111827"),
304+
x=0.0,
305+
),
306+
xaxis=dict(
307+
side="top",
308+
tickfont=dict(size=11),
309+
),
310+
yaxis=dict(
311+
tickfont=dict(size=12),
312+
automargin=True,
313+
),
314+
plot_bgcolor=COLOR_BG,
315+
paper_bgcolor=COLOR_BG,
316+
margin=dict(l=10, r=20, t=80, b=30),
317+
height=max(300, 50 * len(competitors) + 120),
318+
width=750,
319+
)
320+
321+
png_path = output_dir / "benchmark_overview.png"
322+
fig.write_image(str(png_path), scale=2)
323+
324+
html_path = RESULTS_DIR / "benchmark_overview.html"
325+
fig.write_html(str(html_path), include_plotlyjs="cdn")
326+
327+
return png_path
328+
329+
172330
def main() -> None:
173331
parser = argparse.ArgumentParser(
174332
description="Generate benchmark charts from results"
@@ -208,6 +366,13 @@ def main() -> None:
208366
]
209367

210368
generated = []
369+
370+
# Overview chart (capability matrix)
371+
overview = generate_overview_chart(results, output_dir)
372+
print(f" Generated {overview.name}")
373+
generated.append(overview)
374+
375+
# Per-scenario charts
211376
for scenario in scenarios:
212377
png = generate_chart(scenario, results, output_dir)
213378
if png:

benchmarks/competitors.py

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -105,19 +105,17 @@ def _register(c: Competitor) -> None:
105105
{"email_id": None},
106106
), # email_id discovered at runtime
107107
"search_subject": ToolCall(
108-
"search_email_content",
108+
"search_emails",
109109
{
110110
"account": BENCHMARK_ACCOUNT,
111-
"search_text": SEARCH_QUERY,
111+
"subject": SEARCH_QUERY,
112112
},
113113
),
114114
"search_body": ToolCall(
115-
"search_email_content",
115+
"search_emails",
116116
{
117117
"account": BENCHMARK_ACCOUNT,
118-
"search_text": SEARCH_QUERY,
119-
"search_subject": False,
120-
"search_body": True,
118+
"body": SEARCH_QUERY,
121119
},
122120
),
123121
},
@@ -227,3 +225,32 @@ def _register(c: Competitor) -> None:
227225
notes="Go binary, no list_accounts or body search",
228226
)
229227
)
228+
229+
# 7. like-a-freedom/rusty_apple_mail_mcp (Rust, reads Envelope Index)
230+
_register(
231+
Competitor(
232+
name="rusty_apple_mail_mcp",
233+
key="rusty",
234+
command=[
235+
f"{CACHE_DIR}/rusty-apple-mail-mcp"
236+
"/target/release/rusty_apple_mail_mcp",
237+
],
238+
tool_mapping={
239+
"list_accounts": ToolCall(
240+
"list_accounts", {"include_mailboxes": False}
241+
),
242+
"get_emails": ToolCall(
243+
"search_messages",
244+
{"mailbox": "INBOX", "limit": 50},
245+
),
246+
"get_email": ToolCall(
247+
"get_message", {"message_id": None}
248+
), # message_id is a string, not int
249+
"search_subject": ToolCall(
250+
"search_messages",
251+
{"subject_query": SEARCH_QUERY, "limit": 50},
252+
),
253+
},
254+
notes="Rust binary, reads Apple Envelope Index directly",
255+
)
256+
)

0 commit comments

Comments
 (0)