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.

@@ -0,0 +1,10 @@
1
+ """JXA Mail MCP - Fast Apple Mail automation via optimized JXA scripts."""
2
+
3
+ from .server import mcp
4
+
5
+ __all__ = ["main", "mcp"]
6
+
7
+
8
+ def main() -> None:
9
+ """Entry point for the MCP server."""
10
+ mcp.run()
@@ -0,0 +1,243 @@
1
+ """
2
+ JXA Script Builders for Apple Mail operations.
3
+
4
+ These builders generate optimized JXA scripts that use batch property
5
+ fetching for maximum performance.
6
+ """
7
+
8
+ import json
9
+ from dataclasses import dataclass, field
10
+
11
+ # Standard email properties available for batch fetching
12
+ EMAIL_PROPERTIES = {
13
+ "id": "id",
14
+ "subject": "subject",
15
+ "sender": "sender",
16
+ "date_received": "dateReceived",
17
+ "date_sent": "dateSent",
18
+ "read": "readStatus",
19
+ "flagged": "flaggedStatus",
20
+ "deleted": "deletedStatus",
21
+ "junk": "junkMailStatus",
22
+ "reply_to": "replyTo",
23
+ "message_id": "messageId",
24
+ "source": "source", # Raw email source - expensive!
25
+ }
26
+
27
+ # Shorthand aliases for common property sets
28
+ PROPERTY_SETS = {
29
+ "minimal": ["id", "subject", "sender", "date_received"],
30
+ "standard": [
31
+ "id",
32
+ "subject",
33
+ "sender",
34
+ "date_received",
35
+ "read",
36
+ "flagged",
37
+ ],
38
+ "full": [
39
+ "id",
40
+ "subject",
41
+ "sender",
42
+ "date_received",
43
+ "date_sent",
44
+ "read",
45
+ "flagged",
46
+ "reply_to",
47
+ "message_id",
48
+ ],
49
+ }
50
+
51
+
52
+ @dataclass
53
+ class QueryBuilder:
54
+ """
55
+ Builder for constructing optimized email query scripts.
56
+
57
+ Uses batch property fetching for fast execution. Supports filtering,
58
+ limiting, and property selection.
59
+
60
+ Example:
61
+ query = (QueryBuilder()
62
+ .from_mailbox("Work", "INBOX")
63
+ .select("sender", "subject", "date_received", "read")
64
+ .where("data.dateReceived[i] >= MailCore.today()")
65
+ .limit(50)
66
+ .build())
67
+ """
68
+
69
+ _account: str | None = None
70
+ _mailbox: str = "INBOX"
71
+ _properties: list[str] = field(default_factory=list)
72
+ _filter_expr: str | None = None
73
+ _limit: int | None = None
74
+ _order_by: str | None = None
75
+ _descending: bool = True
76
+
77
+ def from_mailbox(
78
+ self, account: str | None = None, mailbox: str = "INBOX"
79
+ ) -> "QueryBuilder":
80
+ """
81
+ Set the source mailbox for the query.
82
+
83
+ Args:
84
+ account: Account name (None for first/default account)
85
+ mailbox: Mailbox name (default: "INBOX")
86
+ """
87
+ self._account = account
88
+ self._mailbox = mailbox
89
+ return self
90
+
91
+ def select(self, *props: str) -> "QueryBuilder":
92
+ """
93
+ Select properties to fetch.
94
+
95
+ Use property names like: id, subject, sender, date_received,
96
+ read, flagged, etc. Or use a preset: "minimal", "standard", "full".
97
+
98
+ Args:
99
+ props: Property names or preset names
100
+ """
101
+ for prop in props:
102
+ if prop in PROPERTY_SETS:
103
+ self._properties.extend(PROPERTY_SETS[prop])
104
+ elif prop in EMAIL_PROPERTIES:
105
+ self._properties.append(prop)
106
+ else:
107
+ raise ValueError(
108
+ f"Unknown property: {prop}. "
109
+ f"Valid: {list(EMAIL_PROPERTIES.keys())}"
110
+ )
111
+ return self
112
+
113
+ def where(self, js_expression: str) -> "QueryBuilder":
114
+ """
115
+ Add a filter expression (JavaScript).
116
+
117
+ The expression has access to:
118
+ - `data`: Object with arrays of fetched properties
119
+ - `i`: Current index in the loop
120
+ - `MailCore`: The MailCore utilities
121
+
122
+ Example:
123
+ .where("data.dateReceived[i] >= MailCore.today()")
124
+ .where("data.subject[i].toLowerCase().includes('urgent')")
125
+
126
+ Args:
127
+ js_expression: JavaScript boolean expression
128
+ """
129
+ self._filter_expr = js_expression
130
+ return self
131
+
132
+ def limit(self, n: int) -> "QueryBuilder":
133
+ """Limit the number of results."""
134
+ self._limit = n
135
+ return self
136
+
137
+ def order_by(self, prop: str, descending: bool = True) -> "QueryBuilder":
138
+ """
139
+ Order results by a property.
140
+
141
+ Args:
142
+ prop: Property name to sort by
143
+ descending: Sort descending (default: True, newest first)
144
+ """
145
+ if prop not in EMAIL_PROPERTIES:
146
+ raise ValueError(f"Unknown property for ordering: {prop}")
147
+ self._order_by = prop
148
+ self._descending = descending
149
+ return self
150
+
151
+ def build(self) -> str:
152
+ """
153
+ Generate the JXA script.
154
+
155
+ Returns:
156
+ JavaScript code that uses MailCore and returns JSON
157
+ """
158
+ if not self._properties:
159
+ # Default to standard properties
160
+ self._properties = PROPERTY_SETS["standard"].copy()
161
+
162
+ # Remove duplicates while preserving order
163
+ props = list(dict.fromkeys(self._properties))
164
+
165
+ # Map Python property names to JXA property names
166
+ jxa_props = [EMAIL_PROPERTIES[p] for p in props]
167
+
168
+ # Build the script
169
+ account_json = json.dumps(self._account)
170
+ mailbox_json = json.dumps(self._mailbox)
171
+ props_json = json.dumps(jxa_props)
172
+
173
+ lines = [
174
+ "// Setup",
175
+ f"const account = MailCore.getAccount({account_json});",
176
+ f"const mailbox = MailCore.getMailbox(account, {mailbox_json});",
177
+ "const msgs = mailbox.messages;",
178
+ "",
179
+ "// Batch fetch (optimized - single IPC per property)",
180
+ f"const data = MailCore.batchFetch(msgs, {props_json});",
181
+ "",
182
+ "// Build results",
183
+ "const results = [];",
184
+ f"const len = data.{jxa_props[0]}.length;",
185
+ "",
186
+ ]
187
+
188
+ # Loop with optional limit
189
+ if self._limit:
190
+ loop_cond = f"i < len && results.length < {self._limit}"
191
+ lines.append(f"for (let i = 0; {loop_cond}; i++) {{")
192
+ else:
193
+ lines.append("for (let i = 0; i < len; i++) {")
194
+
195
+ # Optional filter
196
+ if self._filter_expr:
197
+ lines.append(f" if (!({self._filter_expr})) continue;")
198
+
199
+ # Build result object
200
+ lines.append(" results.push({")
201
+ for py_name, jxa_name in zip(props, jxa_props, strict=True):
202
+ if jxa_name in ("dateReceived", "dateSent"):
203
+ fmt = f"MailCore.formatDate(data.{jxa_name}[i])"
204
+ lines.append(f" {py_name}: {fmt},")
205
+ else:
206
+ lines.append(f" {py_name}: data.{jxa_name}[i],")
207
+ lines.append(" });")
208
+ lines.append("}")
209
+
210
+ # Optional sorting (in JS after collection)
211
+ if self._order_by:
212
+ direction = -1 if self._descending else 1
213
+ lines.append("")
214
+ lines.append("// Sort results")
215
+ lines.append("results.sort((a, b) => {")
216
+ lines.append(f" const va = a.{self._order_by};")
217
+ lines.append(f" const vb = b.{self._order_by};")
218
+ lines.append(f" if (va < vb) return {-direction};")
219
+ lines.append(f" if (va > vb) return {direction};")
220
+ lines.append(" return 0;")
221
+ lines.append("});")
222
+
223
+ lines.append("")
224
+ lines.append("JSON.stringify(results);")
225
+
226
+ return "\n".join(lines)
227
+
228
+
229
+ @dataclass
230
+ class AccountsQueryBuilder:
231
+ """Builder for listing accounts and mailboxes."""
232
+
233
+ def list_accounts(self) -> str:
234
+ """Generate script to list all mail accounts."""
235
+ return "JSON.stringify(MailCore.listAccounts());"
236
+
237
+ def list_mailboxes(self, account: str | None = None) -> str:
238
+ """Generate script to list mailboxes for an account."""
239
+ account_json = json.dumps(account)
240
+ return f"""
241
+ const account = MailCore.getAccount({account_json});
242
+ JSON.stringify(MailCore.listMailboxes(account));
243
+ """
jxa_mail_mcp/config.py ADDED
@@ -0,0 +1,29 @@
1
+ """Configuration for JXA Mail MCP server."""
2
+
3
+ import os
4
+
5
+
6
+ def get_default_account() -> str | None:
7
+ """
8
+ Get the default account from environment variable.
9
+
10
+ Set JXA_MAIL_DEFAULT_ACCOUNT to use a specific account by default.
11
+ If not set, the first account in Apple Mail will be used.
12
+
13
+ Returns:
14
+ Account name or None to use first account.
15
+ """
16
+ return os.environ.get("JXA_MAIL_DEFAULT_ACCOUNT")
17
+
18
+
19
+ def get_default_mailbox() -> str:
20
+ """
21
+ Get the default mailbox from environment variable.
22
+
23
+ Set JXA_MAIL_DEFAULT_MAILBOX to use a specific mailbox by default.
24
+ Defaults to "INBOX".
25
+
26
+ Returns:
27
+ Mailbox name.
28
+ """
29
+ return os.environ.get("JXA_MAIL_DEFAULT_MAILBOX", "Inbox")
@@ -0,0 +1,84 @@
1
+ """JXA script execution utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import subprocess
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ from .jxa import MAIL_CORE_JS
10
+
11
+ if TYPE_CHECKING:
12
+ from .builders import QueryBuilder
13
+
14
+
15
+ class JXAError(Exception):
16
+ """Raised when a JXA script fails to execute."""
17
+
18
+ def __init__(self, message: str, stderr: str = ""):
19
+ super().__init__(message)
20
+ self.stderr = stderr
21
+
22
+
23
+ def run_jxa(script: str, timeout: int = 120) -> str:
24
+ """
25
+ Execute a raw JXA script and return the output.
26
+
27
+ Args:
28
+ script: JavaScript code to execute via osascript
29
+ timeout: Maximum execution time in seconds
30
+
31
+ Returns:
32
+ The script's stdout output (stripped)
33
+
34
+ Raises:
35
+ JXAError: If the script fails to execute
36
+ subprocess.TimeoutExpired: If execution exceeds timeout
37
+ """
38
+ result = subprocess.run(
39
+ ["osascript", "-l", "JavaScript", "-e", script],
40
+ capture_output=True,
41
+ text=True,
42
+ timeout=timeout,
43
+ )
44
+ if result.returncode != 0:
45
+ raise JXAError(f"JXA script failed: {result.stderr}", result.stderr)
46
+ return result.stdout.strip()
47
+
48
+
49
+ def execute_with_core(script_body: str, timeout: int = 120) -> Any:
50
+ """
51
+ Execute a JXA script with MailCore library injected.
52
+
53
+ The script should use MailCore utilities and end with a
54
+ JSON.stringify() call to return data.
55
+
56
+ Args:
57
+ script_body: JavaScript code that uses MailCore
58
+ timeout: Maximum execution time in seconds
59
+
60
+ Returns:
61
+ Parsed JSON result from the script
62
+
63
+ Raises:
64
+ JXAError: If execution fails
65
+ json.JSONDecodeError: If output isn't valid JSON
66
+ """
67
+ full_script = f"{MAIL_CORE_JS}\n\n{script_body}"
68
+ output = run_jxa(full_script, timeout)
69
+ return json.loads(output)
70
+
71
+
72
+ def execute_query(query: QueryBuilder, timeout: int = 120) -> list[dict]:
73
+ """
74
+ Execute a QueryBuilder and return results.
75
+
76
+ Args:
77
+ query: A configured QueryBuilder instance
78
+ timeout: Maximum execution time in seconds
79
+
80
+ Returns:
81
+ List of email dictionaries matching the query
82
+ """
83
+ script = query.build()
84
+ return execute_with_core(script, timeout)
@@ -0,0 +1,8 @@
1
+ """JXA script resources for Apple Mail automation."""
2
+
3
+ from pathlib import Path
4
+
5
+ # Path to the mail_core.js library
6
+ MAIL_CORE_JS = (Path(__file__).parent / "mail_core.js").read_text()
7
+
8
+ __all__ = ["MAIL_CORE_JS"]
@@ -0,0 +1,294 @@
1
+ /**
2
+ * Apple Mail JXA Core Library
3
+ *
4
+ * Shared utilities for fast, batch-optimized Mail.app automation.
5
+ * This library is injected into all JXA scripts to provide consistent
6
+ * error handling, account/mailbox resolution, and batch fetching.
7
+ */
8
+
9
+ const Mail = Application("Mail");
10
+
11
+ const MailCore = {
12
+ /**
13
+ * Get an account by name, or the first account if name is null/empty.
14
+ * @param {string|null} name - Account name or null for default
15
+ * @returns {Account} Mail account object
16
+ */
17
+ getAccount(name) {
18
+ if (name) {
19
+ return Mail.accounts.byName(name);
20
+ }
21
+ const accounts = Mail.accounts();
22
+ if (accounts.length === 0) {
23
+ throw new Error("No mail accounts configured");
24
+ }
25
+ return accounts[0];
26
+ },
27
+
28
+ /**
29
+ * Get a mailbox from an account.
30
+ * @param {Account} account - Mail account object
31
+ * @param {string} name - Mailbox name (e.g., "INBOX", "Sent")
32
+ * @returns {Mailbox} Mailbox object
33
+ */
34
+ getMailbox(account, name) {
35
+ return account.mailboxes.byName(name);
36
+ },
37
+
38
+ /**
39
+ * Batch fetch multiple properties from a messages collection.
40
+ * This is THE critical optimization - one IPC call per property
41
+ * instead of one per message.
42
+ *
43
+ * @param {Messages} msgs - Messages collection from a mailbox
44
+ * @param {string[]} props - Property names to fetch
45
+ * @returns {Object} Map of property name to array of values
46
+ */
47
+ batchFetch(msgs, props) {
48
+ const result = {};
49
+ for (const prop of props) {
50
+ result[prop] = msgs[prop]();
51
+ }
52
+ return result;
53
+ },
54
+
55
+ /**
56
+ * Get message IDs for referencing specific messages later.
57
+ * @param {Messages} msgs - Messages collection
58
+ * @returns {string[]} Array of message IDs
59
+ */
60
+ getMessageIds(msgs) {
61
+ return msgs.id();
62
+ },
63
+
64
+ /**
65
+ * Get a specific message by ID.
66
+ * @param {string} messageId - The message ID
67
+ * @returns {Message} Message object
68
+ */
69
+ getMessageById(messageId) {
70
+ // Messages are referenced by ID across all accounts
71
+ return Mail.messages.byId(messageId);
72
+ },
73
+
74
+ /**
75
+ * Wrap an operation with error handling.
76
+ * @param {Function} fn - Function to execute
77
+ * @returns {Object} {ok: true, data: ...} or {ok: false, error: ...}
78
+ */
79
+ safely(fn) {
80
+ try {
81
+ return { ok: true, data: fn() };
82
+ } catch (e) {
83
+ return { ok: false, error: String(e) };
84
+ }
85
+ },
86
+
87
+ /**
88
+ * Get today's date at midnight for filtering.
89
+ * @returns {Date} Today at 00:00:00
90
+ */
91
+ today() {
92
+ const d = new Date();
93
+ d.setHours(0, 0, 0, 0);
94
+ return d;
95
+ },
96
+
97
+ /**
98
+ * Format a date for JSON output.
99
+ * @param {Date} date - Date to format
100
+ * @returns {string} ISO string or null if invalid
101
+ */
102
+ formatDate(date) {
103
+ if (!date || !(date instanceof Date)) return null;
104
+ return date.toISOString();
105
+ },
106
+
107
+ /**
108
+ * List all accounts.
109
+ * @returns {Object[]} Array of {name, id} objects
110
+ */
111
+ listAccounts() {
112
+ const accounts = Mail.accounts();
113
+ const names = Mail.accounts.name();
114
+ const ids = Mail.accounts.id();
115
+ const results = [];
116
+ for (let i = 0; i < accounts.length; i++) {
117
+ results.push({ name: names[i], id: ids[i] });
118
+ }
119
+ return results;
120
+ },
121
+
122
+ /**
123
+ * List mailboxes for an account.
124
+ * Note: messageCount is not available via batch fetch, only unreadCount.
125
+ * @param {Account} account - Mail account
126
+ * @returns {Object[]} Array of {name, unreadCount}
127
+ */
128
+ listMailboxes(account) {
129
+ const mboxes = account.mailboxes();
130
+ const names = account.mailboxes.name();
131
+ const unread = account.mailboxes.unreadCount();
132
+ const results = [];
133
+ for (let i = 0; i < mboxes.length; i++) {
134
+ results.push({
135
+ name: names[i],
136
+ unreadCount: unread[i],
137
+ });
138
+ }
139
+ return results;
140
+ },
141
+
142
+ // ========== Fuzzy Search Utilities ==========
143
+
144
+ /**
145
+ * Extract trigrams (3-character sequences) from a string.
146
+ * @param {string} str - Input string
147
+ * @returns {Set<string>} Set of trigrams
148
+ */
149
+ trigrams(str) {
150
+ const s = (str || "").toLowerCase().trim();
151
+ const result = new Set();
152
+ if (s.length < 3) {
153
+ result.add(s);
154
+ return result;
155
+ }
156
+ for (let i = 0; i <= s.length - 3; i++) {
157
+ result.add(s.substring(i, i + 3));
158
+ }
159
+ return result;
160
+ },
161
+
162
+ /**
163
+ * Calculate trigram similarity (Jaccard index).
164
+ * @param {Set<string>} set1 - First trigram set
165
+ * @param {Set<string>} set2 - Second trigram set
166
+ * @returns {number} Similarity score 0-1
167
+ */
168
+ trigramSimilarity(set1, set2) {
169
+ if (set1.size === 0 && set2.size === 0) return 1;
170
+ if (set1.size === 0 || set2.size === 0) return 0;
171
+ let intersection = 0;
172
+ for (const t of set1) {
173
+ if (set2.has(t)) intersection++;
174
+ }
175
+ const union = set1.size + set2.size - intersection;
176
+ return intersection / union;
177
+ },
178
+
179
+ /**
180
+ * Calculate Levenshtein edit distance between two strings.
181
+ * @param {string} a - First string
182
+ * @param {string} b - Second string
183
+ * @returns {number} Edit distance
184
+ */
185
+ levenshtein(a, b) {
186
+ const s1 = (a || "").toLowerCase();
187
+ const s2 = (b || "").toLowerCase();
188
+ if (s1 === s2) return 0;
189
+ if (s1.length === 0) return s2.length;
190
+ if (s2.length === 0) return s1.length;
191
+
192
+ // Use two rows instead of full matrix for memory efficiency
193
+ let prev = [];
194
+ let curr = [];
195
+ for (let j = 0; j <= s2.length; j++) prev[j] = j;
196
+
197
+ for (let i = 1; i <= s1.length; i++) {
198
+ curr[0] = i;
199
+ for (let j = 1; j <= s2.length; j++) {
200
+ const cost = s1[i - 1] === s2[j - 1] ? 0 : 1;
201
+ curr[j] = Math.min(
202
+ prev[j] + 1, // deletion
203
+ curr[j - 1] + 1, // insertion
204
+ prev[j - 1] + cost // substitution
205
+ );
206
+ }
207
+ [prev, curr] = [curr, prev];
208
+ }
209
+ return prev[s2.length];
210
+ },
211
+
212
+ /**
213
+ * Calculate normalized Levenshtein similarity (0-1).
214
+ * @param {string} a - First string
215
+ * @param {string} b - Second string
216
+ * @returns {number} Similarity score 0-1
217
+ */
218
+ levenshteinSimilarity(a, b) {
219
+ const maxLen = Math.max((a || "").length, (b || "").length);
220
+ if (maxLen === 0) return 1;
221
+ return 1 - this.levenshtein(a, b) / maxLen;
222
+ },
223
+
224
+ /**
225
+ * Fuzzy match a query against text.
226
+ * Uses trigrams for fast candidate filtering, Levenshtein for scoring.
227
+ * @param {string} query - Search query
228
+ * @param {string} text - Text to search in
229
+ * @param {number} trigramThreshold - Min trigram similarity (default 0.2)
230
+ * @returns {object|null} {score, matched} or null if no match
231
+ */
232
+ fuzzyMatch(query, text, trigramThreshold = 0.2) {
233
+ const q = (query || "").toLowerCase().trim();
234
+ const t = (text || "").toLowerCase();
235
+
236
+ if (!q || !t) return null;
237
+
238
+ // Exact substring match is best
239
+ if (t.includes(q)) {
240
+ return { score: 1.0, matched: q };
241
+ }
242
+
243
+ // Split text into words for word-level matching
244
+ const words = t.split(/\s+/);
245
+ const queryTrigrams = this.trigrams(q);
246
+
247
+ let bestScore = 0;
248
+ let bestMatch = null;
249
+
250
+ for (const word of words) {
251
+ // Quick trigram filter
252
+ const wordTrigrams = this.trigrams(word);
253
+ const trigramSim = this.trigramSimilarity(
254
+ queryTrigrams,
255
+ wordTrigrams
256
+ );
257
+
258
+ if (trigramSim >= trigramThreshold) {
259
+ // Candidate found, calculate precise score
260
+ const levSim = this.levenshteinSimilarity(q, word);
261
+ if (levSim > bestScore) {
262
+ bestScore = levSim;
263
+ bestMatch = word;
264
+ }
265
+ }
266
+ }
267
+
268
+ // Also check multi-word phrases (sliding window)
269
+ const queryWords = q.split(/\s+/).length;
270
+ if (queryWords > 1 && words.length >= queryWords) {
271
+ for (let i = 0; i <= words.length - queryWords; i++) {
272
+ const phrase = words.slice(i, i + queryWords).join(" ");
273
+ const phraseTrigrams = this.trigrams(phrase);
274
+ const trigramSim = this.trigramSimilarity(
275
+ queryTrigrams,
276
+ phraseTrigrams
277
+ );
278
+
279
+ if (trigramSim >= trigramThreshold) {
280
+ const levSim = this.levenshteinSimilarity(q, phrase);
281
+ if (levSim > bestScore) {
282
+ bestScore = levSim;
283
+ bestMatch = phrase;
284
+ }
285
+ }
286
+ }
287
+ }
288
+
289
+ if (bestScore > 0) {
290
+ return { score: bestScore, matched: bestMatch };
291
+ }
292
+ return null;
293
+ },
294
+ };