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/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
|
|
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[
|
|
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
|
|
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[
|
|
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
|
|
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[
|
|
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
|
|
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[
|
|
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
|
|
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[
|
|
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
|
|
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[
|
|
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
|
|
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[
|
|
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
|
-
#
|
|
226
|
-
|
|
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(
|
|
230
|
-
(data.sender[i] || '').toLowerCase().includes(
|
|
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
|
|
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[
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
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
|
-
|
|
365
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
394
|
-
if
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
) ->
|
|
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
|
-
#
|
|
508
|
-
|
|
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
|
-
|
|
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
|
|
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__":
|