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 +14 -6
- jxa_mail_mcp/cli.py +358 -0
- jxa_mail_mcp/config.py +52 -0
- jxa_mail_mcp/executor.py +178 -4
- jxa_mail_mcp/index/__init__.py +14 -0
- jxa_mail_mcp/index/disk.py +485 -0
- jxa_mail_mcp/index/manager.py +458 -0
- jxa_mail_mcp/index/schema.py +277 -0
- jxa_mail_mcp/index/search.py +331 -0
- jxa_mail_mcp/index/sync.py +305 -0
- jxa_mail_mcp/index/watcher.py +341 -0
- jxa_mail_mcp/server.py +450 -201
- jxa_mail_mcp-0.3.0.dist-info/METADATA +355 -0
- jxa_mail_mcp-0.3.0.dist-info/RECORD +20 -0
- jxa_mail_mcp-0.2.0.dist-info/METADATA +0 -264
- jxa_mail_mcp-0.2.0.dist-info/RECORD +0 -12
- {jxa_mail_mcp-0.2.0.dist-info → jxa_mail_mcp-0.3.0.dist-info}/WHEEL +0 -0
- {jxa_mail_mcp-0.2.0.dist-info → jxa_mail_mcp-0.3.0.dist-info}/entry_points.txt +0 -0
- {jxa_mail_mcp-0.2.0.dist-info → jxa_mail_mcp-0.3.0.dist-info}/licenses/LICENSE +0 -0
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"]
|