icloud-mcp 2.2.0 → 2.4.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/.claude/settings.local.json +10 -1
- package/README.md +32 -2
- package/index.js +300 -1
- package/lib/caldav.js +502 -0
- package/lib/carddav.js +401 -0
- package/lib/digest.js +46 -0
- package/lib/event-extractor.js +47 -0
- package/package.json +3 -1
package/lib/carddav.js
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
// ─── lib/carddav.js — iCloud CardDAV (Contacts) ──────────────────────────────
|
|
2
|
+
import { randomUUID } from 'crypto';
|
|
3
|
+
|
|
4
|
+
const CONTACTS_HOST = 'https://contacts.icloud.com';
|
|
5
|
+
|
|
6
|
+
// ─── Credentials & HTTP ───────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
function getCredentials() {
|
|
9
|
+
const user = process.env.IMAP_USER;
|
|
10
|
+
const pass = process.env.IMAP_PASSWORD;
|
|
11
|
+
if (!user || !pass) throw new Error('IMAP_USER and IMAP_PASSWORD are required');
|
|
12
|
+
return { user, auth: Buffer.from(`${user}:${pass}`).toString('base64') };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function davRequest(method, url, opts = {}) {
|
|
16
|
+
const { auth } = getCredentials();
|
|
17
|
+
const headers = {
|
|
18
|
+
Authorization: `Basic ${auth}`,
|
|
19
|
+
...(opts.depth !== undefined ? { Depth: String(opts.depth) } : {}),
|
|
20
|
+
...(opts.contentType ? { 'Content-Type': opts.contentType } : {}),
|
|
21
|
+
...(opts.etag ? { 'If-Match': opts.etag } : {}),
|
|
22
|
+
};
|
|
23
|
+
const res = await fetch(url, { method, headers, body: opts.body });
|
|
24
|
+
const text = await res.text();
|
|
25
|
+
return { status: res.status, etag: res.headers.get('etag'), body: text };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function propfindBody(props) {
|
|
29
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<A:propfind xmlns:A="DAV:"><A:prop>${props}</A:prop></A:propfind>`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ─── Discovery ────────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
let _discoveryCache = null;
|
|
35
|
+
|
|
36
|
+
async function discover() {
|
|
37
|
+
if (_discoveryCache) return _discoveryCache;
|
|
38
|
+
|
|
39
|
+
// Step 1: well-known → current-user-principal
|
|
40
|
+
const wk = await davRequest('PROPFIND', `${CONTACTS_HOST}/.well-known/carddav`, {
|
|
41
|
+
depth: 0,
|
|
42
|
+
contentType: 'application/xml; charset=utf-8',
|
|
43
|
+
body: propfindBody('<A:current-user-principal/>'),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
let principalPath = extractHrefIn(wk.body, 'current-user-principal');
|
|
47
|
+
if (!principalPath) {
|
|
48
|
+
const root = await davRequest('PROPFIND', `${CONTACTS_HOST}/`, {
|
|
49
|
+
depth: 0,
|
|
50
|
+
contentType: 'application/xml; charset=utf-8',
|
|
51
|
+
body: propfindBody('<A:current-user-principal/>'),
|
|
52
|
+
});
|
|
53
|
+
principalPath = extractHrefIn(root.body, 'current-user-principal');
|
|
54
|
+
}
|
|
55
|
+
if (!principalPath) throw new Error('CardDAV: could not discover principal URL');
|
|
56
|
+
|
|
57
|
+
// Step 2: principal → addressbook-home-set
|
|
58
|
+
const principalUrl = toAbsolute(principalPath, CONTACTS_HOST);
|
|
59
|
+
const principalResp = await davRequest('PROPFIND', principalUrl, {
|
|
60
|
+
depth: 0,
|
|
61
|
+
contentType: 'application/xml; charset=utf-8',
|
|
62
|
+
body: propfindBody('<C:addressbook-home-set xmlns:C="urn:ietf:params:xml:ns:carddav"/>'),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const homeHref = extractHrefIn(principalResp.body, 'addressbook-home-set');
|
|
66
|
+
if (!homeHref) throw new Error('CardDAV: could not find addressbook-home-set');
|
|
67
|
+
|
|
68
|
+
const homeSetUrl = homeHref.startsWith('http') ? homeHref : null;
|
|
69
|
+
// The home-set URL includes the partition host (e.g. p137-contacts.icloud.com)
|
|
70
|
+
const dataHost = homeSetUrl ? new URL(homeSetUrl).origin : CONTACTS_HOST;
|
|
71
|
+
const homeSetPath = homeHref.startsWith('http') ? new URL(homeHref).pathname : homeHref;
|
|
72
|
+
|
|
73
|
+
// Step 3: list address books, find the main one (resourcetype = addressbook)
|
|
74
|
+
const listing = await davRequest('PROPFIND', `${dataHost}${homeSetPath}`, {
|
|
75
|
+
depth: 1,
|
|
76
|
+
contentType: 'application/xml; charset=utf-8',
|
|
77
|
+
body: propfindBody('<A:resourcetype/><A:displayname/>'),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const blocks = splitResponses(listing.body);
|
|
81
|
+
let addressBookPath = null;
|
|
82
|
+
for (const block of blocks) {
|
|
83
|
+
if (block.includes('addressbook')) {
|
|
84
|
+
const hrefMatch = block.match(/<[^>:]*:?href[^>]*>([^<]+)<\/[^>:]*:?href>/);
|
|
85
|
+
if (hrefMatch) {
|
|
86
|
+
const p = hrefMatch[1].startsWith('http') ? new URL(hrefMatch[1]).pathname : hrefMatch[1];
|
|
87
|
+
if (!addressBookPath || p.includes('/card')) addressBookPath = p;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (!addressBookPath) throw new Error('CardDAV: could not find address book');
|
|
92
|
+
|
|
93
|
+
_discoveryCache = { dataHost, homeSetPath, addressBookPath };
|
|
94
|
+
return _discoveryCache;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ─── XML / text helpers ───────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
function toAbsolute(path, base) {
|
|
100
|
+
return path.startsWith('http') ? path : `${base}${path}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function extractHrefIn(xml, parentTag) {
|
|
104
|
+
const re = new RegExp(
|
|
105
|
+
`<[^>:]*:?${parentTag}[\\s\\S]*?>[\\s\\S]*?<[^>:]*:?href[^>]*>([^<]+)<\\/[^>:]*:?href>`,
|
|
106
|
+
'i'
|
|
107
|
+
);
|
|
108
|
+
const m = xml.match(re);
|
|
109
|
+
return m ? m[1].trim() : null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function splitResponses(xml) {
|
|
113
|
+
return [...xml.matchAll(/<[^>:]*:?response[\s\S]*?<\/[^>:]*:?response>/g)].map(m => m[0]);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ─── VCARD value escaping ─────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
// VCARD 3.0: newlines in values must be \n (backslash-n), not actual newlines
|
|
119
|
+
function vcardEscape(str) {
|
|
120
|
+
if (!str) return str;
|
|
121
|
+
return str.replace(/\\/g, '\\\\').replace(/\n/g, '\\n').replace(/\r/g, '');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function vcardUnescape(str) {
|
|
125
|
+
if (!str) return str;
|
|
126
|
+
return str.replace(/\\n/gi, '\n').replace(/\\,/g, ',').replace(/\\;/g, ';').replace(/\\\\/g, '\\');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ─── VCARD parsing ────────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
function parseVCard(text) {
|
|
132
|
+
// Unfold continuation lines (CRLF + SPACE/TAB)
|
|
133
|
+
const unfolded = text.replace(/\r?\n[ \t]/g, '');
|
|
134
|
+
const lines = unfolded.split(/\r?\n/).filter(l => l && l !== 'BEGIN:VCARD' && l !== 'END:VCARD');
|
|
135
|
+
|
|
136
|
+
const contact = { phones: [], emails: [], addresses: [], _rawLines: [] };
|
|
137
|
+
|
|
138
|
+
for (const line of lines) {
|
|
139
|
+
const colonIdx = line.indexOf(':');
|
|
140
|
+
if (colonIdx < 0) continue;
|
|
141
|
+
const fullKey = line.slice(0, colonIdx);
|
|
142
|
+
const val = line.slice(colonIdx + 1);
|
|
143
|
+
const key = fullKey.split(';')[0].toUpperCase();
|
|
144
|
+
|
|
145
|
+
switch (key) {
|
|
146
|
+
case 'VERSION': break;
|
|
147
|
+
case 'PRODID': break;
|
|
148
|
+
case 'FN': contact.fullName = val; break;
|
|
149
|
+
case 'UID': contact.uid = val; break;
|
|
150
|
+
case 'ORG': contact.org = val.replace(/;$/, '').trim(); break;
|
|
151
|
+
case 'BDAY': contact.birthday = val; break;
|
|
152
|
+
case 'REV': contact.rev = val; break;
|
|
153
|
+
case 'NOTE': contact.note = vcardUnescape(val); break;
|
|
154
|
+
case 'URL': contact.url = vcardUnescape(val); break;
|
|
155
|
+
case 'N': {
|
|
156
|
+
const parts = val.split(';');
|
|
157
|
+
contact.lastName = parts[0] || '';
|
|
158
|
+
contact.firstName = parts[1] || '';
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
default: {
|
|
162
|
+
if (key.includes('TEL')) {
|
|
163
|
+
const typeMatch = fullKey.match(/type=([^;:]+)/i);
|
|
164
|
+
const rawType = typeMatch?.[1]?.toLowerCase() || 'phone';
|
|
165
|
+
// Skip 'pref' as the type label, use the next type if available
|
|
166
|
+
const types = fullKey.match(/type=([^;:]+)/gi)?.map(t => t.split('=')[1].toLowerCase()) || [];
|
|
167
|
+
const type = types.find(t => t !== 'pref') || rawType;
|
|
168
|
+
contact.phones.push({ type, number: val });
|
|
169
|
+
} else if (key.includes('EMAIL')) {
|
|
170
|
+
const types = fullKey.match(/type=([^;:]+)/gi)?.map(t => t.split('=')[1].toLowerCase()) || [];
|
|
171
|
+
const type = types.find(t => !['internet', 'pref'].includes(t)) || 'home';
|
|
172
|
+
contact.emails.push({ type, email: val });
|
|
173
|
+
} else if (key.includes('ADR')) {
|
|
174
|
+
const parts = val.split(';');
|
|
175
|
+
const types = fullKey.match(/type=([^;:]+)/gi)?.map(t => t.split('=')[1].toLowerCase()) || [];
|
|
176
|
+
const type = types.find(t => t !== 'pref') || 'home';
|
|
177
|
+
contact.addresses.push({
|
|
178
|
+
type,
|
|
179
|
+
street: parts[2] || '',
|
|
180
|
+
city: parts[3] || '',
|
|
181
|
+
state: parts[4] || '',
|
|
182
|
+
zip: parts[5] || '',
|
|
183
|
+
country: parts[6] || '',
|
|
184
|
+
});
|
|
185
|
+
} else {
|
|
186
|
+
// Preserve unknown lines: PHOTO, X-*, item*.X-ABLabel, TITLE, etc.
|
|
187
|
+
contact._rawLines.push(line);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return contact;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ─── VCARD serialization ──────────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
function serializeVCard(fields, uid = null) {
|
|
199
|
+
const lines = [
|
|
200
|
+
'BEGIN:VCARD',
|
|
201
|
+
'VERSION:3.0',
|
|
202
|
+
'PRODID:-//icloud-mcp//EN',
|
|
203
|
+
];
|
|
204
|
+
const vcardUid = uid || randomUUID().toUpperCase();
|
|
205
|
+
|
|
206
|
+
const fn = fields.fullName ||
|
|
207
|
+
[fields.firstName, fields.lastName].filter(Boolean).join(' ') ||
|
|
208
|
+
fields.org || 'Unknown';
|
|
209
|
+
|
|
210
|
+
lines.push(`N:${fields.lastName || ''};${fields.firstName || ''};;;`);
|
|
211
|
+
lines.push(`FN:${fn}`);
|
|
212
|
+
|
|
213
|
+
if (fields.org) lines.push(`ORG:${fields.org};`);
|
|
214
|
+
if (fields.birthday) lines.push(`BDAY:${fields.birthday}`);
|
|
215
|
+
if (fields.note) lines.push(`NOTE:${vcardEscape(fields.note)}`);
|
|
216
|
+
if (fields.url) lines.push(`URL:${vcardEscape(fields.url)}`);
|
|
217
|
+
|
|
218
|
+
const phones = normalizeArray(fields.phones, fields.phone ? { number: fields.phone, type: 'cell' } : null);
|
|
219
|
+
phones.forEach((p, i) => {
|
|
220
|
+
lines.push(`item${i + 1}.TEL;type=${(p.type || 'cell').toLowerCase()};type=pref:${p.number}`);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
const emails = normalizeArray(fields.emails, fields.email ? { email: fields.email, type: 'home' } : null);
|
|
224
|
+
emails.forEach(e => {
|
|
225
|
+
lines.push(`EMAIL;type=INTERNET;type=${(e.type || 'home').toLowerCase()};type=pref:${e.email}`);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
const addresses = Array.isArray(fields.addresses) ? fields.addresses : [];
|
|
229
|
+
addresses.forEach(a => {
|
|
230
|
+
lines.push(`ADR;type=${(a.type || 'home').toLowerCase()};type=pref:;;${a.street || ''};${a.city || ''};${a.state || ''};${a.zip || ''};${a.country || ''}`);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// Preserve unknown fields from the original VCARD (PHOTO, X-*, item*.X-ABLabel, etc.)
|
|
234
|
+
if (Array.isArray(fields._rawLines)) {
|
|
235
|
+
for (const rawLine of fields._rawLines) lines.push(rawLine);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
lines.push(`UID:${vcardUid}`);
|
|
239
|
+
const rev = new Date().toISOString().replace(/[-:.]/g, '').slice(0, 15) + 'Z';
|
|
240
|
+
lines.push(`REV:${rev}`);
|
|
241
|
+
lines.push('END:VCARD');
|
|
242
|
+
|
|
243
|
+
return lines.join('\r\n') + '\r\n';
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function normalizeArray(arr, fallback) {
|
|
247
|
+
if (Array.isArray(arr) && arr.length) return arr;
|
|
248
|
+
if (fallback) return [fallback];
|
|
249
|
+
return [];
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ─── Parse REPORT response blocks ────────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
function parseContactBlocks(xml) {
|
|
255
|
+
return splitResponses(xml).map(block => {
|
|
256
|
+
const hrefMatch = block.match(/<[^>:]*:?href[^>]*>([^<]+)<\/[^>:]*:?href>/);
|
|
257
|
+
const etagMatch = block.match(/<[^>:]*:?getetag[^>]*>"?([^"<]+)"?<\/[^>:]*:?getetag>/);
|
|
258
|
+
const dataMatch = block.match(/<[^>:]*:?address-data[^>]*>([\s\S]*?)<\/[^>:]*:?address-data>/i);
|
|
259
|
+
if (!hrefMatch || !dataMatch) return null;
|
|
260
|
+
|
|
261
|
+
const href = hrefMatch[1];
|
|
262
|
+
const filename = href.split('/').pop();
|
|
263
|
+
const contactId = filename.replace(/\.vcf$/i, '');
|
|
264
|
+
const vcard = dataMatch[1].replace(/ /g, '\r');
|
|
265
|
+
const contact = parseVCard(vcard);
|
|
266
|
+
|
|
267
|
+
return { contactId, etag: etagMatch?.[1] || null, href, ...contact };
|
|
268
|
+
}).filter(Boolean);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
272
|
+
|
|
273
|
+
export async function listContacts(limit = 50, offset = 0) {
|
|
274
|
+
const { dataHost, addressBookPath } = await discover();
|
|
275
|
+
|
|
276
|
+
const fetchLimit = limit + offset;
|
|
277
|
+
const body = `<?xml version="1.0" encoding="UTF-8"?>
|
|
278
|
+
<C:addressbook-query xmlns:C="urn:ietf:params:xml:ns:carddav" xmlns:A="DAV:">
|
|
279
|
+
<A:prop><A:getetag/><C:address-data/></A:prop>
|
|
280
|
+
<C:filter/>
|
|
281
|
+
<C:limit><C:nresults>${fetchLimit}</C:nresults></C:limit>
|
|
282
|
+
</C:addressbook-query>`;
|
|
283
|
+
|
|
284
|
+
const resp = await davRequest('REPORT', `${dataHost}${addressBookPath}`, {
|
|
285
|
+
depth: 1,
|
|
286
|
+
contentType: 'application/xml; charset=utf-8',
|
|
287
|
+
body,
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
const contacts = parseContactBlocks(resp.body).slice(offset, offset + limit);
|
|
291
|
+
return { contacts, count: contacts.length, limit, offset };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export async function searchContacts(query) {
|
|
295
|
+
const { dataHost, addressBookPath } = await discover();
|
|
296
|
+
|
|
297
|
+
// Search FN, EMAIL, TEL — run three queries and merge
|
|
298
|
+
const makeQuery = (propName) => `<?xml version="1.0" encoding="UTF-8"?>
|
|
299
|
+
<C:addressbook-query xmlns:C="urn:ietf:params:xml:ns:carddav" xmlns:A="DAV:">
|
|
300
|
+
<A:prop><A:getetag/><C:address-data/></A:prop>
|
|
301
|
+
<C:filter>
|
|
302
|
+
<C:prop-filter name="${propName}">
|
|
303
|
+
<C:text-match collation="i;unicode-casemap" match-type="contains">${query}</C:text-match>
|
|
304
|
+
</C:prop-filter>
|
|
305
|
+
</C:filter>
|
|
306
|
+
</C:addressbook-query>`;
|
|
307
|
+
|
|
308
|
+
const url = `${dataHost}${addressBookPath}`;
|
|
309
|
+
const opts = { depth: 1, contentType: 'application/xml; charset=utf-8' };
|
|
310
|
+
|
|
311
|
+
const [fnResp, emailResp, telResp] = await Promise.all([
|
|
312
|
+
davRequest('REPORT', url, { ...opts, body: makeQuery('FN') }),
|
|
313
|
+
davRequest('REPORT', url, { ...opts, body: makeQuery('EMAIL') }),
|
|
314
|
+
davRequest('REPORT', url, { ...opts, body: makeQuery('TEL') }),
|
|
315
|
+
]);
|
|
316
|
+
|
|
317
|
+
// Merge and deduplicate by contactId
|
|
318
|
+
const seen = new Set();
|
|
319
|
+
const results = [];
|
|
320
|
+
for (const resp of [fnResp, emailResp, telResp]) {
|
|
321
|
+
for (const c of parseContactBlocks(resp.body)) {
|
|
322
|
+
if (!seen.has(c.contactId)) {
|
|
323
|
+
seen.add(c.contactId);
|
|
324
|
+
results.push(c);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return { contacts: results, count: results.length, query };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export async function getContact(contactId) {
|
|
333
|
+
const { dataHost, addressBookPath } = await discover();
|
|
334
|
+
const url = `${dataHost}${addressBookPath}${contactId}.vcf`;
|
|
335
|
+
const resp = await davRequest('GET', url);
|
|
336
|
+
|
|
337
|
+
if (resp.status === 404) throw new Error(`Contact not found: ${contactId}`);
|
|
338
|
+
if (resp.status >= 400) throw new Error(`CardDAV GET failed: ${resp.status}`);
|
|
339
|
+
|
|
340
|
+
const contact = parseVCard(resp.body);
|
|
341
|
+
return { contactId, etag: resp.etag, ...contact };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export async function createContact(fields) {
|
|
345
|
+
const { dataHost, addressBookPath } = await discover();
|
|
346
|
+
const contactId = randomUUID().toUpperCase();
|
|
347
|
+
const vcard = serializeVCard({ ...fields }, contactId);
|
|
348
|
+
const url = `${dataHost}${addressBookPath}${contactId}.vcf`;
|
|
349
|
+
|
|
350
|
+
const resp = await davRequest('PUT', url, {
|
|
351
|
+
contentType: 'text/vcard; charset=utf-8',
|
|
352
|
+
body: vcard,
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
if (resp.status !== 201 && resp.status !== 204 && resp.status !== 200) {
|
|
356
|
+
throw new Error(`CardDAV PUT failed: ${resp.status} — ${resp.body.slice(0, 200)}`);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return { created: true, contactId, etag: resp.etag };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export async function updateContact(contactId, fields) {
|
|
363
|
+
const { dataHost, addressBookPath } = await discover();
|
|
364
|
+
const url = `${dataHost}${addressBookPath}${contactId}.vcf`;
|
|
365
|
+
|
|
366
|
+
// Fetch existing to get etag and merge fields
|
|
367
|
+
const existing = await davRequest('GET', url);
|
|
368
|
+
if (existing.status === 404) throw new Error(`Contact not found: ${contactId}`);
|
|
369
|
+
|
|
370
|
+
const current = parseVCard(existing.body);
|
|
371
|
+
|
|
372
|
+
// Merge: new fields override, but keep arrays from existing if not overridden
|
|
373
|
+
const merged = { ...current, ...fields };
|
|
374
|
+
// Preserve the original VCARD UID (which may differ from the filename UUID)
|
|
375
|
+
const vcard = serializeVCard(merged, current.uid || contactId);
|
|
376
|
+
|
|
377
|
+
const resp = await davRequest('PUT', url, {
|
|
378
|
+
contentType: 'text/vcard; charset=utf-8',
|
|
379
|
+
etag: existing.etag,
|
|
380
|
+
body: vcard,
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
if (resp.status !== 204 && resp.status !== 200) {
|
|
384
|
+
throw new Error(`CardDAV PUT (update) failed: ${resp.status} — ${resp.body.slice(0, 200)}`);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return { updated: true, contactId, etag: resp.etag };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export async function deleteContact(contactId) {
|
|
391
|
+
const { dataHost, addressBookPath } = await discover();
|
|
392
|
+
const url = `${dataHost}${addressBookPath}${contactId}.vcf`;
|
|
393
|
+
|
|
394
|
+
const resp = await davRequest('DELETE', url);
|
|
395
|
+
if (resp.status === 404) throw new Error(`Contact not found: ${contactId}`);
|
|
396
|
+
if (resp.status !== 204 && resp.status !== 200) {
|
|
397
|
+
throw new Error(`CardDAV DELETE failed: ${resp.status}`);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return { deleted: true, contactId };
|
|
401
|
+
}
|
package/lib/digest.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// ─── Digest State ─────────────────────────────────────────────────────────────
|
|
2
|
+
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
|
|
6
|
+
const DIGEST_FILE = join(homedir(), '.icloud-mcp-digest.json');
|
|
7
|
+
|
|
8
|
+
const DEFAULT_STATE = {
|
|
9
|
+
lastRun: null,
|
|
10
|
+
processedUids: [],
|
|
11
|
+
pendingActions: [],
|
|
12
|
+
skipCounts: {}
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function readDigest() {
|
|
16
|
+
if (!existsSync(DIGEST_FILE)) return { ...DEFAULT_STATE };
|
|
17
|
+
try { return JSON.parse(readFileSync(DIGEST_FILE, 'utf8')); }
|
|
18
|
+
catch { return { ...DEFAULT_STATE }; }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function writeDigest(data) {
|
|
22
|
+
writeFileSync(DIGEST_FILE, JSON.stringify(data, null, 2));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function getDigestState() {
|
|
26
|
+
return readDigest();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function updateDigestState({ processedUids, lastRun, pendingActions, skipCounts } = {}) {
|
|
30
|
+
const state = readDigest();
|
|
31
|
+
if (lastRun !== undefined) state.lastRun = lastRun;
|
|
32
|
+
if (processedUids !== undefined) {
|
|
33
|
+
// Merge with existing, deduplicate, cap at 5000 to prevent unbounded growth
|
|
34
|
+
const merged = [...new Set([...state.processedUids, ...processedUids])];
|
|
35
|
+
state.processedUids = merged.slice(-5000);
|
|
36
|
+
}
|
|
37
|
+
if (pendingActions !== undefined) state.pendingActions = pendingActions;
|
|
38
|
+
if (skipCounts !== undefined) {
|
|
39
|
+
// Accumulate skip counts per sender for smart unsubscribe
|
|
40
|
+
for (const [sender, count] of Object.entries(skipCounts)) {
|
|
41
|
+
state.skipCounts[sender] = (state.skipCounts[sender] || 0) + count;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
writeDigest(state);
|
|
45
|
+
return state;
|
|
46
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// ─── lib/event-extractor.js — Email content formatter for calendar extraction ─
|
|
2
|
+
// Returns structured email content for Claude to extract event details from.
|
|
3
|
+
// No external API calls — Claude (the calling model) does the extraction natively.
|
|
4
|
+
|
|
5
|
+
export function formatEmailForExtraction(email) {
|
|
6
|
+
const sentAt = new Date(email.date);
|
|
7
|
+
const sentFormatted = sentAt.toLocaleString('en-US', {
|
|
8
|
+
timeZone: 'America/New_York',
|
|
9
|
+
weekday: 'long',
|
|
10
|
+
year: 'numeric',
|
|
11
|
+
month: 'long',
|
|
12
|
+
day: 'numeric',
|
|
13
|
+
hour: 'numeric',
|
|
14
|
+
minute: '2-digit',
|
|
15
|
+
timeZoneName: 'short',
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
// Raw email fields
|
|
20
|
+
subject: email.subject,
|
|
21
|
+
from: email.from,
|
|
22
|
+
sentAt: sentFormatted,
|
|
23
|
+
sentAtIso: email.date,
|
|
24
|
+
body: email.body,
|
|
25
|
+
|
|
26
|
+
// Anchor hint for relative date resolution
|
|
27
|
+
_dateAnchor: `The email was sent on ${sentFormatted}. Use this as the reference when resolving relative dates like "Tuesday", "tomorrow", or "next week".`,
|
|
28
|
+
|
|
29
|
+
// Extraction instructions for Claude
|
|
30
|
+
_instructions: [
|
|
31
|
+
'Review the email above and extract the following calendar event fields:',
|
|
32
|
+
' • summary — event title',
|
|
33
|
+
' • start — ISO 8601 datetime (resolve relative dates using sentAt as anchor)',
|
|
34
|
+
' • end — ISO 8601 datetime (estimate if not stated)',
|
|
35
|
+
' • estimatedEnd — true if end time was not explicitly stated',
|
|
36
|
+
' • allDay — true if no specific time is given',
|
|
37
|
+
' • timezone — IANA timezone (infer from location if not stated)',
|
|
38
|
+
' • location — full venue name and address',
|
|
39
|
+
' • description — full agenda, parking info, and any other relevant details',
|
|
40
|
+
' • attendees — array of named people with role/title if mentioned',
|
|
41
|
+
' • organizer — who sent or organized this',
|
|
42
|
+
' • confidence — high / medium / low',
|
|
43
|
+
' • notes — anything ambiguous or worth flagging to the user',
|
|
44
|
+
'Present the extracted fields to the user for confirmation before calling create_event.',
|
|
45
|
+
].join('\n'),
|
|
46
|
+
};
|
|
47
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "icloud-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0",
|
|
4
4
|
"description": "A Model Context Protocol (MCP) server for iCloud Mail",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -21,7 +21,9 @@
|
|
|
21
21
|
"author": "Adam Zaidi",
|
|
22
22
|
"license": "MIT",
|
|
23
23
|
"dependencies": {
|
|
24
|
+
"@anthropic-ai/sdk": "^0.78.0",
|
|
24
25
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
26
|
+
"fast-xml-parser": "^5.4.2",
|
|
25
27
|
"imapflow": "^1.2.10",
|
|
26
28
|
"nodemailer": "^8.0.2"
|
|
27
29
|
}
|