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/server.py CHANGED
@@ -3,17 +3,240 @@ JXA Mail MCP Server
3
3
 
4
4
  Provides MCP tools for interacting with Apple Mail via optimized JXA scripts.
5
5
  Uses batch property fetching for 87x faster performance.
6
+ Includes FTS5 search index for ~100x faster body search.
6
7
  """
7
8
 
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from typing import TypedDict
13
+
8
14
  from fastmcp import FastMCP
9
15
 
10
16
  from .builders import AccountsQueryBuilder, QueryBuilder
11
17
  from .config import get_default_account, get_default_mailbox
12
- from .executor import execute_query, execute_with_core
18
+ from .executor import (
19
+ build_mailbox_setup_js,
20
+ execute_query_async,
21
+ execute_with_core_async,
22
+ )
13
23
 
14
24
  mcp = FastMCP("JXA Mail")
15
25
 
16
26
 
27
+ # ========== Response Type Definitions ==========
28
+ # TypedDict provides explicit typing for API responses, improving
29
+ # code completion, documentation, and type checking.
30
+
31
+
32
+ class Account(TypedDict):
33
+ """An email account in Apple Mail."""
34
+
35
+ name: str
36
+ id: str
37
+
38
+
39
+ class Mailbox(TypedDict):
40
+ """A mailbox within an email account."""
41
+
42
+ name: str
43
+ unreadCount: int
44
+
45
+
46
+ class EmailSummary(TypedDict):
47
+ """Summary of an email (used in list/search results)."""
48
+
49
+ id: int
50
+ subject: str
51
+ sender: str
52
+ date_received: str
53
+ read: bool
54
+ flagged: bool
55
+
56
+
57
+ class FuzzySearchResult(TypedDict, total=False):
58
+ """Result from fuzzy search operations."""
59
+
60
+ id: int
61
+ subject: str
62
+ sender: str
63
+ date_received: str
64
+ read: bool
65
+ flagged: bool
66
+ score: float
67
+ matched_in: str
68
+ matched_text: str
69
+ # Additional fields when searching indexed content
70
+ account: str
71
+ mailbox: str
72
+
73
+
74
+ class EmailFull(TypedDict):
75
+ """Complete email with full content."""
76
+
77
+ id: int
78
+ subject: str
79
+ sender: str
80
+ content: str
81
+ date_received: str
82
+ date_sent: str
83
+ read: bool
84
+ flagged: bool
85
+ reply_to: str
86
+ message_id: str
87
+
88
+
89
+ class IndexStatus(TypedDict, total=False):
90
+ """Status information about the FTS5 search index."""
91
+
92
+ exists: bool
93
+ message: str
94
+ index_path: str
95
+ email_count: int
96
+ mailbox_count: int
97
+ db_size_mb: float
98
+ last_sync: str | None
99
+ staleness_hours: float | None
100
+ is_stale: bool
101
+
102
+
103
+ class OperationResult(TypedDict, total=False):
104
+ """Result from index operations (sync, rebuild)."""
105
+
106
+ success: bool
107
+ message: str
108
+ new_emails: int
109
+ emails_indexed: int
110
+
111
+
112
+ # ========== JXA Script Helpers ==========
113
+
114
+
115
+ def _build_fuzzy_search_script(
116
+ mailbox_setup: str,
117
+ query_js: str,
118
+ threshold: float,
119
+ limit: int,
120
+ include_body: bool = False,
121
+ ) -> str:
122
+ """
123
+ Build a JXA script for fuzzy email search.
124
+
125
+ This helper reduces duplication between fuzzy_search_emails() and
126
+ search_email_bodies() (JXA fallback).
127
+
128
+ Args:
129
+ mailbox_setup: JXA code to set up account/mailbox variables
130
+ query_js: JSON-serialized query string
131
+ threshold: Minimum similarity score
132
+ limit: Maximum results to return
133
+ include_body: Whether to search email body content
134
+
135
+ Returns:
136
+ Complete JXA script string
137
+ """
138
+ # Properties to fetch
139
+ if include_body:
140
+ props = "['id', 'subject', 'sender', 'content', "
141
+ props += "'dateReceived', 'readStatus', 'flaggedStatus']"
142
+ else:
143
+ props = "['id', 'subject', 'sender', "
144
+ props += "'dateReceived', 'readStatus', 'flaggedStatus']"
145
+
146
+ # Content extraction (only if searching body)
147
+ if include_body:
148
+ content_line = "const content = data.content[i] || '';"
149
+ else:
150
+ content_line = ""
151
+
152
+ # Body match logic
153
+ if include_body:
154
+ body_match = (
155
+ "const bodyMatch = MailCore.fuzzyMatchBody(query, content);"
156
+ )
157
+ body_check = """if (bodyMatch && bodyMatch.score > bestScore) {
158
+ bestScore = bodyMatch.score;
159
+ matchedIn = 'body';
160
+ matchedText = bodyMatch.matched;
161
+ }"""
162
+ else:
163
+ body_match = ""
164
+ body_check = ""
165
+
166
+ return f"""
167
+ {mailbox_setup}
168
+ const msgs = mailbox.messages;
169
+ const query = {query_js};
170
+ const threshold = {threshold};
171
+
172
+ // Batch fetch properties
173
+ const data = MailCore.batchFetch(msgs, {props});
174
+
175
+ const results = [];
176
+ const count = data.id.length;
177
+
178
+ for (let i = 0; i < count; i++) {{
179
+ const subject = data.subject[i] || '';
180
+ const sender = data.sender[i] || '';
181
+ {content_line}
182
+
183
+ // Try matching against fields
184
+ const subjectMatch = MailCore.fuzzyMatch(query, subject);
185
+ const senderMatch = MailCore.fuzzyMatch(query, sender);
186
+ {body_match}
187
+
188
+ // Take the best match
189
+ let bestScore = 0;
190
+ let matchedIn = null;
191
+ let matchedText = null;
192
+
193
+ if (subjectMatch && subjectMatch.score > bestScore) {{
194
+ bestScore = subjectMatch.score;
195
+ matchedIn = 'subject';
196
+ matchedText = subjectMatch.matched;
197
+ }}
198
+ if (senderMatch && senderMatch.score > bestScore) {{
199
+ bestScore = senderMatch.score;
200
+ matchedIn = 'sender';
201
+ matchedText = senderMatch.matched;
202
+ }}
203
+ {body_check}
204
+
205
+ if (bestScore >= threshold) {{
206
+ results.push({{
207
+ id: data.id[i],
208
+ subject: subject,
209
+ sender: sender,
210
+ date_received: MailCore.formatDate(data.dateReceived[i]),
211
+ read: data.readStatus[i],
212
+ flagged: data.flaggedStatus[i],
213
+ score: Math.round(bestScore * 100) / 100,
214
+ matched_in: matchedIn,
215
+ matched_text: matchedText
216
+ }});
217
+ }}
218
+ }}
219
+
220
+ // Sort by score descending, then by date
221
+ results.sort((a, b) => {{
222
+ if (b.score !== a.score) return b.score - a.score;
223
+ return new Date(b.date_received) - new Date(a.date_received);
224
+ }});
225
+
226
+ JSON.stringify(results.slice(0, {limit}));
227
+ """
228
+
229
+
230
+ # ========== Helper Functions ==========
231
+
232
+
233
+ def _get_index_manager():
234
+ """Get the IndexManager singleton, lazily imported."""
235
+ from .index import IndexManager
236
+
237
+ return IndexManager.get_instance()
238
+
239
+
17
240
  def _resolve_account(account: str | None) -> str | None:
18
241
  """Resolve account, using default from env if not specified."""
19
242
  return account if account is not None else get_default_account()
@@ -25,7 +248,7 @@ def _resolve_mailbox(mailbox: str | None) -> str:
25
248
 
26
249
 
27
250
  @mcp.tool
28
- def list_accounts() -> list[dict]:
251
+ async def list_accounts() -> list[Account]:
29
252
  """
30
253
  List all configured email accounts in Apple Mail.
31
254
 
@@ -37,11 +260,11 @@ def list_accounts() -> list[dict]:
37
260
  [{"name": "Work", "id": "abc123"}, {"name": "Personal", "id": "def456"}]
38
261
  """
39
262
  script = AccountsQueryBuilder().list_accounts()
40
- return execute_with_core(script)
263
+ return await execute_with_core_async(script)
41
264
 
42
265
 
43
266
  @mcp.tool
44
- def list_mailboxes(account: str | None = None) -> list[dict]:
267
+ async def list_mailboxes(account: str | None = None) -> list[Mailbox]:
45
268
  """
46
269
  List all mailboxes for an email account.
47
270
 
@@ -57,15 +280,15 @@ def list_mailboxes(account: str | None = None) -> list[dict]:
57
280
  [{"name": "INBOX", "unreadCount": 5}, ...]
58
281
  """
59
282
  script = AccountsQueryBuilder().list_mailboxes(_resolve_account(account))
60
- return execute_with_core(script)
283
+ return await execute_with_core_async(script)
61
284
 
62
285
 
63
286
  @mcp.tool
64
- def get_emails(
287
+ async def get_emails(
65
288
  account: str | None = None,
66
289
  mailbox: str | None = None,
67
290
  limit: int = 50,
68
- ) -> list[dict]:
291
+ ) -> list[EmailSummary]:
69
292
  """
70
293
  Get emails from a mailbox.
71
294
 
@@ -93,14 +316,14 @@ def get_emails(
93
316
  .order_by("date_received", descending=True)
94
317
  .limit(limit)
95
318
  )
96
- return execute_query(query)
319
+ return await execute_query_async(query)
97
320
 
98
321
 
99
322
  @mcp.tool
100
- def get_todays_emails(
323
+ async def get_todays_emails(
101
324
  account: str | None = None,
102
325
  mailbox: str | None = None,
103
- ) -> list[dict]:
326
+ ) -> list[EmailSummary]:
104
327
  """
105
328
  Get all emails received today from a mailbox.
106
329
 
@@ -124,15 +347,15 @@ def get_todays_emails(
124
347
  .where("data.dateReceived[i] >= MailCore.today()")
125
348
  .order_by("date_received", descending=True)
126
349
  )
127
- return execute_query(query)
350
+ return await execute_query_async(query)
128
351
 
129
352
 
130
353
  @mcp.tool
131
- def get_unread_emails(
354
+ async def get_unread_emails(
132
355
  account: str | None = None,
133
356
  mailbox: str | None = None,
134
357
  limit: int = 50,
135
- ) -> list[dict]:
358
+ ) -> list[EmailSummary]:
136
359
  """
137
360
  Get unread emails from a mailbox.
138
361
 
@@ -158,15 +381,15 @@ def get_unread_emails(
158
381
  .order_by("date_received", descending=True)
159
382
  .limit(limit)
160
383
  )
161
- return execute_query(query)
384
+ return await execute_query_async(query)
162
385
 
163
386
 
164
387
  @mcp.tool
165
- def get_flagged_emails(
388
+ async def get_flagged_emails(
166
389
  account: str | None = None,
167
390
  mailbox: str | None = None,
168
391
  limit: int = 50,
169
- ) -> list[dict]:
392
+ ) -> list[EmailSummary]:
170
393
  """
171
394
  Get flagged emails from a mailbox.
172
395
 
@@ -192,16 +415,16 @@ def get_flagged_emails(
192
415
  .order_by("date_received", descending=True)
193
416
  .limit(limit)
194
417
  )
195
- return execute_query(query)
418
+ return await execute_query_async(query)
196
419
 
197
420
 
198
421
  @mcp.tool
199
- def search_emails(
422
+ async def search_emails(
200
423
  query: str,
201
424
  account: str | None = None,
202
425
  mailbox: str | None = None,
203
426
  limit: int = 50,
204
- ) -> list[dict]:
427
+ ) -> list[EmailSummary]:
205
428
  """
206
429
  Search for emails matching a query string.
207
430
 
@@ -222,12 +445,12 @@ def search_emails(
222
445
  >>> search_emails("invoice", "Work")
223
446
  [{"subject": "Invoice #123", "sender": "billing@vendor.com", ...}]
224
447
  """
225
- # Escape the query for safe JavaScript string interpolation
226
- safe_query = query.lower().replace("\\", "\\\\").replace("'", "\\'")
448
+ # Use json.dumps for safe JavaScript string serialization
449
+ safe_query_js = json.dumps(query.lower())
227
450
 
228
451
  filter_expr = f"""(
229
- (data.subject[i] || '').toLowerCase().includes('{safe_query}') ||
230
- (data.sender[i] || '').toLowerCase().includes('{safe_query}')
452
+ (data.subject[i] || '').toLowerCase().includes({safe_query_js}) ||
453
+ (data.sender[i] || '').toLowerCase().includes({safe_query_js})
231
454
  )"""
232
455
 
233
456
  q = (
@@ -238,17 +461,17 @@ def search_emails(
238
461
  .order_by("date_received", descending=True)
239
462
  .limit(limit)
240
463
  )
241
- return execute_query(q)
464
+ return await execute_query_async(q)
242
465
 
243
466
 
244
467
  @mcp.tool
245
- def fuzzy_search_emails(
468
+ async def fuzzy_search_emails(
246
469
  query: str,
247
470
  account: str | None = None,
248
471
  mailbox: str | None = None,
249
472
  limit: int = 20,
250
473
  threshold: float = 0.3,
251
- ) -> list[dict]:
474
+ ) -> list[FuzzySearchResult]:
252
475
  """
253
476
  Fuzzy search for emails using trigram + Levenshtein matching.
254
477
 
@@ -271,101 +494,35 @@ def fuzzy_search_emails(
271
494
  >>> fuzzy_search_emails("joob descrption") # typos OK
272
495
  [{"subject": "Job Description", "score": 0.85, ...}, ...]
273
496
  """
274
- safe_query = query.replace("\\", "\\\\").replace("'", "\\'")
275
497
  resolved_account = _resolve_account(account)
276
498
  resolved_mailbox = _resolve_mailbox(mailbox)
277
499
 
278
- # Build account reference
279
- if resolved_account:
280
- safe_account = resolved_account.replace("'", "\\'")
281
- account_js = f"MailCore.getAccount('{safe_account}')"
282
- else:
283
- account_js = "MailCore.getAccount(null)"
284
-
285
- safe_mailbox = resolved_mailbox.replace("'", "\\'")
286
-
287
- script = f"""
288
- const account = {account_js};
289
- const mailbox = MailCore.getMailbox(account, '{safe_mailbox}');
290
- const msgs = mailbox.messages;
291
- const query = '{safe_query}';
292
- const threshold = {threshold};
293
-
294
- // Batch fetch properties
295
- const data = MailCore.batchFetch(msgs, [
296
- 'id', 'subject', 'sender', 'dateReceived', 'readStatus', 'flaggedStatus'
297
- ]);
298
-
299
- const results = [];
300
- const count = data.id.length;
301
-
302
- for (let i = 0; i < count; i++) {{
303
- const subject = data.subject[i] || '';
304
- const sender = data.sender[i] || '';
305
-
306
- // Try matching against subject and sender
307
- const subjectMatch = MailCore.fuzzyMatch(query, subject);
308
- const senderMatch = MailCore.fuzzyMatch(query, sender);
309
-
310
- // Take the best match
311
- let bestScore = 0;
312
- let matchedIn = null;
313
- let matchedText = null;
314
-
315
- if (subjectMatch && subjectMatch.score > bestScore) {{
316
- bestScore = subjectMatch.score;
317
- matchedIn = 'subject';
318
- matchedText = subjectMatch.matched;
319
- }}
320
- if (senderMatch && senderMatch.score > bestScore) {{
321
- bestScore = senderMatch.score;
322
- matchedIn = 'sender';
323
- matchedText = senderMatch.matched;
324
- }}
325
-
326
- if (bestScore >= threshold) {{
327
- results.push({{
328
- id: data.id[i],
329
- subject: subject,
330
- sender: sender,
331
- date_received: MailCore.formatDate(data.dateReceived[i]),
332
- read: data.readStatus[i],
333
- flagged: data.flaggedStatus[i],
334
- score: Math.round(bestScore * 100) / 100,
335
- matched_in: matchedIn,
336
- matched_text: matchedText
337
- }});
338
- }}
339
- }}
340
-
341
- // Sort by score descending, then by date
342
- results.sort((a, b) => {{
343
- if (b.score !== a.score) return b.score - a.score;
344
- return new Date(b.date_received) - new Date(a.date_received);
345
- }});
346
-
347
- JSON.stringify(results.slice(0, {limit}));
348
- """
349
- return execute_with_core(script)
500
+ mailbox_setup = build_mailbox_setup_js(resolved_account, resolved_mailbox)
501
+ script = _build_fuzzy_search_script(
502
+ mailbox_setup=mailbox_setup,
503
+ query_js=json.dumps(query),
504
+ threshold=threshold,
505
+ limit=limit,
506
+ include_body=False,
507
+ )
508
+ return await execute_with_core_async(script)
350
509
 
351
510
 
352
511
  @mcp.tool
353
- def search_email_bodies(
512
+ async def search_email_bodies(
354
513
  query: str,
355
514
  account: str | None = None,
356
515
  mailbox: str | None = None,
357
516
  limit: int = 20,
358
517
  threshold: float = 0.3,
359
- ) -> list[dict]:
518
+ use_index: bool = True,
519
+ ) -> list[FuzzySearchResult]:
360
520
  """
361
521
  Search within email body content using fuzzy matching.
362
522
 
363
523
  Searches the full text content of emails, not just metadata.
364
- Uses a fast two-tier approach: exact substring matching first,
365
- then trigram similarity for typo tolerance.
366
-
367
- Note: Slower than metadata-only search due to fetching email bodies.
368
- Use search_emails() or fuzzy_search_emails() for faster metadata search.
524
+ When the FTS5 index is available (built via 'jxa-mail-mcp index'),
525
+ searches are ~100x faster (~50ms vs ~7s).
369
526
 
370
527
  Args:
371
528
  query: Search term to find in email bodies
@@ -375,109 +532,65 @@ def search_email_bodies(
375
532
  "Inbox" if not specified.
376
533
  limit: Maximum number of results (default: 20)
377
534
  threshold: Minimum similarity score 0-1 (default: 0.3)
535
+ use_index: Use FTS5 index if available (default: True)
378
536
 
379
537
  Returns:
380
538
  List of matching emails with scores, sorted by relevance.
381
539
  Includes 'matched_in' field indicating where match was found
382
- (subject, sender, or body) and 'matched_text' showing the match.
540
+ and 'matched_text'/'content_snippet' showing context.
383
541
 
384
542
  Example:
385
543
  >>> search_email_bodies("project deadline")
386
544
  [{"subject": "Re: Updates", "score": 0.95, "matched_in": "body",
387
545
  "matched_text": "...project deadline is...", ...}]
388
546
  """
389
- safe_query = query.replace("\\", "\\\\").replace("'", "\\'")
390
547
  resolved_account = _resolve_account(account)
391
548
  resolved_mailbox = _resolve_mailbox(mailbox)
392
549
 
393
- # Build account reference
394
- if resolved_account:
395
- safe_account = resolved_account.replace("'", "\\'")
396
- account_js = f"MailCore.getAccount('{safe_account}')"
397
- else:
398
- account_js = "MailCore.getAccount(null)"
399
-
400
- safe_mailbox = resolved_mailbox.replace("'", "\\'")
401
-
402
- script = f"""
403
- const account = {account_js};
404
- const mailbox = MailCore.getMailbox(account, '{safe_mailbox}');
405
- const msgs = mailbox.messages;
406
- const query = '{safe_query}';
407
- const threshold = {threshold};
408
-
409
- // Batch fetch properties INCLUDING content
410
- const data = MailCore.batchFetch(msgs, [
411
- 'id', 'subject', 'sender', 'content',
412
- 'dateReceived', 'readStatus', 'flaggedStatus'
413
- ]);
414
-
415
- const results = [];
416
- const count = data.id.length;
417
-
418
- for (let i = 0; i < count; i++) {{
419
- const subject = data.subject[i] || '';
420
- const sender = data.sender[i] || '';
421
- const content = data.content[i] || '';
422
-
423
- // Try matching against subject, sender, and body
424
- const subjectMatch = MailCore.fuzzyMatch(query, subject);
425
- const senderMatch = MailCore.fuzzyMatch(query, sender);
426
- const bodyMatch = MailCore.fuzzyMatchBody(query, content);
427
-
428
- // Take the best match across all fields
429
- let bestScore = 0;
430
- let matchedIn = null;
431
- let matchedText = null;
432
-
433
- if (subjectMatch && subjectMatch.score > bestScore) {{
434
- bestScore = subjectMatch.score;
435
- matchedIn = 'subject';
436
- matchedText = subjectMatch.matched;
437
- }}
438
- if (senderMatch && senderMatch.score > bestScore) {{
439
- bestScore = senderMatch.score;
440
- matchedIn = 'sender';
441
- matchedText = senderMatch.matched;
442
- }}
443
- if (bodyMatch && bodyMatch.score > bestScore) {{
444
- bestScore = bodyMatch.score;
445
- matchedIn = 'body';
446
- matchedText = bodyMatch.matched;
447
- }}
448
-
449
- if (bestScore >= threshold) {{
450
- results.push({{
451
- id: data.id[i],
452
- subject: subject,
453
- sender: sender,
454
- date_received: MailCore.formatDate(data.dateReceived[i]),
455
- read: data.readStatus[i],
456
- flagged: data.flaggedStatus[i],
457
- score: Math.round(bestScore * 100) / 100,
458
- matched_in: matchedIn,
459
- matched_text: matchedText
460
- }});
461
- }}
462
- }}
463
-
464
- // Sort by score descending, then by date
465
- results.sort((a, b) => {{
466
- if (b.score !== a.score) return b.score - a.score;
467
- return new Date(b.date_received) - new Date(a.date_received);
468
- }});
469
-
470
- JSON.stringify(results.slice(0, {limit}));
471
- """
472
- return execute_with_core(script)
550
+ # Try using the FTS5 index for fast search
551
+ if use_index:
552
+ manager = _get_index_manager()
553
+ if manager.has_index():
554
+ results = manager.search(
555
+ query,
556
+ account=resolved_account,
557
+ mailbox=resolved_mailbox if mailbox else None,
558
+ limit=limit,
559
+ )
560
+ # Convert SearchResult objects to dicts matching JXA output format
561
+ return [
562
+ {
563
+ "id": r.id,
564
+ "subject": r.subject,
565
+ "sender": r.sender,
566
+ "date_received": r.date_received,
567
+ "score": r.score,
568
+ "matched_in": "body",
569
+ "matched_text": r.content_snippet,
570
+ "account": r.account,
571
+ "mailbox": r.mailbox,
572
+ }
573
+ for r in results
574
+ ]
575
+
576
+ # Fallback to JXA-based search (slower)
577
+ mailbox_setup = build_mailbox_setup_js(resolved_account, resolved_mailbox)
578
+ script = _build_fuzzy_search_script(
579
+ mailbox_setup=mailbox_setup,
580
+ query_js=json.dumps(query),
581
+ threshold=threshold,
582
+ limit=limit,
583
+ include_body=True,
584
+ )
585
+ return await execute_with_core_async(script)
473
586
 
474
587
 
475
588
  @mcp.tool
476
- def get_email(
589
+ async def get_email(
477
590
  message_id: int,
478
591
  account: str | None = None,
479
592
  mailbox: str | None = None,
480
- ) -> dict:
593
+ ) -> EmailFull:
481
594
  """
482
595
  Get a single email with full content.
483
596
 
@@ -504,22 +617,15 @@ def get_email(
504
617
  resolved_account = _resolve_account(account)
505
618
  resolved_mailbox = _resolve_mailbox(mailbox)
506
619
 
507
- # Build account reference
508
- if resolved_account:
509
- safe_account = resolved_account.replace("'", "\\'")
510
- account_js = f"MailCore.getAccount('{safe_account}')"
511
- else:
512
- account_js = "MailCore.getAccount(null)"
513
-
514
- safe_mailbox = resolved_mailbox.replace("'", "\\'")
620
+ # Use json.dumps for safe serialization
621
+ mailbox_setup = build_mailbox_setup_js(resolved_account, resolved_mailbox)
515
622
 
516
623
  script = f"""
517
624
  const targetId = {message_id};
518
625
 
519
626
  // Try to find the message in the specified mailbox first
520
627
  let msg = null;
521
- const account = {account_js};
522
- const mailbox = MailCore.getMailbox(account, '{safe_mailbox}');
628
+ {mailbox_setup}
523
629
 
524
630
  // Search in specified mailbox
525
631
  const ids = mailbox.messages.id();
@@ -558,7 +664,150 @@ JSON.stringify({{
558
664
  message_id: msg.messageId()
559
665
  }});
560
666
  """
561
- return execute_with_core(script)
667
+ return await execute_with_core_async(script)
668
+
669
+
670
+ @mcp.tool
671
+ def index_status() -> IndexStatus:
672
+ """
673
+ Get the status of the FTS5 search index.
674
+
675
+ Returns information about the email search index including:
676
+ - Whether an index exists
677
+ - Number of indexed emails and mailboxes
678
+ - Database file size
679
+ - Last sync time and staleness
680
+
681
+ Use this to check if the index needs rebuilding.
682
+
683
+ Returns:
684
+ Dictionary with index statistics or status message if no index.
685
+
686
+ Example:
687
+ >>> index_status()
688
+ {"exists": true, "email_count": 5432, "mailbox_count": 15,
689
+ "db_size_mb": 45.2, "last_sync": "2024-01-15T10:30:00", ...}
690
+ """
691
+ manager = _get_index_manager()
692
+
693
+ if not manager.has_index():
694
+ return {
695
+ "exists": False,
696
+ "message": "No index found. Run 'jxa-mail-mcp index' to build.",
697
+ "index_path": str(manager.db_path),
698
+ }
699
+
700
+ stats = manager.get_stats()
701
+
702
+ staleness = None
703
+ if stats.staleness_hours is not None:
704
+ staleness = round(stats.staleness_hours, 1)
705
+
706
+ return {
707
+ "exists": True,
708
+ "email_count": stats.email_count,
709
+ "mailbox_count": stats.mailbox_count,
710
+ "db_size_mb": round(stats.db_size_mb, 2),
711
+ "last_sync": stats.last_sync.isoformat() if stats.last_sync else None,
712
+ "staleness_hours": staleness,
713
+ "is_stale": manager.is_stale(),
714
+ "index_path": str(manager.db_path),
715
+ }
716
+
717
+
718
+ @mcp.tool
719
+ def sync_index() -> OperationResult:
720
+ """
721
+ Sync the search index with new emails.
722
+
723
+ Fetches emails that arrived since the last sync via JXA
724
+ and adds them to the FTS5 index. This is faster than a full
725
+ rebuild but requires an existing index.
726
+
727
+ Note: For initial indexing, use 'jxa-mail-mcp index' CLI command
728
+ which reads directly from disk (much faster).
729
+
730
+ Returns:
731
+ Dictionary with sync results.
732
+
733
+ Example:
734
+ >>> sync_index()
735
+ {"success": true, "new_emails": 23, "message": "Synced 23 new emails"}
736
+ """
737
+ manager = _get_index_manager()
738
+
739
+ if not manager.has_index():
740
+ return {
741
+ "success": False,
742
+ "message": "No index. Run 'jxa-mail-mcp index' to build.",
743
+ }
744
+
745
+ try:
746
+ count = manager.sync_updates()
747
+ msg = f"Synced {count} new emails" if count else "Index up to date"
748
+ return {
749
+ "success": True,
750
+ "new_emails": count,
751
+ "message": msg,
752
+ }
753
+ except Exception as e:
754
+ return {
755
+ "success": False,
756
+ "message": f"Sync failed: {e}",
757
+ }
758
+
759
+
760
+ @mcp.tool
761
+ def rebuild_index(
762
+ account: str | None = None,
763
+ mailbox: str | None = None,
764
+ ) -> OperationResult:
765
+ """
766
+ Rebuild the search index from disk.
767
+
768
+ Forces a complete rebuild of the FTS5 index by reading .emlx files
769
+ directly from ~/Library/Mail/. This requires Full Disk Access.
770
+
771
+ For normal use, prefer 'sync_index()' which only fetches new emails.
772
+
773
+ Args:
774
+ account: Optional - only rebuild this account (all if not specified)
775
+ mailbox: Optional - only rebuild this mailbox (requires account)
776
+
777
+ Returns:
778
+ Dictionary with rebuild results.
779
+
780
+ Example:
781
+ >>> rebuild_index()
782
+ {"success": true, "emails_indexed": 5432, "message": "Rebuilt index..."}
783
+ """
784
+ manager = _get_index_manager()
785
+
786
+ try:
787
+ count = manager.rebuild(account=account, mailbox=mailbox)
788
+ return {
789
+ "success": True,
790
+ "emails_indexed": count,
791
+ "message": f"Rebuilt index with {count} emails",
792
+ }
793
+ except PermissionError as e:
794
+ return {
795
+ "success": False,
796
+ "message": (
797
+ f"Permission denied: {e}\n"
798
+ "Grant Full Disk Access to the MCP server process."
799
+ ),
800
+ }
801
+ except FileNotFoundError as e:
802
+ return {
803
+ "success": False,
804
+ "message": f"Mail directory not found: {e}",
805
+ }
806
+ except Exception as e:
807
+ return {
808
+ "success": False,
809
+ "message": f"Rebuild failed: {e}",
810
+ }
562
811
 
563
812
 
564
813
  if __name__ == "__main__":