apple-tools-mcp 3.0.8 → 3.1.0
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 +1 -0
- package/contacts.js +13 -1
- package/index.js +19 -3
- package/indexer.js +10 -8
- package/lib/mailLinks.js +294 -0
- package/lib/messageContact.js +106 -0
- package/package.json +1 -1
- package/search.js +22 -9
package/README.md
CHANGED
|
@@ -259,6 +259,7 @@ Your client can also call `audit_index`.
|
|
|
259
259
|
| `mail_senders` | Most frequent senders. |
|
|
260
260
|
| `mail_thread` | Every email in a conversation. |
|
|
261
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. |
|
|
262
|
+
| `mail_links` | The links in one email (by its `mail_find` id), decoded, with each link's visible text. Use it for the URL behind a button, such as a sign-in link; `mail_read` drops link targets. Does not wait on the index. |
|
|
262
263
|
|
|
263
264
|
### Messages
|
|
264
265
|
|
package/contacts.js
CHANGED
|
@@ -104,7 +104,7 @@ function findContactsDatabase() {
|
|
|
104
104
|
* Normalize phone number for consistent matching
|
|
105
105
|
* Strips all non-digit characters except leading +
|
|
106
106
|
*/
|
|
107
|
-
function normalizePhone(phone) {
|
|
107
|
+
export function normalizePhone(phone) {
|
|
108
108
|
if (!phone) return "";
|
|
109
109
|
// Keep leading + for international, then only digits
|
|
110
110
|
const hasPlus = phone.startsWith("+");
|
|
@@ -373,6 +373,18 @@ export function resolveByName(name) {
|
|
|
373
373
|
return matches;
|
|
374
374
|
}
|
|
375
375
|
|
|
376
|
+
/**
|
|
377
|
+
* Resolve a name to contacts by exact full name, first name, or nickname
|
|
378
|
+
* (case-insensitive). No partial matching.
|
|
379
|
+
* @param {string} name - Name to look up
|
|
380
|
+
* @returns {object[]} Matching contacts (may be empty)
|
|
381
|
+
*/
|
|
382
|
+
export function resolveByExactName(name) {
|
|
383
|
+
if (!name) return [];
|
|
384
|
+
loadContacts(); // Ensure contacts are loaded
|
|
385
|
+
return nameToContact.get(name.toLowerCase().trim()) || [];
|
|
386
|
+
}
|
|
387
|
+
|
|
376
388
|
/**
|
|
377
389
|
* Get all identifiers (emails and phones) for a contact
|
|
378
390
|
* @param {number} contactId - Contact ID
|
package/index.js
CHANGED
|
@@ -41,6 +41,7 @@ import { startWriteBridgeServer, defaultSocketPath } from "./lib/writeBridge.js"
|
|
|
41
41
|
import { closeEventKitSession, ensureEventKitSession } from "./lib/eventKitSession.js";
|
|
42
42
|
import { EVENTKIT_JXA_HELPERS } from "./lib/calendarWrite.js";
|
|
43
43
|
import { mailFind, MAILBOX_KINDS, MAIL_FIND_MAX_LIMIT } from "./lib/mailFind.js";
|
|
44
|
+
import { mailLinks } from "./lib/mailLinks.js";
|
|
44
45
|
|
|
45
46
|
const PACKAGE_VERSION = JSON.parse(
|
|
46
47
|
fs.readFileSync(new URL("./package.json", import.meta.url), "utf8")
|
|
@@ -1174,6 +1175,17 @@ function createServer() {
|
|
|
1174
1175
|
},
|
|
1175
1176
|
},
|
|
1176
1177
|
},
|
|
1178
|
+
{
|
|
1179
|
+
name: "mail_links",
|
|
1180
|
+
description: "Links in one email, decoded: each http(s) link with its visible text, from the HTML anchors (quoted-printable and base64 decoded, & unescaped) and bare URLs in the plain-text part. Use the id from mail_find. mail_read strips HTML, so it loses link targets; use this when you need the URL behind a button such as a sign-in or confirm link. Reads Mail's message file directly and never waits on the index. downloaded:false means Mail has not saved the file yet.",
|
|
1181
|
+
inputSchema: {
|
|
1182
|
+
type: "object",
|
|
1183
|
+
properties: {
|
|
1184
|
+
id: { type: "number", description: "Message id from mail_find (Mail's Envelope Index id)" }
|
|
1185
|
+
},
|
|
1186
|
+
required: ["id"],
|
|
1187
|
+
},
|
|
1188
|
+
},
|
|
1177
1189
|
|
|
1178
1190
|
// ============ MESSAGES TOOLS ============
|
|
1179
1191
|
{
|
|
@@ -1185,7 +1197,7 @@ function createServer() {
|
|
|
1185
1197
|
query: { type: "string", description: "Natural language search (e.g., 'dinner plans', 'about the trip', 'address')" },
|
|
1186
1198
|
limit: { type: "number", description: "Maximum results (default 30)" },
|
|
1187
1199
|
days_back: { type: "number", description: "Only messages from last N days (0 = all time)" },
|
|
1188
|
-
contact: { type: "string", description: "Filter by contact name or phone number" },
|
|
1200
|
+
contact: { type: "string", description: "Filter by contact: exact Contacts name (full name, first name, or nickname, e.g. 'Dad'), phone number in any format, or email. Includes my replies in that 1:1 chat." },
|
|
1189
1201
|
group_chat_only: { type: "boolean", description: "Only show messages from group chats" },
|
|
1190
1202
|
group_chat_name: { type: "string", description: "Filter by specific group chat name" },
|
|
1191
1203
|
has_attachment: { type: "boolean", description: "Filter to messages with attachments (photos, files)" },
|
|
@@ -1196,7 +1208,7 @@ function createServer() {
|
|
|
1196
1208
|
},
|
|
1197
1209
|
{
|
|
1198
1210
|
name: "messages_recent",
|
|
1199
|
-
description: "Get most recent messages without semantic search. Use this when the user asks for 'recent messages', 'latest texts', or 'what messages did I get'.",
|
|
1211
|
+
description: "Get most recent messages without semantic search. Use this when the user asks for 'recent messages', 'latest texts', or 'what messages did I get'. My own messages show a To: line naming the contact or group chat they were sent to.",
|
|
1200
1212
|
inputSchema: {
|
|
1201
1213
|
type: "object",
|
|
1202
1214
|
properties: {
|
|
@@ -1211,7 +1223,7 @@ function createServer() {
|
|
|
1211
1223
|
inputSchema: {
|
|
1212
1224
|
type: "object",
|
|
1213
1225
|
properties: {
|
|
1214
|
-
contact: { type: "string", description: "
|
|
1226
|
+
contact: { type: "string", description: "Exact Contacts name (full name, first name, or nickname, e.g. 'Dad'), phone number in any format, or email. Returns both sides of the 1:1 thread, including my replies." },
|
|
1215
1227
|
limit: { type: "number", description: "Maximum messages to return (default 50)" }
|
|
1216
1228
|
},
|
|
1217
1229
|
required: ["contact"],
|
|
@@ -1480,6 +1492,10 @@ function createServer() {
|
|
|
1480
1492
|
result = mailFind(args || {});
|
|
1481
1493
|
break;
|
|
1482
1494
|
|
|
1495
|
+
case "mail_links":
|
|
1496
|
+
result = mailLinks(args || {});
|
|
1497
|
+
break;
|
|
1498
|
+
|
|
1483
1499
|
// Messages tools
|
|
1484
1500
|
case "messages_search":
|
|
1485
1501
|
result = await messagesSearch(args.query, {
|
package/indexer.js
CHANGED
|
@@ -16,6 +16,8 @@ import {
|
|
|
16
16
|
import { safeSqlite3Json, safeOsascript, safeFind } from "./lib/shell.js";
|
|
17
17
|
import { indexUnavailableMessage } from "./lib/indexGate.js";
|
|
18
18
|
import { createLanceTableCache, LANCE_CONNECT_OPTIONS } from "./lib/lancedbTables.js";
|
|
19
|
+
import { createMessageContactMatcher } from "./lib/messageContact.js";
|
|
20
|
+
import { resolveByExactName, normalizePhone } from "./contacts.js";
|
|
19
21
|
|
|
20
22
|
// Re-export contact functions for use by other modules
|
|
21
23
|
export {
|
|
@@ -23,6 +25,8 @@ export {
|
|
|
23
25
|
resolveEmail,
|
|
24
26
|
resolvePhone,
|
|
25
27
|
resolveByName,
|
|
28
|
+
resolveByExactName,
|
|
29
|
+
normalizePhone,
|
|
26
30
|
lookupContact,
|
|
27
31
|
searchContacts,
|
|
28
32
|
getContactIdentifiers,
|
|
@@ -1538,7 +1542,7 @@ export async function getRecentMessages(limit = 10, daysBack = 1) {
|
|
|
1538
1542
|
try {
|
|
1539
1543
|
const cutoff = Date.now() - (daysBack * 24 * 60 * 60 * 1000);
|
|
1540
1544
|
const results = await tables.messages.query()
|
|
1541
|
-
.select(["id", "date", "dateTimestamp", "sender", "text", "chatId", "isGroupChat"])
|
|
1545
|
+
.select(["id", "date", "dateTimestamp", "sender", "text", "chatId", "chatIdentifier", "chatName", "isGroupChat"])
|
|
1542
1546
|
.toArray();
|
|
1543
1547
|
|
|
1544
1548
|
const filtered = results
|
|
@@ -1559,17 +1563,15 @@ export async function getConversation(contact, limit = 50) {
|
|
|
1559
1563
|
if (!tables.messages) return [];
|
|
1560
1564
|
|
|
1561
1565
|
try {
|
|
1562
|
-
const
|
|
1566
|
+
const matcher = createMessageContactMatcher(contact, { resolveExactName: resolveByExactName, normalizePhone });
|
|
1567
|
+
if (!matcher) return [];
|
|
1563
1568
|
const results = await tables.messages.query()
|
|
1564
1569
|
.select(["id", "date", "dateTimestamp", "sender", "text", "chatId", "chatIdentifier"])
|
|
1565
1570
|
.toArray();
|
|
1566
1571
|
|
|
1567
|
-
//
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
const chatId = (r.chatIdentifier || "").toLowerCase();
|
|
1571
|
-
return sender.includes(contactLower) || chatId.includes(contactLower);
|
|
1572
|
-
});
|
|
1572
|
+
// Sender or chat handle matches the contact (name resolved to its handles);
|
|
1573
|
+
// from-me rows match through the 1:1 chat's handle
|
|
1574
|
+
const filtered = results.filter(r => matcher.matches(r));
|
|
1573
1575
|
|
|
1574
1576
|
// Sort chronologically (oldest first for conversation view)
|
|
1575
1577
|
return filtered
|
package/lib/mailLinks.js
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mail_links: the links in one message, decoded, by its Envelope Index id.
|
|
3
|
+
*
|
|
4
|
+
* mail_read strips HTML, which drops every link target, and it reads the
|
|
5
|
+
* .emlx without MIME decoding, so a quoted-printable URL comes back broken
|
|
6
|
+
* across soft line breaks. Sign-in emails (magic links, confirm links) put
|
|
7
|
+
* the one thing a caller needs behind an <a href>. This reads the message
|
|
8
|
+
* file Mail keeps for the id mail_find returns, decodes its MIME parts
|
|
9
|
+
* (multipart, quoted-printable, base64, charset), and returns each http(s)
|
|
10
|
+
* link with its visible text.
|
|
11
|
+
*
|
|
12
|
+
* Read-only. Needs the same Full Disk Access as mail_find and the indexer.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import fs from "fs";
|
|
16
|
+
import path from "path";
|
|
17
|
+
import { envelopeIndexPath, parseMailboxUrl, ENVELOPE_INDEX_ACCESS_GUIDANCE } from "./mailFind.js";
|
|
18
|
+
import { safeSqlite3Json } from "./shell.js";
|
|
19
|
+
import { stripHtmlTags } from "./validators.js";
|
|
20
|
+
|
|
21
|
+
export const MAIL_LINKS_MAX = 200;
|
|
22
|
+
const MAX_MESSAGE_BYTES = 20 * 1024 * 1024;
|
|
23
|
+
const MAX_HTML_CHARS = 2_000_000;
|
|
24
|
+
const MAX_MIME_DEPTH = 8;
|
|
25
|
+
|
|
26
|
+
/** The RFC822 bytes inside an .emlx (first line is the byte count; a plist trails the message). */
|
|
27
|
+
export function emlxMessageBytes(buf) {
|
|
28
|
+
const nl = buf.indexOf(0x0a);
|
|
29
|
+
const count = nl > 0 ? Number.parseInt(buf.subarray(0, nl).toString("ascii").trim(), 10) : NaN;
|
|
30
|
+
if (!Number.isFinite(count) || count <= 0) return buf;
|
|
31
|
+
return buf.subarray(nl + 1, Math.min(buf.length, nl + 1 + count));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function splitHeaderBody(buf) {
|
|
35
|
+
const crlf = buf.indexOf("\r\n\r\n");
|
|
36
|
+
const lf = buf.indexOf("\n\n");
|
|
37
|
+
if (crlf >= 0 && (lf < 0 || crlf < lf)) return [buf.subarray(0, crlf).toString("latin1"), buf.subarray(crlf + 4)];
|
|
38
|
+
if (lf >= 0) return [buf.subarray(0, lf).toString("latin1"), buf.subarray(lf + 2)];
|
|
39
|
+
return [buf.toString("latin1"), Buffer.alloc(0)];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function parseHeaders(text) {
|
|
43
|
+
const headers = new Map();
|
|
44
|
+
for (const line of text.replace(/\r?\n[ \t]+/g, " ").split(/\r?\n/)) {
|
|
45
|
+
const i = line.indexOf(":");
|
|
46
|
+
if (i > 0) headers.set(line.slice(0, i).trim().toLowerCase(), line.slice(i + 1).trim());
|
|
47
|
+
}
|
|
48
|
+
return headers;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** `text/html; charset="utf-8"` → { type: "text/html", params: { charset: "utf-8" } } */
|
|
52
|
+
export function parseContentType(value) {
|
|
53
|
+
const [type, ...rest] = String(value || "text/plain").split(";");
|
|
54
|
+
const params = {};
|
|
55
|
+
for (const p of rest) {
|
|
56
|
+
const i = p.indexOf("=");
|
|
57
|
+
if (i > 0) params[p.slice(0, i).trim().toLowerCase()] = p.slice(i + 1).trim().replace(/^"(.*)"$/, "$1");
|
|
58
|
+
}
|
|
59
|
+
return { type: type.trim().toLowerCase(), params };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function decodeQuotedPrintable(buf) {
|
|
63
|
+
const s = buf.toString("latin1").replace(/=\r?\n/g, "");
|
|
64
|
+
const out = Buffer.alloc(s.length);
|
|
65
|
+
let n = 0;
|
|
66
|
+
for (let i = 0; i < s.length; i++) {
|
|
67
|
+
if (s[i] === "=" && /^[0-9A-Fa-f]{2}$/.test(s.slice(i + 1, i + 3))) {
|
|
68
|
+
out[n++] = Number.parseInt(s.slice(i + 1, i + 3), 16);
|
|
69
|
+
i += 2;
|
|
70
|
+
} else {
|
|
71
|
+
out[n++] = s.charCodeAt(i) & 0xff;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return out.subarray(0, n);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function decodeText(buf, encoding, charset) {
|
|
78
|
+
const enc = String(encoding || "").toLowerCase();
|
|
79
|
+
const bytes =
|
|
80
|
+
enc === "quoted-printable"
|
|
81
|
+
? decodeQuotedPrintable(buf)
|
|
82
|
+
: enc === "base64"
|
|
83
|
+
? Buffer.from(buf.toString("latin1").replace(/[^A-Za-z0-9+/=]/g, ""), "base64")
|
|
84
|
+
: buf;
|
|
85
|
+
try {
|
|
86
|
+
return new TextDecoder(charset || "utf-8").decode(bytes);
|
|
87
|
+
} catch {
|
|
88
|
+
return new TextDecoder("utf-8").decode(bytes);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Text leaves of a MIME message: [{ type, text }] for text/html and text/plain parts. */
|
|
93
|
+
export function mimeTextParts(buf, depth = 0) {
|
|
94
|
+
const [headerText, body] = splitHeaderBody(buf);
|
|
95
|
+
const headers = parseHeaders(headerText);
|
|
96
|
+
const { type, params } = parseContentType(headers.get("content-type"));
|
|
97
|
+
if (type.startsWith("multipart/") && params.boundary && depth < MAX_MIME_DEPTH) {
|
|
98
|
+
const marker = Buffer.from(`--${params.boundary}`, "latin1");
|
|
99
|
+
const parts = [];
|
|
100
|
+
let start = body.indexOf(marker);
|
|
101
|
+
while (start >= 0) {
|
|
102
|
+
const afterMarker = start + marker.length;
|
|
103
|
+
if (body.subarray(afterMarker, afterMarker + 2).toString("latin1") === "--") break;
|
|
104
|
+
const next = body.indexOf(marker, afterMarker);
|
|
105
|
+
let chunk = body.subarray(afterMarker, next >= 0 ? next : body.length);
|
|
106
|
+
chunk = chunk.subarray(chunk[0] === 0x0d && chunk[1] === 0x0a ? 2 : chunk[0] === 0x0a ? 1 : 0);
|
|
107
|
+
parts.push(...mimeTextParts(chunk, depth + 1));
|
|
108
|
+
start = next;
|
|
109
|
+
}
|
|
110
|
+
return parts;
|
|
111
|
+
}
|
|
112
|
+
if (type !== "text/html" && type !== "text/plain") return [];
|
|
113
|
+
return [{ type, text: decodeText(body, headers.get("content-transfer-encoding"), params.charset) }];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function decodeHtmlEntities(s) {
|
|
117
|
+
return String(s)
|
|
118
|
+
.replace(/&#x([0-9a-f]{1,6});/gi, (_, h) => safeCodePoint(Number.parseInt(h, 16)))
|
|
119
|
+
.replace(/&#([0-9]{1,7});/g, (_, d) => safeCodePoint(Number.parseInt(d, 10)))
|
|
120
|
+
.replace(/"/gi, '"')
|
|
121
|
+
.replace(/'|'/gi, "'")
|
|
122
|
+
.replace(/</gi, "<")
|
|
123
|
+
.replace(/>/gi, ">")
|
|
124
|
+
.replace(/ /gi, " ")
|
|
125
|
+
.replace(/&/gi, "&");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function safeCodePoint(n) {
|
|
129
|
+
return Number.isInteger(n) && n > 0 && n <= 0x10ffff ? String.fromCodePoint(n) : "";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function isHttpUrl(url) {
|
|
133
|
+
return /^https?:\/\/[^\s]+$/i.test(url);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function attrValue(tag, name) {
|
|
137
|
+
const lower = tag.toLowerCase();
|
|
138
|
+
let from = 0;
|
|
139
|
+
for (;;) {
|
|
140
|
+
const i = lower.indexOf(name, from);
|
|
141
|
+
if (i < 0) return null;
|
|
142
|
+
from = i + name.length;
|
|
143
|
+
if (i > 0 && !/\s/.test(tag[i - 1])) continue;
|
|
144
|
+
let j = from;
|
|
145
|
+
while (j < tag.length && /\s/.test(tag[j])) j++;
|
|
146
|
+
if (tag[j] !== "=") continue;
|
|
147
|
+
j++;
|
|
148
|
+
while (j < tag.length && /\s/.test(tag[j])) j++;
|
|
149
|
+
const q = tag[j];
|
|
150
|
+
if (q === '"' || q === "'") {
|
|
151
|
+
const end = tag.indexOf(q, j + 1);
|
|
152
|
+
return end < 0 ? null : tag.slice(j + 1, end);
|
|
153
|
+
}
|
|
154
|
+
let end = j;
|
|
155
|
+
while (end < tag.length && !/[\s>]/.test(tag[end])) end++;
|
|
156
|
+
return tag.slice(j, end);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** <a href> links in HTML as { text, url }, scanned without regex backtracking. */
|
|
161
|
+
export function htmlLinks(html) {
|
|
162
|
+
const s = String(html || "").slice(0, MAX_HTML_CHARS);
|
|
163
|
+
const lower = s.toLowerCase();
|
|
164
|
+
const links = [];
|
|
165
|
+
let pos = 0;
|
|
166
|
+
while (links.length < MAIL_LINKS_MAX) {
|
|
167
|
+
const open = lower.indexOf("<a", pos);
|
|
168
|
+
if (open < 0) break;
|
|
169
|
+
pos = open + 2;
|
|
170
|
+
if (!/[\s>]/.test(s[open + 2] || "")) continue;
|
|
171
|
+
let tagEnd = open + 2;
|
|
172
|
+
let quote = null;
|
|
173
|
+
for (; tagEnd < s.length; tagEnd++) {
|
|
174
|
+
const c = s[tagEnd];
|
|
175
|
+
if (quote) {
|
|
176
|
+
if (c === quote) quote = null;
|
|
177
|
+
} else if (c === '"' || c === "'") {
|
|
178
|
+
quote = c;
|
|
179
|
+
} else if (c === ">") {
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (tagEnd >= s.length) break;
|
|
184
|
+
const close = lower.indexOf("</a", tagEnd);
|
|
185
|
+
const nextOpen = lower.indexOf("<a", tagEnd);
|
|
186
|
+
// An unclosed anchor's text runs to the next anchor (or the end).
|
|
187
|
+
const textEnd = close >= 0 && (nextOpen < 0 || close < nextOpen) ? close : nextOpen >= 0 ? nextOpen : s.length;
|
|
188
|
+
const inner = s.slice(tagEnd + 1, textEnd);
|
|
189
|
+
pos = textEnd === close ? close + 3 : textEnd;
|
|
190
|
+
const href = attrValue(s.slice(open, tagEnd), "href");
|
|
191
|
+
const url = href == null ? "" : decodeHtmlEntities(href.trim());
|
|
192
|
+
if (!isHttpUrl(url)) continue;
|
|
193
|
+
links.push({ text: decodeHtmlEntities(stripHtmlTags(inner)).replace(/\s+/g, " ").trim(), url });
|
|
194
|
+
}
|
|
195
|
+
return links;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Bare http(s) URLs in plain text. */
|
|
199
|
+
export function textLinks(text) {
|
|
200
|
+
const found = String(text || "").slice(0, MAX_HTML_CHARS).match(/https?:\/\/[^\s<>"'`)\]]+/g) || [];
|
|
201
|
+
return found.slice(0, MAIL_LINKS_MAX).map((url) => ({ text: "", url: url.replace(/[.,;:!?]+$/, "") }));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Links from a decoded message: HTML anchors first, then plain-text URLs not already listed. */
|
|
205
|
+
export function messageLinks(rfc822) {
|
|
206
|
+
const parts = mimeTextParts(rfc822);
|
|
207
|
+
const links = [];
|
|
208
|
+
const seen = new Set();
|
|
209
|
+
const add = (l) => {
|
|
210
|
+
if (links.length >= MAIL_LINKS_MAX || seen.has(`${l.text}\n${l.url}`)) return;
|
|
211
|
+
seen.add(`${l.text}\n${l.url}`);
|
|
212
|
+
links.push(l);
|
|
213
|
+
};
|
|
214
|
+
for (const p of parts) if (p.type === "text/html") htmlLinks(p.text).forEach(add);
|
|
215
|
+
const urls = new Set(links.map((l) => l.url));
|
|
216
|
+
for (const p of parts) if (p.type === "text/plain") textLinks(p.text).filter((l) => !urls.has(l.url)).forEach(add);
|
|
217
|
+
return links;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Mail's sharded folder for a message id: 73690 → Data/3/7/Messages (digits of id/1000, reversed). */
|
|
221
|
+
export function messageDataDir(id) {
|
|
222
|
+
const thousands = Math.floor(id / 1000);
|
|
223
|
+
const shards = thousands > 0 ? String(thousands).split("").reverse() : [];
|
|
224
|
+
return path.join("Data", ...shards, "Messages");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Where Mail keeps `id`'s message file for mailbox `mailboxUrl`.
|
|
229
|
+
* `file` is the .emlx (or .partial.emlx) path, or null. `mailboxFound` says
|
|
230
|
+
* whether the mailbox folder itself was found, so a caller can tell "not
|
|
231
|
+
* saved yet" (worth retrying) from "cannot locate this mailbox" (not).
|
|
232
|
+
* Account mailboxes live under V<n>/<account id>/; On My Mac mailboxes
|
|
233
|
+
* without an account id live under V<n>/Mailboxes/.
|
|
234
|
+
*/
|
|
235
|
+
export function locateEmlx(id, mailboxUrl, { indexPath } = {}) {
|
|
236
|
+
const box = parseMailboxUrl(mailboxUrl);
|
|
237
|
+
const db = indexPath || envelopeIndexPath();
|
|
238
|
+
if (!box || !db) return { file: null, mailboxFound: false };
|
|
239
|
+
const versionDir = path.dirname(path.dirname(db));
|
|
240
|
+
const base = box.accountId ? path.join(versionDir, box.accountId) : path.join(versionDir, "Mailboxes");
|
|
241
|
+
const segments = box.mailboxPath.split("/").filter(Boolean);
|
|
242
|
+
if (!segments.length || segments.some((p) => p === "." || p === "..")) return { file: null, mailboxFound: false };
|
|
243
|
+
const mboxDir = path.join(base, ...segments.map((p) => `${p}.mbox`));
|
|
244
|
+
let stores;
|
|
245
|
+
try {
|
|
246
|
+
stores = fs.readdirSync(mboxDir, { withFileTypes: true }).filter((d) => d.isDirectory() && !d.name.endsWith(".mbox"));
|
|
247
|
+
} catch {
|
|
248
|
+
return { file: null, mailboxFound: false };
|
|
249
|
+
}
|
|
250
|
+
const rel = messageDataDir(id);
|
|
251
|
+
for (const store of stores) {
|
|
252
|
+
for (const name of [`${id}.emlx`, `${id}.partial.emlx`]) {
|
|
253
|
+
const file = path.join(mboxDir, store.name, rel, name);
|
|
254
|
+
if (fs.existsSync(file)) return { file, mailboxFound: true };
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return { file: null, mailboxFound: true };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function validateLinksArgs(args = {}) {
|
|
261
|
+
const id = Number(args.id);
|
|
262
|
+
if (!Number.isInteger(id) || id <= 0 || id > 1e12) return { error: "id must be a positive integer (the id from mail_find)" };
|
|
263
|
+
return { id };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** mail_links tool entry point. Returns JSON text; throws on invalid args or an unreadable index. */
|
|
267
|
+
export function mailLinks(args = {}, { query = safeSqlite3Json, indexPath, readFile = fs.readFileSync, locate = locateEmlx } = {}) {
|
|
268
|
+
const { id, error } = validateLinksArgs(args);
|
|
269
|
+
if (error) throw new Error(`mail_links: ${error}`);
|
|
270
|
+
const db = indexPath || envelopeIndexPath();
|
|
271
|
+
if (!db) throw new Error(ENVELOPE_INDEX_ACCESS_GUIDANCE);
|
|
272
|
+
let rows;
|
|
273
|
+
try {
|
|
274
|
+
rows = query(db, `SELECT m.ROWID AS id, mb.url AS mailbox_url FROM messages m JOIN mailboxes mb ON mb.ROWID = m.mailbox WHERE m.ROWID = ${id}`, {
|
|
275
|
+
readonly: true,
|
|
276
|
+
timeout: 20000
|
|
277
|
+
});
|
|
278
|
+
} catch (e) {
|
|
279
|
+
if (/unable to open|authoriz|not permitted|denied/i.test(String((e && e.message) || e))) throw new Error(ENVELOPE_INDEX_ACCESS_GUIDANCE);
|
|
280
|
+
throw e;
|
|
281
|
+
}
|
|
282
|
+
if (!rows.length) return JSON.stringify({ id, found: false, note: "No message with that id in Mail's database." }, null, 2);
|
|
283
|
+
const { file, mailboxFound } = locate(id, rows[0].mailbox_url, { indexPath: db });
|
|
284
|
+
if (!file && !mailboxFound) {
|
|
285
|
+
return JSON.stringify({ id, found: true, located: false, note: "Could not find this message's mailbox folder in Mail's data; retrying will not help." }, null, 2);
|
|
286
|
+
}
|
|
287
|
+
if (!file) {
|
|
288
|
+
return JSON.stringify({ id, found: true, downloaded: false, note: "Mail has not saved this message's file yet; try again shortly." }, null, 2);
|
|
289
|
+
}
|
|
290
|
+
const buf = readFile(file);
|
|
291
|
+
if (buf.length > MAX_MESSAGE_BYTES) throw new Error("mail_links: message file is too large to read");
|
|
292
|
+
const links = messageLinks(emlxMessageBytes(buf));
|
|
293
|
+
return JSON.stringify({ id, found: true, downloaded: true, count: links.length, links }, null, 2);
|
|
294
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contact matching and peer naming for indexed Messages rows.
|
|
3
|
+
*
|
|
4
|
+
* Rows carry raw handles (`sender` is a phone/email or "Me"; `chatIdentifier`
|
|
5
|
+
* is the other party's handle in a 1:1 chat, or "chatNNN" for a group). Tool
|
|
6
|
+
* output shows contact names, so callers pass names back ("Dad"). These helpers
|
|
7
|
+
* map that name to handles and tell a reader who a from-me row was sent to.
|
|
8
|
+
*
|
|
9
|
+
* Lookups are injected so the logic is testable without the AddressBook.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const MIN_PHONE_DIGITS = 7;
|
|
13
|
+
|
|
14
|
+
function looksLikeEmail(value) {
|
|
15
|
+
return value.includes("@");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function looksLikePhone(value) {
|
|
19
|
+
return value.replace(/\D/g, "").length >= MIN_PHONE_DIGITS;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Build a predicate that matches message rows to a caller-supplied contact.
|
|
24
|
+
*
|
|
25
|
+
* Keeps the old raw substring match on sender / chatIdentifier, and adds:
|
|
26
|
+
* - phone input matched on normalized digits (605-360-2675 == +16053602675)
|
|
27
|
+
* - email input matched exactly (case-insensitive)
|
|
28
|
+
* - a name matched exactly (full name, first name, or nickname) against
|
|
29
|
+
* Contacts, then every phone and email on those contacts. No fuzzy name
|
|
30
|
+
* matching: a short name must not pull in other people's threads.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} contact - Name, phone, or email
|
|
33
|
+
* @param {object} deps
|
|
34
|
+
* @param {(name: string) => object[]} deps.resolveExactName - contacts with that exact name
|
|
35
|
+
* @param {(phone: string) => string} deps.normalizePhone
|
|
36
|
+
* @returns {{ matches: (row: object) => boolean, isName: boolean, resolvedCount: number } | null}
|
|
37
|
+
*/
|
|
38
|
+
export function createMessageContactMatcher(contact, { resolveExactName, normalizePhone }) {
|
|
39
|
+
const raw = String(contact || "").trim().toLowerCase();
|
|
40
|
+
if (!raw) return null;
|
|
41
|
+
|
|
42
|
+
const phones = new Set();
|
|
43
|
+
const emails = new Set();
|
|
44
|
+
const isName = !looksLikeEmail(raw) && !looksLikePhone(raw);
|
|
45
|
+
let resolvedCount = 0;
|
|
46
|
+
|
|
47
|
+
if (looksLikeEmail(raw)) {
|
|
48
|
+
emails.add(raw);
|
|
49
|
+
} else if (looksLikePhone(raw)) {
|
|
50
|
+
const normalized = normalizePhone(raw);
|
|
51
|
+
if (normalized) phones.add(normalized);
|
|
52
|
+
} else {
|
|
53
|
+
const people = resolveExactName(raw) || [];
|
|
54
|
+
resolvedCount = people.length;
|
|
55
|
+
for (const person of people) {
|
|
56
|
+
for (const p of person.phones || []) {
|
|
57
|
+
const normalized = p.normalized || normalizePhone(p.phone || "");
|
|
58
|
+
if (normalized) phones.add(normalized);
|
|
59
|
+
}
|
|
60
|
+
for (const e of person.emails || []) {
|
|
61
|
+
if (e.email) emails.add(e.email.toLowerCase());
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const fieldMatches = (value) => {
|
|
67
|
+
const lower = String(value || "").toLowerCase();
|
|
68
|
+
if (!lower || lower === "me" || lower === "unknown") return false;
|
|
69
|
+
if (lower.includes(raw)) return true;
|
|
70
|
+
if (emails.has(lower)) return true;
|
|
71
|
+
if (phones.size > 0 && !looksLikeEmail(lower)) {
|
|
72
|
+
const normalized = normalizePhone(lower);
|
|
73
|
+
if (normalized && phones.has(normalized)) return true;
|
|
74
|
+
}
|
|
75
|
+
return false;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
matches: (row) => fieldMatches(row.sender) || fieldMatches(row.chatIdentifier),
|
|
80
|
+
isName,
|
|
81
|
+
resolvedCount
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Name the other side of a from-me row, so "Me" lines say who they went to.
|
|
87
|
+
*
|
|
88
|
+
* @param {object} row - Indexed message row (chatIdentifier, chatName, isGroupChat)
|
|
89
|
+
* @param {object} deps
|
|
90
|
+
* @param {(phone: string) => object|null} deps.resolvePhone
|
|
91
|
+
* @param {(email: string) => object|null} deps.resolveEmail
|
|
92
|
+
* @param {(contact: object) => string} deps.formatContact
|
|
93
|
+
* @returns {string|null} e.g. "Dad (+16053602675)", "group chat \"Family\"", or null
|
|
94
|
+
*/
|
|
95
|
+
export function describeMessagePeer(row, { resolvePhone, resolveEmail, formatContact }) {
|
|
96
|
+
const chatIdentifier = row.chatIdentifier || "";
|
|
97
|
+
if (row.isGroupChat) {
|
|
98
|
+
if (row.chatName) return `group chat "${row.chatName}"`;
|
|
99
|
+
return chatIdentifier ? `group chat ${chatIdentifier}` : "group chat";
|
|
100
|
+
}
|
|
101
|
+
if (!chatIdentifier) return null;
|
|
102
|
+
const person = looksLikeEmail(chatIdentifier)
|
|
103
|
+
? resolveEmail(chatIdentifier)
|
|
104
|
+
: resolvePhone(chatIdentifier);
|
|
105
|
+
return person ? `${formatContact(person)} (${chatIdentifier})` : chatIdentifier;
|
|
106
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "apple-tools-mcp",
|
|
3
|
-
"version": "3.0
|
|
3
|
+
"version": "3.1.0",
|
|
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",
|
package/search.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import * as chrono from "chrono-node";
|
|
2
2
|
import { safeOsascript } from "./lib/shell.js";
|
|
3
3
|
import { safeMatch, validateSearchQuery, toUnixMillis } from "./lib/validators.js";
|
|
4
|
-
import { embed, getOpenTable, getRecentEmails, getEmailsByDateRange, getRecentMessages, getConversation, getEventsOnDate, resolveEmail, resolvePhone, formatContact } from "./indexer.js";
|
|
4
|
+
import { embed, getOpenTable, getRecentEmails, getEmailsByDateRange, getRecentMessages, getConversation, getEventsOnDate, resolveEmail, resolvePhone, resolveByExactName, normalizePhone, formatContact } from "./indexer.js";
|
|
5
|
+
import { createMessageContactMatcher, describeMessagePeer } from "./lib/messageContact.js";
|
|
5
6
|
import { indexUnavailableMessage } from "./lib/indexGate.js";
|
|
6
7
|
|
|
7
8
|
// ============ CACHING ============
|
|
@@ -1024,12 +1025,8 @@ export async function searchMessages(query, options = {}) {
|
|
|
1024
1025
|
}
|
|
1025
1026
|
|
|
1026
1027
|
if (contact) {
|
|
1027
|
-
const
|
|
1028
|
-
results = results.filter(r =>
|
|
1029
|
-
const sender = (r.sender || "").toLowerCase();
|
|
1030
|
-
const chatId = (r.chatIdentifier || "").toLowerCase();
|
|
1031
|
-
return sender.includes(contactLower) || chatId.includes(contactLower);
|
|
1032
|
-
});
|
|
1028
|
+
const matcher = createMessageContactMatcher(contact, { resolveExactName: resolveByExactName, normalizePhone });
|
|
1029
|
+
if (matcher) results = results.filter(r => matcher.matches(r));
|
|
1033
1030
|
}
|
|
1034
1031
|
|
|
1035
1032
|
if (groupChatOnly) {
|
|
@@ -1057,7 +1054,11 @@ export async function searchMessages(query, options = {}) {
|
|
|
1057
1054
|
if (results.length === 0) {
|
|
1058
1055
|
let filterMsg = "";
|
|
1059
1056
|
if (daysBack > 0) filterMsg += ` in the last ${daysBack} days`;
|
|
1060
|
-
if (contact)
|
|
1057
|
+
if (contact) {
|
|
1058
|
+
filterMsg += ` with ${contact}`;
|
|
1059
|
+
const matcher = createMessageContactMatcher(contact, { resolveExactName: resolveByExactName, normalizePhone });
|
|
1060
|
+
if (matcher?.isName && matcher.resolvedCount === 0) filterMsg += ` (no contact named "${contact}")`;
|
|
1061
|
+
}
|
|
1061
1062
|
if (groupChatOnly) filterMsg += " in group chats";
|
|
1062
1063
|
if (groupChatName) filterMsg += ` in "${groupChatName}"`;
|
|
1063
1064
|
if (hasAttachment) filterMsg += " with attachments";
|
|
@@ -1082,6 +1083,7 @@ export async function searchMessages(query, options = {}) {
|
|
|
1082
1083
|
dateTimestamp: toUnixMillis(row.dateTimestamp) || null,
|
|
1083
1084
|
sender: sender,
|
|
1084
1085
|
senderContact: senderContact, // Resolved contact name (if found)
|
|
1086
|
+
to: sender === "Me" ? messagePeer(row) : null,
|
|
1085
1087
|
text: row.text || "",
|
|
1086
1088
|
chatName: row.chatName || "",
|
|
1087
1089
|
isGroupChat: row.isGroupChat || false,
|
|
@@ -1105,6 +1107,11 @@ export async function searchMessages(query, options = {}) {
|
|
|
1105
1107
|
}
|
|
1106
1108
|
}
|
|
1107
1109
|
|
|
1110
|
+
// Who a from-me message was sent to (contact name + handle, or the group chat)
|
|
1111
|
+
function messagePeer(row) {
|
|
1112
|
+
return describeMessagePeer(row, { resolvePhone, resolveEmail, formatContact });
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1108
1115
|
// Get recent messages without semantic search
|
|
1109
1116
|
export async function getRecentMessageResults(limit = 10, daysBack = 1) {
|
|
1110
1117
|
try {
|
|
@@ -1125,6 +1132,7 @@ export async function getRecentMessageResults(limit = 10, daysBack = 1) {
|
|
|
1125
1132
|
dateTimestamp: toUnixMillis(row.dateTimestamp) || null,
|
|
1126
1133
|
sender: sender,
|
|
1127
1134
|
senderContact: senderContact,
|
|
1135
|
+
to: sender === "Me" ? messagePeer(row) : null,
|
|
1128
1136
|
text: row.text || "",
|
|
1129
1137
|
isGroupChat: row.isGroupChat || false
|
|
1130
1138
|
};
|
|
@@ -1153,7 +1161,11 @@ export async function getConversationResults(contact, limit = 50) {
|
|
|
1153
1161
|
}));
|
|
1154
1162
|
|
|
1155
1163
|
if (formattedResults.length === 0) {
|
|
1156
|
-
|
|
1164
|
+
const matcher = createMessageContactMatcher(contact, { resolveExactName: resolveByExactName, normalizePhone });
|
|
1165
|
+
const hint = matcher?.isName && matcher.resolvedCount === 0
|
|
1166
|
+
? ` (no contact named "${contact}"; try a phone number or email)`
|
|
1167
|
+
: "";
|
|
1168
|
+
return { success: true, results: [], message: `No conversation found with ${contact}${hint}` };
|
|
1157
1169
|
}
|
|
1158
1170
|
|
|
1159
1171
|
return { success: true, results: formattedResults, contact };
|
|
@@ -1173,6 +1185,7 @@ export function formatMessageResults(searchResult) {
|
|
|
1173
1185
|
const senderDisplay = r.senderContact ? `${r.senderContact} (${r.sender})` : r.sender;
|
|
1174
1186
|
result += `\nDate: ${r.date}\nFrom: ${senderDisplay}`;
|
|
1175
1187
|
if (r.isGroupChat) result += " (Group)";
|
|
1188
|
+
if (r.to) result += `\nTo: ${r.to}`;
|
|
1176
1189
|
result += `\nMessage: ${r.text}`;
|
|
1177
1190
|
return result + "\n---";
|
|
1178
1191
|
}).join("\n");
|