jxa-mail-mcp 0.2.0__py3-none-any.whl → 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of jxa-mail-mcp might be problematic. Click here for more details.

jxa_mail_mcp/__init__.py CHANGED
@@ -1,10 +1,18 @@
1
- """JXA Mail MCP - Fast Apple Mail automation via optimized JXA scripts."""
1
+ """JXA Mail MCP - Fast Apple Mail automation via optimized JXA scripts.
2
2
 
3
- from .server import mcp
3
+ Features:
4
+ - 87x faster email fetching via batch property fetching
5
+ - FTS5 full-text search index for ~100x faster body search
6
+ - Fuzzy search with trigram + Levenshtein matching
4
7
 
5
- __all__ = ["main", "mcp"]
8
+ Usage:
9
+ jxa-mail-mcp # Run MCP server (default)
10
+ jxa-mail-mcp index # Build search index from disk
11
+ jxa-mail-mcp status # Show index statistics
12
+ jxa-mail-mcp rebuild # Force rebuild index
13
+ """
6
14
 
15
+ from .cli import main
16
+ from .server import mcp
7
17
 
8
- def main() -> None:
9
- """Entry point for the MCP server."""
10
- mcp.run()
18
+ __all__ = ["main", "mcp"]
jxa_mail_mcp/cli.py ADDED
@@ -0,0 +1,358 @@
1
+ """Command-line interface for jxa-mail-mcp.
2
+
3
+ Provides commands for:
4
+ - index: Build search index from disk (requires Full Disk Access)
5
+ - status: Show index statistics
6
+ - rebuild: Force rebuild the index
7
+ - serve: Run the MCP server (default)
8
+
9
+ Usage:
10
+ jxa-mail-mcp # Run MCP server (default)
11
+ jxa-mail-mcp serve # Run MCP server explicitly
12
+ jxa-mail-mcp --watch # Run with real-time index updates
13
+ jxa-mail-mcp index # Build index from disk
14
+ jxa-mail-mcp status # Show index status
15
+ jxa-mail-mcp rebuild # Force rebuild index
16
+ """
17
+
18
+ import sys
19
+ import time
20
+ from typing import Annotated
21
+
22
+ import cyclopts
23
+
24
+ from .config import get_index_path
25
+
26
+ app = cyclopts.App(
27
+ name="jxa-mail-mcp",
28
+ help="Fast MCP server for Apple Mail with FTS5 search index.",
29
+ )
30
+
31
+
32
+ def _format_size(size_mb: float) -> str:
33
+ """Format file size for display."""
34
+ if size_mb < 1:
35
+ return f"{size_mb * 1024:.1f} KB"
36
+ return f"{size_mb:.1f} MB"
37
+
38
+
39
+ def _format_time(seconds: float) -> str:
40
+ """Format duration for display."""
41
+ if seconds < 60:
42
+ return f"{seconds:.1f}s"
43
+ minutes = int(seconds // 60)
44
+ secs = seconds % 60
45
+ return f"{minutes}m {secs:.1f}s"
46
+
47
+
48
+ def _progress_bar(current: int, total: int | None, width: int = 40) -> str:
49
+ """Create a progress bar string."""
50
+ if total is None or total == 0:
51
+ # Indeterminate progress
52
+ return f"[{'=' * (current % width)}>]"
53
+
54
+ pct = min(current / total, 1.0)
55
+ filled = int(width * pct)
56
+ bar = "=" * filled + "-" * (width - filled)
57
+ return f"[{bar}] {pct * 100:.0f}%"
58
+
59
+
60
+ def _run_serve(watch: bool = False) -> None:
61
+ """Internal function to run the MCP server."""
62
+ # Perform startup sync if index exists
63
+ from .index import IndexManager
64
+ from .server import mcp
65
+
66
+ manager = IndexManager.get_instance()
67
+
68
+ if manager.has_index():
69
+ try:
70
+ print("Syncing index...", file=sys.stderr, flush=True)
71
+ start = time.time()
72
+ count = manager.sync_updates()
73
+ elapsed = time.time() - start
74
+ if count > 0:
75
+ print(
76
+ f"Synced {count} new emails in {_format_time(elapsed)}",
77
+ file=sys.stderr,
78
+ )
79
+ except Exception as e:
80
+ print(f"Warning: Index sync failed: {e}", file=sys.stderr)
81
+
82
+ # Start file watcher if requested
83
+ if watch:
84
+ try:
85
+
86
+ def on_update(added: int, removed: int) -> None:
87
+ if added or removed:
88
+ print(
89
+ f"Index updated: +{added} -{removed}",
90
+ file=sys.stderr,
91
+ )
92
+
93
+ if manager.start_watcher(on_update=on_update):
94
+ print("File watcher started", file=sys.stderr)
95
+ else:
96
+ print(
97
+ "Warning: Could not start file watcher",
98
+ file=sys.stderr,
99
+ )
100
+ except Exception as e:
101
+ print(f"Warning: File watcher failed: {e}", file=sys.stderr)
102
+
103
+ mcp.run()
104
+
105
+
106
+ @app.command
107
+ def serve(
108
+ watch: Annotated[
109
+ bool,
110
+ cyclopts.Parameter(
111
+ name=["--watch", "-w"],
112
+ help="Watch for new emails and update index in real-time",
113
+ ),
114
+ ] = False,
115
+ verbose: Annotated[
116
+ bool,
117
+ cyclopts.Parameter(
118
+ name=["--verbose", "-v"],
119
+ help="Enable verbose output",
120
+ ),
121
+ ] = False,
122
+ ) -> None:
123
+ """
124
+ Run the MCP server.
125
+
126
+ This is the default command when no subcommand is specified.
127
+ The server provides email search and access tools to MCP clients.
128
+
129
+ Use --watch to enable real-time index updates when emails arrive.
130
+ Requires Full Disk Access for the terminal.
131
+ """
132
+ _run_serve(watch=watch)
133
+
134
+
135
+ @app.command
136
+ def index(
137
+ verbose: Annotated[
138
+ bool,
139
+ cyclopts.Parameter(name=["--verbose", "-v"], help="Show progress"),
140
+ ] = False,
141
+ ) -> None:
142
+ """
143
+ Build the search index from disk.
144
+
145
+ Reads .emlx files directly from ~/Library/Mail/V10/ for fast indexing.
146
+ This is much faster than fetching via JXA (~30x faster).
147
+
148
+ IMPORTANT: Requires Full Disk Access permission for Terminal.
149
+ Grant access in System Settings → Privacy & Security → Full Disk Access.
150
+ """
151
+ from .index import IndexManager
152
+
153
+ print("Building search index from disk...")
154
+ print(f"Index location: {get_index_path()}")
155
+ print()
156
+
157
+ manager = IndexManager()
158
+ start = time.time()
159
+ last_report = start
160
+
161
+ def progress(current: int, total: int | None, message: str) -> None:
162
+ nonlocal last_report
163
+ now = time.time()
164
+
165
+ # Throttle updates to avoid spam
166
+ if now - last_report < 0.5 and total is None:
167
+ return
168
+ last_report = now
169
+
170
+ if verbose:
171
+ if total:
172
+ bar = _progress_bar(current, total)
173
+ print(f"\r{bar} {message}", end="", flush=True)
174
+ else:
175
+ print(f"\r{message}", end="", flush=True)
176
+
177
+ try:
178
+ callback = progress if verbose else None
179
+ count = manager.build_from_disk(progress_callback=callback)
180
+ elapsed = time.time() - start
181
+
182
+ if verbose:
183
+ print() # Newline after progress
184
+
185
+ print()
186
+ print(f"✓ Indexed {count:,} emails in {_format_time(elapsed)}")
187
+
188
+ stats = manager.get_stats()
189
+ print(f" Mailboxes: {stats.mailbox_count}")
190
+ print(f" Database size: {_format_size(stats.db_size_mb)}")
191
+
192
+ except PermissionError as e:
193
+ print(f"\n✗ Permission denied: {e}", file=sys.stderr)
194
+ print("\nTo fix this:", file=sys.stderr)
195
+ print(" 1. Open System Settings", file=sys.stderr)
196
+ print(" 2. Privacy & Security → Full Disk Access", file=sys.stderr)
197
+ print(" 3. Add and enable Terminal.app", file=sys.stderr)
198
+ print(" 4. Restart terminal and try again", file=sys.stderr)
199
+ sys.exit(1)
200
+
201
+ except FileNotFoundError as e:
202
+ print(f"\n✗ Not found: {e}", file=sys.stderr)
203
+ sys.exit(1)
204
+
205
+ except Exception as e:
206
+ print(f"\n✗ Error: {e}", file=sys.stderr)
207
+ sys.exit(1)
208
+
209
+
210
+ @app.command
211
+ def status(
212
+ verbose: Annotated[
213
+ bool,
214
+ cyclopts.Parameter(
215
+ name=["--verbose", "-v"],
216
+ help="Enable verbose output",
217
+ ),
218
+ ] = False,
219
+ ) -> None:
220
+ """
221
+ Show index statistics.
222
+
223
+ Displays:
224
+ - Email count and mailbox count
225
+ - Last sync time and staleness
226
+ - Database file size
227
+ """
228
+ from .index import IndexManager
229
+
230
+ manager = IndexManager()
231
+
232
+ if not manager.has_index():
233
+ print("No index found.")
234
+ print(f"Expected location: {get_index_path()}")
235
+ print()
236
+ print("Run 'jxa-mail-mcp index' to build the index.")
237
+ sys.exit(1)
238
+
239
+ stats = manager.get_stats()
240
+
241
+ print("JXA Mail MCP Index Status")
242
+ print("=" * 40)
243
+ print(f"Location: {get_index_path()}")
244
+ print(f"Emails: {stats.email_count:,}")
245
+ print(f"Mailboxes: {stats.mailbox_count}")
246
+ print(f"Database: {_format_size(stats.db_size_mb)}")
247
+ print()
248
+
249
+ if stats.last_sync:
250
+ print(f"Last sync: {stats.last_sync.strftime('%Y-%m-%d %H:%M:%S')}")
251
+ if stats.staleness_hours is not None:
252
+ if stats.staleness_hours < 1:
253
+ staleness = f"{stats.staleness_hours * 60:.0f} minutes ago"
254
+ elif stats.staleness_hours < 24:
255
+ staleness = f"{stats.staleness_hours:.1f} hours ago"
256
+ else:
257
+ staleness = f"{stats.staleness_hours / 24:.1f} days ago"
258
+ print(f"Staleness: {staleness}")
259
+
260
+ if manager.is_stale():
261
+ print()
262
+ print("⚠ Index is stale. Run 'jxa-mail-mcp index' to refresh.")
263
+ else:
264
+ print("Last sync: Never")
265
+ print()
266
+ print("⚠ No sync recorded. Run 'jxa-mail-mcp index' to build.")
267
+
268
+
269
+ @app.command
270
+ def rebuild(
271
+ account: Annotated[
272
+ str | None,
273
+ cyclopts.Parameter(
274
+ name=["--account", "-a"],
275
+ help="Rebuild only this account (all if not specified)",
276
+ ),
277
+ ] = None,
278
+ mailbox: Annotated[
279
+ str | None,
280
+ cyclopts.Parameter(
281
+ name=["--mailbox", "-m"],
282
+ help="Rebuild only this mailbox (requires --account)",
283
+ ),
284
+ ] = None,
285
+ verbose: Annotated[
286
+ bool,
287
+ cyclopts.Parameter(name=["--verbose", "-v"], help="Show progress"),
288
+ ] = False,
289
+ ) -> None:
290
+ """
291
+ Force rebuild the search index.
292
+
293
+ Clears existing data and rebuilds from disk.
294
+ Optionally scope to a specific account or mailbox.
295
+ """
296
+ if mailbox and not account:
297
+ print("Error: --mailbox requires --account", file=sys.stderr)
298
+ sys.exit(1)
299
+
300
+ from .index import IndexManager
301
+
302
+ scope = "entire index"
303
+ if account and mailbox:
304
+ scope = f"{account}/{mailbox}"
305
+ elif account:
306
+ scope = f"account {account}"
307
+
308
+ print(f"Rebuilding {scope}...")
309
+
310
+ manager = IndexManager()
311
+ start = time.time()
312
+
313
+ def progress(current: int, total: int | None, message: str) -> None:
314
+ if verbose:
315
+ print(f"\r{message}", end="", flush=True)
316
+
317
+ try:
318
+ count = manager.rebuild(
319
+ account=account,
320
+ mailbox=mailbox,
321
+ progress_callback=progress if verbose else None,
322
+ )
323
+ elapsed = time.time() - start
324
+
325
+ if verbose:
326
+ print()
327
+
328
+ print(f"✓ Rebuilt {count:,} emails in {_format_time(elapsed)}")
329
+
330
+ except Exception as e:
331
+ print(f"\n✗ Error: {e}", file=sys.stderr)
332
+ sys.exit(1)
333
+
334
+
335
+ @app.default
336
+ def default_handler(
337
+ watch: Annotated[
338
+ bool,
339
+ cyclopts.Parameter(
340
+ name=["--watch", "-w"],
341
+ help="Watch for new emails and update index in real-time",
342
+ ),
343
+ ] = False,
344
+ verbose: Annotated[
345
+ bool,
346
+ cyclopts.Parameter(
347
+ name=["--verbose", "-v"],
348
+ help="Enable verbose output",
349
+ ),
350
+ ] = False,
351
+ ) -> None:
352
+ """Run the MCP server (default when no command specified)."""
353
+ _run_serve(watch=watch)
354
+
355
+
356
+ def main() -> None:
357
+ """Entry point for the CLI."""
358
+ app()
jxa_mail_mcp/config.py CHANGED
@@ -1,6 +1,10 @@
1
1
  """Configuration for JXA Mail MCP server."""
2
2
 
3
3
  import os
4
+ from pathlib import Path
5
+
6
+ # Default index location
7
+ DEFAULT_INDEX_PATH = Path.home() / ".jxa-mail-mcp" / "index.db"
4
8
 
5
9
 
6
10
  def get_default_account() -> str | None:
@@ -27,3 +31,51 @@ def get_default_mailbox() -> str:
27
31
  Mailbox name.
28
32
  """
29
33
  return os.environ.get("JXA_MAIL_DEFAULT_MAILBOX", "Inbox")
34
+
35
+
36
+ # ========== Index Configuration ==========
37
+
38
+
39
+ def get_index_path() -> Path:
40
+ """
41
+ Get the FTS5 index database path.
42
+
43
+ Set JXA_MAIL_INDEX_PATH to customize the location.
44
+ Defaults to ~/.jxa-mail-mcp/index.db
45
+
46
+ Returns:
47
+ Path to the index database file.
48
+ """
49
+ env_path = os.environ.get("JXA_MAIL_INDEX_PATH")
50
+ if env_path:
51
+ return Path(env_path).expanduser()
52
+ return DEFAULT_INDEX_PATH
53
+
54
+
55
+ def get_index_max_emails() -> int:
56
+ """
57
+ Get the maximum number of emails to index per mailbox.
58
+
59
+ Set JXA_MAIL_INDEX_MAX_EMAILS to customize.
60
+ Defaults to 5000 emails per mailbox.
61
+
62
+ Returns:
63
+ Maximum emails per mailbox.
64
+ """
65
+ return int(os.environ.get("JXA_MAIL_INDEX_MAX_EMAILS", "5000"))
66
+
67
+
68
+ def get_index_staleness_hours() -> float:
69
+ """
70
+ Get the staleness threshold for the index.
71
+
72
+ After this many hours without a sync, the index is considered stale
73
+ and should be refreshed.
74
+
75
+ Set JXA_MAIL_INDEX_STALENESS_HOURS to customize.
76
+ Defaults to 24 hours.
77
+
78
+ Returns:
79
+ Staleness threshold in hours.
80
+ """
81
+ return float(os.environ.get("JXA_MAIL_INDEX_STALENESS_HOURS", "24"))
jxa_mail_mcp/executor.py CHANGED
@@ -1,7 +1,19 @@
1
- """JXA script execution utilities."""
1
+ """JXA script execution utilities.
2
+
3
+ Provides:
4
+ - run_jxa() / run_jxa_async(): Execute raw JXA scripts
5
+ - execute_with_core() / execute_with_core_async(): Execute with MailCore
6
+ - execute_query() / execute_query_async(): Execute a QueryBuilder
7
+ - build_account_js(): Build JXA code to get an account reference
8
+ - build_mailbox_setup_js(): Build JXA code to set up account + mailbox
9
+
10
+ The async versions use asyncio.create_subprocess_exec to avoid blocking
11
+ the event loop, which is important for MCP server responsiveness.
12
+ """
2
13
 
3
14
  from __future__ import annotations
4
15
 
16
+ import asyncio
5
17
  import json
6
18
  import subprocess
7
19
  from typing import TYPE_CHECKING, Any
@@ -20,6 +32,65 @@ class JXAError(Exception):
20
32
  self.stderr = stderr
21
33
 
22
34
 
35
+ # ========== JXA Code Building Helpers ==========
36
+
37
+
38
+ def build_account_js(account: str | None) -> str:
39
+ """
40
+ Build JXA expression to get an account reference.
41
+
42
+ Uses json.dumps() for safe string serialization to prevent injection.
43
+
44
+ Args:
45
+ account: Account name, or None for first/default account
46
+
47
+ Returns:
48
+ JXA expression string like: MailCore.getAccount("Work")
49
+
50
+ Example:
51
+ >>> build_account_js("Work")
52
+ 'MailCore.getAccount("Work")'
53
+ >>> build_account_js(None)
54
+ 'MailCore.getAccount(null)'
55
+ """
56
+ account_json = json.dumps(account)
57
+ return f"MailCore.getAccount({account_json})"
58
+
59
+
60
+ def build_mailbox_setup_js(
61
+ account: str | None,
62
+ mailbox: str,
63
+ account_var: str = "account",
64
+ mailbox_var: str = "mailbox",
65
+ ) -> str:
66
+ """
67
+ Build JXA code to set up account and mailbox variables.
68
+
69
+ Uses json.dumps() for safe string serialization to prevent injection.
70
+
71
+ Args:
72
+ account: Account name, or None for first/default account
73
+ mailbox: Mailbox name
74
+ account_var: Variable name for account (default: "account")
75
+ mailbox_var: Variable name for mailbox (default: "mailbox")
76
+
77
+ Returns:
78
+ JXA code declaring account and mailbox variables
79
+
80
+ Example:
81
+ >>> build_mailbox_setup_js("Work", "INBOX")
82
+ 'const account = MailCore.getAccount("Work");
83
+ const mailbox = MailCore.getMailbox(account, "INBOX");'
84
+ """
85
+ account_json = json.dumps(account)
86
+ mailbox_json = json.dumps(mailbox)
87
+ return f"""const {account_var} = MailCore.getAccount({account_json});
88
+ const {mailbox_var} = MailCore.getMailbox({account_var}, {mailbox_json});"""
89
+
90
+
91
+ # ========== Script Execution ==========
92
+
93
+
23
94
  def run_jxa(script: str, timeout: int = 120) -> str:
24
95
  """
25
96
  Execute a raw JXA script and return the output.
@@ -61,12 +132,20 @@ def execute_with_core(script_body: str, timeout: int = 120) -> Any:
61
132
  Parsed JSON result from the script
62
133
 
63
134
  Raises:
64
- JXAError: If execution fails
65
- json.JSONDecodeError: If output isn't valid JSON
135
+ JXAError: If execution fails or output isn't valid JSON
66
136
  """
67
137
  full_script = f"{MAIL_CORE_JS}\n\n{script_body}"
68
138
  output = run_jxa(full_script, timeout)
69
- return json.loads(output)
139
+
140
+ try:
141
+ return json.loads(output)
142
+ except json.JSONDecodeError as e:
143
+ # Truncate long output for the error message
144
+ preview = output[:500] + "..." if len(output) > 500 else output
145
+ raise JXAError(
146
+ f"Failed to parse JXA output as JSON: {e}\nOutput: {preview}",
147
+ stderr=output,
148
+ ) from e
70
149
 
71
150
 
72
151
  def execute_query(query: QueryBuilder, timeout: int = 120) -> list[dict]:
@@ -82,3 +161,98 @@ def execute_query(query: QueryBuilder, timeout: int = 120) -> list[dict]:
82
161
  """
83
162
  script = query.build()
84
163
  return execute_with_core(script, timeout)
164
+
165
+
166
+ # ========== Async Script Execution ==========
167
+
168
+
169
+ async def run_jxa_async(script: str, timeout: int = 120) -> str:
170
+ """
171
+ Execute a raw JXA script asynchronously.
172
+
173
+ Uses asyncio.create_subprocess_exec to avoid blocking the event loop.
174
+ This is preferred for MCP server tools to maintain responsiveness.
175
+
176
+ Args:
177
+ script: JavaScript code to execute via osascript
178
+ timeout: Maximum execution time in seconds
179
+
180
+ Returns:
181
+ The script's stdout output (stripped)
182
+
183
+ Raises:
184
+ JXAError: If the script fails to execute
185
+ asyncio.TimeoutError: If execution exceeds timeout
186
+ """
187
+ process = await asyncio.create_subprocess_exec(
188
+ "osascript",
189
+ "-l",
190
+ "JavaScript",
191
+ "-e",
192
+ script,
193
+ stdout=asyncio.subprocess.PIPE,
194
+ stderr=asyncio.subprocess.PIPE,
195
+ )
196
+
197
+ try:
198
+ stdout, stderr = await asyncio.wait_for(
199
+ process.communicate(), timeout=timeout
200
+ )
201
+ except TimeoutError:
202
+ process.kill()
203
+ await process.wait()
204
+ raise
205
+
206
+ if process.returncode != 0:
207
+ stderr_text = stderr.decode("utf-8", errors="replace")
208
+ raise JXAError(f"JXA script failed: {stderr_text}", stderr_text)
209
+
210
+ return stdout.decode("utf-8", errors="replace").strip()
211
+
212
+
213
+ async def execute_with_core_async(script_body: str, timeout: int = 120) -> Any:
214
+ """
215
+ Execute a JXA script with MailCore library injected (async version).
216
+
217
+ The script should use MailCore utilities and end with a
218
+ JSON.stringify() call to return data.
219
+
220
+ Args:
221
+ script_body: JavaScript code that uses MailCore
222
+ timeout: Maximum execution time in seconds
223
+
224
+ Returns:
225
+ Parsed JSON result from the script
226
+
227
+ Raises:
228
+ JXAError: If execution fails or output isn't valid JSON
229
+ """
230
+ full_script = f"{MAIL_CORE_JS}\n\n{script_body}"
231
+ output = await run_jxa_async(full_script, timeout)
232
+
233
+ try:
234
+ return json.loads(output)
235
+ except json.JSONDecodeError as e:
236
+ # Truncate long output for the error message
237
+ preview = output[:500] + "..." if len(output) > 500 else output
238
+ raise JXAError(
239
+ f"Failed to parse JXA output as JSON: {e}\nOutput: {preview}",
240
+ stderr=output,
241
+ ) from e
242
+
243
+
244
+ async def execute_query_async(
245
+ query: QueryBuilder, timeout: int = 120
246
+ ) -> list[dict]:
247
+ """
248
+ Execute a QueryBuilder asynchronously and return results.
249
+
250
+ Args:
251
+ query: A configured QueryBuilder instance
252
+ timeout: Maximum execution time in seconds
253
+
254
+ Returns:
255
+ List of email dictionaries matching the query
256
+ """
257
+ script = query.build()
258
+ return await execute_with_core_async(script, timeout)
@@ -0,0 +1,14 @@
1
+ """FTS5 search index for fast email body search.
2
+
3
+ This module provides:
4
+ - IndexManager: Main interface for building, syncing, and searching the index
5
+ - IndexWatcher: Real-time file watcher for automatic index updates
6
+ - Pre-indexing from disk via CLI (requires Full Disk Access)
7
+ - Incremental sync via JXA for new emails
8
+ - FTS5 full-text search with BM25 ranking
9
+ """
10
+
11
+ from .manager import IndexManager, IndexStats, SearchResult
12
+ from .watcher import IndexWatcher
13
+
14
+ __all__ = ["IndexManager", "IndexStats", "IndexWatcher", "SearchResult"]