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.
- 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/__init__.py
ADDED
jxa_mail_mcp/builders.py
ADDED
|
@@ -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")
|
jxa_mail_mcp/executor.py
ADDED
|
@@ -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,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
|
+
};
|