icloud-mcp 2.2.0 → 2.3.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.
@@ -16,7 +16,16 @@
16
16
  "mcp__icloud-mail__archive_older_than",
17
17
  "mcp__icloud-mail__bulk_move_by_sender",
18
18
  "mcp__icloud-mail__get_move_status",
19
- "mcp__icloud-mail__bulk_move_by_domain"
19
+ "mcp__icloud-mail__bulk_move_by_domain",
20
+ "WebSearch",
21
+ "WebFetch(domain:www.mdcourts.gov)",
22
+ "WebFetch(domain:probonomd.org)",
23
+ "WebFetch(domain:www.courts.state.md.us)",
24
+ "WebFetch(domain:msa.maryland.gov)",
25
+ "WebFetch(domain:www.harford.edu)",
26
+ "WebFetch(domain:thedailyrecord.com)",
27
+ "WebFetch(domain:ballotpedia.org)",
28
+ "mcp__icloud-mail__compose_email"
20
29
  ]
21
30
  },
22
31
  "enableAllProjectMcpServers": true,
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # icloud-mcp
2
2
 
3
- A Model Context Protocol (MCP) server that connects Claude to your iCloud Mail account. Read, search, organize, send, and automate your inbox directly through Claude.
3
+ A Model Context Protocol (MCP) server that connects Claude to your iCloud account — Mail, Contacts, and Calendar. Read, search, organize, send, and automate across the full iCloud suite.
4
4
 
5
5
  ## Features
6
6
 
@@ -21,6 +21,8 @@ A Model Context Protocol (MCP) server that connects Claude to your iCloud Mail a
21
21
  - 🔄 Dry run mode for bulk operations — preview before committing
22
22
  - 🔐 Safe move — emails are fingerprinted and verified in the destination before removal from source
23
23
  - 📝 Session logging — Claude tracks progress across long multi-step operations
24
+ - 👤 Contacts — list, search, create, update, and delete iCloud Contacts via CardDAV
25
+ - 📅 Calendar — list calendars, query events by date, create/update/delete events via CalDAV
24
26
 
25
27
  ## Prerequisites
26
28
 
@@ -161,7 +163,7 @@ You're all set. Try asking Claude:
161
163
  - *"Show me the top senders in my iCloud inbox"*
162
164
  - *"How many unread emails do I have?"*
163
165
 
164
- ## Available Tools (55)
166
+ ## Available Tools (65)
165
167
 
166
168
  ### Read & Search
167
169
 
@@ -247,6 +249,29 @@ You're all set. Try asking Claude:
247
249
  | `run_all_rules` | Run all saved rules in sequence; supports `dryRun` |
248
250
  | `delete_rule` | Delete a saved rule by name |
249
251
 
252
+ ### Contacts (CardDAV)
253
+
254
+ | Tool | Description |
255
+ |------|-------------|
256
+ | `list_contacts` | List contacts from iCloud Contacts; supports `limit`, `offset` for pagination |
257
+ | `search_contacts` | Search contacts by name, email, or phone number |
258
+ | `get_contact` | Get full details for a specific contact by ID |
259
+ | `create_contact` | Create a new contact; supports name, phones, emails, address, org, birthday, note |
260
+ | `update_contact` | Update an existing contact; only provided fields are changed |
261
+ | `delete_contact` | Permanently delete a contact |
262
+
263
+ ### Calendar (CalDAV)
264
+
265
+ | Tool | Description |
266
+ |------|-------------|
267
+ | `list_calendars` | List all iCloud calendars with name, ID, and supported event types |
268
+ | `list_events` | List events in a calendar within a date range; supports `since`, `before`, `limit` |
269
+ | `get_event` | Get full details of a specific event by ID |
270
+ | `create_event` | Create a new event; supports title, start/end, timezone, all-day, description, location, recurrence |
271
+ | `update_event` | Update an existing event; only provided fields are changed |
272
+ | `delete_event` | Permanently delete a calendar event |
273
+ | `search_events` | Search for events by title across all calendars; supports date range |
274
+
250
275
  ### Session Log
251
276
 
252
277
  | Tool | Description |
@@ -299,6 +324,11 @@ Once configured, you can ask Claude things like:
299
324
  - *"Create a rule that moves all emails from spotify.com to bulk-mail/services"*
300
325
  - *"Reply to the last email from John and cc Sarah"*
301
326
  - *"Draft a follow-up email to the team about the Q1 report"*
327
+ - *"Find John Smith's phone number in my contacts"*
328
+ - *"Add a new contact: Jane Doe, jane@example.com, +1 555 123 4567"*
329
+ - *"What's on my calendar next week?"*
330
+ - *"Create an event: dentist appointment Monday at 10am Eastern"*
331
+ - *"Find all my calendar events about 'team meeting'"*
302
332
 
303
333
  ## Security
304
334
 
package/index.js CHANGED
@@ -19,6 +19,9 @@ import {
19
19
  } from './lib/imap.js';
20
20
  import { logRead, logWrite, logClear } from './lib/session.js';
21
21
  import { composeEmail, replyToEmail, forwardEmail, saveDraft } from './lib/smtp.js';
22
+ import { listContacts, searchContacts, getContact, createContact, updateContact, deleteContact } from './lib/carddav.js';
23
+ import { formatEmailForExtraction } from './lib/event-extractor.js';
24
+ import { listCalendars, listEvents, getEvent, createEvent, updateEvent, deleteEvent, searchEvents } from './lib/caldav.js';
22
25
 
23
26
  const IMAP_USER = process.env.IMAP_USER;
24
27
  const IMAP_PASSWORD = process.env.IMAP_PASSWORD;
@@ -36,7 +39,7 @@ if (!IMAP_USER || !IMAP_PASSWORD) {
36
39
 
37
40
  async function main() {
38
41
  const server = new Server(
39
- { name: 'icloud-mail', version: '2.1.0' },
42
+ { name: 'icloud-mail', version: '2.3.0' },
40
43
  { capabilities: { tools: {} } }
41
44
  );
42
45
 
@@ -673,6 +676,208 @@ async function main() {
673
676
  },
674
677
  required: ['to', 'subject']
675
678
  }
679
+ },
680
+ // ── CardDAV / Contacts ──
681
+ {
682
+ name: 'list_contacts',
683
+ description: 'List contacts from iCloud Contacts. Returns names, phones, emails, and other fields.',
684
+ inputSchema: {
685
+ type: 'object',
686
+ properties: {
687
+ limit: { type: 'number', description: 'Max contacts to return (default 50)' },
688
+ offset: { type: 'number', description: 'Skip this many contacts (default 0, for pagination)' }
689
+ }
690
+ }
691
+ },
692
+ {
693
+ name: 'search_contacts',
694
+ description: 'Search iCloud Contacts by name, email address, or phone number.',
695
+ inputSchema: {
696
+ type: 'object',
697
+ properties: {
698
+ query: { type: 'string', description: 'Text to search for (matched against name, email, and phone)' }
699
+ },
700
+ required: ['query']
701
+ }
702
+ },
703
+ {
704
+ name: 'get_contact',
705
+ description: 'Get full details for a specific contact by ID. Use list_contacts or search_contacts to find a contactId.',
706
+ inputSchema: {
707
+ type: 'object',
708
+ properties: {
709
+ contactId: { type: 'string', description: 'Contact ID (UUID from list_contacts or search_contacts)' }
710
+ },
711
+ required: ['contactId']
712
+ }
713
+ },
714
+ {
715
+ name: 'create_contact',
716
+ description: 'Create a new contact in iCloud Contacts.',
717
+ inputSchema: {
718
+ type: 'object',
719
+ properties: {
720
+ firstName: { type: 'string', description: 'First name' },
721
+ lastName: { type: 'string', description: 'Last name' },
722
+ fullName: { type: 'string', description: 'Full display name (overrides firstName + lastName for FN field)' },
723
+ org: { type: 'string', description: 'Organization / company name' },
724
+ phone: { type: 'string', description: 'Primary phone number (shorthand for phones array)' },
725
+ email: { type: 'string', description: 'Primary email address (shorthand for emails array)' },
726
+ phones: { type: 'array', description: 'Array of phone objects: [{ number, type }] where type is cell/home/work/etc.' },
727
+ emails: { type: 'array', description: 'Array of email objects: [{ email, type }] where type is home/work/etc.' },
728
+ addresses: { type: 'array', description: 'Array of address objects: [{ street, city, state, zip, country, type }]' },
729
+ birthday: { type: 'string', description: 'Birthday in YYYY-MM-DD format' },
730
+ note: { type: 'string', description: 'Notes / free text' },
731
+ url: { type: 'string', description: 'Website URL' }
732
+ }
733
+ }
734
+ },
735
+ {
736
+ name: 'update_contact',
737
+ description: 'Update an existing contact in iCloud Contacts. Only provided fields are changed; others are preserved.',
738
+ inputSchema: {
739
+ type: 'object',
740
+ properties: {
741
+ contactId: { type: 'string', description: 'Contact ID to update' },
742
+ firstName: { type: 'string' },
743
+ lastName: { type: 'string' },
744
+ fullName: { type: 'string' },
745
+ org: { type: 'string' },
746
+ phone: { type: 'string' },
747
+ email: { type: 'string' },
748
+ phones: { type: 'array' },
749
+ emails: { type: 'array' },
750
+ addresses: { type: 'array' },
751
+ birthday: { type: 'string' },
752
+ note: { type: 'string' },
753
+ url: { type: 'string' }
754
+ },
755
+ required: ['contactId']
756
+ }
757
+ },
758
+ {
759
+ name: 'delete_contact',
760
+ description: 'Delete a contact from iCloud Contacts permanently.',
761
+ inputSchema: {
762
+ type: 'object',
763
+ properties: {
764
+ contactId: { type: 'string', description: 'Contact ID to delete' }
765
+ },
766
+ required: ['contactId']
767
+ }
768
+ },
769
+ // ── CalDAV / Calendar ──
770
+ {
771
+ name: 'list_calendars',
772
+ description: 'List all calendars in iCloud Calendar (e.g. Personal, Work, LSAT PREP). Returns calendarId, name, and supported event types.',
773
+ inputSchema: { type: 'object', properties: {} }
774
+ },
775
+ {
776
+ name: 'list_events',
777
+ description: 'List events in a specific iCloud calendar within a date range. Use list_calendars first to get a calendarId.',
778
+ inputSchema: {
779
+ type: 'object',
780
+ properties: {
781
+ calendarId: { type: 'string', description: 'Calendar ID from list_calendars' },
782
+ since: { type: 'string', description: 'Start of range (YYYY-MM-DD, default: 30 days ago)' },
783
+ before: { type: 'string', description: 'End of range (YYYY-MM-DD, default: 30 days ahead)' },
784
+ limit: { type: 'number', description: 'Max events to return (default 50)' }
785
+ },
786
+ required: ['calendarId']
787
+ }
788
+ },
789
+ {
790
+ name: 'get_event',
791
+ description: 'Get full details of a specific calendar event by its ID.',
792
+ inputSchema: {
793
+ type: 'object',
794
+ properties: {
795
+ calendarId: { type: 'string', description: 'Calendar ID containing the event' },
796
+ eventId: { type: 'string', description: 'Event ID (UUID from list_events or search_events)' }
797
+ },
798
+ required: ['calendarId', 'eventId']
799
+ }
800
+ },
801
+ {
802
+ name: 'create_event',
803
+ description: 'Create a new event in an iCloud calendar. For all-day events use allDay:true and YYYY-MM-DD for start/end.',
804
+ inputSchema: {
805
+ type: 'object',
806
+ properties: {
807
+ calendarId: { type: 'string', description: 'Calendar ID to add the event to' },
808
+ summary: { type: 'string', description: 'Event title' },
809
+ start: { type: 'string', description: 'Start date/time — ISO 8601 (e.g. 2026-03-15T10:00:00) or YYYY-MM-DD for all-day' },
810
+ end: { type: 'string', description: 'End date/time — ISO 8601 or YYYY-MM-DD. Defaults to 1 hour after start.' },
811
+ timezone: { type: 'string', description: 'IANA timezone (e.g. America/New_York). Use "UTC" or omit for UTC.' },
812
+ allDay: { type: 'boolean', description: 'True for all-day event (uses DATE values, no time)' },
813
+ description: { type: 'string', description: 'Event description / notes' },
814
+ location: { type: 'string', description: 'Event location' },
815
+ recurrence: { type: 'string', description: 'iCal RRULE string (e.g. FREQ=WEEKLY;BYDAY=MO,WE,FR)' },
816
+ status: { type: 'string', description: 'Event status: CONFIRMED, TENTATIVE, or CANCELLED' },
817
+ reminder: { type: 'number', description: 'Alert this many minutes before the event (default 30, set to 0 to disable)' }
818
+ },
819
+ required: ['calendarId', 'summary', 'start']
820
+ }
821
+ },
822
+ {
823
+ name: 'update_event',
824
+ description: 'Update an existing calendar event. Only provided fields are changed; others are preserved.',
825
+ inputSchema: {
826
+ type: 'object',
827
+ properties: {
828
+ calendarId: { type: 'string', description: 'Calendar ID containing the event' },
829
+ eventId: { type: 'string', description: 'Event ID to update' },
830
+ summary: { type: 'string' },
831
+ start: { type: 'string' },
832
+ end: { type: 'string' },
833
+ timezone: { type: 'string' },
834
+ allDay: { type: 'boolean' },
835
+ description: { type: 'string' },
836
+ location: { type: 'string' },
837
+ recurrence: { type: 'string' },
838
+ status: { type: 'string' },
839
+ reminder: { type: 'number', description: 'Alert minutes before event (0 to disable)' }
840
+ },
841
+ required: ['calendarId', 'eventId']
842
+ }
843
+ },
844
+ {
845
+ name: 'delete_event',
846
+ description: 'Delete a calendar event permanently from iCloud Calendar.',
847
+ inputSchema: {
848
+ type: 'object',
849
+ properties: {
850
+ calendarId: { type: 'string', description: 'Calendar ID containing the event' },
851
+ eventId: { type: 'string', description: 'Event ID to delete' }
852
+ },
853
+ required: ['calendarId', 'eventId']
854
+ }
855
+ },
856
+ {
857
+ name: 'search_events',
858
+ description: 'Search for events by title/summary across all calendars within an optional date range.',
859
+ inputSchema: {
860
+ type: 'object',
861
+ properties: {
862
+ query: { type: 'string', description: 'Text to search for in event titles' },
863
+ since: { type: 'string', description: 'Start of search range (YYYY-MM-DD, default: 1 year ago)' },
864
+ before: { type: 'string', description: 'End of search range (YYYY-MM-DD, default: 1 year ahead)' }
865
+ },
866
+ required: ['query']
867
+ }
868
+ },
869
+ // ── Smart extraction ──
870
+ {
871
+ name: 'suggest_event_from_email',
872
+ description: 'Fetch an email and return its content formatted for calendar event extraction. After calling this tool, extract the event fields from the returned content (pay attention to _dateAnchor for resolving relative dates like "Tuesday"), present a summary to the user for confirmation, then call create_event. No API key required.',
873
+ inputSchema: {
874
+ type: 'object',
875
+ properties: {
876
+ uid: { type: 'number', description: 'Email UID to extract event from' },
877
+ mailbox: { type: 'string', description: 'Mailbox containing the email (default INBOX)' }
878
+ },
879
+ required: ['uid']
880
+ }
676
881
  }
677
882
  ]
678
883
  }));
@@ -815,6 +1020,70 @@ async function main() {
815
1020
  result = await withTimeout('save_draft', TIMEOUT.FETCH, () =>
816
1021
  saveDraft(args.to, args.subject, args.body, { html: args.html, cc: args.cc, bcc: args.bcc })
817
1022
  );
1023
+ // ── CardDAV / Contacts (FETCH tier 30s) ──
1024
+ } else if (name === 'list_contacts') {
1025
+ result = await withTimeout('list_contacts', TIMEOUT.FETCH, () =>
1026
+ listContacts(args.limit || 50, args.offset || 0)
1027
+ );
1028
+ } else if (name === 'search_contacts') {
1029
+ result = await withTimeout('search_contacts', TIMEOUT.FETCH, () =>
1030
+ searchContacts(args.query)
1031
+ );
1032
+ } else if (name === 'get_contact') {
1033
+ result = await withTimeout('get_contact', TIMEOUT.FETCH, () =>
1034
+ getContact(args.contactId)
1035
+ );
1036
+ } else if (name === 'create_contact') {
1037
+ const { contactId: _ignore, ...fields } = args;
1038
+ result = await withTimeout('create_contact', TIMEOUT.FETCH, () =>
1039
+ createContact(fields)
1040
+ );
1041
+ } else if (name === 'update_contact') {
1042
+ const { contactId, ...fields } = args;
1043
+ result = await withTimeout('update_contact', TIMEOUT.FETCH, () =>
1044
+ updateContact(contactId, fields)
1045
+ );
1046
+ } else if (name === 'delete_contact') {
1047
+ result = await withTimeout('delete_contact', TIMEOUT.SINGLE, () =>
1048
+ deleteContact(args.contactId)
1049
+ );
1050
+ // ── CalDAV / Calendar (FETCH tier 30s) ──
1051
+ } else if (name === 'list_calendars') {
1052
+ result = await withTimeout('list_calendars', TIMEOUT.FETCH, () =>
1053
+ listCalendars()
1054
+ );
1055
+ } else if (name === 'list_events') {
1056
+ result = await withTimeout('list_events', TIMEOUT.FETCH, () =>
1057
+ listEvents(args.calendarId, args.since || null, args.before || null, args.limit || 50)
1058
+ );
1059
+ } else if (name === 'get_event') {
1060
+ result = await withTimeout('get_event', TIMEOUT.FETCH, () =>
1061
+ getEvent(args.calendarId, args.eventId)
1062
+ );
1063
+ } else if (name === 'create_event') {
1064
+ const { calendarId, ...fields } = args;
1065
+ result = await withTimeout('create_event', TIMEOUT.FETCH, () =>
1066
+ createEvent(calendarId, fields)
1067
+ );
1068
+ } else if (name === 'update_event') {
1069
+ const { calendarId, eventId, ...fields } = args;
1070
+ result = await withTimeout('update_event', TIMEOUT.FETCH, () =>
1071
+ updateEvent(calendarId, eventId, fields)
1072
+ );
1073
+ } else if (name === 'delete_event') {
1074
+ result = await withTimeout('delete_event', TIMEOUT.SINGLE, () =>
1075
+ deleteEvent(args.calendarId, args.eventId)
1076
+ );
1077
+ } else if (name === 'search_events') {
1078
+ result = await withTimeout('search_events', TIMEOUT.FETCH, () =>
1079
+ searchEvents(args.query, args.since || null, args.before || null)
1080
+ );
1081
+ // ── Smart extraction (SCAN tier 60s — LLM round-trip) ──
1082
+ } else if (name === 'suggest_event_from_email') {
1083
+ const email = await withTimeout('get_email_for_extraction', TIMEOUT.FETCH, () =>
1084
+ getEmailContent(args.uid, args.mailbox || 'INBOX', 10000, false)
1085
+ );
1086
+ result = formatEmailForExtraction(email);
818
1087
  } else {
819
1088
  throw new Error(`Unknown tool: ${name}`);
820
1089
  }
package/lib/caldav.js ADDED
@@ -0,0 +1,502 @@
1
+ // ─── lib/caldav.js — iCloud CalDAV (Calendar) ────────────────────────────────
2
+ import { randomUUID } from 'crypto';
3
+
4
+ const CALDAV_HOST = 'https://caldav.icloud.com';
5
+ // Calendars to exclude from list_calendars (scheduling containers, not user calendars)
6
+ const EXCLUDED_NAMES = new Set(['inbox', 'outbox', 'notification', 'notification/']);
7
+
8
+ // ─── Credentials & HTTP ───────────────────────────────────────────────────────
9
+
10
+ function getCredentials() {
11
+ const user = process.env.IMAP_USER;
12
+ const pass = process.env.IMAP_PASSWORD;
13
+ if (!user || !pass) throw new Error('IMAP_USER and IMAP_PASSWORD are required');
14
+ return { user, auth: Buffer.from(`${user}:${pass}`).toString('base64') };
15
+ }
16
+
17
+ async function davRequest(method, url, opts = {}) {
18
+ const { auth } = getCredentials();
19
+ const headers = {
20
+ Authorization: `Basic ${auth}`,
21
+ ...(opts.depth !== undefined ? { Depth: String(opts.depth) } : {}),
22
+ ...(opts.contentType ? { 'Content-Type': opts.contentType } : {}),
23
+ ...(opts.etag ? { 'If-Match': opts.etag } : {}),
24
+ };
25
+ const res = await fetch(url, { method, headers, body: opts.body });
26
+ const text = await res.text();
27
+ return { status: res.status, etag: res.headers.get('etag'), body: text };
28
+ }
29
+
30
+ function propfindBody(props) {
31
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<A:propfind xmlns:A="DAV:"><A:prop>${props}</A:prop></A:propfind>`;
32
+ }
33
+
34
+ // ─── Discovery ────────────────────────────────────────────────────────────────
35
+
36
+ let _discoveryCache = null;
37
+
38
+ async function discover() {
39
+ if (_discoveryCache) return _discoveryCache;
40
+
41
+ // Step 1: well-known → principal
42
+ const wk = await davRequest('PROPFIND', `${CALDAV_HOST}/.well-known/caldav`, {
43
+ depth: 0,
44
+ contentType: 'application/xml; charset=utf-8',
45
+ body: propfindBody('<A:current-user-principal/>'),
46
+ });
47
+
48
+ let principalPath = extractHrefIn(wk.body, 'current-user-principal');
49
+ if (!principalPath) {
50
+ const root = await davRequest('PROPFIND', `${CALDAV_HOST}/`, {
51
+ depth: 0,
52
+ contentType: 'application/xml; charset=utf-8',
53
+ body: propfindBody('<A:current-user-principal/>'),
54
+ });
55
+ principalPath = extractHrefIn(root.body, 'current-user-principal');
56
+ }
57
+ if (!principalPath) throw new Error('CalDAV: could not discover principal URL');
58
+
59
+ // Step 2: principal → calendar-home-set
60
+ const principalUrl = principalPath.startsWith('http')
61
+ ? principalPath
62
+ : `${CALDAV_HOST}${principalPath}`;
63
+
64
+ const principalResp = await davRequest('PROPFIND', principalUrl, {
65
+ depth: 0,
66
+ contentType: 'application/xml; charset=utf-8',
67
+ body: propfindBody('<C:calendar-home-set xmlns:C="urn:ietf:params:xml:ns:caldav"/>'),
68
+ });
69
+
70
+ const homeHref = extractHrefIn(principalResp.body, 'calendar-home-set');
71
+ if (!homeHref) throw new Error('CalDAV: could not find calendar-home-set');
72
+
73
+ // homeHref includes partition host (e.g. https://p137-caldav.icloud.com:443/dsid/calendars/)
74
+ const dataHost = homeHref.startsWith('http') ? new URL(homeHref).origin : CALDAV_HOST;
75
+ const calendarsPath = homeHref.startsWith('http')
76
+ ? new URL(homeHref).pathname
77
+ : homeHref;
78
+
79
+ _discoveryCache = { dataHost, calendarsPath };
80
+ return _discoveryCache;
81
+ }
82
+
83
+ // ─── XML helpers ──────────────────────────────────────────────────────────────
84
+
85
+ function extractHrefIn(xml, parentTag) {
86
+ const re = new RegExp(
87
+ `<[^>:]*:?${parentTag}[\\s\\S]*?>[\\s\\S]*?<[^>:]*:?href[^>]*>([^<]+)<\\/[^>:]*:?href>`,
88
+ 'i'
89
+ );
90
+ const m = xml.match(re);
91
+ return m ? m[1].trim() : null;
92
+ }
93
+
94
+ function splitResponses(xml) {
95
+ return [...xml.matchAll(/<[^>:]*:?response[\s\S]*?<\/[^>:]*:?response>/g)].map(m => m[0]);
96
+ }
97
+
98
+ function xmlText(xml, tag) {
99
+ const re = new RegExp(`<[^>:]*:?${tag}[^>]*>([\\s\\S]*?)<\\/[^>:]*:?${tag}>`, 'i');
100
+ const m = xml.match(re);
101
+ return m ? m[1].trim() : null;
102
+ }
103
+
104
+ // ─── iCal text escaping ───────────────────────────────────────────────────────
105
+ // iCal property values must not contain raw newlines — escape as \n (literal backslash-n)
106
+
107
+ function icalEscape(str) {
108
+ if (!str) return str;
109
+ return str
110
+ .replace(/\\/g, '\\\\') // backslash → \\
111
+ .replace(/\n/g, '\\n') // newline → \n (literal)
112
+ .replace(/\r/g, ''); // strip carriage returns
113
+ }
114
+
115
+ function icalUnescape(str) {
116
+ if (!str) return str;
117
+ return str
118
+ .replace(/\\n/g, '\n')
119
+ .replace(/\\,/g, ',')
120
+ .replace(/\\;/g, ';')
121
+ .replace(/\\\\/g, '\\');
122
+ }
123
+
124
+ // ─── iCal date helpers ────────────────────────────────────────────────────────
125
+
126
+ function toIcalUtc(date) {
127
+ // YYYYMMDDTHHMMSSZ
128
+ return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
129
+ }
130
+
131
+ function toIcalLocal(date) {
132
+ // YYYYMMDDTHHMMSS (no Z, for use with TZID=...)
133
+ const pad = n => String(n).padStart(2, '0');
134
+ return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}` +
135
+ `T${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
136
+ }
137
+
138
+ function parseIcalDate(val, fullKey = '') {
139
+ if (fullKey.includes('VALUE=DATE')) {
140
+ // YYYYMMDD → YYYY-MM-DD
141
+ const m = val.match(/^(\d{4})(\d{2})(\d{2})$/);
142
+ return m ? `${m[1]}-${m[2]}-${m[3]}` : val;
143
+ }
144
+ // YYYYMMDDTHHMMSS[Z] → YYYY-MM-DDTHH:MM:SS[Z]
145
+ const m = val.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z?)$/);
146
+ if (!m) return val;
147
+ return `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}${m[7]}`;
148
+ }
149
+
150
+ // ─── iCal parsing ─────────────────────────────────────────────────────────────
151
+
152
+ function parseVEvent(ical) {
153
+ // Unfold continuation lines
154
+ const unfolded = ical.replace(/\r?\n[ \t]/g, '');
155
+ const lines = unfolded.split(/\r?\n/);
156
+
157
+ let inEvent = false;
158
+ const event = {};
159
+
160
+ for (const line of lines) {
161
+ if (line === 'BEGIN:VEVENT') { inEvent = true; continue; }
162
+ if (line === 'END:VEVENT') { inEvent = false; continue; }
163
+ if (!inEvent) continue;
164
+
165
+ const colonIdx = line.indexOf(':');
166
+ if (colonIdx < 0) continue;
167
+ const fullKey = line.slice(0, colonIdx);
168
+ const val = line.slice(colonIdx + 1);
169
+ const key = fullKey.split(';')[0].toUpperCase();
170
+
171
+ switch (key) {
172
+ case 'UID': event.uid = val; break;
173
+ case 'SUMMARY': event.summary = icalUnescape(val); break;
174
+ case 'DESCRIPTION': event.description = icalUnescape(val); break;
175
+ case 'LOCATION': event.location = icalUnescape(val); break;
176
+ case 'STATUS': event.status = val; break;
177
+ case 'RRULE': event.recurrence = val; break;
178
+ case 'DTSTART': {
179
+ event.start = parseIcalDate(val, fullKey);
180
+ const tzM = fullKey.match(/TZID=([^;:]+)/);
181
+ if (tzM) event.timezone = tzM[1];
182
+ event.allDay = fullKey.includes('VALUE=DATE');
183
+ break;
184
+ }
185
+ case 'DTEND': {
186
+ event.end = parseIcalDate(val, fullKey);
187
+ break;
188
+ }
189
+ case 'CREATED': event.created = parseIcalDate(val, fullKey); break;
190
+ case 'LAST-MODIFIED': event.lastModified = parseIcalDate(val, fullKey); break;
191
+ case 'ORGANIZER': event.organizer = val.replace(/^mailto:/i, ''); break;
192
+ case 'ATTENDEE': {
193
+ if (!event.attendees) event.attendees = [];
194
+ const cn = fullKey.match(/CN=([^;:]+)/i)?.[1];
195
+ const email = val.replace(/^mailto:/i, '');
196
+ event.attendees.push(cn ? `${cn} <${email}>` : email);
197
+ break;
198
+ }
199
+ case 'EXDATE': {
200
+ if (!event.exDates) event.exDates = [];
201
+ event.exDates.push(parseIcalDate(val, fullKey));
202
+ break;
203
+ }
204
+ }
205
+ }
206
+
207
+ return event;
208
+ }
209
+
210
+ // ─── iCal serialization ───────────────────────────────────────────────────────
211
+
212
+ function serializeVEvent(fields, uid = null) {
213
+ const id = uid || randomUUID().toUpperCase();
214
+ const now = new Date();
215
+ const dtstamp = toIcalUtc(now);
216
+
217
+ const lines = [
218
+ 'BEGIN:VCALENDAR',
219
+ 'CALSCALE:GREGORIAN',
220
+ 'PRODID:-//icloud-mcp//EN',
221
+ 'VERSION:2.0',
222
+ 'BEGIN:VEVENT',
223
+ `DTSTAMP:${dtstamp}`,
224
+ `CREATED:${dtstamp}`,
225
+ `UID:${id}`,
226
+ `SUMMARY:${icalEscape(fields.summary || '(No title)')}`,
227
+ ];
228
+
229
+ if (fields.allDay) {
230
+ const start = (fields.start || '').replace(/-/g, '').slice(0, 8);
231
+ const end = (fields.end || fields.start || '').replace(/-/g, '').slice(0, 8);
232
+ lines.push(`DTSTART;VALUE=DATE:${start}`);
233
+ lines.push(`DTEND;VALUE=DATE:${end}`);
234
+ } else {
235
+ const tz = fields.timezone || 'UTC';
236
+ const startDate = fields.start ? new Date(fields.start) : now;
237
+ const endDate = fields.end ? new Date(fields.end) : new Date(startDate.getTime() + 3600_000);
238
+
239
+ if (tz === 'UTC') {
240
+ lines.push(`DTSTART:${toIcalUtc(startDate)}`);
241
+ lines.push(`DTEND:${toIcalUtc(endDate)}`);
242
+ } else {
243
+ lines.push(`DTSTART;TZID=${tz}:${toIcalLocal(startDate)}`);
244
+ lines.push(`DTEND;TZID=${tz}:${toIcalLocal(endDate)}`);
245
+ }
246
+ }
247
+
248
+ if (fields.description) lines.push(`DESCRIPTION:${icalEscape(fields.description)}`);
249
+ if (fields.location) lines.push(`LOCATION:${icalEscape(fields.location)}`);
250
+ if (fields.recurrence) lines.push(`RRULE:${fields.recurrence}`);
251
+ if (fields.status) lines.push(`STATUS:${fields.status}`);
252
+
253
+ // VALARM — reminder N minutes before (default: 30 min if not specified, 0 to disable)
254
+ const reminderMins = fields.reminder !== undefined ? Number(fields.reminder) : 30;
255
+ if (reminderMins > 0) {
256
+ lines.push(
257
+ 'BEGIN:VALARM',
258
+ 'ACTION:DISPLAY',
259
+ 'DESCRIPTION:Reminder',
260
+ `TRIGGER:-PT${reminderMins}M`,
261
+ 'END:VALARM'
262
+ );
263
+ }
264
+
265
+ lines.push('SEQUENCE:0', 'END:VEVENT', 'END:VCALENDAR');
266
+ return { ical: lines.join('\r\n') + '\r\n', uid: id };
267
+ }
268
+
269
+ // ─── Parse REPORT response blocks ────────────────────────────────────────────
270
+
271
+ function parseEventBlocks(xml) {
272
+ return splitResponses(xml).map(block => {
273
+ const hrefMatch = block.match(/<[^>:]*:?href[^>]*>([^<]+)<\/[^>:]*:?href>/);
274
+ const etagMatch = block.match(/<[^>:]*:?getetag[^>]*>"?([^"<]+)"?<\/[^>:]*:?getetag>/);
275
+
276
+ // Extract calendar-data — may be in CDATA or as plain text
277
+ let icalText = null;
278
+ const cdataMatch = block.match(/<!\[CDATA\[([\s\S]*?)\]\]>/);
279
+ if (cdataMatch) {
280
+ icalText = cdataMatch[1];
281
+ } else {
282
+ const dataMatch = block.match(/<[^>:]*:?calendar-data[^>]*>([\s\S]*?)<\/[^>:]*:?calendar-data>/i);
283
+ if (dataMatch) icalText = dataMatch[1];
284
+ }
285
+
286
+ if (!hrefMatch || !icalText) return null;
287
+
288
+ const href = hrefMatch[1];
289
+ const parts = href.split('/').filter(Boolean);
290
+ const filename = parts[parts.length - 1];
291
+ const eventId = filename.replace(/\.ics$/i, '');
292
+ // calendarId is the UUID segment before the filename
293
+ const calendarId = parts[parts.length - 2] || null;
294
+
295
+ const event = parseVEvent(icalText);
296
+ return { eventId, calendarId, etag: etagMatch?.[1] || null, href, ...event };
297
+ }).filter(Boolean);
298
+ }
299
+
300
+ function parseCalendarBlocks(xml) {
301
+ return splitResponses(xml).map(block => {
302
+ const hrefMatch = block.match(/<[^>:]*:?href[^>]*>([^<]+)<\/[^>:]*:?href>/);
303
+ if (!hrefMatch) return null;
304
+
305
+ const href = hrefMatch[1];
306
+ const parts = href.split('/').filter(Boolean);
307
+ const last = parts[parts.length - 1];
308
+
309
+ // Skip scheduling/system containers
310
+ if (EXCLUDED_NAMES.has(last)) return null;
311
+
312
+ // Must have resourcetype = calendar
313
+ if (!block.includes('calendar') || !block.includes('collection')) return null;
314
+ // Skip the home-set itself (no calendar element, just collection)
315
+ const resourceBlock = xmlText(block, 'resourcetype') || '';
316
+ if (!resourceBlock.includes('calendar')) return null;
317
+
318
+ const displayName = xmlText(block, 'displayname') || last;
319
+ const syncToken = xmlText(block, 'sync-token') || null;
320
+
321
+ // supported component types
322
+ const compMatches = [...block.matchAll(/comp\s+name=['"]([^'"]+)['"]/g)].map(m => m[1]);
323
+
324
+ // calendarId is the last non-empty path segment
325
+ const calendarId = last.replace(/\/$/, '');
326
+
327
+ return { calendarId, name: displayName, href, supportedTypes: compMatches, syncToken };
328
+ }).filter(Boolean);
329
+ }
330
+
331
+ // ─── Public API ───────────────────────────────────────────────────────────────
332
+
333
+ export async function listCalendars() {
334
+ const { dataHost, calendarsPath } = await discover();
335
+
336
+ const body = propfindBody(`
337
+ <A:resourcetype/>
338
+ <A:displayname/>
339
+ <A:sync-token/>
340
+ <C:supported-calendar-component-set xmlns:C="urn:ietf:params:xml:ns:caldav"/>
341
+ `);
342
+
343
+ const resp = await davRequest('PROPFIND', `${dataHost}${calendarsPath}`, {
344
+ depth: 1,
345
+ contentType: 'application/xml; charset=utf-8',
346
+ body,
347
+ });
348
+
349
+ const calendars = parseCalendarBlocks(resp.body);
350
+ return { calendars, count: calendars.length };
351
+ }
352
+
353
+ export async function listEvents(calendarId, since = null, before = null, limit = 50) {
354
+ const { dataHost, calendarsPath } = await discover();
355
+
356
+ const sinceDate = since ? new Date(since) : new Date(Date.now() - 30 * 86400_000);
357
+ const beforeDate = before ? new Date(before) : new Date(Date.now() + 30 * 86400_000);
358
+
359
+ const start = toIcalUtc(sinceDate);
360
+ const end = toIcalUtc(beforeDate);
361
+
362
+ const body = `<?xml version="1.0" encoding="UTF-8"?>
363
+ <C:calendar-query xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:A="DAV:">
364
+ <A:prop><A:getetag/><C:calendar-data/></A:prop>
365
+ <C:filter>
366
+ <C:comp-filter name="VCALENDAR">
367
+ <C:comp-filter name="VEVENT">
368
+ <C:time-range start="${start}" end="${end}"/>
369
+ </C:comp-filter>
370
+ </C:comp-filter>
371
+ </C:filter>
372
+ </C:calendar-query>`;
373
+
374
+ const url = `${dataHost}${calendarsPath}${calendarId}/`;
375
+ const resp = await davRequest('REPORT', url, {
376
+ depth: 1,
377
+ contentType: 'application/xml; charset=utf-8',
378
+ body,
379
+ });
380
+
381
+ if (resp.status === 403 || resp.status === 404) {
382
+ throw new Error(`Calendar not found or access denied: ${calendarId} (${resp.status})`);
383
+ }
384
+
385
+ const events = parseEventBlocks(resp.body).slice(0, limit);
386
+ return { events, count: events.length, calendarId, since: sinceDate.toISOString(), before: beforeDate.toISOString() };
387
+ }
388
+
389
+ export async function getEvent(calendarId, eventId) {
390
+ const { dataHost, calendarsPath } = await discover();
391
+ const url = `${dataHost}${calendarsPath}${calendarId}/${eventId}.ics`;
392
+ const resp = await davRequest('GET', url);
393
+
394
+ if (resp.status === 404) throw new Error(`Event not found: ${calendarId}/${eventId}`);
395
+ if (resp.status >= 400) throw new Error(`CalDAV GET failed: ${resp.status}`);
396
+
397
+ const event = parseVEvent(resp.body);
398
+ return { eventId, calendarId, etag: resp.etag, ...event };
399
+ }
400
+
401
+ export async function createEvent(calendarId, fields) {
402
+ const { dataHost, calendarsPath } = await discover();
403
+ const { ical, uid } = serializeVEvent(fields);
404
+ const eventId = uid;
405
+ const url = `${dataHost}${calendarsPath}${calendarId}/${eventId}.ics`;
406
+
407
+ const resp = await davRequest('PUT', url, {
408
+ contentType: 'text/calendar; charset=utf-8',
409
+ body: ical,
410
+ });
411
+
412
+ if (resp.status !== 201 && resp.status !== 204 && resp.status !== 200) {
413
+ throw new Error(`CalDAV PUT failed: ${resp.status} — ${resp.body.slice(0, 200)}`);
414
+ }
415
+
416
+ return { created: true, eventId, calendarId, etag: resp.etag };
417
+ }
418
+
419
+ export async function updateEvent(calendarId, eventId, fields) {
420
+ const { dataHost, calendarsPath } = await discover();
421
+ const url = `${dataHost}${calendarsPath}${calendarId}/${eventId}.ics`;
422
+
423
+ // Fetch current to get etag and existing fields
424
+ const existing = await davRequest('GET', url);
425
+ if (existing.status === 404) throw new Error(`Event not found: ${calendarId}/${eventId}`);
426
+
427
+ const current = parseVEvent(existing.body);
428
+ const merged = { ...current, ...fields };
429
+ const { ical } = serializeVEvent(merged, eventId);
430
+
431
+ const resp = await davRequest('PUT', url, {
432
+ contentType: 'text/calendar; charset=utf-8',
433
+ etag: existing.etag,
434
+ body: ical,
435
+ });
436
+
437
+ if (resp.status !== 204 && resp.status !== 200) {
438
+ throw new Error(`CalDAV PUT (update) failed: ${resp.status} — ${resp.body.slice(0, 200)}`);
439
+ }
440
+
441
+ return { updated: true, eventId, calendarId, etag: resp.etag };
442
+ }
443
+
444
+ export async function deleteEvent(calendarId, eventId) {
445
+ const { dataHost, calendarsPath } = await discover();
446
+ const url = `${dataHost}${calendarsPath}${calendarId}/${eventId}.ics`;
447
+
448
+ const resp = await davRequest('DELETE', url);
449
+ if (resp.status === 404) throw new Error(`Event not found: ${calendarId}/${eventId}`);
450
+ if (resp.status !== 204 && resp.status !== 200) {
451
+ throw new Error(`CalDAV DELETE failed: ${resp.status}`);
452
+ }
453
+
454
+ return { deleted: true, eventId, calendarId };
455
+ }
456
+
457
+ export async function searchEvents(query, since = null, before = null) {
458
+ const { dataHost, calendarsPath } = await discover();
459
+
460
+ const sinceDate = since ? new Date(since) : new Date(Date.now() - 365 * 86400_000);
461
+ const beforeDate = before ? new Date(before) : new Date(Date.now() + 365 * 86400_000);
462
+ const start = toIcalUtc(sinceDate);
463
+ const end = toIcalUtc(beforeDate);
464
+
465
+ // First list all calendars to search across all of them
466
+ const cals = await listCalendars();
467
+ const veventCals = cals.calendars.filter(c => c.supportedTypes.includes('VEVENT'));
468
+
469
+ const body = `<?xml version="1.0" encoding="UTF-8"?>
470
+ <C:calendar-query xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:A="DAV:">
471
+ <A:prop><A:getetag/><C:calendar-data/></A:prop>
472
+ <C:filter>
473
+ <C:comp-filter name="VCALENDAR">
474
+ <C:comp-filter name="VEVENT">
475
+ <C:time-range start="${start}" end="${end}"/>
476
+ <C:prop-filter name="SUMMARY">
477
+ <C:text-match collation="i;unicode-casemap" match-type="contains">${query}</C:text-match>
478
+ </C:prop-filter>
479
+ </C:comp-filter>
480
+ </C:comp-filter>
481
+ </C:filter>
482
+ </C:calendar-query>`;
483
+
484
+ const results = await Promise.allSettled(
485
+ veventCals.map(cal =>
486
+ davRequest('REPORT', `${dataHost}${calendarsPath}${cal.calendarId}/`, {
487
+ depth: 1,
488
+ contentType: 'application/xml; charset=utf-8',
489
+ body,
490
+ })
491
+ )
492
+ );
493
+
494
+ const events = [];
495
+ for (const r of results) {
496
+ if (r.status === 'fulfilled' && r.value.status === 207) {
497
+ events.push(...parseEventBlocks(r.value.body));
498
+ }
499
+ }
500
+
501
+ return { events, count: events.length, query };
502
+ }
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(/&#13;/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
+ }
@@ -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.2.0",
3
+ "version": "2.3.0",
4
4
  "description": "A Model Context Protocol (MCP) server for iCloud Mail",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -22,6 +22,7 @@
22
22
  "license": "MIT",
23
23
  "dependencies": {
24
24
  "@modelcontextprotocol/sdk": "^1.27.1",
25
+ "fast-xml-parser": "^5.4.2",
25
26
  "imapflow": "^1.2.10",
26
27
  "nodemailer": "^8.0.2"
27
28
  }