jxa-mail-mcp 0.1.0__tar.gz → 0.2.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jxa-mail-mcp
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Fast MCP server for Apple Mail via optimized JXA scripts
5
5
  Project-URL: Homepage, https://github.com/imdinu/jxa-mail-mcp
6
6
  Project-URL: Repository, https://github.com/imdinu/jxa-mail-mcp
@@ -33,15 +33,27 @@ A fast MCP (Model Context Protocol) server for Apple Mail, using optimized JXA (
33
33
  - **list_accounts** - List all configured email accounts
34
34
  - **list_mailboxes** - List mailboxes for an account
35
35
  - **get_emails** - Fetch emails from any mailbox with pagination
36
+ - **get_email** - Fetch a single email with full body content
36
37
  - **get_todays_emails** - Fetch all emails received today
37
38
  - **get_unread_emails** - Fetch unread emails
38
39
  - **get_flagged_emails** - Fetch flagged emails
39
40
  - **search_emails** - Search emails by subject or sender
40
41
  - **fuzzy_search_emails** - Typo-tolerant search using trigram + Levenshtein matching
42
+ - **search_email_bodies** - Fuzzy search within email body content
41
43
 
42
44
  ## Installation
43
45
 
44
- ### With pipx (recommended)
46
+ ### No installation required
47
+
48
+ Use `pipx run` to run directly from PyPI:
49
+
50
+ ```bash
51
+ pipx run jxa-mail-mcp
52
+ ```
53
+
54
+ ### With pipx (optional)
55
+
56
+ For faster startup, install globally:
45
57
 
46
58
  ```bash
47
59
  pipx install jxa-mail-mcp
@@ -61,26 +73,26 @@ uv sync
61
73
 
62
74
  ### Add to Claude Code
63
75
 
64
- After installing with pipx:
76
+ Using `pipx run` (no installation required):
65
77
 
66
78
  ```json
67
79
  {
68
80
  "mcpServers": {
69
81
  "mail": {
70
- "command": "jxa-mail-mcp"
82
+ "command": "pipx",
83
+ "args": ["run", "jxa-mail-mcp"]
71
84
  }
72
85
  }
73
86
  }
74
87
  ```
75
88
 
76
- Or from source:
89
+ Or if installed with `pipx install jxa-mail-mcp`:
77
90
 
78
91
  ```json
79
92
  {
80
93
  "mcpServers": {
81
94
  "mail": {
82
- "command": "uv",
83
- "args": ["run", "--directory", "/path/to/jxa-mail-mcp", "jxa-mail-mcp"]
95
+ "command": "jxa-mail-mcp"
84
96
  }
85
97
  }
86
98
  }
@@ -89,6 +101,8 @@ Or from source:
89
101
  ### Run directly
90
102
 
91
103
  ```bash
104
+ pipx run jxa-mail-mcp
105
+ # or after installing
92
106
  jxa-mail-mcp
93
107
  ```
94
108
 
@@ -119,13 +133,26 @@ Or in Claude Code config:
119
133
  ### Test in Python
120
134
 
121
135
  ```python
122
- from jxa_mail_mcp.server import get_todays_emails, search_emails, fuzzy_search_emails
136
+ from jxa_mail_mcp.server import (
137
+ get_todays_emails,
138
+ search_emails,
139
+ fuzzy_search_emails,
140
+ search_email_bodies,
141
+ get_email,
142
+ )
123
143
 
124
144
  emails = get_todays_emails(account="iCloud", mailbox="Inbox")
125
145
  results = search_emails("meeting", account="Work", limit=10)
126
146
 
127
147
  # Fuzzy search - tolerates typos
128
148
  results = fuzzy_search_emails("meetting nottes", limit=10) # finds "meeting notes"
149
+
150
+ # Search within email bodies (slower but searches full content)
151
+ results = search_email_bodies("project deadline", account="Work", limit=10)
152
+
153
+ # Get full email content
154
+ email = get_email(message_id=12345, account="Work", mailbox="INBOX")
155
+ print(email["content"]) # Full body text
129
156
  ```
130
157
 
131
158
  ## Architecture
@@ -203,6 +230,20 @@ The trigram pre-filtering keeps fuzzy search fast by avoiding expensive Levensht
203
230
 
204
231
  **Example**: Searching for "reserch studies" (typo) correctly finds "research studies" with 0.94 similarity score.
205
232
 
233
+ ### Body Search Performance
234
+
235
+ Body search (`search_email_bodies`) fetches full email content, which is slower than metadata-only search but enables searching within email text:
236
+
237
+ | Search Type | Time (20 emails) | Notes |
238
+ |-------------|------------------|-------|
239
+ | Metadata search | ~0.1s | Subject/sender only |
240
+ | Body search | ~7s | Full content fetch + fuzzy match |
241
+ | Single email fetch | ~0.3s | `get_email()` with full body |
242
+
243
+ Body search uses a tiered matching approach:
244
+ 1. **Exact substring** (score 0.95) - Fast, catches most searches
245
+ 2. **Trigram similarity** (score 0.25-0.72) - Tolerates typos without expensive Levenshtein on long text
246
+
206
247
  ## Development
207
248
 
208
249
  ```bash
@@ -7,15 +7,27 @@ A fast MCP (Model Context Protocol) server for Apple Mail, using optimized JXA (
7
7
  - **list_accounts** - List all configured email accounts
8
8
  - **list_mailboxes** - List mailboxes for an account
9
9
  - **get_emails** - Fetch emails from any mailbox with pagination
10
+ - **get_email** - Fetch a single email with full body content
10
11
  - **get_todays_emails** - Fetch all emails received today
11
12
  - **get_unread_emails** - Fetch unread emails
12
13
  - **get_flagged_emails** - Fetch flagged emails
13
14
  - **search_emails** - Search emails by subject or sender
14
15
  - **fuzzy_search_emails** - Typo-tolerant search using trigram + Levenshtein matching
16
+ - **search_email_bodies** - Fuzzy search within email body content
15
17
 
16
18
  ## Installation
17
19
 
18
- ### With pipx (recommended)
20
+ ### No installation required
21
+
22
+ Use `pipx run` to run directly from PyPI:
23
+
24
+ ```bash
25
+ pipx run jxa-mail-mcp
26
+ ```
27
+
28
+ ### With pipx (optional)
29
+
30
+ For faster startup, install globally:
19
31
 
20
32
  ```bash
21
33
  pipx install jxa-mail-mcp
@@ -35,26 +47,26 @@ uv sync
35
47
 
36
48
  ### Add to Claude Code
37
49
 
38
- After installing with pipx:
50
+ Using `pipx run` (no installation required):
39
51
 
40
52
  ```json
41
53
  {
42
54
  "mcpServers": {
43
55
  "mail": {
44
- "command": "jxa-mail-mcp"
56
+ "command": "pipx",
57
+ "args": ["run", "jxa-mail-mcp"]
45
58
  }
46
59
  }
47
60
  }
48
61
  ```
49
62
 
50
- Or from source:
63
+ Or if installed with `pipx install jxa-mail-mcp`:
51
64
 
52
65
  ```json
53
66
  {
54
67
  "mcpServers": {
55
68
  "mail": {
56
- "command": "uv",
57
- "args": ["run", "--directory", "/path/to/jxa-mail-mcp", "jxa-mail-mcp"]
69
+ "command": "jxa-mail-mcp"
58
70
  }
59
71
  }
60
72
  }
@@ -63,6 +75,8 @@ Or from source:
63
75
  ### Run directly
64
76
 
65
77
  ```bash
78
+ pipx run jxa-mail-mcp
79
+ # or after installing
66
80
  jxa-mail-mcp
67
81
  ```
68
82
 
@@ -93,13 +107,26 @@ Or in Claude Code config:
93
107
  ### Test in Python
94
108
 
95
109
  ```python
96
- from jxa_mail_mcp.server import get_todays_emails, search_emails, fuzzy_search_emails
110
+ from jxa_mail_mcp.server import (
111
+ get_todays_emails,
112
+ search_emails,
113
+ fuzzy_search_emails,
114
+ search_email_bodies,
115
+ get_email,
116
+ )
97
117
 
98
118
  emails = get_todays_emails(account="iCloud", mailbox="Inbox")
99
119
  results = search_emails("meeting", account="Work", limit=10)
100
120
 
101
121
  # Fuzzy search - tolerates typos
102
122
  results = fuzzy_search_emails("meetting nottes", limit=10) # finds "meeting notes"
123
+
124
+ # Search within email bodies (slower but searches full content)
125
+ results = search_email_bodies("project deadline", account="Work", limit=10)
126
+
127
+ # Get full email content
128
+ email = get_email(message_id=12345, account="Work", mailbox="INBOX")
129
+ print(email["content"]) # Full body text
103
130
  ```
104
131
 
105
132
  ## Architecture
@@ -177,6 +204,20 @@ The trigram pre-filtering keeps fuzzy search fast by avoiding expensive Levensht
177
204
 
178
205
  **Example**: Searching for "reserch studies" (typo) correctly finds "research studies" with 0.94 similarity score.
179
206
 
207
+ ### Body Search Performance
208
+
209
+ Body search (`search_email_bodies`) fetches full email content, which is slower than metadata-only search but enables searching within email text:
210
+
211
+ | Search Type | Time (20 emails) | Notes |
212
+ |-------------|------------------|-------|
213
+ | Metadata search | ~0.1s | Subject/sender only |
214
+ | Body search | ~7s | Full content fetch + fuzzy match |
215
+ | Single email fetch | ~0.3s | `get_email()` with full body |
216
+
217
+ Body search uses a tiered matching approach:
218
+ 1. **Exact substring** (score 0.95) - Fast, catches most searches
219
+ 2. **Trigram similarity** (score 0.25-0.72) - Tolerates typos without expensive Levenshtein on long text
220
+
180
221
  ## Development
181
222
 
182
223
  ```bash
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "jxa-mail-mcp"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "Fast MCP server for Apple Mail via optimized JXA scripts"
5
5
  readme = "README.md"
6
6
  license = "GPL-3.0-or-later"
@@ -291,4 +291,72 @@ const MailCore = {
291
291
  }
292
292
  return null;
293
293
  },
294
+
295
+ /**
296
+ * Fast body search - optimized for long text.
297
+ * Uses substring match first, then trigram-only (skips expensive Levenshtein).
298
+ *
299
+ * @param {string} query - Search query
300
+ * @param {string} body - Email body content
301
+ * @param {number} maxChars - Max characters to search (default 2000)
302
+ * @returns {object|null} {score, matched, tier} or null if no match
303
+ */
304
+ fuzzyMatchBody(query, body, maxChars = 2000) {
305
+ const q = (query || "").toLowerCase().trim();
306
+ const b = (body || "").toLowerCase().substring(0, maxChars);
307
+
308
+ if (!q || !b) return null;
309
+
310
+ // Tier 1: Exact substring match (fastest, best result)
311
+ const exactIndex = b.indexOf(q);
312
+ if (exactIndex !== -1) {
313
+ // Extract context around the match
314
+ const start = Math.max(0, exactIndex - 20);
315
+ const end = Math.min(b.length, exactIndex + q.length + 20);
316
+ const context = b.substring(start, end).trim();
317
+ return { score: 0.95, matched: context, tier: "exact" };
318
+ }
319
+
320
+ // Tier 2: Word-level trigram matching (no Levenshtein)
321
+ // Find words that share enough trigrams with query
322
+ const queryTrigrams = this.trigrams(q);
323
+ const words = b.split(/\s+/);
324
+ let bestSim = 0;
325
+ let bestWord = null;
326
+
327
+ for (const word of words) {
328
+ if (word.length < 2) continue;
329
+ const wordTrigrams = this.trigrams(word);
330
+ const sim = this.trigramSimilarity(queryTrigrams, wordTrigrams);
331
+ if (sim > bestSim) {
332
+ bestSim = sim;
333
+ bestWord = word;
334
+ }
335
+ }
336
+
337
+ // Also check multi-word phrases for multi-word queries
338
+ const queryWords = q.split(/\s+/).length;
339
+ if (queryWords > 1 && words.length >= queryWords) {
340
+ for (let i = 0; i <= words.length - queryWords; i++) {
341
+ const phrase = words.slice(i, i + queryWords).join(" ");
342
+ const phraseTrigrams = this.trigrams(phrase);
343
+ const sim = this.trigramSimilarity(queryTrigrams, phraseTrigrams);
344
+ if (sim > bestSim) {
345
+ bestSim = sim;
346
+ bestWord = phrase;
347
+ }
348
+ }
349
+ }
350
+
351
+ // Require higher threshold for trigram-only matching
352
+ if (bestSim >= 0.25) {
353
+ return {
354
+ score: Math.round(bestSim * 0.85 * 100) / 100,
355
+ matched: bestWord,
356
+ tier: "trigram",
357
+ };
358
+ }
359
+
360
+ return null;
361
+ },
294
362
  };
@@ -349,5 +349,217 @@ JSON.stringify(results.slice(0, {limit}));
349
349
  return execute_with_core(script)
350
350
 
351
351
 
352
+ @mcp.tool
353
+ def search_email_bodies(
354
+ query: str,
355
+ account: str | None = None,
356
+ mailbox: str | None = None,
357
+ limit: int = 20,
358
+ threshold: float = 0.3,
359
+ ) -> list[dict]:
360
+ """
361
+ Search within email body content using fuzzy matching.
362
+
363
+ Searches the full text content of emails, not just metadata.
364
+ Uses a fast two-tier approach: exact substring matching first,
365
+ then trigram similarity for typo tolerance.
366
+
367
+ Note: Slower than metadata-only search due to fetching email bodies.
368
+ Use search_emails() or fuzzy_search_emails() for faster metadata search.
369
+
370
+ Args:
371
+ query: Search term to find in email bodies
372
+ account: Account name. Uses JXA_MAIL_DEFAULT_ACCOUNT env var or
373
+ first account if not specified.
374
+ mailbox: Mailbox name. Uses JXA_MAIL_DEFAULT_MAILBOX env var or
375
+ "Inbox" if not specified.
376
+ limit: Maximum number of results (default: 20)
377
+ threshold: Minimum similarity score 0-1 (default: 0.3)
378
+
379
+ Returns:
380
+ List of matching emails with scores, sorted by relevance.
381
+ Includes 'matched_in' field indicating where match was found
382
+ (subject, sender, or body) and 'matched_text' showing the match.
383
+
384
+ Example:
385
+ >>> search_email_bodies("project deadline")
386
+ [{"subject": "Re: Updates", "score": 0.95, "matched_in": "body",
387
+ "matched_text": "...project deadline is...", ...}]
388
+ """
389
+ safe_query = query.replace("\\", "\\\\").replace("'", "\\'")
390
+ resolved_account = _resolve_account(account)
391
+ resolved_mailbox = _resolve_mailbox(mailbox)
392
+
393
+ # Build account reference
394
+ if resolved_account:
395
+ safe_account = resolved_account.replace("'", "\\'")
396
+ account_js = f"MailCore.getAccount('{safe_account}')"
397
+ else:
398
+ account_js = "MailCore.getAccount(null)"
399
+
400
+ safe_mailbox = resolved_mailbox.replace("'", "\\'")
401
+
402
+ script = f"""
403
+ const account = {account_js};
404
+ const mailbox = MailCore.getMailbox(account, '{safe_mailbox}');
405
+ const msgs = mailbox.messages;
406
+ const query = '{safe_query}';
407
+ const threshold = {threshold};
408
+
409
+ // Batch fetch properties INCLUDING content
410
+ const data = MailCore.batchFetch(msgs, [
411
+ 'id', 'subject', 'sender', 'content',
412
+ 'dateReceived', 'readStatus', 'flaggedStatus'
413
+ ]);
414
+
415
+ const results = [];
416
+ const count = data.id.length;
417
+
418
+ for (let i = 0; i < count; i++) {{
419
+ const subject = data.subject[i] || '';
420
+ const sender = data.sender[i] || '';
421
+ const content = data.content[i] || '';
422
+
423
+ // Try matching against subject, sender, and body
424
+ const subjectMatch = MailCore.fuzzyMatch(query, subject);
425
+ const senderMatch = MailCore.fuzzyMatch(query, sender);
426
+ const bodyMatch = MailCore.fuzzyMatchBody(query, content);
427
+
428
+ // Take the best match across all fields
429
+ let bestScore = 0;
430
+ let matchedIn = null;
431
+ let matchedText = null;
432
+
433
+ if (subjectMatch && subjectMatch.score > bestScore) {{
434
+ bestScore = subjectMatch.score;
435
+ matchedIn = 'subject';
436
+ matchedText = subjectMatch.matched;
437
+ }}
438
+ if (senderMatch && senderMatch.score > bestScore) {{
439
+ bestScore = senderMatch.score;
440
+ matchedIn = 'sender';
441
+ matchedText = senderMatch.matched;
442
+ }}
443
+ if (bodyMatch && bodyMatch.score > bestScore) {{
444
+ bestScore = bodyMatch.score;
445
+ matchedIn = 'body';
446
+ matchedText = bodyMatch.matched;
447
+ }}
448
+
449
+ if (bestScore >= threshold) {{
450
+ results.push({{
451
+ id: data.id[i],
452
+ subject: subject,
453
+ sender: sender,
454
+ date_received: MailCore.formatDate(data.dateReceived[i]),
455
+ read: data.readStatus[i],
456
+ flagged: data.flaggedStatus[i],
457
+ score: Math.round(bestScore * 100) / 100,
458
+ matched_in: matchedIn,
459
+ matched_text: matchedText
460
+ }});
461
+ }}
462
+ }}
463
+
464
+ // Sort by score descending, then by date
465
+ results.sort((a, b) => {{
466
+ if (b.score !== a.score) return b.score - a.score;
467
+ return new Date(b.date_received) - new Date(a.date_received);
468
+ }});
469
+
470
+ JSON.stringify(results.slice(0, {limit}));
471
+ """
472
+ return execute_with_core(script)
473
+
474
+
475
+ @mcp.tool
476
+ def get_email(
477
+ message_id: int,
478
+ account: str | None = None,
479
+ mailbox: str | None = None,
480
+ ) -> dict:
481
+ """
482
+ Get a single email with full content.
483
+
484
+ Retrieves complete email details including the full body text.
485
+ Use this after finding an email via search to read its content.
486
+
487
+ Args:
488
+ message_id: The email's unique ID (from search results)
489
+ account: Account name (optional, helps find message faster)
490
+ mailbox: Mailbox name (optional, helps find message faster)
491
+
492
+ Returns:
493
+ Email dictionary with full content including:
494
+ - id, subject, sender, date_received, date_sent
495
+ - content: Full plain text body
496
+ - read, flagged status
497
+ - reply_to, message_id (email Message-ID header)
498
+
499
+ Example:
500
+ >>> get_email(12345)
501
+ {"id": 12345, "subject": "Meeting notes",
502
+ "content": "Hi team,\\n\\nHere are the notes...", ...}
503
+ """
504
+ resolved_account = _resolve_account(account)
505
+ resolved_mailbox = _resolve_mailbox(mailbox)
506
+
507
+ # Build account reference
508
+ if resolved_account:
509
+ safe_account = resolved_account.replace("'", "\\'")
510
+ account_js = f"MailCore.getAccount('{safe_account}')"
511
+ else:
512
+ account_js = "MailCore.getAccount(null)"
513
+
514
+ safe_mailbox = resolved_mailbox.replace("'", "\\'")
515
+
516
+ script = f"""
517
+ const targetId = {message_id};
518
+
519
+ // Try to find the message in the specified mailbox first
520
+ let msg = null;
521
+ const account = {account_js};
522
+ const mailbox = MailCore.getMailbox(account, '{safe_mailbox}');
523
+
524
+ // Search in specified mailbox
525
+ const ids = mailbox.messages.id();
526
+ const idx = ids.indexOf(targetId);
527
+ if (idx !== -1) {{
528
+ msg = mailbox.messages[idx];
529
+ }}
530
+
531
+ // If not found, search all mailboxes in the account
532
+ if (!msg) {{
533
+ const allMailboxes = account.mailboxes();
534
+ for (let i = 0; i < allMailboxes.length && !msg; i++) {{
535
+ const mb = allMailboxes[i];
536
+ const mbIds = mb.messages.id();
537
+ const mbIdx = mbIds.indexOf(targetId);
538
+ if (mbIdx !== -1) {{
539
+ msg = mb.messages[mbIdx];
540
+ }}
541
+ }}
542
+ }}
543
+
544
+ if (!msg) {{
545
+ throw new Error('Message not found with ID: ' + targetId);
546
+ }}
547
+
548
+ JSON.stringify({{
549
+ id: msg.id(),
550
+ subject: msg.subject(),
551
+ sender: msg.sender(),
552
+ content: msg.content(),
553
+ date_received: MailCore.formatDate(msg.dateReceived()),
554
+ date_sent: MailCore.formatDate(msg.dateSent()),
555
+ read: msg.readStatus(),
556
+ flagged: msg.flaggedStatus(),
557
+ reply_to: msg.replyTo(),
558
+ message_id: msg.messageId()
559
+ }});
560
+ """
561
+ return execute_with_core(script)
562
+
563
+
352
564
  if __name__ == "__main__":
353
565
  mcp.run()
File without changes
File without changes