jxa-mail-mcp 0.1.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,82 +494,320 @@ 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)"
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)
284
509
 
285
- safe_mailbox = resolved_mailbox.replace("'", "\\'")
286
510
 
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};
511
+ @mcp.tool
512
+ async def search_email_bodies(
513
+ query: str,
514
+ account: str | None = None,
515
+ mailbox: str | None = None,
516
+ limit: int = 20,
517
+ threshold: float = 0.3,
518
+ use_index: bool = True,
519
+ ) -> list[FuzzySearchResult]:
520
+ """
521
+ Search within email body content using fuzzy matching.
293
522
 
294
- // Batch fetch properties
295
- const data = MailCore.batchFetch(msgs, [
296
- 'id', 'subject', 'sender', 'dateReceived', 'readStatus', 'flaggedStatus'
297
- ]);
523
+ Searches the full text content of emails, not just metadata.
524
+ When the FTS5 index is available (built via 'jxa-mail-mcp index'),
525
+ searches are ~100x faster (~50ms vs ~7s).
298
526
 
299
- const results = [];
300
- const count = data.id.length;
527
+ Args:
528
+ query: Search term to find in email bodies
529
+ account: Account name. Uses JXA_MAIL_DEFAULT_ACCOUNT env var or
530
+ first account if not specified.
531
+ mailbox: Mailbox name. Uses JXA_MAIL_DEFAULT_MAILBOX env var or
532
+ "Inbox" if not specified.
533
+ limit: Maximum number of results (default: 20)
534
+ threshold: Minimum similarity score 0-1 (default: 0.3)
535
+ use_index: Use FTS5 index if available (default: True)
301
536
 
302
- for (let i = 0; i < count; i++) {{
303
- const subject = data.subject[i] || '';
304
- const sender = data.sender[i] || '';
537
+ Returns:
538
+ List of matching emails with scores, sorted by relevance.
539
+ Includes 'matched_in' field indicating where match was found
540
+ and 'matched_text'/'content_snippet' showing context.
305
541
 
306
- // Try matching against subject and sender
307
- const subjectMatch = MailCore.fuzzyMatch(query, subject);
308
- const senderMatch = MailCore.fuzzyMatch(query, sender);
542
+ Example:
543
+ >>> search_email_bodies("project deadline")
544
+ [{"subject": "Re: Updates", "score": 0.95, "matched_in": "body",
545
+ "matched_text": "...project deadline is...", ...}]
546
+ """
547
+ resolved_account = _resolve_account(account)
548
+ resolved_mailbox = _resolve_mailbox(mailbox)
309
549
 
310
- // Take the best match
311
- let bestScore = 0;
312
- let matchedIn = null;
313
- let matchedText = null;
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)
314
586
 
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
587
 
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
- }});
588
+ @mcp.tool
589
+ async def get_email(
590
+ message_id: int,
591
+ account: str | None = None,
592
+ mailbox: str | None = None,
593
+ ) -> EmailFull:
594
+ """
595
+ Get a single email with full content.
596
+
597
+ Retrieves complete email details including the full body text.
598
+ Use this after finding an email via search to read its content.
599
+
600
+ Args:
601
+ message_id: The email's unique ID (from search results)
602
+ account: Account name (optional, helps find message faster)
603
+ mailbox: Mailbox name (optional, helps find message faster)
604
+
605
+ Returns:
606
+ Email dictionary with full content including:
607
+ - id, subject, sender, date_received, date_sent
608
+ - content: Full plain text body
609
+ - read, flagged status
610
+ - reply_to, message_id (email Message-ID header)
611
+
612
+ Example:
613
+ >>> get_email(12345)
614
+ {"id": 12345, "subject": "Meeting notes",
615
+ "content": "Hi team,\\n\\nHere are the notes...", ...}
616
+ """
617
+ resolved_account = _resolve_account(account)
618
+ resolved_mailbox = _resolve_mailbox(mailbox)
619
+
620
+ # Use json.dumps for safe serialization
621
+ mailbox_setup = build_mailbox_setup_js(resolved_account, resolved_mailbox)
622
+
623
+ script = f"""
624
+ const targetId = {message_id};
625
+
626
+ // Try to find the message in the specified mailbox first
627
+ let msg = null;
628
+ {mailbox_setup}
629
+
630
+ // Search in specified mailbox
631
+ const ids = mailbox.messages.id();
632
+ const idx = ids.indexOf(targetId);
633
+ if (idx !== -1) {{
634
+ msg = mailbox.messages[idx];
635
+ }}
636
+
637
+ // If not found, search all mailboxes in the account
638
+ if (!msg) {{
639
+ const allMailboxes = account.mailboxes();
640
+ for (let i = 0; i < allMailboxes.length && !msg; i++) {{
641
+ const mb = allMailboxes[i];
642
+ const mbIds = mb.messages.id();
643
+ const mbIdx = mbIds.indexOf(targetId);
644
+ if (mbIdx !== -1) {{
645
+ msg = mb.messages[mbIdx];
646
+ }}
338
647
  }}
339
648
  }}
340
649
 
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
- }});
650
+ if (!msg) {{
651
+ throw new Error('Message not found with ID: ' + targetId);
652
+ }}
346
653
 
347
- JSON.stringify(results.slice(0, {limit}));
654
+ JSON.stringify({{
655
+ id: msg.id(),
656
+ subject: msg.subject(),
657
+ sender: msg.sender(),
658
+ content: msg.content(),
659
+ date_received: MailCore.formatDate(msg.dateReceived()),
660
+ date_sent: MailCore.formatDate(msg.dateSent()),
661
+ read: msg.readStatus(),
662
+ flagged: msg.flaggedStatus(),
663
+ reply_to: msg.replyTo(),
664
+ message_id: msg.messageId()
665
+ }});
348
666
  """
349
- 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
+ }
350
811
 
351
812
 
352
813
  if __name__ == "__main__":