jxa-mail-mcp 0.1.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 +10 -0
- jxa_mail_mcp/builders.py +243 -0
- jxa_mail_mcp/config.py +29 -0
- jxa_mail_mcp/executor.py +84 -0
- jxa_mail_mcp/jxa/__init__.py +8 -0
- jxa_mail_mcp/jxa/mail_core.js +294 -0
- jxa_mail_mcp/server.py +353 -0
- jxa_mail_mcp-0.1.0.dist-info/METADATA +223 -0
- jxa_mail_mcp-0.1.0.dist-info/RECORD +12 -0
- jxa_mail_mcp-0.1.0.dist-info/WHEEL +4 -0
- jxa_mail_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- jxa_mail_mcp-0.1.0.dist-info/licenses/LICENSE +674 -0
jxa_mail_mcp/server.py
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
"""
|
|
2
|
+
JXA Mail MCP Server
|
|
3
|
+
|
|
4
|
+
Provides MCP tools for interacting with Apple Mail via optimized JXA scripts.
|
|
5
|
+
Uses batch property fetching for 87x faster performance.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from fastmcp import FastMCP
|
|
9
|
+
|
|
10
|
+
from .builders import AccountsQueryBuilder, QueryBuilder
|
|
11
|
+
from .config import get_default_account, get_default_mailbox
|
|
12
|
+
from .executor import execute_query, execute_with_core
|
|
13
|
+
|
|
14
|
+
mcp = FastMCP("JXA Mail")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _resolve_account(account: str | None) -> str | None:
|
|
18
|
+
"""Resolve account, using default from env if not specified."""
|
|
19
|
+
return account if account is not None else get_default_account()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _resolve_mailbox(mailbox: str | None) -> str:
|
|
23
|
+
"""Resolve mailbox, using default from env if not specified."""
|
|
24
|
+
return mailbox if mailbox is not None else get_default_mailbox()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@mcp.tool
|
|
28
|
+
def list_accounts() -> list[dict]:
|
|
29
|
+
"""
|
|
30
|
+
List all configured email accounts in Apple Mail.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
List of account dictionaries with 'name' and 'id' fields.
|
|
34
|
+
|
|
35
|
+
Example:
|
|
36
|
+
>>> list_accounts()
|
|
37
|
+
[{"name": "Work", "id": "abc123"}, {"name": "Personal", "id": "def456"}]
|
|
38
|
+
"""
|
|
39
|
+
script = AccountsQueryBuilder().list_accounts()
|
|
40
|
+
return execute_with_core(script)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@mcp.tool
|
|
44
|
+
def list_mailboxes(account: str | None = None) -> list[dict]:
|
|
45
|
+
"""
|
|
46
|
+
List all mailboxes for an email account.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
account: Account name. Uses JXA_MAIL_DEFAULT_ACCOUNT env var or
|
|
50
|
+
first account if not specified.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
List of mailbox dictionaries with 'name' and 'unreadCount' fields.
|
|
54
|
+
|
|
55
|
+
Example:
|
|
56
|
+
>>> list_mailboxes("Work")
|
|
57
|
+
[{"name": "INBOX", "unreadCount": 5}, ...]
|
|
58
|
+
"""
|
|
59
|
+
script = AccountsQueryBuilder().list_mailboxes(_resolve_account(account))
|
|
60
|
+
return execute_with_core(script)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@mcp.tool
|
|
64
|
+
def get_emails(
|
|
65
|
+
account: str | None = None,
|
|
66
|
+
mailbox: str | None = None,
|
|
67
|
+
limit: int = 50,
|
|
68
|
+
) -> list[dict]:
|
|
69
|
+
"""
|
|
70
|
+
Get emails from a mailbox.
|
|
71
|
+
|
|
72
|
+
Retrieves emails with standard properties: id, subject, sender,
|
|
73
|
+
date_received, read status, and flagged status.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
account: Account name. Uses JXA_MAIL_DEFAULT_ACCOUNT env var or
|
|
77
|
+
first account if not specified.
|
|
78
|
+
mailbox: Mailbox name. Uses JXA_MAIL_DEFAULT_MAILBOX env var or
|
|
79
|
+
"Inbox" if not specified.
|
|
80
|
+
limit: Maximum number of emails to return (default: 50)
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
List of email dictionaries sorted by date (newest first).
|
|
84
|
+
|
|
85
|
+
Example:
|
|
86
|
+
>>> get_emails("Work", "INBOX", limit=10)
|
|
87
|
+
[{"subject": "Meeting tomorrow", "sender": "boss@work.com", ...}, ...]
|
|
88
|
+
"""
|
|
89
|
+
query = (
|
|
90
|
+
QueryBuilder()
|
|
91
|
+
.from_mailbox(_resolve_account(account), _resolve_mailbox(mailbox))
|
|
92
|
+
.select("standard")
|
|
93
|
+
.order_by("date_received", descending=True)
|
|
94
|
+
.limit(limit)
|
|
95
|
+
)
|
|
96
|
+
return execute_query(query)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@mcp.tool
|
|
100
|
+
def get_todays_emails(
|
|
101
|
+
account: str | None = None,
|
|
102
|
+
mailbox: str | None = None,
|
|
103
|
+
) -> list[dict]:
|
|
104
|
+
"""
|
|
105
|
+
Get all emails received today from a mailbox.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
account: Account name. Uses JXA_MAIL_DEFAULT_ACCOUNT env var or
|
|
109
|
+
first account if not specified.
|
|
110
|
+
mailbox: Mailbox name. Uses JXA_MAIL_DEFAULT_MAILBOX env var or
|
|
111
|
+
"Inbox" if not specified.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
List of today's emails sorted by date (newest first).
|
|
115
|
+
|
|
116
|
+
Example:
|
|
117
|
+
>>> get_todays_emails("Work")
|
|
118
|
+
[{"subject": "Urgent: Review needed", "sender": "team@work.com", ...}]
|
|
119
|
+
"""
|
|
120
|
+
query = (
|
|
121
|
+
QueryBuilder()
|
|
122
|
+
.from_mailbox(_resolve_account(account), _resolve_mailbox(mailbox))
|
|
123
|
+
.select("standard")
|
|
124
|
+
.where("data.dateReceived[i] >= MailCore.today()")
|
|
125
|
+
.order_by("date_received", descending=True)
|
|
126
|
+
)
|
|
127
|
+
return execute_query(query)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@mcp.tool
|
|
131
|
+
def get_unread_emails(
|
|
132
|
+
account: str | None = None,
|
|
133
|
+
mailbox: str | None = None,
|
|
134
|
+
limit: int = 50,
|
|
135
|
+
) -> list[dict]:
|
|
136
|
+
"""
|
|
137
|
+
Get unread emails from a mailbox.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
account: Account name. Uses JXA_MAIL_DEFAULT_ACCOUNT env var or
|
|
141
|
+
first account if not specified.
|
|
142
|
+
mailbox: Mailbox name. Uses JXA_MAIL_DEFAULT_MAILBOX env var or
|
|
143
|
+
"Inbox" if not specified.
|
|
144
|
+
limit: Maximum number of emails to return (default: 50)
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
List of unread emails sorted by date (newest first).
|
|
148
|
+
|
|
149
|
+
Example:
|
|
150
|
+
>>> get_unread_emails("Work", limit=20)
|
|
151
|
+
[{"subject": "New message", "read": false, ...}, ...]
|
|
152
|
+
"""
|
|
153
|
+
query = (
|
|
154
|
+
QueryBuilder()
|
|
155
|
+
.from_mailbox(_resolve_account(account), _resolve_mailbox(mailbox))
|
|
156
|
+
.select("standard")
|
|
157
|
+
.where("data.readStatus[i] === false")
|
|
158
|
+
.order_by("date_received", descending=True)
|
|
159
|
+
.limit(limit)
|
|
160
|
+
)
|
|
161
|
+
return execute_query(query)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@mcp.tool
|
|
165
|
+
def get_flagged_emails(
|
|
166
|
+
account: str | None = None,
|
|
167
|
+
mailbox: str | None = None,
|
|
168
|
+
limit: int = 50,
|
|
169
|
+
) -> list[dict]:
|
|
170
|
+
"""
|
|
171
|
+
Get flagged emails from a mailbox.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
account: Account name. Uses JXA_MAIL_DEFAULT_ACCOUNT env var or
|
|
175
|
+
first account if not specified.
|
|
176
|
+
mailbox: Mailbox name. Uses JXA_MAIL_DEFAULT_MAILBOX env var or
|
|
177
|
+
"Inbox" if not specified.
|
|
178
|
+
limit: Maximum number of emails to return (default: 50)
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
List of flagged emails sorted by date (newest first).
|
|
182
|
+
|
|
183
|
+
Example:
|
|
184
|
+
>>> get_flagged_emails("Work")
|
|
185
|
+
[{"subject": "Important task", "flagged": true, ...}, ...]
|
|
186
|
+
"""
|
|
187
|
+
query = (
|
|
188
|
+
QueryBuilder()
|
|
189
|
+
.from_mailbox(_resolve_account(account), _resolve_mailbox(mailbox))
|
|
190
|
+
.select("standard")
|
|
191
|
+
.where("data.flaggedStatus[i] === true")
|
|
192
|
+
.order_by("date_received", descending=True)
|
|
193
|
+
.limit(limit)
|
|
194
|
+
)
|
|
195
|
+
return execute_query(query)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@mcp.tool
|
|
199
|
+
def search_emails(
|
|
200
|
+
query: str,
|
|
201
|
+
account: str | None = None,
|
|
202
|
+
mailbox: str | None = None,
|
|
203
|
+
limit: int = 50,
|
|
204
|
+
) -> list[dict]:
|
|
205
|
+
"""
|
|
206
|
+
Search for emails matching a query string.
|
|
207
|
+
|
|
208
|
+
Searches in both subject and sender fields (case-insensitive).
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
query: Search term to look for
|
|
212
|
+
account: Account name. Uses JXA_MAIL_DEFAULT_ACCOUNT env var or
|
|
213
|
+
first account if not specified.
|
|
214
|
+
mailbox: Mailbox name. Uses JXA_MAIL_DEFAULT_MAILBOX env var or
|
|
215
|
+
"Inbox" if not specified.
|
|
216
|
+
limit: Maximum number of results (default: 50)
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
List of matching emails sorted by date (newest first).
|
|
220
|
+
|
|
221
|
+
Example:
|
|
222
|
+
>>> search_emails("invoice", "Work")
|
|
223
|
+
[{"subject": "Invoice #123", "sender": "billing@vendor.com", ...}]
|
|
224
|
+
"""
|
|
225
|
+
# Escape the query for safe JavaScript string interpolation
|
|
226
|
+
safe_query = query.lower().replace("\\", "\\\\").replace("'", "\\'")
|
|
227
|
+
|
|
228
|
+
filter_expr = f"""(
|
|
229
|
+
(data.subject[i] || '').toLowerCase().includes('{safe_query}') ||
|
|
230
|
+
(data.sender[i] || '').toLowerCase().includes('{safe_query}')
|
|
231
|
+
)"""
|
|
232
|
+
|
|
233
|
+
q = (
|
|
234
|
+
QueryBuilder()
|
|
235
|
+
.from_mailbox(_resolve_account(account), _resolve_mailbox(mailbox))
|
|
236
|
+
.select("standard")
|
|
237
|
+
.where(filter_expr)
|
|
238
|
+
.order_by("date_received", descending=True)
|
|
239
|
+
.limit(limit)
|
|
240
|
+
)
|
|
241
|
+
return execute_query(q)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@mcp.tool
|
|
245
|
+
def fuzzy_search_emails(
|
|
246
|
+
query: str,
|
|
247
|
+
account: str | None = None,
|
|
248
|
+
mailbox: str | None = None,
|
|
249
|
+
limit: int = 20,
|
|
250
|
+
threshold: float = 0.3,
|
|
251
|
+
) -> list[dict]:
|
|
252
|
+
"""
|
|
253
|
+
Fuzzy search for emails using trigram + Levenshtein matching.
|
|
254
|
+
|
|
255
|
+
Finds emails even with typos or partial matches. Uses trigrams for
|
|
256
|
+
fast candidate selection and Levenshtein distance for accurate ranking.
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
query: Search term (fuzzy matched against subject and sender)
|
|
260
|
+
account: Account name. Uses JXA_MAIL_DEFAULT_ACCOUNT env var or
|
|
261
|
+
first account if not specified.
|
|
262
|
+
mailbox: Mailbox name. Uses JXA_MAIL_DEFAULT_MAILBOX env var or
|
|
263
|
+
"Inbox" if not specified.
|
|
264
|
+
limit: Maximum number of results (default: 20)
|
|
265
|
+
threshold: Minimum similarity score 0-1 (default: 0.3)
|
|
266
|
+
|
|
267
|
+
Returns:
|
|
268
|
+
List of matching emails with similarity scores, sorted by score.
|
|
269
|
+
|
|
270
|
+
Example:
|
|
271
|
+
>>> fuzzy_search_emails("joob descrption") # typos OK
|
|
272
|
+
[{"subject": "Job Description", "score": 0.85, ...}, ...]
|
|
273
|
+
"""
|
|
274
|
+
safe_query = query.replace("\\", "\\\\").replace("'", "\\'")
|
|
275
|
+
resolved_account = _resolve_account(account)
|
|
276
|
+
resolved_mailbox = _resolve_mailbox(mailbox)
|
|
277
|
+
|
|
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)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
if __name__ == "__main__":
|
|
353
|
+
mcp.run()
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: jxa-mail-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Fast MCP server for Apple Mail via optimized JXA scripts
|
|
5
|
+
Project-URL: Homepage, https://github.com/imdinu/jxa-mail-mcp
|
|
6
|
+
Project-URL: Repository, https://github.com/imdinu/jxa-mail-mcp
|
|
7
|
+
Project-URL: Issues, https://github.com/imdinu/jxa-mail-mcp/issues
|
|
8
|
+
Author-email: Ioan-Mihail Dinu <iodinu@icloud.com>
|
|
9
|
+
License-Expression: GPL-3.0-or-later
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: apple-mail,automation,email,jxa,macos,mcp,model-context-protocol
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: MacOS X
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)
|
|
16
|
+
Classifier: Operating System :: MacOS
|
|
17
|
+
Classifier: Programming Language :: JavaScript
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Communications :: Email
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
|
+
Requires-Python: >=3.13
|
|
23
|
+
Requires-Dist: cyclopts>=5.0.0a1
|
|
24
|
+
Requires-Dist: fastmcp<4,>=3.0.0b1
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# JXA Mail MCP
|
|
28
|
+
|
|
29
|
+
A fast MCP (Model Context Protocol) server for Apple Mail, using optimized JXA (JavaScript for Automation) scripts with batch property fetching for **87x faster** performance.
|
|
30
|
+
|
|
31
|
+
## Features
|
|
32
|
+
|
|
33
|
+
- **list_accounts** - List all configured email accounts
|
|
34
|
+
- **list_mailboxes** - List mailboxes for an account
|
|
35
|
+
- **get_emails** - Fetch emails from any mailbox with pagination
|
|
36
|
+
- **get_todays_emails** - Fetch all emails received today
|
|
37
|
+
- **get_unread_emails** - Fetch unread emails
|
|
38
|
+
- **get_flagged_emails** - Fetch flagged emails
|
|
39
|
+
- **search_emails** - Search emails by subject or sender
|
|
40
|
+
- **fuzzy_search_emails** - Typo-tolerant search using trigram + Levenshtein matching
|
|
41
|
+
|
|
42
|
+
## Installation
|
|
43
|
+
|
|
44
|
+
### With pipx (recommended)
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pipx install jxa-mail-mcp
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### From source
|
|
51
|
+
|
|
52
|
+
Requires Python 3.13+ and [uv](https://docs.astral.sh/uv/):
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
git clone https://github.com/imdinu/jxa-mail-mcp
|
|
56
|
+
cd jxa-mail-mcp
|
|
57
|
+
uv sync
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Usage
|
|
61
|
+
|
|
62
|
+
### Add to Claude Code
|
|
63
|
+
|
|
64
|
+
After installing with pipx:
|
|
65
|
+
|
|
66
|
+
```json
|
|
67
|
+
{
|
|
68
|
+
"mcpServers": {
|
|
69
|
+
"mail": {
|
|
70
|
+
"command": "jxa-mail-mcp"
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Or from source:
|
|
77
|
+
|
|
78
|
+
```json
|
|
79
|
+
{
|
|
80
|
+
"mcpServers": {
|
|
81
|
+
"mail": {
|
|
82
|
+
"command": "uv",
|
|
83
|
+
"args": ["run", "--directory", "/path/to/jxa-mail-mcp", "jxa-mail-mcp"]
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Run directly
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
jxa-mail-mcp
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Configuration
|
|
96
|
+
|
|
97
|
+
Set default account and mailbox via environment variables:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
export JXA_MAIL_DEFAULT_ACCOUNT="Work"
|
|
101
|
+
export JXA_MAIL_DEFAULT_MAILBOX="Inbox"
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Or in Claude Code config:
|
|
105
|
+
|
|
106
|
+
```json
|
|
107
|
+
{
|
|
108
|
+
"mcpServers": {
|
|
109
|
+
"mail": {
|
|
110
|
+
"command": "jxa-mail-mcp",
|
|
111
|
+
"env": {
|
|
112
|
+
"JXA_MAIL_DEFAULT_ACCOUNT": "Work"
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Test in Python
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
from jxa_mail_mcp.server import get_todays_emails, search_emails, fuzzy_search_emails
|
|
123
|
+
|
|
124
|
+
emails = get_todays_emails(account="iCloud", mailbox="Inbox")
|
|
125
|
+
results = search_emails("meeting", account="Work", limit=10)
|
|
126
|
+
|
|
127
|
+
# Fuzzy search - tolerates typos
|
|
128
|
+
results = fuzzy_search_emails("meetting nottes", limit=10) # finds "meeting notes"
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Architecture
|
|
132
|
+
|
|
133
|
+
```
|
|
134
|
+
src/jxa_mail_mcp/
|
|
135
|
+
├── __init__.py # Exports mcp instance and main()
|
|
136
|
+
├── server.py # FastMCP server and MCP tools
|
|
137
|
+
├── config.py # Environment variable configuration
|
|
138
|
+
├── builders.py # QueryBuilder for constructing JXA scripts
|
|
139
|
+
├── executor.py # JXA script execution utilities
|
|
140
|
+
└── jxa/
|
|
141
|
+
├── __init__.py # Exports MAIL_CORE_JS
|
|
142
|
+
└── mail_core.js # Shared JXA utilities library
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### Design Principles
|
|
146
|
+
|
|
147
|
+
1. **Separation of concerns**: Python handles logic/types, JavaScript handles Mail.app interaction
|
|
148
|
+
2. **Builder pattern**: `QueryBuilder` constructs optimized JXA scripts programmatically
|
|
149
|
+
3. **Shared JS library**: `mail_core.js` provides reusable utilities injected into all scripts
|
|
150
|
+
4. **Type safety**: Python type hints ensure correct usage
|
|
151
|
+
|
|
152
|
+
## Performance
|
|
153
|
+
|
|
154
|
+
### The Problem
|
|
155
|
+
|
|
156
|
+
Naive AppleScript/JXA iteration is extremely slow:
|
|
157
|
+
|
|
158
|
+
```javascript
|
|
159
|
+
// SLOW: ~54 seconds for a few hundred messages
|
|
160
|
+
for (let msg of inbox.messages()) {
|
|
161
|
+
results.push({
|
|
162
|
+
from: msg.sender(), // IPC call to Mail.app
|
|
163
|
+
subject: msg.subject(), // IPC call to Mail.app
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Each property access triggers a separate Apple Event IPC round-trip.
|
|
169
|
+
|
|
170
|
+
### The Solution: Batch Property Fetching
|
|
171
|
+
|
|
172
|
+
JXA supports fetching a property from all elements at once:
|
|
173
|
+
|
|
174
|
+
```javascript
|
|
175
|
+
// FAST: ~0.6 seconds (87x faster)
|
|
176
|
+
const msgs = inbox.messages;
|
|
177
|
+
const senders = msgs.sender(); // Single IPC call returns array
|
|
178
|
+
const subjects = msgs.subject(); // Single IPC call returns array
|
|
179
|
+
|
|
180
|
+
for (let i = 0; i < senders.length; i++) {
|
|
181
|
+
results.push({ from: senders[i], subject: subjects[i] });
|
|
182
|
+
}
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### Benchmark Results
|
|
186
|
+
|
|
187
|
+
| Method | Time | Speedup |
|
|
188
|
+
|--------|------|---------|
|
|
189
|
+
| AppleScript (per-message) | 54.1s | 1x |
|
|
190
|
+
| JXA (per-message) | 53.9s | 1x |
|
|
191
|
+
| **JXA (batch fetching)** | **0.62s** | **87x** |
|
|
192
|
+
|
|
193
|
+
### Fuzzy Search Performance
|
|
194
|
+
|
|
195
|
+
Fuzzy search uses trigrams for fast candidate selection and Levenshtein distance for accurate ranking. Tested on a mailbox with ~6,000 emails:
|
|
196
|
+
|
|
197
|
+
| Search Type | Time | Overhead |
|
|
198
|
+
|-------------|------|----------|
|
|
199
|
+
| Regular search | ~360ms | - |
|
|
200
|
+
| Fuzzy search | ~480ms | +33% |
|
|
201
|
+
|
|
202
|
+
The trigram pre-filtering keeps fuzzy search fast by avoiding expensive Levenshtein calculations on non-matching words.
|
|
203
|
+
|
|
204
|
+
**Example**: Searching for "reserch studies" (typo) correctly finds "research studies" with 0.94 similarity score.
|
|
205
|
+
|
|
206
|
+
## Development
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
uv sync
|
|
210
|
+
uv run ruff check src/
|
|
211
|
+
uv run ruff format src/
|
|
212
|
+
|
|
213
|
+
# Test
|
|
214
|
+
uv run python -c "
|
|
215
|
+
from jxa_mail_mcp.server import list_accounts, get_todays_emails
|
|
216
|
+
print('Accounts:', len(list_accounts()))
|
|
217
|
+
print('Today:', len(get_todays_emails()))
|
|
218
|
+
"
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
## License
|
|
222
|
+
|
|
223
|
+
GPL-3.0-or-later
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
jxa_mail_mcp/__init__.py,sha256=99llxfT4C82jO2bvqHNz8wjXcNhAW9yA1z8BdD7zNHw,205
|
|
2
|
+
jxa_mail_mcp/builders.py,sha256=-dApdhFAndaCBnm6Lgt_isDjhGfbIfQO9xrB6JSG2zM,7571
|
|
3
|
+
jxa_mail_mcp/config.py,sha256=yfzTGnfyu_9veyUxgNlSqgcci2fYe1v3rHbroUnPDxM,726
|
|
4
|
+
jxa_mail_mcp/executor.py,sha256=fsIX59V1h0V2PWMiGstwAtS9MoX4mKDTDJdlTVLzYIg,2214
|
|
5
|
+
jxa_mail_mcp/server.py,sha256=ZcBpTyRot7tKlzDs6YDVpjqangc22xzXx8TLYhHF5fE,10658
|
|
6
|
+
jxa_mail_mcp/jxa/__init__.py,sha256=9UOQfRfZQNh-Z11msvBPYY34zFZALXXuk4moloqRT38,212
|
|
7
|
+
jxa_mail_mcp/jxa/mail_core.js,sha256=D-ne9ZclJFOS0qCsQO9oEEyP2Wn4ie2KNxQLVEv5O_4,9230
|
|
8
|
+
jxa_mail_mcp-0.1.0.dist-info/METADATA,sha256=idSfMCjy0sKWYbD5qYWLA5EWIs2REqI8EiVPRqHrI6s,5895
|
|
9
|
+
jxa_mail_mcp-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
10
|
+
jxa_mail_mcp-0.1.0.dist-info/entry_points.txt,sha256=02Yq1vcKewiaIMcukAPh43Q2f4FmxhSzMGvwhD_7iJk,51
|
|
11
|
+
jxa_mail_mcp-0.1.0.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
|
|
12
|
+
jxa_mail_mcp-0.1.0.dist-info/RECORD,,
|