apple-tools-mcp 3.0.5 → 3.0.6
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.
- package/README.md +15 -2
- package/index.js +39 -1
- package/lib/indexerRuntime.js +89 -0
- package/lib/mailFind.js +323 -0
- package/lib/mailWrite.js +168 -0
- package/lib/shell.js +5 -0
- package/lib/writeTools.js +2 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -103,6 +103,16 @@ Claude Desktop keeps this in `~/Library/Application Support/Claude/claude_deskto
|
|
|
103
103
|
|
|
104
104
|
The MCP process is short-lived: it exits when the client disconnects. Ongoing indexing belongs on the indexer, not on a wrapper that holds this process open.
|
|
105
105
|
|
|
106
|
+
When stdin closes, requests already received still get their replies before the process exits (up to two minutes). A script can pipe a whole session in one go:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
printf '%s\n' \
|
|
110
|
+
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"script","version":"0"}}}' \
|
|
111
|
+
'{"jsonrpc":"2.0","method":"notifications/initialized"}' \
|
|
112
|
+
'{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"mail_search","arguments":{"query":"invoice","limit":5}}}' \
|
|
113
|
+
| apple-tools-mcp
|
|
114
|
+
```
|
|
115
|
+
|
|
106
116
|
### 4. Indexer
|
|
107
117
|
|
|
108
118
|
On first use the server builds a search index of your mail, messages, and calendar events. That can take a while if you have a lot of mail. The index is stored in `~/.apple-tools-mcp/vector-index/`.
|
|
@@ -248,6 +258,7 @@ Your client can also call `audit_index`.
|
|
|
248
258
|
| `mail_read` | Full email content by file path. |
|
|
249
259
|
| `mail_senders` | Most frequent senders. |
|
|
250
260
|
| `mail_thread` | Every email in a conversation. |
|
|
261
|
+
| `mail_find` | Exact lookup in Mail's own database by subject prefix or text, sender, recipient, mailbox, and date. Returns every copy with its Message-ID. Does not wait on the index. |
|
|
251
262
|
|
|
252
263
|
### Messages
|
|
253
264
|
|
|
@@ -294,7 +305,7 @@ Every write tool accepts:
|
|
|
294
305
|
| `dry_run` | Preview only. Nothing is sent or changed. This wins even if `confirm` is also set. |
|
|
295
306
|
| `confirm` | Approves an action that is otherwise blocked. |
|
|
296
307
|
|
|
297
|
-
Deletes need `confirm: true`: `mail_trash`, `calendar_remove`, `contacts_remove`, and `contacts_edit` when it clears every email or phone.
|
|
308
|
+
Deletes need `confirm: true`: `mail_trash`, `calendar_remove`, `contacts_remove`, and `contacts_edit` when it clears every email or phone. These take one id per call, except `mail_trash`, which also takes a list (`message_ids`).
|
|
298
309
|
|
|
299
310
|
Sends to more than one person need `confirm: true`: `mail_send` and `mail_forward` when To, Cc, and Bcc add up to more than one address, `mail_reply` with `reply_all: true`, and `messages_send` to several people or to a group chat. A single recipient sends on the first call. `mail_draft` never sends, so it does not need that confirm.
|
|
300
311
|
|
|
@@ -314,10 +325,12 @@ A missing or malformed id or recipient is refused. Calendar times are local date
|
|
|
314
325
|
| `mail_forward` | `message_id` or `file_path`, `to[]` (required), `body`, `save_as_draft` | when recipients > 1 |
|
|
315
326
|
| `mail_mark` | `message_id` or `file_path`, `status` (`read` or `unread`, default `read`) | no |
|
|
316
327
|
| `mail_archive` | `message_id` or `file_path` | no |
|
|
317
|
-
| `mail_trash` | `message_id` or `file_path` | yes |
|
|
328
|
+
| `mail_trash` | `message_id` or `file_path`, or `message_ids[]` (up to 500) | yes |
|
|
318
329
|
|
|
319
330
|
Pass the RFC822 Message-ID, or the `file_path` from `mail_search` or `mail_recent` and the server reads the Message-ID from the file. `mail_archive` moves the message to that account's Archive (or All Mail). `mail_trash` moves it to Trash.
|
|
320
331
|
|
|
332
|
+
`mail_trash` with `message_ids` moves every copy of each message, for example the INBOX copy and the Sent copy of a mail you sent yourself, to that account's Trash. It finds each copy through Mail's database (`mail_find` uses the same lookup) and checks its Message-ID again before it moves it. One call stops starting new moves after about 35 seconds and reports `remaining`. Run it again for the rest. Copies that are already in Trash are left alone. Nothing is deleted for good. Mail empties Trash on its own schedule.
|
|
333
|
+
|
|
321
334
|
`body_format: "html"` pastes the text into Mail's compose window. Mail may still create its own HTML version. New mail is a new message, not a quoted reply. Reply and forward still include the original.
|
|
322
335
|
|
|
323
336
|
A send is successful only after the message is in **Sent** or still in **Outbox**. If the call times out, check Sent before you try again. Retrying a message that already went out sends a second copy. A timeout is not the same thing as a denied Automation prompt.
|
package/index.js
CHANGED
|
@@ -21,6 +21,8 @@ import { createIndexerLock, DEFAULT_LOCK_HEARTBEAT_MS } from "./lib/indexerLock.
|
|
|
21
21
|
import {
|
|
22
22
|
shouldConnectMcpStdio,
|
|
23
23
|
bindStdinCloseExit,
|
|
24
|
+
trackInFlightRequests,
|
|
25
|
+
drainInFlightThenExit,
|
|
24
26
|
beginIndexCycle,
|
|
25
27
|
applyIndexerCycleEnd,
|
|
26
28
|
mcpIndexingStartup,
|
|
@@ -38,6 +40,7 @@ import {
|
|
|
38
40
|
import { startWriteBridgeServer, defaultSocketPath } from "./lib/writeBridge.js";
|
|
39
41
|
import { closeEventKitSession, ensureEventKitSession } from "./lib/eventKitSession.js";
|
|
40
42
|
import { EVENTKIT_JXA_HELPERS } from "./lib/calendarWrite.js";
|
|
43
|
+
import { mailFind, MAILBOX_KINDS, MAIL_FIND_MAX_LIMIT } from "./lib/mailFind.js";
|
|
41
44
|
|
|
42
45
|
const PACKAGE_VERSION = JSON.parse(
|
|
43
46
|
fs.readFileSync(new URL("./package.json", import.meta.url), "utf8")
|
|
@@ -166,9 +169,17 @@ process.on("unhandledRejection", (reason, promise) => {
|
|
|
166
169
|
// MCP stdio clients exit when the host closes stdin. The indexer daemon,
|
|
167
170
|
// HTTP server, and short-lived CLIs must not — LaunchAgent / KeepAlive
|
|
168
171
|
// often attaches stdin to /dev/null, and the CLIs exit on their own.
|
|
172
|
+
// A caller that pipes a batch and closes stdin still gets every reply:
|
|
173
|
+
// in-flight requests finish (up to STDIN_CLOSE_DRAIN_MS) before exit.
|
|
174
|
+
const STDIN_CLOSE_DRAIN_MS = 120000;
|
|
175
|
+
let stdioInFlight = null;
|
|
169
176
|
bindStdinCloseExit(process.stdin, INDEXER_MODE || PERMISSIONS_MODE || HTTP_MODE || HTTP_TOKEN_MODE, () => {
|
|
170
177
|
console.error("Client disconnected. Exiting.");
|
|
171
|
-
|
|
178
|
+
drainInFlightThenExit({
|
|
179
|
+
inFlight: stdioInFlight,
|
|
180
|
+
timeoutMs: STDIN_CLOSE_DRAIN_MS,
|
|
181
|
+
exit: () => shutdownIndexing(0)
|
|
182
|
+
});
|
|
172
183
|
});
|
|
173
184
|
|
|
174
185
|
// Vector search imports
|
|
@@ -1141,6 +1152,28 @@ function createServer() {
|
|
|
1141
1152
|
required: ["file_path"],
|
|
1142
1153
|
},
|
|
1143
1154
|
},
|
|
1155
|
+
{
|
|
1156
|
+
name: "mail_find",
|
|
1157
|
+
description: "Exact email lookup in Mail's own database (not the semantic index, so it never waits on indexing and matches filters exactly). Returns JSON: one row per copy (the same Message-ID in INBOX and Sent is two rows) with message_id, subject, from, to, received, mailbox, account_id. Needs at least one of subject_prefix, subject_contains, from, to, message_ids. Trash is excluded unless include_trash or mailboxes asks for it. Pair with mail_trash message_ids to clear what it finds.",
|
|
1158
|
+
inputSchema: {
|
|
1159
|
+
type: "object",
|
|
1160
|
+
properties: {
|
|
1161
|
+
subject_prefix: { type: "string", description: "Subject starts with this text (case-insensitive), e.g. '[Network] '" },
|
|
1162
|
+
subject_contains: { type: "string", description: "Subject contains this text (case-insensitive)" },
|
|
1163
|
+
from: { type: "string", description: "Exact sender address" },
|
|
1164
|
+
from_me: { type: "boolean", description: "Only messages sent from one of your own addresses (any address that appears as a sender in a Sent mailbox)" },
|
|
1165
|
+
to: { type: "string", description: "Exact recipient address (To or Cc)" },
|
|
1166
|
+
to_only: { type: "boolean", description: "With to: that address is the only recipient" },
|
|
1167
|
+
message_ids: { type: "array", items: { type: "string" }, description: "RFC822 Message-IDs (finds every copy)" },
|
|
1168
|
+
mailboxes: { type: "array", items: { type: "string", enum: MAILBOX_KINDS }, description: "Only these mailbox kinds (default: every mailbox except trash)" },
|
|
1169
|
+
include_trash: { type: "boolean", description: "Also return copies already in Trash (default false)" },
|
|
1170
|
+
older_than_hours: { type: "number", description: "Only messages received more than this many hours ago" },
|
|
1171
|
+
received_after: { type: "string", description: "ISO 8601 date/time; only messages received at or after it" },
|
|
1172
|
+
received_before: { type: "string", description: "ISO 8601 date/time; only messages received before it" },
|
|
1173
|
+
limit: { type: "number", description: `Maximum rows (default 50, max ${MAIL_FIND_MAX_LIMIT}); 'truncated' says when more matched` }
|
|
1174
|
+
},
|
|
1175
|
+
},
|
|
1176
|
+
},
|
|
1144
1177
|
|
|
1145
1178
|
// ============ MESSAGES TOOLS ============
|
|
1146
1179
|
{
|
|
@@ -1443,6 +1476,10 @@ function createServer() {
|
|
|
1443
1476
|
result = readFullEmail(args.file_path);
|
|
1444
1477
|
break;
|
|
1445
1478
|
|
|
1479
|
+
case "mail_find":
|
|
1480
|
+
result = mailFind(args || {});
|
|
1481
|
+
break;
|
|
1482
|
+
|
|
1446
1483
|
// Messages tools
|
|
1447
1484
|
case "messages_search":
|
|
1448
1485
|
result = await messagesSearch(args.query, {
|
|
@@ -1624,6 +1661,7 @@ const server = createServer();
|
|
|
1624
1661
|
async function main() {
|
|
1625
1662
|
const transport = new StdioServerTransport();
|
|
1626
1663
|
await server.connect(transport);
|
|
1664
|
+
stdioInFlight = trackInFlightRequests(transport);
|
|
1627
1665
|
console.error(`Apple Tools MCP server running (v${PACKAGE_VERSION})`);
|
|
1628
1666
|
// Background indexing: indexer daemon when --mode=indexer; otherwise local
|
|
1629
1667
|
// fallback on this stdio process only if indexer.lock is free.
|
package/lib/indexerRuntime.js
CHANGED
|
@@ -37,6 +37,95 @@ export function bindStdinCloseExit(stdin, indexerMode, onClose) {
|
|
|
37
37
|
return { bound: true };
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Track JSON-RPC requests the server has received but not yet answered.
|
|
42
|
+
* A caller that pipes a batch and closes stdin (`printf ... | apple-tools-mcp`)
|
|
43
|
+
* fires stdin `close` right after the last line is read, while tools/call is
|
|
44
|
+
* still running; without this the close handler exits mid-call.
|
|
45
|
+
* Call after server.connect(transport) — Protocol sets onmessage there.
|
|
46
|
+
*
|
|
47
|
+
* @param {{ onmessage?: (message: any, extra?: any) => void, send: (message: any, options?: any) => Promise<void> }} transport
|
|
48
|
+
* @returns {{ readonly size: number, whenIdle: () => Promise<void> }}
|
|
49
|
+
*/
|
|
50
|
+
export function trackInFlightRequests(transport) {
|
|
51
|
+
const pending = new Set();
|
|
52
|
+
let idleWaiters = [];
|
|
53
|
+
const settle = () => {
|
|
54
|
+
if (pending.size > 0) return;
|
|
55
|
+
const waiters = idleWaiters;
|
|
56
|
+
idleWaiters = [];
|
|
57
|
+
for (const resolve of waiters) resolve();
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const onmessage = transport.onmessage;
|
|
61
|
+
transport.onmessage = (message, extra) => {
|
|
62
|
+
if (message?.method === "notifications/cancelled") {
|
|
63
|
+
// The SDK sends no response for a cancelled request.
|
|
64
|
+
pending.delete(message.params?.requestId);
|
|
65
|
+
settle();
|
|
66
|
+
} else if (message?.method && message.id !== undefined) {
|
|
67
|
+
pending.add(message.id);
|
|
68
|
+
}
|
|
69
|
+
onmessage?.(message, extra);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const send = transport.send.bind(transport);
|
|
73
|
+
transport.send = async (message, options) => {
|
|
74
|
+
try {
|
|
75
|
+
return await send(message, options);
|
|
76
|
+
} finally {
|
|
77
|
+
if (!message?.method && message?.id !== undefined && pending.delete(message.id)) {
|
|
78
|
+
settle();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
get size() {
|
|
85
|
+
return pending.size;
|
|
86
|
+
},
|
|
87
|
+
whenIdle() {
|
|
88
|
+
if (pending.size === 0) return Promise.resolve();
|
|
89
|
+
return new Promise((resolve) => idleWaiters.push(resolve));
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* On stdin close: let in-flight requests answer (up to timeoutMs), then exit.
|
|
96
|
+
* Exiting synchronously drops the last tools/call. The SDK's send() resolves
|
|
97
|
+
* only once stdout has drained, so an answered request is fully written.
|
|
98
|
+
*
|
|
99
|
+
* @param {{
|
|
100
|
+
* inFlight: { size: number, whenIdle: () => Promise<void> } | null,
|
|
101
|
+
* timeoutMs: number,
|
|
102
|
+
* exit: () => void,
|
|
103
|
+
* log?: (msg: string) => void
|
|
104
|
+
* }} options
|
|
105
|
+
* @returns {Promise<{ drained: boolean, pending: number }>}
|
|
106
|
+
*/
|
|
107
|
+
export async function drainInFlightThenExit(options) {
|
|
108
|
+
const { inFlight, timeoutMs, exit } = options;
|
|
109
|
+
const log = options.log || ((msg) => console.error(msg));
|
|
110
|
+
|
|
111
|
+
const pending = inFlight ? inFlight.size : 0;
|
|
112
|
+
let drained = true;
|
|
113
|
+
if (pending > 0) {
|
|
114
|
+
log(`Waiting for ${pending} in-flight request(s) before exit...`);
|
|
115
|
+
let timer;
|
|
116
|
+
const timedOut = new Promise((resolve) => {
|
|
117
|
+
timer = setTimeout(() => resolve(true), timeoutMs);
|
|
118
|
+
});
|
|
119
|
+
drained = !(await Promise.race([inFlight.whenIdle().then(() => false), timedOut]));
|
|
120
|
+
clearTimeout(timer);
|
|
121
|
+
if (!drained) {
|
|
122
|
+
log(`Gave up on ${inFlight.size} in-flight request(s) after ${timeoutMs}ms.`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
exit();
|
|
126
|
+
return { drained, pending };
|
|
127
|
+
}
|
|
128
|
+
|
|
40
129
|
/**
|
|
41
130
|
* Start one index cycle, or skip if a cycle is already running.
|
|
42
131
|
*
|
package/lib/mailFind.js
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exact mail lookup against Mail's own Envelope Index (no vector index).
|
|
3
|
+
*
|
|
4
|
+
* mail_find answers "which messages match these exact facts" (subject
|
|
5
|
+
* prefix, sender, recipient, mailbox, date) and bulk mail_trash uses
|
|
6
|
+
* locateMessageCopies() to address every copy of a Message-ID by Mail's
|
|
7
|
+
* internal id. A message's AppleScript `id` is its Envelope Index ROWID, so
|
|
8
|
+
* `messages of <one mailbox> whose id is N` resolves in about 2s even on a
|
|
9
|
+
* 60k-message INBOX, where `whose message id is X` across every mailbox
|
|
10
|
+
* takes 30-38s (see MAIL_FIND_TIMEOUT_MS in mailWrite.js).
|
|
11
|
+
*
|
|
12
|
+
* Read-only: sqlite3 opens the database with -readonly and Mail stays its
|
|
13
|
+
* only writer. Needs the same Full Disk Access as the .emlx indexer.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import fs from "fs";
|
|
17
|
+
import path from "path";
|
|
18
|
+
import { safeSqlite3Json } from "./shell.js";
|
|
19
|
+
import { normalizeList, validateMessageId } from "./writeGuards.js";
|
|
20
|
+
|
|
21
|
+
const MAIL_DIR = path.join(process.env.HOME || "", "Library", "Mail");
|
|
22
|
+
|
|
23
|
+
export const MAIL_FIND_DEFAULT_LIMIT = 50;
|
|
24
|
+
export const MAIL_FIND_MAX_LIMIT = 500;
|
|
25
|
+
export const MAIL_FIND_MAX_IDS = 500;
|
|
26
|
+
const MAX_TEXT_FILTER = 300;
|
|
27
|
+
const MAX_COPIES = 5000;
|
|
28
|
+
|
|
29
|
+
/** Mailbox leaf names per kind, as Mail names them across iCloud, IMAP, Exchange and Gmail. */
|
|
30
|
+
export const MAILBOX_KIND_LEAVES = {
|
|
31
|
+
inbox: ["INBOX"],
|
|
32
|
+
sent: ["Sent Messages", "Sent", "Sent Items", "Sent Mail"],
|
|
33
|
+
trash: ["Deleted Messages", "Trash", "Bin", "Deleted Items"],
|
|
34
|
+
junk: ["Junk", "Junk E-mail", "Spam"],
|
|
35
|
+
drafts: ["Drafts"],
|
|
36
|
+
archive: ["Archive", "All Mail"]
|
|
37
|
+
};
|
|
38
|
+
export const MAILBOX_KINDS = Object.keys(MAILBOX_KIND_LEAVES);
|
|
39
|
+
export const TRASH_MAILBOX_NAMES = MAILBOX_KIND_LEAVES.trash;
|
|
40
|
+
|
|
41
|
+
const ACCOUNT_ID_RE = /^[0-9A-Fa-f-]{8,64}$/;
|
|
42
|
+
const ADDRESS_RE = /^[^\s@'"]{1,200}@[^\s@'"]{1,200}$/;
|
|
43
|
+
|
|
44
|
+
export const ENVELOPE_INDEX_ACCESS_GUIDANCE =
|
|
45
|
+
"Mail's Envelope Index could not be read. Give the node binary that runs Apple Tools Full Disk Access " +
|
|
46
|
+
"(System Settings → Privacy & Security → Full Disk Access), and make sure Mail has been set up on this Mac.";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Newest `~/Library/Mail/V<n>/MailData/Envelope Index`, or null.
|
|
50
|
+
*/
|
|
51
|
+
export function envelopeIndexPath(mailDir = MAIL_DIR) {
|
|
52
|
+
let entries;
|
|
53
|
+
try {
|
|
54
|
+
entries = fs.readdirSync(mailDir);
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
const versions = entries
|
|
59
|
+
.map((name) => /^V(\d+)$/.exec(name))
|
|
60
|
+
.filter(Boolean)
|
|
61
|
+
.map((m) => Number(m[1]))
|
|
62
|
+
.sort((a, b) => b - a);
|
|
63
|
+
for (const v of versions) {
|
|
64
|
+
const candidate = path.join(mailDir, `V${v}`, "MailData", "Envelope Index");
|
|
65
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* `imap://<account uuid>/Sent%20Messages` → account id + decoded mailbox path.
|
|
72
|
+
* Local ("On My Mac") mailboxes have no account id and cannot be addressed
|
|
73
|
+
* by `account id` in AppleScript.
|
|
74
|
+
*/
|
|
75
|
+
export function parseMailboxUrl(url) {
|
|
76
|
+
const m = /^([a-z][a-z0-9+.-]*):\/\/([^/]*)\/(.+)$/i.exec(String(url || ""));
|
|
77
|
+
if (!m) return null;
|
|
78
|
+
let mailboxPath;
|
|
79
|
+
try {
|
|
80
|
+
mailboxPath = m[3].split("/").map((part) => decodeURIComponent(part)).join("/");
|
|
81
|
+
} catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
const accountId = ACCOUNT_ID_RE.test(m[2]) ? m[2] : null;
|
|
85
|
+
return { scheme: m[1].toLowerCase(), accountId, mailboxPath };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function mailboxKind(mailboxPath) {
|
|
89
|
+
const leaf = String(mailboxPath || "").split("/").pop().toLowerCase();
|
|
90
|
+
for (const [kind, leaves] of Object.entries(MAILBOX_KIND_LEAVES)) {
|
|
91
|
+
if (leaves.some((name) => name.toLowerCase() === leaf)) return kind;
|
|
92
|
+
}
|
|
93
|
+
return "other";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function sqlLiteral(value) {
|
|
97
|
+
return `'${String(value).replace(/'/g, "''")}'`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function likeEscape(value) {
|
|
101
|
+
return String(value).replace(/[\\%_]/g, (c) => `\\${c}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function likeClause(expr, pattern) {
|
|
105
|
+
return `${expr} LIKE ${sqlLiteral(pattern)} ESCAPE '\\'`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** SQL condition: mailbox URL `column` ends in one of `kind`'s leaf names. */
|
|
109
|
+
export function mailboxKindCondition(kind, column = "mb.url") {
|
|
110
|
+
const leaves = MAILBOX_KIND_LEAVES[kind] || [];
|
|
111
|
+
return `(${leaves.map((leaf) => likeClause(column, `%/${likeEscape(encodeURIComponent(leaf))}`)).join(" OR ")})`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function cleanText(value, field) {
|
|
115
|
+
if (value === undefined || value === null || value === "") return { value: null };
|
|
116
|
+
if (typeof value !== "string") return { error: `${field} must be a string` };
|
|
117
|
+
const trimmed = value.replace(/\s+/g, " ").trim();
|
|
118
|
+
if (!trimmed) return { value: null };
|
|
119
|
+
if (trimmed.length > MAX_TEXT_FILTER) return { error: `${field} is longer than ${MAX_TEXT_FILTER} characters` };
|
|
120
|
+
return { value: trimmed };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function cleanAddress(value, field) {
|
|
124
|
+
const text = cleanText(value, field);
|
|
125
|
+
if (text.error || !text.value) return text;
|
|
126
|
+
if (!ADDRESS_RE.test(text.value)) return { error: `${field} must be one email address` };
|
|
127
|
+
return { value: text.value.toLowerCase() };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function parseTime(value, field) {
|
|
131
|
+
if (value === undefined || value === null || value === "") return { value: null };
|
|
132
|
+
const ms = typeof value === "number" ? value * 1000 : Date.parse(String(value));
|
|
133
|
+
if (!Number.isFinite(ms)) return { error: `${field} must be an ISO 8601 date/time` };
|
|
134
|
+
return { value: Math.floor(ms / 1000) };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Validate mail_find arguments into SQL-ready filters.
|
|
139
|
+
* @returns {{ filters?: object, error?: string }}
|
|
140
|
+
*/
|
|
141
|
+
export function validateFindArgs(args = {}, { now = Date.now() } = {}) {
|
|
142
|
+
const f = {};
|
|
143
|
+
for (const [key, field] of [["subjectPrefix", "subject_prefix"], ["subjectContains", "subject_contains"]]) {
|
|
144
|
+
const r = cleanText(args[field], field);
|
|
145
|
+
if (r.error) return { error: r.error };
|
|
146
|
+
f[key] = r.value;
|
|
147
|
+
}
|
|
148
|
+
for (const [key, field] of [["from", "from"], ["to", "to"]]) {
|
|
149
|
+
const r = cleanAddress(args[field], field);
|
|
150
|
+
if (r.error) return { error: r.error };
|
|
151
|
+
f[key] = r.value;
|
|
152
|
+
}
|
|
153
|
+
f.toOnly = args.to_only === true || args.to_only === "true";
|
|
154
|
+
if (f.toOnly && !f.to) return { error: "to_only needs to" };
|
|
155
|
+
f.fromMe = args.from_me === true || args.from_me === "true";
|
|
156
|
+
|
|
157
|
+
const ids = normalizeList(args.message_ids);
|
|
158
|
+
if (ids.length > MAIL_FIND_MAX_IDS) return { error: `message_ids accepts at most ${MAIL_FIND_MAX_IDS} ids` };
|
|
159
|
+
f.messageIds = [];
|
|
160
|
+
for (const raw of ids) {
|
|
161
|
+
const id = validateMessageId(raw);
|
|
162
|
+
if (!id) return { error: "message_ids contains an invalid RFC822 Message-ID" };
|
|
163
|
+
f.messageIds.push(id);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const mailboxes = normalizeList(args.mailboxes).map((m) => m.toLowerCase());
|
|
167
|
+
const unknown = mailboxes.filter((m) => !MAILBOX_KINDS.includes(m));
|
|
168
|
+
if (unknown.length) return { error: `mailboxes must be from: ${MAILBOX_KINDS.join(", ")}` };
|
|
169
|
+
f.mailboxes = mailboxes;
|
|
170
|
+
f.includeTrash = args.include_trash === true || args.include_trash === "true";
|
|
171
|
+
|
|
172
|
+
const after = parseTime(args.received_after, "received_after");
|
|
173
|
+
if (after.error) return { error: after.error };
|
|
174
|
+
const before = parseTime(args.received_before, "received_before");
|
|
175
|
+
if (before.error) return { error: before.error };
|
|
176
|
+
f.receivedAfter = after.value;
|
|
177
|
+
f.receivedBefore = before.value;
|
|
178
|
+
if (args.older_than_hours !== undefined && args.older_than_hours !== null && args.older_than_hours !== "") {
|
|
179
|
+
const hours = Number(args.older_than_hours);
|
|
180
|
+
if (!Number.isFinite(hours) || hours < 0 || hours > 24 * 365 * 20) {
|
|
181
|
+
return { error: "older_than_hours must be a number of hours (0 or more)" };
|
|
182
|
+
}
|
|
183
|
+
const cutoff = Math.floor(now / 1000 - hours * 3600);
|
|
184
|
+
f.receivedBefore = f.receivedBefore === null ? cutoff : Math.min(f.receivedBefore, cutoff);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (!f.subjectPrefix && !f.subjectContains && !f.from && !f.to && f.messageIds.length === 0) {
|
|
188
|
+
return { error: "add at least one of subject_prefix, subject_contains, from, to, or message_ids" };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const limit = args.limit === undefined || args.limit === null ? MAIL_FIND_DEFAULT_LIMIT : Number(args.limit);
|
|
192
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > MAIL_FIND_MAX_LIMIT) {
|
|
193
|
+
return { error: `limit must be an integer from 1 to ${MAIL_FIND_MAX_LIMIT}` };
|
|
194
|
+
}
|
|
195
|
+
f.limit = limit;
|
|
196
|
+
return { filters: f };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const SUBJECT_EXPR = "(coalesce(m.subject_prefix, '') || s.subject)";
|
|
200
|
+
|
|
201
|
+
/** Build the Envelope Index query for validated filters. Selects limit+1 rows to detect truncation. */
|
|
202
|
+
export function buildFindQuery(f) {
|
|
203
|
+
const where = ["m.deleted = 0"];
|
|
204
|
+
if (f.subjectPrefix) where.push(likeClause(SUBJECT_EXPR, `${likeEscape(f.subjectPrefix)}%`));
|
|
205
|
+
if (f.subjectContains) where.push(likeClause(SUBJECT_EXPR, `%${likeEscape(f.subjectContains)}%`));
|
|
206
|
+
if (f.from) where.push(`lower(a.address) = ${sqlLiteral(f.from)}`);
|
|
207
|
+
if (f.to) {
|
|
208
|
+
where.push(
|
|
209
|
+
`EXISTS (SELECT 1 FROM recipients r JOIN addresses ra ON ra.ROWID = r.address WHERE r.message = m.ROWID AND lower(ra.address) = ${sqlLiteral(f.to)})`
|
|
210
|
+
);
|
|
211
|
+
if (f.toOnly) where.push("(SELECT count(*) FROM recipients r WHERE r.message = m.ROWID) = 1");
|
|
212
|
+
}
|
|
213
|
+
if (f.fromMe) {
|
|
214
|
+
// "Me" = any address that appears as the sender of a message in a Sent mailbox.
|
|
215
|
+
where.push(
|
|
216
|
+
`m.sender IN (SELECT DISTINCT m2.sender FROM messages m2 JOIN mailboxes mb2 ON mb2.ROWID = m2.mailbox WHERE m2.sender IS NOT NULL AND ${mailboxKindCondition("sent", "mb2.url")})`
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
if (f.messageIds.length) {
|
|
220
|
+
where.push(`g.message_id_header IN (${f.messageIds.map((id) => sqlLiteral(`<${id}>`)).join(", ")})`);
|
|
221
|
+
}
|
|
222
|
+
if (f.receivedAfter !== null) where.push(`m.date_received >= ${Math.floor(f.receivedAfter)}`);
|
|
223
|
+
if (f.receivedBefore !== null) where.push(`m.date_received < ${Math.floor(f.receivedBefore)}`);
|
|
224
|
+
if (f.mailboxes.length) {
|
|
225
|
+
where.push(`(${f.mailboxes.map((kind) => mailboxKindCondition(kind)).join(" OR ")})`);
|
|
226
|
+
} else if (!f.includeTrash) {
|
|
227
|
+
where.push(`NOT ${mailboxKindCondition("trash")}`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return `SELECT m.ROWID AS id, g.message_id_header AS message_id, ${SUBJECT_EXPR} AS subject,
|
|
231
|
+
a.address AS sender, m.date_received AS received, mb.url AS mailbox_url,
|
|
232
|
+
(SELECT group_concat(ra.address, ',') FROM recipients r JOIN addresses ra ON ra.ROWID = r.address WHERE r.message = m.ROWID) AS recipients
|
|
233
|
+
FROM messages m
|
|
234
|
+
JOIN subjects s ON s.ROWID = m.subject
|
|
235
|
+
JOIN mailboxes mb ON mb.ROWID = m.mailbox
|
|
236
|
+
LEFT JOIN addresses a ON a.ROWID = m.sender
|
|
237
|
+
LEFT JOIN message_global_data g ON g.ROWID = m.global_message_id
|
|
238
|
+
WHERE ${where.join("\n AND ")}
|
|
239
|
+
ORDER BY m.date_received DESC, m.ROWID DESC
|
|
240
|
+
LIMIT ${f.limit + 1}`;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function mapRow(row) {
|
|
244
|
+
const box = parseMailboxUrl(row.mailbox_url) || { accountId: null, mailboxPath: String(row.mailbox_url || "") };
|
|
245
|
+
return {
|
|
246
|
+
id: row.id,
|
|
247
|
+
message_id: row.message_id ? String(row.message_id).replace(/^</, "").replace(/>$/, "") : null,
|
|
248
|
+
subject: row.subject || "",
|
|
249
|
+
from: row.sender || null,
|
|
250
|
+
to: row.recipients ? String(row.recipients).split(",") : [],
|
|
251
|
+
received: Number.isFinite(row.received) ? new Date(row.received * 1000).toISOString() : null,
|
|
252
|
+
mailbox: mailboxKind(box.mailboxPath),
|
|
253
|
+
mailbox_path: box.mailboxPath,
|
|
254
|
+
account_id: box.accountId
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function runQuery(sql, { dbPath, query = safeSqlite3Json } = {}) {
|
|
259
|
+
const db = dbPath || envelopeIndexPath();
|
|
260
|
+
if (!db) throw new Error(ENVELOPE_INDEX_ACCESS_GUIDANCE);
|
|
261
|
+
try {
|
|
262
|
+
return query(db, sql, { readonly: true, timeout: 20000 });
|
|
263
|
+
} catch (e) {
|
|
264
|
+
const msg = String((e && e.message) || e);
|
|
265
|
+
if (/unable to open|authoriz|not permitted|denied/i.test(msg)) throw new Error(ENVELOPE_INDEX_ACCESS_GUIDANCE);
|
|
266
|
+
throw e;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Exact lookup. Every copy is its own row (the same Message-ID in INBOX and
|
|
272
|
+
* Sent shows twice).
|
|
273
|
+
* @returns {{ messages: object[], truncated: boolean }}
|
|
274
|
+
*/
|
|
275
|
+
export function findMail(filters, options = {}) {
|
|
276
|
+
const rows = runQuery(buildFindQuery(filters), options);
|
|
277
|
+
const truncated = rows.length > filters.limit;
|
|
278
|
+
return { messages: rows.slice(0, filters.limit).map(mapRow), truncated };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Every live copy of each Message-ID, split into copies outside Trash (to
|
|
283
|
+
* move) and the ids that have no such copy (already trashed or not in Mail).
|
|
284
|
+
*/
|
|
285
|
+
export function locateMessageCopies(messageIds, options = {}) {
|
|
286
|
+
const filters = {
|
|
287
|
+
subjectPrefix: null,
|
|
288
|
+
subjectContains: null,
|
|
289
|
+
from: null,
|
|
290
|
+
to: null,
|
|
291
|
+
toOnly: false,
|
|
292
|
+
fromMe: false,
|
|
293
|
+
messageIds,
|
|
294
|
+
mailboxes: [],
|
|
295
|
+
includeTrash: false,
|
|
296
|
+
receivedAfter: null,
|
|
297
|
+
receivedBefore: null,
|
|
298
|
+
limit: MAX_COPIES
|
|
299
|
+
};
|
|
300
|
+
const { messages } = findMail(filters, options);
|
|
301
|
+
const found = new Set(messages.map((m) => m.message_id));
|
|
302
|
+
return { copies: messages, missingIds: messageIds.filter((id) => !found.has(id)) };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function formatFindResult({ messages, truncated }) {
|
|
306
|
+
return JSON.stringify(
|
|
307
|
+
{
|
|
308
|
+
count: messages.length,
|
|
309
|
+
truncated,
|
|
310
|
+
note: truncated ? "More messages match; raise limit or narrow the filters." : undefined,
|
|
311
|
+
messages
|
|
312
|
+
},
|
|
313
|
+
null,
|
|
314
|
+
2
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** mail_find tool entry point. Returns text; throws on invalid args or an unreadable index. */
|
|
319
|
+
export function mailFind(args = {}, options = {}) {
|
|
320
|
+
const { filters, error } = validateFindArgs(args, options);
|
|
321
|
+
if (error) throw new Error(`mail_find: ${error}`);
|
|
322
|
+
return formatFindResult(findMail(filters, options));
|
|
323
|
+
}
|
package/lib/mailWrite.js
CHANGED
|
@@ -42,8 +42,10 @@ import {
|
|
|
42
42
|
writeErrorMessage,
|
|
43
43
|
writeSuccessMessage,
|
|
44
44
|
isFlagTrue,
|
|
45
|
+
normalizeList,
|
|
45
46
|
truncate
|
|
46
47
|
} from "./writeGuards.js";
|
|
48
|
+
import { locateMessageCopies, TRASH_MAILBOX_NAMES, MAIL_FIND_MAX_IDS } from "./mailFind.js";
|
|
47
49
|
|
|
48
50
|
const MAIL_DIR = path.join(process.env.HOME || "", "Library", "Mail");
|
|
49
51
|
|
|
@@ -1785,8 +1787,174 @@ export function mailArchive(args = {}) {
|
|
|
1785
1787
|
return { ok: true, message: writeSuccessMessage(action, "moved to Archive", { message_id: resolved.messageId }) };
|
|
1786
1788
|
}
|
|
1787
1789
|
|
|
1790
|
+
// Bulk mail_trash stops starting new moves after this many seconds so the
|
|
1791
|
+
// call answers inside MCP_CALL_DEADLINE_MS. Each copy takes ~2s (one
|
|
1792
|
+
// `whose id is` lookup in its own mailbox, then the move); callers re-run
|
|
1793
|
+
// for whatever is left.
|
|
1794
|
+
export const MAIL_BULK_TRASH_BUDGET_SEC = 35;
|
|
1795
|
+
export const MAIL_TRASH_MAX_IDS = MAIL_FIND_MAX_IDS;
|
|
1796
|
+
|
|
1797
|
+
/**
|
|
1798
|
+
* One osascript for every copy. Each copy is found by Mail's internal id in
|
|
1799
|
+
* its own mailbox (fast) and moved only if its Message-ID still matches, so
|
|
1800
|
+
* a stale Envelope Index row can never move the wrong message.
|
|
1801
|
+
* Output: one `<id>\t<status>` line per copy; status is moved, missing,
|
|
1802
|
+
* mismatch, no_account, no_trash, skipped (time budget) or `error <n>`.
|
|
1803
|
+
*/
|
|
1804
|
+
export function buildBulkTrashScript(copies, { budgetSec = MAIL_BULK_TRASH_BUDGET_SEC } = {}) {
|
|
1805
|
+
const items = copies
|
|
1806
|
+
.map((c) => `{${asString(c.account_id)}, ${asString(c.mailbox_path)}, ${asInteger(c.id, { min: 1, field: "id" })}, ${asString(c.message_id)}}`)
|
|
1807
|
+
.join(", ");
|
|
1808
|
+
const trashNames = TRASH_MAILBOX_NAMES.map((name) => asString(name)).join(", ");
|
|
1809
|
+
return `on atmTrashBox(acct)
|
|
1810
|
+
tell application "Mail"
|
|
1811
|
+
repeat with nm in {${trashNames}}
|
|
1812
|
+
if (exists mailbox (contents of nm) of acct) then return mailbox (contents of nm) of acct
|
|
1813
|
+
end repeat
|
|
1814
|
+
end tell
|
|
1815
|
+
return missing value
|
|
1816
|
+
end atmTrashBox
|
|
1817
|
+
|
|
1818
|
+
on atmTrashCopy(acctId, boxPath, rid, mid)
|
|
1819
|
+
tell application "Mail"
|
|
1820
|
+
try
|
|
1821
|
+
set acct to account id acctId
|
|
1822
|
+
-- An account specifier resolves lazily; reading a property proves it exists.
|
|
1823
|
+
set acctName to name of acct
|
|
1824
|
+
on error
|
|
1825
|
+
return "no_account"
|
|
1826
|
+
end try
|
|
1827
|
+
try
|
|
1828
|
+
set hits to (messages of (mailbox boxPath of acct) whose id is rid)
|
|
1829
|
+
on error errMsg number errNum
|
|
1830
|
+
return "error " & errNum
|
|
1831
|
+
end try
|
|
1832
|
+
if (count of hits) is 0 then return "missing"
|
|
1833
|
+
set msg to item 1 of hits
|
|
1834
|
+
if (message id of msg) is not mid then return "mismatch"
|
|
1835
|
+
set trashBox to my atmTrashBox(acct)
|
|
1836
|
+
if trashBox is missing value then return "no_trash"
|
|
1837
|
+
try
|
|
1838
|
+
set mailbox of msg to trashBox
|
|
1839
|
+
on error errMsg number errNum
|
|
1840
|
+
return "error " & errNum
|
|
1841
|
+
end try
|
|
1842
|
+
return "moved"
|
|
1843
|
+
end tell
|
|
1844
|
+
end atmTrashCopy
|
|
1845
|
+
|
|
1846
|
+
set atmStart to current date
|
|
1847
|
+
set atmOut to ""
|
|
1848
|
+
repeat with atmItem in {${items}}
|
|
1849
|
+
if ((current date) - atmStart) > ${asInteger(budgetSec, { min: 1, max: 600, field: "budgetSec" })} then
|
|
1850
|
+
set atmStatus to "skipped"
|
|
1851
|
+
else
|
|
1852
|
+
set atmStatus to atmTrashCopy(item 1 of atmItem, item 2 of atmItem, item 3 of atmItem, item 4 of atmItem)
|
|
1853
|
+
end if
|
|
1854
|
+
set atmOut to atmOut & (item 3 of atmItem) & tab & atmStatus & linefeed
|
|
1855
|
+
end repeat
|
|
1856
|
+
return atmOut`;
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
export function parseBulkTrashOutput(output) {
|
|
1860
|
+
const statuses = new Map();
|
|
1861
|
+
for (const line of String(output || "").split(/\r?\n|\r/)) {
|
|
1862
|
+
const m = /^(\d+)\t(.+)$/.exec(line.trim());
|
|
1863
|
+
if (m) statuses.set(Number(m[1]), m[2].trim());
|
|
1864
|
+
}
|
|
1865
|
+
return statuses;
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
function plural(n, word, many = `${word}s`) {
|
|
1869
|
+
return `${n} ${n === 1 ? word : many}`;
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
/**
|
|
1873
|
+
* mail_trash with message_ids: move every copy (INBOX, Sent, Archive, ...)
|
|
1874
|
+
* of each message to its account's Trash.
|
|
1875
|
+
*/
|
|
1876
|
+
export function mailTrashMany(args = {}, { locate = locateMessageCopies, budgetSec = MAIL_BULK_TRASH_BUDGET_SEC } = {}) {
|
|
1877
|
+
const action = "mail_trash";
|
|
1878
|
+
if (args.message_id || args.file_path) {
|
|
1879
|
+
return { ok: false, message: `${action} refused: pass message_ids, or message_id / file_path — not both.` };
|
|
1880
|
+
}
|
|
1881
|
+
const raw = normalizeList(args.message_ids);
|
|
1882
|
+
if (raw.length === 0) return { ok: false, message: `${action} refused: message_ids is empty.` };
|
|
1883
|
+
if (raw.length > MAIL_TRASH_MAX_IDS) {
|
|
1884
|
+
return { ok: false, message: `${action} refused: message_ids accepts at most ${MAIL_TRASH_MAX_IDS} ids per call.` };
|
|
1885
|
+
}
|
|
1886
|
+
const ids = [];
|
|
1887
|
+
for (const value of raw) {
|
|
1888
|
+
const id = validateMessageId(value);
|
|
1889
|
+
if (!id) return { ok: false, message: `${action} refused: message_ids contains an invalid RFC822 Message-ID.` };
|
|
1890
|
+
if (!ids.includes(id)) ids.push(id);
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
let located;
|
|
1894
|
+
try {
|
|
1895
|
+
located = locate(ids);
|
|
1896
|
+
} catch (e) {
|
|
1897
|
+
return { ok: false, message: `${action} failed — could not look the messages up. ${e.message}` };
|
|
1898
|
+
}
|
|
1899
|
+
const live = located.copies.filter((c) => c.mailbox !== "trash");
|
|
1900
|
+
const movable = live.filter((c) => c.account_id);
|
|
1901
|
+
const unaddressable = live.length - movable.length;
|
|
1902
|
+
const summary = `move ${plural(movable.length, "copy", "copies")} of ${plural(ids.length - located.missingIds.length, "message")} to Trash` +
|
|
1903
|
+
(located.missingIds.length ? ` (${located.missingIds.length} not found outside Trash)` : "");
|
|
1904
|
+
|
|
1905
|
+
const plan = planWrite({
|
|
1906
|
+
action,
|
|
1907
|
+
summary,
|
|
1908
|
+
destructive: true,
|
|
1909
|
+
dryRun: isFlagTrue(args.dry_run),
|
|
1910
|
+
confirm: isFlagTrue(args.confirm)
|
|
1911
|
+
});
|
|
1912
|
+
if (!plan.proceed) return plannedWriteResult(plan);
|
|
1913
|
+
|
|
1914
|
+
if (movable.length === 0) {
|
|
1915
|
+
return {
|
|
1916
|
+
ok: true,
|
|
1917
|
+
message: writeSuccessMessage(action, "nothing to move", { not_found: located.missingIds.length, unaddressable: unaddressable || undefined })
|
|
1918
|
+
};
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
const result = runAppleScript(buildBulkTrashScript(movable, { budgetSec }), {
|
|
1922
|
+
timeout: MCP_CALL_DEADLINE_MS,
|
|
1923
|
+
appName: "Mail"
|
|
1924
|
+
});
|
|
1925
|
+
if (!result.ok) return { ok: false, message: failure(action, summary, result, []) };
|
|
1926
|
+
|
|
1927
|
+
const statuses = parseBulkTrashOutput(result.output);
|
|
1928
|
+
const counts = { moved: 0, stale: 0, remaining: 0, failed: 0 };
|
|
1929
|
+
const failures = new Set();
|
|
1930
|
+
for (const copy of movable) {
|
|
1931
|
+
const status = statuses.get(copy.id) || "skipped";
|
|
1932
|
+
if (status === "moved") counts.moved += 1;
|
|
1933
|
+
else if (status === "missing" || status === "mismatch") counts.stale += 1;
|
|
1934
|
+
else if (status === "skipped") counts.remaining += 1;
|
|
1935
|
+
else {
|
|
1936
|
+
counts.failed += 1;
|
|
1937
|
+
failures.add(status);
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1940
|
+
const details = {
|
|
1941
|
+
moved: counts.moved,
|
|
1942
|
+
copies: movable.length,
|
|
1943
|
+
not_found: located.missingIds.length,
|
|
1944
|
+
stale: counts.stale || undefined,
|
|
1945
|
+
remaining: counts.remaining || undefined,
|
|
1946
|
+
failed: counts.failed || undefined,
|
|
1947
|
+
failure_reasons: failures.size ? [...failures].join(", ") : undefined,
|
|
1948
|
+
unaddressable: unaddressable || undefined
|
|
1949
|
+
};
|
|
1950
|
+
const tail = counts.remaining ? " Time budget reached; run again for the rest." : "";
|
|
1951
|
+
const message = writeSuccessMessage(action, `moved ${counts.moved} of ${plural(movable.length, "copy", "copies")} to Trash`, details) + tail;
|
|
1952
|
+
return { ok: counts.failed === 0, message };
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1788
1955
|
export function mailTrash(args = {}) {
|
|
1789
1956
|
const action = "mail_trash";
|
|
1957
|
+
if (args.message_ids !== undefined && args.message_ids !== null) return mailTrashMany(args);
|
|
1790
1958
|
const resolved = resolveMailMessageId({ messageId: args.message_id, filePath: args.file_path });
|
|
1791
1959
|
if (resolved.error) return { ok: false, message: `${action} refused: ${resolved.error}` };
|
|
1792
1960
|
|
package/lib/shell.js
CHANGED
|
@@ -22,6 +22,7 @@ import { escapeSQL } from "./validators.js";
|
|
|
22
22
|
* @param {string} query - SQL query to execute
|
|
23
23
|
* @param {object} options - Additional options
|
|
24
24
|
* @param {boolean} options.json - Return JSON output (default: true)
|
|
25
|
+
* @param {boolean} options.readonly - Open the database read-only (default: false)
|
|
25
26
|
* @param {number} options.timeout - Timeout in ms (default: 30000)
|
|
26
27
|
* @param {number} options.maxBuffer - Max buffer size (default: 50MB)
|
|
27
28
|
* @returns {string} Raw output from sqlite3
|
|
@@ -30,6 +31,7 @@ import { escapeSQL } from "./validators.js";
|
|
|
30
31
|
export function safeSqlite3(dbPath, query, options = {}) {
|
|
31
32
|
const {
|
|
32
33
|
json = true,
|
|
34
|
+
readonly = false,
|
|
33
35
|
timeout = 30000,
|
|
34
36
|
maxBuffer = 50 * 1024 * 1024
|
|
35
37
|
} = options;
|
|
@@ -52,6 +54,9 @@ export function safeSqlite3(dbPath, query, options = {}) {
|
|
|
52
54
|
if (json) {
|
|
53
55
|
args.push('-json');
|
|
54
56
|
}
|
|
57
|
+
if (readonly) {
|
|
58
|
+
args.push('-readonly');
|
|
59
|
+
}
|
|
55
60
|
args.push(dbPath);
|
|
56
61
|
args.push(query.replace(/\n/g, ' ')); // Normalize whitespace
|
|
57
62
|
|
package/lib/writeTools.js
CHANGED
|
@@ -137,11 +137,12 @@ export const WRITE_TOOL_DEFINITIONS = [
|
|
|
137
137
|
},
|
|
138
138
|
{
|
|
139
139
|
name: "mail_trash",
|
|
140
|
-
description: "Move
|
|
140
|
+
description: "Move email to Trash. Destructive: requires confirm=true, otherwise the call only reports what would be trashed. One message: message_id or file_path. Many: message_ids (up to 500, e.g. from mail_find) moves every copy of each (INBOX, Sent, Archive...) to that account's Trash; each copy's Message-ID is re-checked before it moves. A bulk call stops starting new moves after ~35s and reports 'remaining'; run it again for the rest.",
|
|
141
141
|
inputSchema: {
|
|
142
142
|
type: "object",
|
|
143
143
|
properties: {
|
|
144
144
|
...MESSAGE_TARGET_PROPS,
|
|
145
|
+
message_ids: { type: "array", items: { type: "string" }, description: "Bulk: RFC822 Message-IDs (from mail_find). Every copy outside Trash is moved. Do not combine with message_id / file_path." },
|
|
145
146
|
...CONFIRM_PROPS
|
|
146
147
|
},
|
|
147
148
|
required: []
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "apple-tools-mcp",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.6",
|
|
4
4
|
"description": "MCP server for semantic search and write actions across Apple Mail, Messages, Calendar, and Contacts. Speaks stdio (local) or bearer-token-authenticated Streamable HTTP (LAN / Tailscale).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|