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/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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ jxa-mail-mcp = jxa_mail_mcp:main