From 1a5c16ea6e88a4aa78b80cbe594c13795199ddc6 Mon Sep 17 00:00:00 2001 From: Jonathan Godley Date: Fri, 3 Apr 2026 22:09:39 +1100 Subject: [PATCH 1/3] Feat: calendar attendee support and non-destructive updates - Parse attendees/organizer from CalDAV events (PARTSTAT, ROLE, CUTYPE, RSVP) - Rewrite updateCalendarEvent to patch-in-place instead of rebuilding VEVENT, preserving all existing data (attendees, reminders, X-GOOGLE-*, etc.) - Add participant support to createCalendarEvent with ORGANIZER auto-generation - Add all-day event support (VALUE=DATE) on create and update - Parse DURATION and compute end time for DURATION-based events - Handle recurring events safely: require confirmRecurring for time changes that would orphan exception VEVENTs, using rrule for expansion - Preserve timezone (TZID) on floating-time updates - Remove JMAP calendar fallback (dead code), call CalDAV directly - Fix extractVEvent to return null instead of full input on no match - Add quote-aware iCal parsing (findValueBoundary) for properties with quoted colons in parameters (DELEGATED-FROM, ALTREP, etc.) - Validate attendee emails to prevent iCal property injection - SEQUENCE increment only for scheduling-significant changes with attendees - Orphaned VTIMEZONE cleanup after time changes - 355 tests (all passing), 97% statement coverage on caldav-client.ts --- README.md | 25 +- package-lock.json | 23 +- package.json | 1 + src/caldav-client.test.ts | 1598 ++++++++++++++++++++++++++++++++++++- src/caldav-client.ts | 1012 ++++++++++++++++++++++- src/contacts-calendar.ts | 8 +- src/index.ts | 193 +++-- 7 files changed, 2766 insertions(+), 94 deletions(-) diff --git a/README.md b/README.md index 07af8f7..8392cc7 100644 --- a/README.md +++ b/README.md @@ -213,12 +213,21 @@ You can install this server as a Desktop Extension for Claude Desktop using the ### Calendar Tools - **list_calendars**: List all calendars -- **list_calendar_events**: List calendar events - - Parameters: `calendarId` (optional), `limit` (default: 50) -- **get_calendar_event**: Get a specific calendar event by ID +- **list_calendar_events**: List calendar events (core fields only — no participants for token efficiency) + - Parameters: `calendarId` (optional), `startDate` (optional, ISO 8601), `endDate` (optional, ISO 8601), `limit` (default: 50) +- **get_calendar_event**: Get a specific calendar event by ID. Returns organizer and participants when available. - Parameters: `eventId` (required) -- **create_calendar_event**: Create a new calendar event - - Parameters: `calendarId` (required), `title` (required), `description` (optional), `start` (required, ISO 8601), `end` (required, ISO 8601), `location` (optional), `participants` (optional array) +- **create_calendar_event**: Create a new calendar event. Supports date-only (e.g. `2026-04-01`) for all-day events. DTEND is exclusive per RFC 5545 — a one-day event on April 1 needs `end: "2026-04-02"`. + - Parameters: `calendarId` (required), `title` (required), `description` (optional), `start` (required, ISO 8601 or date-only), `end` (required, ISO 8601 or date-only), `location` (optional), `participants` (optional array of `{email, name?}`) +- **update_calendar_event**: Patch an existing calendar event. Preserves all existing data (attendees, reminders, recurrence rules, etc.) not being changed. Floating times (no Z/offset) preserve the original timezone. WARNING: providing `participants` replaces ALL existing attendee data; `participants: []` removes all attendees. + - Parameters: `eventId` (required), `title`, `description`, `start`, `end`, `location`, `participants` (array of `{email, name?}`), `confirmRecurring` (boolean) +- **delete_calendar_event**: Delete a calendar event + - Parameters: `eventId` (required) + +#### Calendar known limitations + +- **Recurring events**: Only "all events" modification is supported (master VEVENT). "This event only" or "this and future events" are not supported. Changing start/end on recurring events with exception overrides requires `confirmRecurring: true` — orphaned exceptions are pruned to prevent server errors. +- **Attendee parameters**: RSVP, ROLE, CUTYPE and other attendee parameters are parsed on read but not settable on create/update — only `email` and `name` are accepted. ### Identity & Testing Tools @@ -248,7 +257,7 @@ Fastmail applies rate limits to API requests. The server handles standard rate l Fastmail does not currently expose calendar access via JMAP API tokens — the `urn:ietf:params:jmap:calendars` scope is not available because the JMAP Calendars specification is still an IETF Internet-Draft ([draft-ietf-jmap-calendars](https://datatracker.ietf.org/doc/draft-ietf-jmap-calendars/)). Fastmail has stated they will add JMAP calendar support once the spec becomes an RFC, but there is no public timeline. -However, Fastmail fully supports **CalDAV** for calendar access via `caldav.fastmail.com`. This server automatically falls back to CalDAV when JMAP calendar access is unavailable. +However, Fastmail fully supports **CalDAV** for calendar access via `caldav.fastmail.com`. All calendar tools use CalDAV directly. ### Setup @@ -260,9 +269,11 @@ However, Fastmail fully supports **CalDAV** for calendar access via `caldav.fast ```bash export FASTMAIL_CALDAV_USERNAME="your-email@fastmail.com" export FASTMAIL_CALDAV_PASSWORD="your-app-specific-password" + # Optional: display name for ORGANIZER when creating events with participants + export FASTMAIL_CALDAV_DISPLAY_NAME="Your Name" ``` -When these variables are set, the calendar tools (`list_calendars`, `list_calendar_events`, `get_calendar_event`, `create_calendar_event`) will automatically fall back to CalDAV if JMAP calendars are not available. When these variables are not set, the server behaves exactly as before (JMAP only). +When these variables are set, all calendar tools are available. When they are not set, calendar tools will return an error with setup instructions. ## Development diff --git a/package-lock.json b/package-lock.json index 313da7c..70d407a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,16 @@ { "name": "fastmail-mcp", - "version": "1.8.2", + "version": "1.9.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "fastmail-mcp", - "version": "1.8.2", + "version": "1.9.1", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.27.1", + "rrule": "^2.8.1", "tsdav": "^2.1.8" }, "bin": { @@ -890,6 +891,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -1122,6 +1124,7 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.8.tgz", "integrity": "sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -1475,6 +1478,15 @@ "node": ">= 18" } }, + "node_modules/rrule": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/rrule/-/rrule-2.8.1.tgz", + "integrity": "sha512-hM3dHSBMeaJ0Ktp7W38BJZ7O1zOgaFEsn41PDk+yHoEtfLV+PoJt9E9xAlZiWgf/iqEqionN0ebHFZIDAp+iGw==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -1673,6 +1685,12 @@ "node": ">=18" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/tsx": { "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", @@ -1800,6 +1818,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 5545a30..db3d3e0 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.27.1", + "rrule": "^2.8.1", "tsdav": "^2.1.8" }, "devDependencies": { diff --git a/src/caldav-client.test.ts b/src/caldav-client.test.ts index bee16bc..f289ad4 100644 --- a/src/caldav-client.test.ts +++ b/src/caldav-client.test.ts @@ -3,9 +3,24 @@ import assert from 'node:assert/strict'; import { extractVEvent, parseICalValue, + findValueBoundary, + parseAllICalProperties, + parseAttendee, + parseICalDuration, formatICalDate, parseCalendarObject, escapeICalText, + unescapeICalText, + toICalUTC, + foldICalLine, + detectLineEnding, + replaceICalProperty, + removeAllICalProperties, + removeOrphanedVTimezones, + removeExceptionVEvents, + insertBeforeEndVEvent, + validateAttendeeEmail, + quoteParamValue, CalDAVCalendarClient, } from './caldav-client.js'; @@ -31,9 +46,9 @@ describe('extractVEvent', () => { assert.ok(!vevent.includes('TZID:Europe/Rome')); }); - it('returns original data when no VEVENT block found', () => { + it('returns null when no VEVENT block found', () => { const data = 'no vevent here'; - assert.equal(extractVEvent(data), data); + assert.equal(extractVEvent(data), null); }); it('ignores VTIMEZONE DTSTART when extracting VEVENT', () => { @@ -336,3 +351,1582 @@ describe('CalDAVCalendarClient.getCalendarEvents', () => { assert.equal(callArgs.timeRange, undefined); }); }); + +describe('CalDAVCalendarClient.updateCalendarEvent', () => { + function makeFullIcal(uid: string, summary: string, dtstart: string, dtend: string, description?: string, location?: string): string { + const lines = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//fastmail-mcp//CalDAV//EN', + 'BEGIN:VEVENT', + `UID:${uid}`, + `DTSTAMP:20260401T000000Z`, + `DTSTART:${dtstart}`, + `DTEND:${dtend}`, + `SUMMARY:${escapeICalText(summary)}`, + ]; + if (description) lines.push(`DESCRIPTION:${escapeICalText(description)}`); + if (location) lines.push(`LOCATION:${escapeICalText(location)}`); + lines.push('END:VEVENT', 'END:VCALENDAR'); + return lines.join('\r\n'); + } + + function createMockedClientWithUpdateDelete(calendarObjects: Array<{ data: string; url: string; etag?: string }>) { + const client = new CalDAVCalendarClient({ username: 'test', password: 'test' }); + const mockDAVClient = { + login: mock.fn(async () => {}), + fetchCalendars: mock.fn(async () => [ + { displayName: 'Personal', url: '/cal/personal/' }, + ]), + fetchCalendarObjects: mock.fn(async () => calendarObjects), + updateCalendarObject: mock.fn(async () => ({})), + deleteCalendarObject: mock.fn(async () => ({})), + }; + (client as any).client = mockDAVClient; + return { client, mockDAVClient }; + } + + it('updates only the title, preserving other fields', async () => { + const ical = makeFullIcal('evt1@fm', 'Original Title', '20260401T100000Z', '20260401T110000Z', 'My description', 'Room A'); + const objects = [{ data: ical, url: '/cal/evt1.ics', etag: '"etag1"' }]; + const { client, mockDAVClient } = createMockedClientWithUpdateDelete(objects); + + const result = await client.updateCalendarEvent('evt1@fm', { title: 'New Title' }); + + assert.equal(result, 'evt1@fm'); + assert.equal(mockDAVClient.updateCalendarObject.mock.calls.length, 1); + const updatedObj = mockDAVClient.updateCalendarObject.mock.calls[0].arguments[0].calendarObject; + assert.ok(updatedObj.data.includes('SUMMARY:New Title')); + assert.ok(updatedObj.data.includes('DESCRIPTION:My description')); + assert.ok(updatedObj.data.includes('LOCATION:Room A')); + assert.ok(updatedObj.data.includes('DTSTART:20260401T100000Z')); + assert.ok(updatedObj.data.includes('UID:evt1@fm')); + }); + + it('updates start and end times', async () => { + const ical = makeFullIcal('evt2@fm', 'Meeting', '20260401T100000Z', '20260401T110000Z'); + const objects = [{ data: ical, url: '/cal/evt2.ics' }]; + const { client, mockDAVClient } = createMockedClientWithUpdateDelete(objects); + + await client.updateCalendarEvent('evt2@fm', { + start: '2026-04-02T14:00:00Z', + end: '2026-04-02T15:00:00Z', + }); + + const updatedObj = mockDAVClient.updateCalendarObject.mock.calls[0].arguments[0].calendarObject; + assert.ok(updatedObj.data.includes('DTSTART:20260402T140000Z')); + assert.ok(updatedObj.data.includes('DTEND:20260402T150000Z')); + assert.ok(updatedObj.data.includes('SUMMARY:Meeting')); + }); + + it('throws when event not found', async () => { + const { client } = createMockedClientWithUpdateDelete([]); + await assert.rejects( + () => client.updateCalendarEvent('nonexistent@fm', { title: 'X' }), + /Calendar event not found: nonexistent@fm/ + ); + }); +}); + +describe('CalDAVCalendarClient.deleteCalendarEvent', () => { + function createMockedClientWithDelete(calendarObjects: Array<{ data: string; url: string; etag?: string }>) { + const client = new CalDAVCalendarClient({ username: 'test', password: 'test' }); + const mockDAVClient = { + login: mock.fn(async () => {}), + fetchCalendars: mock.fn(async () => [ + { displayName: 'Personal', url: '/cal/personal/' }, + ]), + fetchCalendarObjects: mock.fn(async () => calendarObjects), + deleteCalendarObject: mock.fn(async () => ({})), + }; + (client as any).client = mockDAVClient; + return { client, mockDAVClient }; + } + + it('deletes an event by UID', async () => { + const ical = [ + 'BEGIN:VCALENDAR', + 'BEGIN:VEVENT', + 'UID:del1@fm', + 'DTSTART:20260401T100000Z', + 'SUMMARY:To Delete', + 'END:VEVENT', + 'END:VCALENDAR', + ].join('\r\n'); + const objects = [{ data: ical, url: '/cal/del1.ics', etag: '"etag1"' }]; + const { client, mockDAVClient } = createMockedClientWithDelete(objects); + + await client.deleteCalendarEvent('del1@fm'); + + assert.equal(mockDAVClient.deleteCalendarObject.mock.calls.length, 1); + const deletedObj = mockDAVClient.deleteCalendarObject.mock.calls[0].arguments[0].calendarObject; + assert.equal(deletedObj.url, '/cal/del1.ics'); + }); + + it('throws when event not found', async () => { + const { client } = createMockedClientWithDelete([]); + await assert.rejects( + () => client.deleteCalendarEvent('nonexistent@fm'), + /Calendar event not found: nonexistent@fm/ + ); + }); +}); + +// ============================================================ +// New tests for calendar attendee support & non-destructive updates +// ============================================================ + +describe('findValueBoundary', () => { + it('finds colon in simple property', () => { + assert.equal(findValueBoundary('SUMMARY:Test'), 7); + }); + + it('skips colons inside quoted parameter values', () => { + const line = 'ATTENDEE;DELEGATED-FROM="mailto:boss@example.com";CN="Smith, John":mailto:john@example.com'; + const idx = findValueBoundary(line); + assert.equal(line.substring(idx + 1), 'mailto:john@example.com'); + }); + + it('handles ALTREP with URL', () => { + const line = 'DESCRIPTION;ALTREP="http://example.com/desc":Plain text'; + const idx = findValueBoundary(line); + assert.equal(line.substring(idx + 1), 'Plain text'); + }); + + it('returns -1 when no colon found', () => { + assert.equal(findValueBoundary('NOCOLON'), -1); + }); +}); + +describe('parseAllICalProperties', () => { + it('returns multiple ATTENDEEs', () => { + const vevent = [ + 'BEGIN:VEVENT', + 'ATTENDEE;CN=Alice:mailto:alice@example.com', + 'ATTENDEE;CN=Bob:mailto:bob@example.com', + 'SUMMARY:Test', + 'END:VEVENT', + ].join('\n'); + const results = parseAllICalProperties(vevent, 'ATTENDEE'); + assert.equal(results.length, 2); + assert.ok(results[0].includes('alice@')); + assert.ok(results[1].includes('bob@')); + }); + + it('returns empty array when none found', () => { + const vevent = 'BEGIN:VEVENT\nSUMMARY:Test\nEND:VEVENT'; + assert.deepEqual(parseAllICalProperties(vevent, 'ATTENDEE'), []); + }); + + it('handles folded ATTENDEE lines', () => { + const vevent = [ + 'BEGIN:VEVENT', + 'ATTENDEE;CN=Very Long Name;PARTSTAT=ACCEPTED:mailto:long', + ' name@example.com', + 'SUMMARY:Test', + 'END:VEVENT', + ].join('\n'); + const results = parseAllICalProperties(vevent, 'ATTENDEE'); + assert.equal(results.length, 1); + assert.ok(results[0].includes('longname@example.com')); + }); + + it('handles CRLF input', () => { + const vevent = 'BEGIN:VEVENT\r\nATTENDEE;CN=Alice:mailto:a@b.com\r\nEND:VEVENT'; + const results = parseAllICalProperties(vevent, 'ATTENDEE'); + assert.equal(results.length, 1); + assert.ok(!results[0].includes('\r')); + }); + + it('does not match partial property names', () => { + const vevent = 'BEGIN:VEVENT\nATTENDEE-X:foo\nATTENDEE:bar\nEND:VEVENT'; + const results = parseAllICalProperties(vevent, 'ATTENDEE'); + assert.equal(results.length, 1); + assert.equal(results[0], 'ATTENDEE:bar'); + }); +}); + +describe('parseAttendee', () => { + it('parses simple ATTENDEE with CN', () => { + const result = parseAttendee('ATTENDEE;CN=Alice:mailto:alice@example.com'); + assert.equal(result.email, 'alice@example.com'); + assert.equal(result.name, 'Alice'); + }); + + it('parses quoted CN with comma', () => { + const result = parseAttendee('ATTENDEE;CN="Doe, John":mailto:john@example.com'); + assert.equal(result.name, 'Doe, John'); + assert.equal(result.email, 'john@example.com'); + }); + + it('parses PARTSTAT, ROLE, CUTYPE, RSVP', () => { + const result = parseAttendee('ATTENDEE;CN=Alice;PARTSTAT=ACCEPTED;ROLE=REQ-PARTICIPANT;CUTYPE=INDIVIDUAL;RSVP=TRUE:mailto:alice@example.com'); + assert.equal(result.status, 'ACCEPTED'); + assert.equal(result.role, 'REQ-PARTICIPANT'); + assert.equal(result.cutype, 'INDIVIDUAL'); + assert.equal(result.rsvp, true); + }); + + it('converts RSVP=FALSE to boolean false', () => { + const result = parseAttendee('ATTENDEE;RSVP=FALSE:mailto:alice@example.com'); + assert.equal(result.rsvp, false); + }); + + it('parses ORGANIZER lines', () => { + const result = parseAttendee('ORGANIZER;CN=Boss:mailto:boss@example.com'); + assert.equal(result.email, 'boss@example.com'); + assert.equal(result.name, 'Boss'); + }); + + it('handles missing CN', () => { + const result = parseAttendee('ATTENDEE:mailto:anon@example.com'); + assert.equal(result.email, 'anon@example.com'); + assert.equal(result.name, undefined); + }); + + it('handles non-mailto URI', () => { + const result = parseAttendee('ATTENDEE:urn:uuid:550e8400-e29b-41d4-a716-446655440000'); + assert.equal(result.email, 'urn:uuid:550e8400-e29b-41d4-a716-446655440000'); + }); + + it('handles bare email without mailto', () => { + const result = parseAttendee('ATTENDEE:alice@example.com'); + assert.equal(result.email, 'alice@example.com'); + }); + + it('handles DELEGATED-FROM with quoted mailto (colon inside quotes)', () => { + const result = parseAttendee('ATTENDEE;DELEGATED-FROM="mailto:boss@example.com";CN=Alice:mailto:alice@example.com'); + assert.equal(result.email, 'alice@example.com'); + assert.equal(result.name, 'Alice'); + }); + + it('omits empty CN', () => { + const result = parseAttendee('ATTENDEE;CN=:mailto:alice@example.com'); + assert.equal(result.name, undefined); + }); + + it('handles CN with literal DQUOTE character', () => { + // CN value with embedded quote — parseAttendee should store the raw value + const result = parseAttendee('ATTENDEE;CN="John \'Doc\' Smith":mailto:john@example.com'); + assert.equal(result.name, "John 'Doc' Smith"); + assert.equal(result.email, 'john@example.com'); + }); +}); + +describe('parseICalDuration', () => { + it('parses PT2H', () => { + assert.equal(parseICalDuration('PT2H', '2026-04-01T10:00:00Z'), '2026-04-01T12:00:00Z'); + }); + + it('parses P1D', () => { + assert.equal(parseICalDuration('P1D', '2026-04-01T10:00:00Z'), '2026-04-02T10:00:00Z'); + }); + + it('parses P1W', () => { + assert.equal(parseICalDuration('P1W', '2026-04-01T10:00:00Z'), '2026-04-08T10:00:00Z'); + }); + + it('parses P1DT2H30M', () => { + assert.equal(parseICalDuration('P1DT2H30M', '2026-04-01T10:00:00Z'), '2026-04-02T12:30:00Z'); + }); + + it('parses PT90M', () => { + assert.equal(parseICalDuration('PT90M', '2026-04-01T10:00:00Z'), '2026-04-01T11:30:00Z'); + }); + + it('parses PT0S (zero duration)', () => { + assert.equal(parseICalDuration('PT0S', '2026-04-01T10:00:00Z'), '2026-04-01T10:00:00Z'); + }); + + it('parses P1DT0H0M0S (verbose)', () => { + assert.equal(parseICalDuration('P1DT0H0M0S', '2026-04-01T10:00:00Z'), '2026-04-02T10:00:00Z'); + }); + + it('returns undefined for malformed input', () => { + assert.equal(parseICalDuration('2H', '2026-04-01T10:00:00Z'), undefined); + assert.equal(parseICalDuration('PTXYZ', '2026-04-01T10:00:00Z'), undefined); + }); + + it('rejects bare P', () => { + assert.equal(parseICalDuration('P', '2026-04-01T10:00:00Z'), undefined); + }); + + it('rejects P1DT (T with no time components)', () => { + assert.equal(parseICalDuration('P1DT', '2026-04-01T10:00:00Z'), undefined); + }); + + it('handles date-only start', () => { + assert.equal(parseICalDuration('P1D', '2026-04-01'), '2026-04-02'); + }); + + it('returns floating time for floating start (no Z)', () => { + const result = parseICalDuration('PT2H', '2026-04-01T10:00:00'); + assert.equal(result, '2026-04-01T12:00:00'); + assert.ok(!result!.includes('Z'), 'floating start should produce floating end'); + }); +}); + +describe('parseCalendarObject with participants', () => { + it('parses ATTENDEE and ORGANIZER', () => { + const data = [ + 'BEGIN:VCALENDAR', + 'BEGIN:VEVENT', + 'UID:evt@fm', + 'DTSTART:20260401T100000Z', + 'SUMMARY:Meeting', + 'ORGANIZER;CN=Boss:mailto:boss@example.com', + 'ATTENDEE;CN=Alice;PARTSTAT=ACCEPTED;ROLE=REQ-PARTICIPANT:mailto:alice@example.com', + 'ATTENDEE;CN=Bob;PARTSTAT=TENTATIVE:mailto:bob@example.com', + 'END:VEVENT', + 'END:VCALENDAR', + ].join('\r\n'); + + const event = parseCalendarObject({ data, url: '' }, { includeParticipants: true }); + assert.equal(event.organizer?.email, 'boss@example.com'); + assert.equal(event.organizer?.name, 'Boss'); + assert.equal(event.participants?.length, 2); + assert.equal(event.participants?.[0].email, 'alice@example.com'); + assert.equal(event.participants?.[0].status, 'ACCEPTED'); + assert.equal(event.participants?.[1].email, 'bob@example.com'); + }); + + it('omits participants when includeParticipants is false', () => { + const data = [ + 'BEGIN:VCALENDAR', + 'BEGIN:VEVENT', + 'UID:evt@fm', + 'DTSTART:20260401T100000Z', + 'SUMMARY:Meeting', + 'ATTENDEE;CN=Alice:mailto:alice@example.com', + 'END:VEVENT', + 'END:VCALENDAR', + ].join('\r\n'); + + const event = parseCalendarObject({ data, url: '' }); + assert.equal(event.participants, undefined); + assert.equal(event.organizer, undefined); + }); + + it('computes end from DURATION', () => { + const data = [ + 'BEGIN:VCALENDAR', + 'BEGIN:VEVENT', + 'UID:dur@fm', + 'DTSTART:20260401T100000Z', + 'DURATION:PT2H', + 'SUMMARY:Duration Event', + 'END:VEVENT', + 'END:VCALENDAR', + ].join('\r\n'); + + const event = parseCalendarObject({ data, url: '' }); + assert.equal(event.start, '2026-04-01T10:00:00Z'); + assert.equal(event.end, '2026-04-01T12:00:00Z'); + }); + + it('returns minimal event when no VEVENT found', () => { + const data = 'BEGIN:VCALENDAR\r\nEND:VCALENDAR'; + const event = parseCalendarObject({ data, url: '/test.ics' }); + assert.equal(event.title, 'Untitled'); + assert.equal(event.url, '/test.ics'); + }); +}); + +describe('replaceICalProperty', () => { + const simpleEvent = [ + 'BEGIN:VCALENDAR', + 'BEGIN:VEVENT', + 'UID:test@fm', + 'SUMMARY:Original', + 'DTSTART:20260401T100000Z', + 'END:VEVENT', + 'END:VCALENDAR', + ].join('\n'); + + it('replaces an existing property', () => { + const result = replaceICalProperty(simpleEvent, 'SUMMARY', 'SUMMARY:Updated'); + assert.ok(result.includes('SUMMARY:Updated')); + assert.ok(!result.includes('SUMMARY:Original')); + }); + + it('preserves other properties when replacing', () => { + const result = replaceICalProperty(simpleEvent, 'SUMMARY', 'SUMMARY:Updated'); + assert.ok(result.includes('UID:test@fm')); + assert.ok(result.includes('DTSTART:20260401T100000Z')); + }); + + it('patches parameterized DTSTART (TZID form)', () => { + const event = simpleEvent.replace('DTSTART:20260401T100000Z', 'DTSTART;TZID=Europe/Rome:20260401T100000'); + const result = replaceICalProperty(event, 'DTSTART', 'DTSTART;TZID=Europe/Rome:20260402T090000'); + assert.ok(result.includes('DTSTART;TZID=Europe/Rome:20260402T090000')); + assert.ok(!result.includes('20260401')); + }); + + it('inserts a new property before END:VEVENT', () => { + const result = replaceICalProperty(simpleEvent, 'LOCATION', 'LOCATION:Room A'); + assert.ok(result.includes('LOCATION:Room A')); + const lines = result.split('\n'); + const locIdx = lines.findIndex(l => l === 'LOCATION:Room A'); + const endIdx = lines.findIndex(l => l === 'END:VEVENT'); + assert.ok(locIdx < endIdx); + }); + + it('removes a property when newLine is null', () => { + const result = replaceICalProperty(simpleEvent, 'SUMMARY', null); + assert.ok(!result.includes('SUMMARY')); + }); + + it('only patches first VEVENT in multi-VEVENT iCal', () => { + const multiVevent = [ + 'BEGIN:VCALENDAR', + 'BEGIN:VEVENT', + 'UID:master@fm', + 'SUMMARY:Master', + 'END:VEVENT', + 'BEGIN:VEVENT', + 'UID:master@fm', + 'RECURRENCE-ID:20260401T100000Z', + 'SUMMARY:Exception', + 'END:VEVENT', + 'END:VCALENDAR', + ].join('\n'); + + const result = replaceICalProperty(multiVevent, 'SUMMARY', 'SUMMARY:Updated Master'); + assert.ok(result.includes('SUMMARY:Updated Master')); + assert.ok(result.includes('SUMMARY:Exception')); + }); + + it('skips properties inside VALARM', () => { + const eventWithValarm = [ + 'BEGIN:VCALENDAR', + 'BEGIN:VEVENT', + 'UID:test@fm', + 'DESCRIPTION:Event description', + 'BEGIN:VALARM', + 'TRIGGER:-PT15M', + 'ACTION:DISPLAY', + 'DESCRIPTION:Reminder', + 'END:VALARM', + 'END:VEVENT', + 'END:VCALENDAR', + ].join('\n'); + + const result = replaceICalProperty(eventWithValarm, 'DESCRIPTION', 'DESCRIPTION:Updated event'); + assert.ok(result.includes('DESCRIPTION:Updated event')); + assert.ok(result.includes('DESCRIPTION:Reminder')); // VALARM DESCRIPTION preserved + }); + + it('does not touch VTIMEZONE DTSTART', () => { + const eventWithTz = [ + 'BEGIN:VCALENDAR', + 'BEGIN:VTIMEZONE', + 'TZID:Europe/Rome', + 'DTSTART:19700101T000000', + 'END:VTIMEZONE', + 'BEGIN:VEVENT', + 'DTSTART;TZID=Europe/Rome:20260401T100000', + 'SUMMARY:Test', + 'END:VEVENT', + 'END:VCALENDAR', + ].join('\n'); + + const result = replaceICalProperty(eventWithTz, 'DTSTART', 'DTSTART;TZID=Europe/Rome:20260402T090000'); + assert.ok(result.includes('DTSTART:19700101T000000')); // VTIMEZONE preserved + assert.ok(result.includes('DTSTART;TZID=Europe/Rome:20260402T090000')); + }); + + it('throws on missing BEGIN:VEVENT', () => { + assert.throws(() => replaceICalProperty('no vevent', 'SUMMARY', 'SUMMARY:x'), /BEGIN:VEVENT not found/); + }); + + it('throws on empty input', () => { + assert.throws(() => replaceICalProperty('', 'SUMMARY', 'SUMMARY:x'), /empty input/); + }); + + it('handles folded property lines', () => { + const event = [ + 'BEGIN:VCALENDAR', + 'BEGIN:VEVENT', + 'SUMMARY:Very long title that was', + ' folded across two lines', + 'END:VEVENT', + 'END:VCALENDAR', + ].join('\n'); + + const result = replaceICalProperty(event, 'SUMMARY', 'SUMMARY:Short'); + assert.ok(result.includes('SUMMARY:Short')); + assert.ok(!result.includes('folded across')); + }); +}); + +describe('removeAllICalProperties', () => { + it('removes multiple ATTENDEEs', () => { + const event = [ + 'BEGIN:VCALENDAR', + 'BEGIN:VEVENT', + 'UID:test@fm', + 'SUMMARY:Test', + 'ATTENDEE;CN=Alice:mailto:alice@example.com', + 'ATTENDEE;CN=Bob:mailto:bob@example.com', + 'ORGANIZER;CN=Boss:mailto:boss@example.com', + 'END:VEVENT', + 'END:VCALENDAR', + ].join('\n'); + + const result = removeAllICalProperties(event, 'ATTENDEE'); + assert.ok(!result.includes('ATTENDEE')); + assert.ok(result.includes('ORGANIZER')); + assert.ok(result.includes('SUMMARY:Test')); + }); + + it('handles folded ATTENDEE lines', () => { + const event = [ + 'BEGIN:VCALENDAR', + 'BEGIN:VEVENT', + 'ATTENDEE;CN=Very Long Name;PARTSTAT=ACCEPTED:mailto:long', + ' name@example.com', + 'SUMMARY:Test', + 'END:VEVENT', + 'END:VCALENDAR', + ].join('\n'); + + const result = removeAllICalProperties(event, 'ATTENDEE'); + assert.ok(!result.includes('ATTENDEE')); + assert.ok(!result.includes('longname')); + assert.ok(result.includes('SUMMARY:Test')); + }); + + it('preserves CRLF line endings', () => { + const event = 'BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nATTENDEE:mailto:a@b.com\r\nSUMMARY:Test\r\nEND:VEVENT\r\nEND:VCALENDAR'; + const result = removeAllICalProperties(event, 'ATTENDEE'); + assert.ok(result.includes('\r\n')); + assert.ok(!result.includes('ATTENDEE')); + }); + + it('preserves LF-only line endings', () => { + const event = 'BEGIN:VCALENDAR\nBEGIN:VEVENT\nATTENDEE:mailto:a@b.com\nSUMMARY:Test\nEND:VEVENT\nEND:VCALENDAR'; + const result = removeAllICalProperties(event, 'ATTENDEE'); + assert.ok(!result.includes('\r\n')); + assert.ok(!result.includes('ATTENDEE')); + }); +}); + +describe('foldICalLine with custom line ending', () => { + it('uses LF when specified', () => { + const long = 'DESCRIPTION:' + 'x'.repeat(80); + const folded = foldICalLine(long, '\n'); + assert.ok(!folded.includes('\r')); + assert.ok(folded.includes('\n')); + }); + + it('defaults to CRLF', () => { + const long = 'DESCRIPTION:' + 'x'.repeat(80); + const folded = foldICalLine(long); + assert.ok(folded.includes('\r\n')); + }); +}); + +describe('removeOrphanedVTimezones', () => { + it('removes VTIMEZONE with no references', () => { + const data = [ + 'BEGIN:VCALENDAR', + 'BEGIN:VTIMEZONE', + 'TZID:Europe/Rome', + 'END:VTIMEZONE', + 'BEGIN:VEVENT', + 'DTSTART:20260401T100000Z', + 'END:VEVENT', + 'END:VCALENDAR', + ].join('\n'); + + const result = removeOrphanedVTimezones(data); + assert.ok(!result.includes('VTIMEZONE')); + assert.ok(!result.includes('Europe/Rome')); + }); + + it('preserves VTIMEZONE with references', () => { + const data = [ + 'BEGIN:VCALENDAR', + 'BEGIN:VTIMEZONE', + 'TZID:Europe/Rome', + 'END:VTIMEZONE', + 'BEGIN:VEVENT', + 'DTSTART;TZID=Europe/Rome:20260401T100000', + 'END:VEVENT', + 'END:VCALENDAR', + ].join('\n'); + + const result = removeOrphanedVTimezones(data); + assert.ok(result.includes('VTIMEZONE')); + assert.ok(result.includes('Europe/Rome')); + }); +}); + +describe('removeExceptionVEvents', () => { + it('removes only orphaned exception VEVENTs', () => { + const data = [ + 'BEGIN:VCALENDAR', + 'BEGIN:VEVENT', + 'UID:rec@fm', + 'DTSTART:20260401T100000Z', + 'RRULE:FREQ=WEEKLY', + 'SUMMARY:Master', + 'END:VEVENT', + 'BEGIN:VEVENT', + 'UID:rec@fm', + 'RECURRENCE-ID:20260408T100000Z', + 'SUMMARY:Exception 1', + 'END:VEVENT', + 'BEGIN:VEVENT', + 'UID:rec@fm', + 'RECURRENCE-ID:20260415T100000Z', + 'SUMMARY:Exception 2', + 'END:VEVENT', + 'END:VCALENDAR', + ].join('\n'); + + // Only orphan the April 8 exception + const orphaned = [new Date('2026-04-08T10:00:00Z')]; + const result = removeExceptionVEvents(data, orphaned); + assert.ok(result.includes('SUMMARY:Master')); + assert.ok(!result.includes('Exception 1')); + assert.ok(result.includes('Exception 2')); + }); + + it('never touches master VEVENT', () => { + const data = [ + 'BEGIN:VCALENDAR', + 'BEGIN:VEVENT', + 'UID:rec@fm', + 'SUMMARY:Master', + 'END:VEVENT', + 'END:VCALENDAR', + ].join('\n'); + + const result = removeExceptionVEvents(data, [new Date()]); + assert.ok(result.includes('SUMMARY:Master')); + }); +}); + +describe('validateAttendeeEmail', () => { + it('accepts valid email', () => { + assert.doesNotThrow(() => validateAttendeeEmail('alice@example.com')); + }); + + it('rejects empty email', () => { + assert.throws(() => validateAttendeeEmail(''), /required/); + }); + + it('rejects email without @', () => { + assert.throws(() => validateAttendeeEmail('notanemail'), /Invalid/); + }); + + it('rejects email with newline', () => { + assert.throws(() => validateAttendeeEmail('a@b.com\r\nX-INJECT:true'), /illegal/i); + }); + + it('rejects email with colon', () => { + assert.throws(() => validateAttendeeEmail('a:b@example.com'), /illegal/i); + }); + + it('rejects email with semicolon', () => { + assert.throws(() => validateAttendeeEmail('a;b@example.com'), /illegal/i); + }); + + it('rejects email with double quote', () => { + assert.throws(() => validateAttendeeEmail('a"b@example.com'), /illegal/i); + }); + + it('rejects email with backslash', () => { + assert.throws(() => validateAttendeeEmail('a\\b@example.com'), /illegal/i); + }); + + it('rejects email with whitespace', () => { + assert.throws(() => validateAttendeeEmail('a b@example.com'), /illegal/i); + assert.throws(() => validateAttendeeEmail('a\tb@example.com'), /illegal/i); + }); +}); + +describe('quoteParamValue', () => { + it('returns unquoted for simple values', () => { + assert.equal(quoteParamValue('Alice'), 'Alice'); + }); + + it('quotes values with comma', () => { + assert.equal(quoteParamValue('Doe, John'), '"Doe, John"'); + }); + + it('quotes values with semicolon', () => { + assert.equal(quoteParamValue('A;B'), '"A;B"'); + }); + + it('replaces double quotes with single quotes', () => { + assert.equal(quoteParamValue('He said "hi"'), '"He said \'hi\'"'); + }); + + it('strips newlines to prevent injection', () => { + // Colon in result triggers DQUOTE quoting — that's correct + assert.equal(quoteParamValue('Alice\r\nX-EVIL:payload'), '"Alice X-EVIL:payload"'); + }); +}); + +describe('parseICalValue with CRLF and folded lines', () => { + it('handles CRLF input with folded lines', () => { + const vevent = 'DESCRIPTION:This is a long\r\n description that wraps\r\nSUMMARY:Test'; + assert.equal(parseICalValue(vevent, 'DESCRIPTION'), 'This is a longdescription that wraps'); + }); + + it('uses quote-aware colon detection', () => { + const vevent = 'ATTENDEE;DELEGATED-FROM="mailto:boss@example.com":mailto:alice@example.com\nSUMMARY:Test'; + assert.equal(parseICalValue(vevent, 'ATTENDEE'), 'mailto:alice@example.com'); + }); +}); + +describe('toICalUTC', () => { + it('throws on date-only input', () => { + assert.throws(() => toICalUTC('2026-04-01'), /date-only input must be handled by caller/); + }); +}); + +describe('CalDAVCalendarClient.updateCalendarEvent (patch-based)', () => { + function makeRichIcal(uid: string, extra: string[] = []): string { + return [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//Google Inc//Google Calendar//EN', + 'BEGIN:VEVENT', + `UID:${uid}`, + 'DTSTAMP:20260401T000000Z', + 'DTSTART;TZID=Europe/Rome:20260401T100000', + 'DTEND;TZID=Europe/Rome:20260401T110000', + 'SUMMARY:Original Title', + 'DESCRIPTION:Original description', + 'LOCATION:Room A', + 'ATTENDEE;CN=Alice;PARTSTAT=ACCEPTED:mailto:alice@example.com', + 'ATTENDEE;CN=Bob;PARTSTAT=TENTATIVE:mailto:bob@example.com', + 'ORGANIZER;CN=Boss:mailto:boss@example.com', + 'BEGIN:VALARM', + 'TRIGGER:-PT15M', + 'ACTION:DISPLAY', + 'DESCRIPTION:Reminder', + 'END:VALARM', + 'X-GOOGLE-CONFERENCE:https://meet.google.com/abc-def-ghi', + 'SEQUENCE:2', + ...extra, + 'END:VEVENT', + 'END:VCALENDAR', + ].join('\r\n'); + } + + function createMockedPatchClient(calendarObjects: Array<{ data: string; url: string; etag?: string }>) { + const client = new CalDAVCalendarClient({ username: 'test@fastmail.com', password: 'test' }); + const mockDAVClient = { + login: mock.fn(async () => {}), + fetchCalendars: mock.fn(async () => [ + { displayName: 'Personal', url: '/cal/personal/' }, + ]), + fetchCalendarObjects: mock.fn(async () => calendarObjects), + updateCalendarObject: mock.fn(async () => ({})), + }; + (client as any).client = mockDAVClient; + return { client, mockDAVClient }; + } + + it('preserves unknown properties when updating title only', async () => { + const ical = makeRichIcal('evt1@fm'); + const objects = [{ data: ical, url: '/cal/evt1.ics' }]; + const { client, mockDAVClient } = createMockedPatchClient(objects); + + await client.updateCalendarEvent('evt1@fm', { title: 'New Title' }); + + const updatedData = mockDAVClient.updateCalendarObject.mock.calls[0].arguments[0].calendarObject.data; + assert.ok(updatedData.includes('SUMMARY:New Title')); + // Preserved properties + assert.ok(updatedData.includes('ATTENDEE;CN=Alice')); + assert.ok(updatedData.includes('ATTENDEE;CN=Bob')); + assert.ok(updatedData.includes('ORGANIZER;CN=Boss')); + assert.ok(updatedData.includes('VALARM')); + assert.ok(updatedData.includes('X-GOOGLE-CONFERENCE')); + assert.ok(updatedData.includes('DESCRIPTION:Original description')); + assert.ok(updatedData.includes('LOCATION:Room A')); + }); + + it('does NOT re-emit DTSTART when only title changes (timezone preservation)', async () => { + const ical = makeRichIcal('evt2@fm'); + const objects = [{ data: ical, url: '/cal/evt2.ics' }]; + const { client, mockDAVClient } = createMockedPatchClient(objects); + + await client.updateCalendarEvent('evt2@fm', { title: 'New Title' }); + + const updatedData = mockDAVClient.updateCalendarObject.mock.calls[0].arguments[0].calendarObject.data; + // Original DTSTART with TZID should be preserved exactly + assert.ok(updatedData.includes('DTSTART;TZID=Europe/Rome:20260401T100000')); + }); + + it('increments SEQUENCE for location change when ATTENDEEs exist', async () => { + const ical = makeRichIcal('evt3@fm'); + const objects = [{ data: ical, url: '/cal/evt3.ics' }]; + const { client, mockDAVClient } = createMockedPatchClient(objects); + + await client.updateCalendarEvent('evt3@fm', { location: 'New Room' }); + + const updatedData = mockDAVClient.updateCalendarObject.mock.calls[0].arguments[0].calendarObject.data; + assert.ok(updatedData.includes('SEQUENCE:3')); // Was 2, now 3 + }); + + it('does NOT increment SEQUENCE for title-only changes', async () => { + const ical = makeRichIcal('evt4@fm'); + const objects = [{ data: ical, url: '/cal/evt4.ics' }]; + const { client, mockDAVClient } = createMockedPatchClient(objects); + + await client.updateCalendarEvent('evt4@fm', { title: 'New Title' }); + + const updatedData = mockDAVClient.updateCalendarObject.mock.calls[0].arguments[0].calendarObject.data; + assert.ok(updatedData.includes('SEQUENCE:2')); // Unchanged + }); + + it('does NOT increment SEQUENCE when no ATTENDEEs exist', async () => { + const noAttendeeIcal = [ + 'BEGIN:VCALENDAR', + 'BEGIN:VEVENT', + 'UID:solo@fm', + 'DTSTART:20260401T100000Z', + 'DTEND:20260401T110000Z', + 'SUMMARY:Solo Event', + 'SEQUENCE:0', + 'END:VEVENT', + 'END:VCALENDAR', + ].join('\r\n'); + const objects = [{ data: noAttendeeIcal, url: '/cal/solo.ics' }]; + const { client, mockDAVClient } = createMockedPatchClient(objects); + + await client.updateCalendarEvent('solo@fm', { start: '2026-04-02T10:00:00Z' }); + + const updatedData = mockDAVClient.updateCalendarObject.mock.calls[0].arguments[0].calendarObject.data; + assert.ok(updatedData.includes('SEQUENCE:0')); + }); + + it('removes DURATION when setting end', async () => { + const durationIcal = [ + 'BEGIN:VCALENDAR', + 'BEGIN:VEVENT', + 'UID:dur@fm', + 'DTSTART:20260401T100000Z', + 'DURATION:PT2H', + 'SUMMARY:Duration Event', + 'END:VEVENT', + 'END:VCALENDAR', + ].join('\r\n'); + const objects = [{ data: durationIcal, url: '/cal/dur.ics' }]; + const { client, mockDAVClient } = createMockedPatchClient(objects); + + await client.updateCalendarEvent('dur@fm', { end: '2026-04-01T13:00:00Z' }); + + const updatedData = mockDAVClient.updateCalendarObject.mock.calls[0].arguments[0].calendarObject.data; + assert.ok(updatedData.includes('DTEND:20260401T130000Z')); + assert.ok(!updatedData.includes('DURATION')); + }); + + it('does NOT remove DURATION when only setting start', async () => { + const durationIcal = [ + 'BEGIN:VCALENDAR', + 'BEGIN:VEVENT', + 'UID:dur2@fm', + 'DTSTART:20260401T100000Z', + 'DURATION:PT2H', + 'SUMMARY:Duration Event', + 'END:VEVENT', + 'END:VCALENDAR', + ].join('\r\n'); + const objects = [{ data: durationIcal, url: '/cal/dur2.ics' }]; + const { client, mockDAVClient } = createMockedPatchClient(objects); + + await client.updateCalendarEvent('dur2@fm', { start: '2026-04-02T10:00:00Z' }); + + const updatedData = mockDAVClient.updateCalendarObject.mock.calls[0].arguments[0].calendarObject.data; + assert.ok(updatedData.includes('DURATION:PT2H')); + }); + + it('participants: [] removes all ATTENDEEs, preserves ORGANIZER', async () => { + const ical = makeRichIcal('evt5@fm'); + const objects = [{ data: ical, url: '/cal/evt5.ics' }]; + const { client, mockDAVClient } = createMockedPatchClient(objects); + + await client.updateCalendarEvent('evt5@fm', { participants: [] }); + + const updatedData = mockDAVClient.updateCalendarObject.mock.calls[0].arguments[0].calendarObject.data; + assert.ok(!updatedData.includes('ATTENDEE')); + assert.ok(updatedData.includes('ORGANIZER')); + }); + + it('floating end time preserves DTEND TZID', async () => { + const ical = makeRichIcal('evt6@fm'); + const objects = [{ data: ical, url: '/cal/evt6.ics' }]; + const { client, mockDAVClient } = createMockedPatchClient(objects); + + await client.updateCalendarEvent('evt6@fm', { end: '2026-04-01T12:00:00' }); + + const updatedData = mockDAVClient.updateCalendarObject.mock.calls[0].arguments[0].calendarObject.data; + assert.ok(updatedData.includes('DTEND;TZID=Europe/Rome:20260401T120000')); + }); + + it('date-only start emits VALUE=DATE', async () => { + const allDayIcal = [ + 'BEGIN:VCALENDAR', + 'BEGIN:VEVENT', + 'UID:allday@fm', + 'DTSTART;VALUE=DATE:20260401', + 'DTEND;VALUE=DATE:20260402', + 'SUMMARY:All Day', + 'END:VEVENT', + 'END:VCALENDAR', + ].join('\r\n'); + const objects = [{ data: allDayIcal, url: '/cal/allday.ics' }]; + const { client, mockDAVClient } = createMockedPatchClient(objects); + + await client.updateCalendarEvent('allday@fm', { start: '2026-04-05' }); + + const updatedData = mockDAVClient.updateCalendarObject.mock.calls[0].arguments[0].calendarObject.data; + assert.ok(updatedData.includes('DTSTART;VALUE=DATE:20260405')); + }); + + it('throws on event with no VEVENT', async () => { + // Simulate an object found by URL but with no VEVENT block + const noVeventIcal = 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nEND:VCALENDAR'; + const objects = [{ data: noVeventIcal, url: '/cal/bad.ics' }]; + const { client } = createMockedPatchClient(objects); + + await assert.rejects( + () => client.updateCalendarEvent('/cal/bad.ics', { title: 'X' }), + /no iCal data|not found/ + ); + }); + + it('throws when VEVENT block is malformed (BEGIN without END)', async () => { + // Has BEGIN:VEVENT (passes string check) but no END:VEVENT (extractVEvent returns null) + const brokenIcal = 'BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:broken@fm\r\nEND:VCALENDAR'; + const objects = [{ data: brokenIcal, url: '/cal/broken.ics' }]; + const { client } = createMockedPatchClient(objects); + + await assert.rejects( + () => client.updateCalendarEvent('/cal/broken.ics', { title: 'X' }), + /not found|no VEVENT/ + ); + }); + + it('throws on malformed start date format', async () => { + const ical = makeRichIcal('evtbad@fm'); + const objects = [{ data: ical, url: '/cal/evtbad.ics' }]; + const { client } = createMockedPatchClient(objects); + + await assert.rejects( + () => client.updateCalendarEvent('evtbad@fm', { start: 'not-a-date' }), + /Invalid start date format/ + ); + }); + + it('throws on malformed end date format', async () => { + const ical = makeRichIcal('evtbad2@fm'); + const objects = [{ data: ical, url: '/cal/evtbad2.ics' }]; + const { client } = createMockedPatchClient(objects); + + await assert.rejects( + () => client.updateCalendarEvent('evtbad2@fm', { end: 'garbage' }), + /Invalid end date format/ + ); + }); + + it('throws when adding participants with non-email CalDAV username', async () => { + const noOrganizerIcal = [ + 'BEGIN:VCALENDAR', + 'BEGIN:VEVENT', + 'UID:noorg2@fm', + 'DTSTART:20260401T100000Z', + 'DTEND:20260401T110000Z', + 'SUMMARY:Simple Event', + 'END:VEVENT', + 'END:VCALENDAR', + ].join('\r\n'); + const objects = [{ data: noOrganizerIcal, url: '/cal/noorg2.ics' }]; + // Use non-email username + const client = new CalDAVCalendarClient({ username: 'not-an-email', password: 'test' }); + const mockDAVClient = { + login: mock.fn(async () => {}), + fetchCalendars: mock.fn(async () => [{ displayName: 'Personal', url: '/cal/personal/' }]), + fetchCalendarObjects: mock.fn(async () => objects), + updateCalendarObject: mock.fn(async () => ({})), + }; + (client as any).client = mockDAVClient; + + await assert.rejects( + () => client.updateCalendarEvent('noorg2@fm', { + participants: [{ email: 'alice@example.com' }], + }), + /CalDAV username is not an email/ + ); + }); + + it('adds ORGANIZER when adding participants to event with no existing ORGANIZER', async () => { + const noOrganizerIcal = [ + 'BEGIN:VCALENDAR', + 'BEGIN:VEVENT', + 'UID:noorg@fm', + 'DTSTART:20260401T100000Z', + 'DTEND:20260401T110000Z', + 'SUMMARY:Simple Event', + 'END:VEVENT', + 'END:VCALENDAR', + ].join('\r\n'); + const objects = [{ data: noOrganizerIcal, url: '/cal/noorg.ics' }]; + const { client, mockDAVClient } = createMockedPatchClient(objects); + + await client.updateCalendarEvent('noorg@fm', { + participants: [{ email: 'alice@example.com', name: 'Alice' }], + }); + + const updatedData = mockDAVClient.updateCalendarObject.mock.calls[0].arguments[0].calendarObject.data; + assert.ok(updatedData.includes('ORGANIZER')); + assert.ok(updatedData.includes('mailto:test@fastmail.com')); + assert.ok(updatedData.includes('ATTENDEE')); + }); + + it('updates description only', async () => { + const ical = makeRichIcal('evtdesc@fm'); + const objects = [{ data: ical, url: '/cal/evtdesc.ics' }]; + const { client, mockDAVClient } = createMockedPatchClient(objects); + + await client.updateCalendarEvent('evtdesc@fm', { description: 'New description' }); + + const updatedData = mockDAVClient.updateCalendarObject.mock.calls[0].arguments[0].calendarObject.data; + assert.ok(updatedData.includes('DESCRIPTION:New description')); + assert.ok(updatedData.includes('SUMMARY:Original Title')); // Other fields preserved + }); + + it('updates DTSTAMP and LAST-MODIFIED', async () => { + const ical = makeRichIcal('evt7@fm'); + const objects = [{ data: ical, url: '/cal/evt7.ics' }]; + const { client, mockDAVClient } = createMockedPatchClient(objects); + + await client.updateCalendarEvent('evt7@fm', { title: 'X' }); + + const updatedData = mockDAVClient.updateCalendarObject.mock.calls[0].arguments[0].calendarObject.data; + assert.ok(updatedData.includes('LAST-MODIFIED:')); + // DTSTAMP should be updated (not the original) + assert.ok(!updatedData.includes('DTSTAMP:20260401T000000Z')); + }); + + it('preserves Google PRODID (does not stamp ours)', async () => { + const ical = makeRichIcal('evt8@fm'); + const objects = [{ data: ical, url: '/cal/evt8.ics' }]; + const { client, mockDAVClient } = createMockedPatchClient(objects); + + await client.updateCalendarEvent('evt8@fm', { title: 'X' }); + + const updatedData = mockDAVClient.updateCalendarObject.mock.calls[0].arguments[0].calendarObject.data; + assert.ok(updatedData.includes('PRODID:-//Google Inc//Google Calendar//EN')); + }); +}); + +describe('CalDAVCalendarClient.updateCalendarEvent recurring events', () => { + function makeRecurringIcal(exceptions: Array<{ recurrenceId: string; summary: string }> = []): string { + const lines = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'BEGIN:VEVENT', + 'UID:recur@fm', + 'DTSTAMP:20260401T000000Z', + 'DTSTART:20260406T100000Z', + 'DTEND:20260406T110000Z', + 'RRULE:FREQ=WEEKLY;COUNT=10', + 'SUMMARY:Weekly Meeting', + 'SEQUENCE:0', + 'END:VEVENT', + ]; + for (const exc of exceptions) { + lines.push( + 'BEGIN:VEVENT', + 'UID:recur@fm', + `RECURRENCE-ID:${exc.recurrenceId}`, + 'DTSTAMP:20260401T000000Z', + `DTSTART:${exc.recurrenceId}`, + `DTEND:${exc.recurrenceId.replace('T10', 'T11')}`, + `SUMMARY:${exc.summary}`, + 'END:VEVENT', + ); + } + lines.push('END:VCALENDAR'); + return lines.join('\r\n'); + } + + function createMockedRecurringClient(ical: string) { + const client = new CalDAVCalendarClient({ username: 'test@fastmail.com', password: 'test' }); + const objects = [{ data: ical, url: '/cal/recur.ics' }]; + const mockDAVClient = { + login: mock.fn(async () => {}), + fetchCalendars: mock.fn(async () => [ + { displayName: 'Personal', url: '/cal/personal/' }, + ]), + fetchCalendarObjects: mock.fn(async () => objects), + updateCalendarObject: mock.fn(async () => ({})), + }; + (client as any).client = mockDAVClient; + return { client, mockDAVClient }; + } + + it('time change with no exceptions proceeds without confirmRecurring', async () => { + const ical = makeRecurringIcal([]); // No exceptions + const { client, mockDAVClient } = createMockedRecurringClient(ical); + + await client.updateCalendarEvent('recur@fm', { start: '2026-04-07T10:00:00Z' }); + + assert.equal(mockDAVClient.updateCalendarObject.mock.calls.length, 1); + const updatedData = mockDAVClient.updateCalendarObject.mock.calls[0].arguments[0].calendarObject.data; + assert.ok(updatedData.includes('DTSTART:20260407T100000Z')); + }); + + it('time change with exceptions throws without confirmRecurring', async () => { + const ical = makeRecurringIcal([ + { recurrenceId: '20260413T100000Z', summary: 'Exception Week 2' }, + { recurrenceId: '20260420T100000Z', summary: 'Exception Week 3' }, + ]); + const { client } = createMockedRecurringClient(ical); + + await assert.rejects( + () => client.updateCalendarEvent('recur@fm', { start: '2026-04-07T10:00:00Z' }), + /confirmRecurring/ + ); + }); + + it('time change with confirmRecurring: true removes orphaned exceptions', async () => { + const ical = makeRecurringIcal([ + { recurrenceId: '20260413T100000Z', summary: 'Exception Week 2' }, + ]); + const { client, mockDAVClient } = createMockedRecurringClient(ical); + + // Shift start by one day — the exception on April 13 (Monday) won't match + // the new Tuesday schedule + await client.updateCalendarEvent('recur@fm', { + start: '2026-04-07T10:00:00Z', + confirmRecurring: true, + }); + + assert.equal(mockDAVClient.updateCalendarObject.mock.calls.length, 1); + const updatedData = mockDAVClient.updateCalendarObject.mock.calls[0].arguments[0].calendarObject.data; + assert.ok(updatedData.includes('SUMMARY:Weekly Meeting')); // Master preserved + assert.ok(updatedData.includes('DTSTART:20260407T100000Z')); // Start updated + }); + + it('time change with offset-bearing timestamp works for orphan detection', async () => { + const ical = makeRecurringIcal([ + { recurrenceId: '20260413T100000Z', summary: 'Exception Week 2' }, + ]); + const { client } = createMockedRecurringClient(ical); + + // Offset timestamp should be converted to UTC for rrule parsing + await assert.rejects( + () => client.updateCalendarEvent('recur@fm', { start: '2026-04-07T20:00:00+10:00' }), + /confirmRecurring/ + ); + }); + + it('confirmRecurring: true keeps valid exceptions and removes orphaned ones', async () => { + // Weekly on Monday (April 6). Exception on April 13 (week 2) and April 20 (week 3). + // Shift start by exactly 1 week — April 13 becomes the new first occurrence, + // so the April 13 exception is still valid, but April 20 should also still be valid + // (it's still week 3 of the new schedule). + // Actually, let's shift to Tuesday (April 7). Now occurrences are April 7, 14, 21, 28... + // Exception on April 13 (Monday) is orphaned. Exception on April 14 would match but + // we have April 20 which is also orphaned (not a Tuesday). + const ical = makeRecurringIcal([ + { recurrenceId: '20260413T100000Z', summary: 'Exception Mon Week 2' }, + { recurrenceId: '20260414T100000Z', summary: 'Exception Tue Week 2' }, + ]); + const { client, mockDAVClient } = createMockedRecurringClient(ical); + + await client.updateCalendarEvent('recur@fm', { + start: '2026-04-07T10:00:00Z', + confirmRecurring: true, + }); + + const updatedData = mockDAVClient.updateCalendarObject.mock.calls[0].arguments[0].calendarObject.data; + assert.ok(updatedData.includes('SUMMARY:Weekly Meeting')); // Master preserved + // April 14 (Tuesday) should be kept — matches new weekly Tuesday schedule + assert.ok(updatedData.includes('Exception Tue Week 2')); + // April 13 (Monday) should be removed — doesn't match new Tuesday schedule + assert.ok(!updatedData.includes('Exception Mon Week 2')); + }); + + it('non-time change on recurring event does not require confirmRecurring', async () => { + const ical = makeRecurringIcal([ + { recurrenceId: '20260413T100000Z', summary: 'Exception Week 2' }, + ]); + const { client, mockDAVClient } = createMockedRecurringClient(ical); + + // Title-only change should not trigger the recurring guard + await client.updateCalendarEvent('recur@fm', { title: 'New Title' }); + + assert.equal(mockDAVClient.updateCalendarObject.mock.calls.length, 1); + const updatedData = mockDAVClient.updateCalendarObject.mock.calls[0].arguments[0].calendarObject.data; + assert.ok(updatedData.includes('SUMMARY:New Title')); + assert.ok(updatedData.includes('Exception Week 2')); // Exception preserved + }); +}); + +describe('CalDAVCalendarClient.createCalendarEvent with participants', () => { + function createMockedCreateClient() { + const client = new CalDAVCalendarClient({ username: 'me@fastmail.com', password: 'test' }); + const mockDAVClient = { + login: mock.fn(async () => {}), + fetchCalendars: mock.fn(async () => [ + { displayName: 'Personal', url: '/cal/personal/' }, + ]), + createCalendarObject: mock.fn(async () => ({})), + }; + (client as any).client = mockDAVClient; + return { client, mockDAVClient }; + } + + it('includes ATTENDEE and ORGANIZER lines with mailto URI', async () => { + const { client, mockDAVClient } = createMockedCreateClient(); + await client.createCalendarEvent({ + calendarId: 'Personal', + title: 'Meeting', + start: '2026-04-07T14:00:00Z', + end: '2026-04-07T15:00:00Z', + participants: [ + { email: 'alice@example.com', name: 'Alice' }, + { email: 'bob@example.com', name: 'Bob' }, + ], + }); + + const ical = mockDAVClient.createCalendarObject.mock.calls[0].arguments[0].iCalString; + assert.ok(ical.includes('ORGANIZER;CN=me@fastmail.com:mailto:me@fastmail.com')); + assert.ok(ical.includes('ATTENDEE;CN=Alice:mailto:alice@example.com')); + assert.ok(ical.includes('ATTENDEE;CN=Bob:mailto:bob@example.com')); + }); + + it('does not include ATTENDEE/ORGANIZER when no participants', async () => { + const { client, mockDAVClient } = createMockedCreateClient(); + await client.createCalendarEvent({ + calendarId: 'Personal', + title: 'Solo Event', + start: '2026-04-07T14:00:00Z', + end: '2026-04-07T15:00:00Z', + }); + + const ical = mockDAVClient.createCalendarObject.mock.calls[0].arguments[0].iCalString; + assert.ok(!ical.includes('ATTENDEE')); + assert.ok(!ical.includes('ORGANIZER')); + }); + + it('does not emit RSVP=TRUE', async () => { + const { client, mockDAVClient } = createMockedCreateClient(); + await client.createCalendarEvent({ + calendarId: 'Personal', + title: 'Meeting', + start: '2026-04-07T14:00:00Z', + end: '2026-04-07T15:00:00Z', + participants: [{ email: 'alice@example.com' }], + }); + + const ical = mockDAVClient.createCalendarObject.mock.calls[0].arguments[0].iCalString; + assert.ok(!ical.includes('RSVP')); + }); + + it('rejects email with injection attempt', async () => { + const { client } = createMockedCreateClient(); + await assert.rejects( + () => client.createCalendarEvent({ + calendarId: 'Personal', + title: 'Meeting', + start: '2026-04-07T14:00:00Z', + end: '2026-04-07T15:00:00Z', + participants: [{ email: 'a@b.com\r\nX-INJECT:true' }], + }), + /illegal/i + ); + }); + + it('uses DQUOTE quoting for CN, not backslash escaping', async () => { + const { client, mockDAVClient } = createMockedCreateClient(); + await client.createCalendarEvent({ + calendarId: 'Personal', + title: 'Meeting', + start: '2026-04-07T14:00:00Z', + end: '2026-04-07T15:00:00Z', + participants: [{ email: 'alice@example.com', name: 'Doe, Alice' }], + }); + + const ical = mockDAVClient.createCalendarObject.mock.calls[0].arguments[0].iCalString; + // Should use DQUOTE quoting: CN="Doe, Alice" + assert.ok(ical.includes('CN="Doe, Alice"')); + // Should NOT use backslash escaping: CN=Doe\, Alice + assert.ok(!ical.includes('CN=Doe\\, Alice')); + }); + + it('handles date-only start/end (all-day event)', async () => { + const { client, mockDAVClient } = createMockedCreateClient(); + await client.createCalendarEvent({ + calendarId: 'Personal', + title: 'All Day', + start: '2026-04-07', + end: '2026-04-08', + }); + + const ical = mockDAVClient.createCalendarObject.mock.calls[0].arguments[0].iCalString; + assert.ok(ical.includes('DTSTART;VALUE=DATE:20260407')); + assert.ok(ical.includes('DTEND;VALUE=DATE:20260408')); + }); + + it('rejects date-only start and end with same value', async () => { + const { client } = createMockedCreateClient(); + await assert.rejects( + () => client.createCalendarEvent({ + calendarId: 'Personal', + title: 'Bad', + start: '2026-04-07', + end: '2026-04-07', + }), + /DTEND is exclusive/ + ); + }); + + it('ends with trailing CRLF', async () => { + const { client, mockDAVClient } = createMockedCreateClient(); + await client.createCalendarEvent({ + calendarId: 'Personal', + title: 'Test', + start: '2026-04-07T14:00:00Z', + end: '2026-04-07T15:00:00Z', + }); + + const ical = mockDAVClient.createCalendarObject.mock.calls[0].arguments[0].iCalString; + assert.ok(ical.endsWith('\r\n')); + }); +}); + +describe('CRLF vs LF line ending preservation', () => { + it('replaceICalProperty preserves LF-only endings', () => { + const event = 'BEGIN:VCALENDAR\nBEGIN:VEVENT\nSUMMARY:Old\nEND:VEVENT\nEND:VCALENDAR'; + const result = replaceICalProperty(event, 'SUMMARY', 'SUMMARY:New'); + assert.ok(!result.includes('\r\n')); + assert.ok(result.includes('SUMMARY:New')); + }); + + it('replaceICalProperty preserves CRLF endings', () => { + const event = 'BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nSUMMARY:Old\r\nEND:VEVENT\r\nEND:VCALENDAR'; + const result = replaceICalProperty(event, 'SUMMARY', 'SUMMARY:New'); + assert.ok(result.includes('\r\n')); + assert.ok(result.includes('SUMMARY:New')); + }); + + it('no mixed line endings when patching LF-only input', () => { + const event = 'BEGIN:VCALENDAR\nBEGIN:VEVENT\nSUMMARY:Old\nEND:VEVENT\nEND:VCALENDAR'; + const result = replaceICalProperty(event, 'SUMMARY', 'SUMMARY:New'); + assert.ok(!result.includes('\r\n'), 'Should not contain CRLF in LF-only input'); + }); + + it('foldICalLine with wrong lineEnding produces consistent output', () => { + // Simulate a caller using CRLF fold on what will be inserted into LF document + const folded = foldICalLine('DESCRIPTION:' + 'x'.repeat(80), '\r\n'); + // The fold itself should use CRLF consistently + assert.ok(folded.includes('\r\n')); + // When replaceICalProperty re-splits and re-joins with LF, CRLF folds are preserved + // inside the replacement line — this is the caller's responsibility to match + const lfEvent = 'BEGIN:VCALENDAR\nBEGIN:VEVENT\nSUMMARY:Old\nEND:VEVENT\nEND:VCALENDAR'; + const result = replaceICalProperty(lfEvent, 'SUMMARY', foldICalLine('SUMMARY:Short', '\n')); + assert.ok(!result.includes('\r\n'), 'Caller using correct lineEnding prevents mixing'); + }); +}); + +describe('VEVENT extraction consistency', () => { + it('extractVEvent and replaceICalProperty agree on VEVENT boundaries', () => { + const multiVevent = [ + 'BEGIN:VCALENDAR', + 'BEGIN:VTIMEZONE', + 'TZID:Europe/Rome', + 'END:VTIMEZONE', + 'BEGIN:VEVENT', + 'UID:master@fm', + 'SUMMARY:Master', + 'END:VEVENT', + 'BEGIN:VEVENT', + 'UID:master@fm', + 'RECURRENCE-ID:20260408T100000Z', + 'SUMMARY:Exception', + 'END:VEVENT', + 'END:VCALENDAR', + ].join('\n'); + + const extracted = extractVEvent(multiVevent)!; + assert.ok(extracted.includes('SUMMARY:Master')); + assert.ok(!extracted.includes('SUMMARY:Exception')); + + // replaceICalProperty should operate on the same first VEVENT + const patched = replaceICalProperty(multiVevent, 'SUMMARY', 'SUMMARY:Updated'); + assert.ok(patched.includes('SUMMARY:Updated')); + assert.ok(patched.includes('SUMMARY:Exception')); // Second VEVENT untouched + }); +}); + +describe('Additional plan-required updateCalendarEvent tests', () => { + function makeRichIcal(uid: string, extra: string[] = []): string { + return [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//Google Inc//Google Calendar//EN', + 'BEGIN:VEVENT', + `UID:${uid}`, + 'DTSTAMP:20260401T000000Z', + 'DTSTART;TZID=Europe/Rome:20260401T100000', + 'DTEND;TZID=Europe/Rome:20260401T110000', + 'SUMMARY:Original Title', + 'DESCRIPTION:Original description', + 'LOCATION:Room A', + 'ATTENDEE;CN=Alice;PARTSTAT=ACCEPTED:mailto:alice@example.com', + 'ORGANIZER;CN=Boss:mailto:boss@example.com', + 'BEGIN:VALARM', + 'TRIGGER:-PT15M', + 'ACTION:DISPLAY', + 'DESCRIPTION:Reminder', + 'END:VALARM', + 'SEQUENCE:2', + ...extra, + 'END:VEVENT', + 'END:VCALENDAR', + ].join('\r\n'); + } + + function createMockedClient(calendarObjects: Array<{ data: string; url: string }>) { + const client = new CalDAVCalendarClient({ username: 'test@fastmail.com', password: 'test' }); + const mockDAVClient = { + login: mock.fn(async () => {}), + fetchCalendars: mock.fn(async () => [ + { displayName: 'Personal', url: '/cal/personal/' }, + ]), + fetchCalendarObjects: mock.fn(async () => calendarObjects), + updateCalendarObject: mock.fn(async () => ({})), + }; + (client as any).client = mockDAVClient; + return { client, mockDAVClient }; + } + + it('increments SEQUENCE for start change when ATTENDEEs exist', async () => { + const ical = makeRichIcal('seqstart@fm'); + const { client, mockDAVClient } = createMockedClient([{ data: ical, url: '/cal/seqstart.ics' }]); + + await client.updateCalendarEvent('seqstart@fm', { start: '2026-04-02T10:00:00' }); + + const updatedData = mockDAVClient.updateCalendarObject.mock.calls[0].arguments[0].calendarObject.data; + assert.ok(updatedData.includes('SEQUENCE:3')); + }); + + it('increments SEQUENCE for end change when ATTENDEEs exist', async () => { + const ical = makeRichIcal('seqend@fm'); + const { client, mockDAVClient } = createMockedClient([{ data: ical, url: '/cal/seqend.ics' }]); + + await client.updateCalendarEvent('seqend@fm', { end: '2026-04-01T12:00:00' }); + + const updatedData = mockDAVClient.updateCalendarObject.mock.calls[0].arguments[0].calendarObject.data; + assert.ok(updatedData.includes('SEQUENCE:3')); + }); + + it('increments SEQUENCE for participants change when ATTENDEEs exist', async () => { + const ical = makeRichIcal('seqpart@fm'); + const { client, mockDAVClient } = createMockedClient([{ data: ical, url: '/cal/seqpart.ics' }]); + + await client.updateCalendarEvent('seqpart@fm', { + participants: [{ email: 'new@example.com', name: 'New' }], + }); + + const updatedData = mockDAVClient.updateCalendarObject.mock.calls[0].arguments[0].calendarObject.data; + assert.ok(updatedData.includes('SEQUENCE:3')); + }); + + it('floating end time falls back to DTSTART TZID when DTEND was DURATION-computed', async () => { + const durationIcal = [ + 'BEGIN:VCALENDAR', + 'BEGIN:VEVENT', + 'UID:durtz@fm', + 'DTSTART;TZID=Europe/Rome:20260401T100000', + 'DURATION:PT2H', + 'SUMMARY:Duration Event', + 'END:VEVENT', + 'END:VCALENDAR', + ].join('\r\n'); + const { client, mockDAVClient } = createMockedClient([{ data: durationIcal, url: '/cal/durtz.ics' }]); + + await client.updateCalendarEvent('durtz@fm', { end: '2026-04-01T13:00:00' }); + + const updatedData = mockDAVClient.updateCalendarObject.mock.calls[0].arguments[0].calendarObject.data; + // Should fall back to DTSTART's TZID since there was no DTEND + assert.ok(updatedData.includes('DTEND;TZID=Europe/Rome:20260401T130000')); + }); + + it('rejects date-only start and end with same value on update', async () => { + const allDayIcal = [ + 'BEGIN:VCALENDAR', + 'BEGIN:VEVENT', + 'UID:allday2@fm', + 'DTSTART;VALUE=DATE:20260401', + 'DTEND;VALUE=DATE:20260402', + 'SUMMARY:All Day', + 'END:VEVENT', + 'END:VCALENDAR', + ].join('\r\n'); + const { client } = createMockedClient([{ data: allDayIcal, url: '/cal/allday2.ics' }]); + + await assert.rejects( + () => client.updateCalendarEvent('allday2@fm', { start: '2026-04-05', end: '2026-04-05' }), + /DTEND is exclusive/ + ); + }); +}); + +describe('Recurring event: no orphans proceeds without confirmRecurring', () => { + it('time-only shift that keeps all exceptions valid proceeds without confirmRecurring', async () => { + // Weekly on Monday at 10:00. Exception on April 13 at 10:00 (still a Monday). + // Shift time to 11:00 but same day — April 13 at 11:00 is still week 2. + // The exception's RECURRENCE-ID (April 13T10:00Z) should still match + // because rrule expansion produces occurrences on the same dates. + const ical = [ + 'BEGIN:VCALENDAR', + 'BEGIN:VEVENT', + 'UID:noorphan@fm', + 'DTSTAMP:20260401T000000Z', + 'DTSTART:20260406T100000Z', + 'DTEND:20260406T110000Z', + 'RRULE:FREQ=WEEKLY;COUNT=10', + 'SUMMARY:Weekly', + 'SEQUENCE:0', + 'END:VEVENT', + 'BEGIN:VEVENT', + 'UID:noorphan@fm', + 'RECURRENCE-ID:20260413T100000Z', + 'DTSTAMP:20260401T000000Z', + 'DTSTART:20260413T100000Z', + 'DTEND:20260413T110000Z', + 'SUMMARY:Modified Week 2', + 'END:VEVENT', + 'END:VCALENDAR', + ].join('\r\n'); + + const client = new CalDAVCalendarClient({ username: 'test@fastmail.com', password: 'test' }); + const objects = [{ data: ical, url: '/cal/noorphan.ics' }]; + const mockDAVClient = { + login: mock.fn(async () => {}), + fetchCalendars: mock.fn(async () => [{ displayName: 'Personal', url: '/cal/personal/' }]), + fetchCalendarObjects: mock.fn(async () => objects), + updateCalendarObject: mock.fn(async () => ({})), + }; + (client as any).client = mockDAVClient; + + // Change only end time (not start) — RRULE anchor stays the same, no orphans + await client.updateCalendarEvent('noorphan@fm', { end: '2026-04-06T12:00:00Z' }); + + assert.equal(mockDAVClient.updateCalendarObject.mock.calls.length, 1); + const updatedData = mockDAVClient.updateCalendarObject.mock.calls[0].arguments[0].calendarObject.data; + assert.ok(updatedData.includes('Modified Week 2')); // Exception preserved + assert.ok(updatedData.includes('DTEND:20260406T120000Z')); // End updated + }); +}); diff --git a/src/caldav-client.ts b/src/caldav-client.ts index 144839f..4f64973 100644 --- a/src/caldav-client.ts +++ b/src/caldav-client.ts @@ -1,4 +1,6 @@ import { DAVClient, DAVCalendar, DAVCalendarObject } from 'tsdav'; +import rruleLib from 'rrule'; +const { rrulestr } = rruleLib; export interface CalDAVConfig { username: string; @@ -14,6 +16,15 @@ export interface CalendarInfo { color?: string; } +export interface Participant { + email: string; + name?: string; + role?: string; // REQ-PARTICIPANT, OPT-PARTICIPANT, CHAIR + status?: string; // PARTSTAT: ACCEPTED, DECLINED, TENTATIVE, NEEDS-ACTION + cutype?: string; // CUTYPE: INDIVIDUAL, ROOM, RESOURCE, GROUP, UNKNOWN + rsvp?: boolean; // RSVP: TRUE/FALSE +} + export interface CalendarEvent { id: string; url: string; @@ -22,15 +33,36 @@ export interface CalendarEvent { start?: string; end?: string; location?: string; + organizer?: Participant; + participants?: Participant[]; } /** * Extract the VEVENT block from iCalendar data. * This avoids matching properties from VTIMEZONE or other components. */ -export function extractVEvent(data: string): string { +export function extractVEvent(data: string): string | null { const match = data.match(/BEGIN:VEVENT[\s\S]*?END:VEVENT/); - return match ? match[0] : data; + return match ? match[0] : null; +} + +/** + * Find the index of the first colon that separates iCal property parameters + * from the property value. Colons inside quoted parameter values (e.g. + * DELEGATED-FROM="mailto:boss@example.com") are skipped. + * Also correctly handles properties like DESCRIPTION;ALTREP="http://...":text + */ +export function findValueBoundary(line: string): number { + let inQuote = false; + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (ch === '"') { + inQuote = !inQuote; + } else if (ch === ':' && !inQuote) { + return i; + } + } + return -1; } /** @@ -45,10 +77,10 @@ export function parseICalValue(vevent: string, key: string): string | undefined const match = vevent.match(regex); if (!match) return undefined; - // Handle line folding: continuation lines start with space or tab - let fullLine = match[1]; + // Strip trailing \r that multiline regex captures on CRLF input + let fullLine = match[1].replace(/\r$/, ''); const lines = vevent.split(/\r?\n/); - const matchIdx = lines.findIndex(l => l === fullLine || l.startsWith(fullLine)); + const matchIdx = lines.findIndex(l => l === fullLine); if (matchIdx >= 0) { for (let i = matchIdx + 1; i < lines.length; i++) { if (lines[i].startsWith(' ') || lines[i].startsWith('\t')) { @@ -59,15 +91,112 @@ export function parseICalValue(vevent: string, key: string): string | undefined } } - // Extract the value after the last colon in the property line - // For DTSTART;TZID=Europe/Rome:20260320T083000 → 20260320T083000 - // For DTSTART:20220210T154500Z → 20220210T154500Z - // For DTSTART;VALUE=DATE:20260324 → 20260324 - const colonIdx = fullLine.indexOf(':'); + // Use quote-aware colon detection for the parameter/value boundary + const colonIdx = findValueBoundary(fullLine); if (colonIdx === -1) return undefined; return fullLine.substring(colonIdx + 1).trim(); } +/** + * Return all occurrences of a property key as full unfolded raw lines. + * Needed because ATTENDEE/EXDATE etc. can appear multiple times. + */ +export function parseAllICalProperties(vevent: string, key: string): string[] { + const lines = vevent.split(/\r?\n/); + const regex = new RegExp(`^${key}[;:]`); + const results: string[] = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i].replace(/\r$/, ''); + if (!regex.test(line)) continue; + + // Unfold continuation lines + let fullLine = line; + for (let j = i + 1; j < lines.length; j++) { + const next = lines[j]; + if (next.startsWith(' ') || next.startsWith('\t')) { + fullLine += next.substring(1); + i = j; // skip continuation lines in outer loop + } else { + break; + } + } + results.push(fullLine); + } + + return results; +} + +/** + * Parse a raw ATTENDEE or ORGANIZER line into a Participant. + * Uses quote-aware scanning for parameter/value boundary detection. + */ +export function parseAttendee(rawLine: string): Participant { + // Find the parameter/value boundary (first colon outside quotes) + const boundaryIdx = findValueBoundary(rawLine); + const paramPart = boundaryIdx >= 0 ? rawLine.substring(0, boundaryIdx) : rawLine; + const valuePart = boundaryIdx >= 0 ? rawLine.substring(boundaryIdx + 1) : ''; + + // Extract email from cal-address value + const email = valuePart.replace(/^mailto:/i, ''); + + // Split parameters on semicolons, respecting quotes + const params: string[] = []; + let current = ''; + let inQuote = false; + // Skip the property name (ATTENDEE or ORGANIZER) — start after first ; + const firstSemi = paramPart.indexOf(';'); + const paramStr = firstSemi >= 0 ? paramPart.substring(firstSemi + 1) : ''; + + for (let i = 0; i < paramStr.length; i++) { + const ch = paramStr[i]; + if (ch === '"') { + inQuote = !inQuote; + current += ch; + } else if (ch === ';' && !inQuote) { + if (current) params.push(current); + current = ''; + } else { + current += ch; + } + } + if (current) params.push(current); + + // Extract known parameters + const result: Participant = { email }; + + for (const param of params) { + const eqIdx = param.indexOf('='); + if (eqIdx === -1) continue; + const pName = param.substring(0, eqIdx).toUpperCase(); + let pValue = param.substring(eqIdx + 1); + // Strip surrounding quotes + if (pValue.startsWith('"') && pValue.endsWith('"')) { + pValue = pValue.slice(1, -1); + } + + switch (pName) { + case 'CN': + if (pValue) result.name = pValue; + break; + case 'PARTSTAT': + result.status = pValue; + break; + case 'ROLE': + result.role = pValue; + break; + case 'CUTYPE': + result.cutype = pValue; + break; + case 'RSVP': + result.rsvp = pValue.toUpperCase() === 'TRUE'; + break; + } + } + + return result; +} + /** * Format an iCalendar date/datetime string to ISO 8601. * Input formats: 20260320T083000, 20260320T083000Z, 20260324 @@ -92,16 +221,428 @@ export function formatICalDate(raw: string | undefined): string | undefined { return cleaned; } -export function parseCalendarObject(obj: DAVCalendarObject): CalendarEvent { +/** + * Convert an ISO 8601 datetime string to iCalendar UTC format. + * Handles timezone offsets by converting to UTC via Date. + * Preserves floating times (no offset, no Z) as-is. + * e.g. "2026-04-07T18:45:00+10:00" → "20260407T084500Z" + */ +export function toICalUTC(isoString: string): string { + // Guard: date-only input must be handled by caller, not passed here + if (/^\d{4}-\d{2}-\d{2}$/.test(isoString)) { + throw new Error('date-only input must be handled by caller, not passed to toICalUTC'); + } + // Floating time (no offset, no Z) — preserve as local iCal datetime + if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(isoString)) { + return isoString.replace(/[-:]/g, ''); + } + const d = new Date(isoString); + if (isNaN(d.getTime())) throw new Error(`Invalid date: ${isoString}`); + return d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, ''); +} + +/** + * Fold an iCalendar content line at 75 octets per RFC 5545 §3.1. + * @param lineEnding Line ending to use for fold breaks (default '\r\n') + */ +export function foldICalLine(line: string, lineEnding: string = '\r\n'): string { + const parts: string[] = []; + while (Buffer.byteLength(line, 'utf8') > 75) { + // Find the largest character count that fits in 75 bytes + let cut = 75; + while (cut > 0 && Buffer.byteLength(line.slice(0, cut), 'utf8') > 75) { + cut--; + } + // Don't split a surrogate pair (characters outside BMP like emoji) + if (cut > 0 && cut < line.length) { + const code = line.charCodeAt(cut); + if (code >= 0xDC00 && code <= 0xDFFF) cut--; + } + parts.push(line.slice(0, cut)); + line = ' ' + line.slice(cut); + } + parts.push(line); + return parts.join(lineEnding); +} + +/** + * Detect line ending style from iCal data. + */ +export function detectLineEnding(data: string): string { + return data.includes('\r\n') ? '\r\n' : '\n'; +} + +/** + * Replace or insert an iCal property within the first VEVENT block. + * Operates on lines only within the first BEGIN:VEVENT/END:VEVENT pair, + * skipping nested sub-components (VALARM etc.). + * @param newLine Pre-folded replacement line, or null to remove the property. + */ +export function replaceICalProperty(icalData: string, key: string, newLine: string | null): string { + if (!icalData) throw new Error('replaceICalProperty: empty input'); + + const lineEnding = detectLineEnding(icalData); + const lines = icalData.split(/\r?\n/); + + const veventStart = lines.findIndex(l => l.trim() === 'BEGIN:VEVENT'); + if (veventStart === -1) throw new Error('replaceICalProperty: BEGIN:VEVENT not found'); + + let veventEnd = -1; + let depth = 0; + for (let i = veventStart; i < lines.length; i++) { + const trimmed = lines[i].trim(); + if (trimmed.startsWith('BEGIN:')) depth++; + if (trimmed.startsWith('END:')) { + depth--; + if (depth === 0) { + veventEnd = i; + break; + } + } + } + if (veventEnd === -1) throw new Error('replaceICalProperty: END:VEVENT not found'); + + const propRegex = new RegExp(`^${key}[;:]`); + let foundIdx = -1; + let foundEndIdx = -1; + let nestDepth = 0; + + for (let i = veventStart + 1; i < veventEnd; i++) { + const trimmed = lines[i].trim(); + if (trimmed.startsWith('BEGIN:')) { nestDepth++; continue; } + if (trimmed.startsWith('END:')) { nestDepth--; continue; } + if (nestDepth > 0) continue; + + if (propRegex.test(lines[i])) { + foundIdx = i; + // Find end of this property (including continuation lines) + foundEndIdx = i + 1; + while (foundEndIdx < veventEnd && (lines[foundEndIdx].startsWith(' ') || lines[foundEndIdx].startsWith('\t'))) { + foundEndIdx++; + } + break; + } + } + + if (foundIdx >= 0) { + // Replace or remove existing property + const newLines = newLine !== null ? newLine.split(/\r?\n/) : []; + lines.splice(foundIdx, foundEndIdx - foundIdx, ...newLines); + } else if (newLine !== null) { + // Insert before END:VEVENT + const newLines = newLine.split(/\r?\n/); + lines.splice(veventEnd, 0, ...newLines); + } + + return lines.join(lineEnding); +} + +/** + * Remove ALL occurrences of a property within the first VEVENT block. + * Skips nested sub-components. + */ +export function removeAllICalProperties(icalData: string, key: string): string { + if (!icalData) throw new Error('removeAllICalProperties: empty input'); + + const lineEnding = detectLineEnding(icalData); + const lines = icalData.split(/\r?\n/); + + const veventStart = lines.findIndex(l => l.trim() === 'BEGIN:VEVENT'); + if (veventStart === -1) throw new Error('removeAllICalProperties: BEGIN:VEVENT not found'); + + let veventEnd = -1; + let depth = 0; + for (let i = veventStart; i < lines.length; i++) { + const trimmed = lines[i].trim(); + if (trimmed.startsWith('BEGIN:')) depth++; + if (trimmed.startsWith('END:')) { + depth--; + if (depth === 0) { + veventEnd = i; + break; + } + } + } + if (veventEnd === -1) throw new Error('removeAllICalProperties: END:VEVENT not found'); + + const propRegex = new RegExp(`^${key}[;:]`); + // Collect indices to remove (in reverse order to avoid index shifting) + const toRemove: Array<[number, number]> = []; + let nestDepth = 0; + + for (let i = veventStart + 1; i < veventEnd; i++) { + const trimmed = lines[i].trim(); + if (trimmed.startsWith('BEGIN:')) { nestDepth++; continue; } + if (trimmed.startsWith('END:')) { nestDepth--; continue; } + if (nestDepth > 0) continue; + + if (propRegex.test(lines[i])) { + const startIdx = i; + let endIdx = i + 1; + while (endIdx < veventEnd && (lines[endIdx].startsWith(' ') || lines[endIdx].startsWith('\t'))) { + endIdx++; + } + toRemove.push([startIdx, endIdx - startIdx]); + i = endIdx - 1; // skip past continuation lines + } + } + + // Remove in reverse to preserve indices + for (let r = toRemove.length - 1; r >= 0; r--) { + lines.splice(toRemove[r][0], toRemove[r][1]); + } + + return lines.join(lineEnding); +} + +/** + * Insert a property line into the first VEVENT block, before any sub-components + * (VALARM etc.) per RFC 5545 ABNF (eventprop before alarmc). + * Falls back to before END:VEVENT if no sub-components exist. + */ +export function insertBeforeEndVEvent(icalData: string, newLine: string): string { + const lineEnding = detectLineEnding(icalData); + const lines = icalData.split(/\r?\n/); + + const veventStart = lines.findIndex(l => l.trim() === 'BEGIN:VEVENT'); + if (veventStart === -1) throw new Error('insertBeforeEndVEvent: BEGIN:VEVENT not found'); + + let veventEnd = -1; + let firstSubComponent = -1; + let depth = 0; + for (let i = veventStart; i < lines.length; i++) { + const trimmed = lines[i].trim(); + if (trimmed.startsWith('BEGIN:')) { + depth++; + // Track first nested sub-component (depth 2 = inside VEVENT) + if (depth === 2 && firstSubComponent === -1) { + firstSubComponent = i; + } + } + if (trimmed.startsWith('END:')) { + depth--; + if (depth === 0) { veventEnd = i; break; } + } + } + if (veventEnd === -1) throw new Error('insertBeforeEndVEvent: END:VEVENT not found'); + + // Insert before first sub-component (VALARM etc.) or before END:VEVENT + const insertIdx = firstSubComponent !== -1 ? firstSubComponent : veventEnd; + const newLines = newLine.split(/\r?\n/); + lines.splice(insertIdx, 0, ...newLines); + return lines.join(lineEnding); +} + +/** + * Remove orphaned VTIMEZONE blocks whose TZID has no remaining references + * in the file (outside VTIMEZONE blocks themselves). + */ +export function removeOrphanedVTimezones(icalData: string): string { + const lineEnding = detectLineEnding(icalData); + const lines = icalData.split(/\r?\n/); + + // Find all VTIMEZONE blocks and their TZIDs + const tzBlocks: Array<{ tzid: string; start: number; end: number }> = []; + for (let i = 0; i < lines.length; i++) { + if (lines[i].trim() === 'BEGIN:VTIMEZONE') { + const blockStart = i; + let blockEnd = -1; + for (let j = i + 1; j < lines.length; j++) { + if (lines[j].trim() === 'END:VTIMEZONE') { + blockEnd = j; + break; + } + } + if (blockEnd === -1) { i = lines.length; break; } + // Use parseICalValue for proper unfolding support + const tzBlock = lines.slice(blockStart, blockEnd + 1).join('\n'); + const tzid = parseICalValue(tzBlock, 'TZID') || ''; + tzBlocks.push({ tzid, start: blockStart, end: blockEnd }); + i = blockEnd; + } + } + + if (tzBlocks.length === 0) return icalData; + + // Build content outside VTIMEZONE blocks for reference scanning + const nonTzLines: string[] = []; + let inTz = false; + for (let i = 0; i < lines.length; i++) { + if (lines[i].trim() === 'BEGIN:VTIMEZONE') { inTz = true; continue; } + if (lines[i].trim() === 'END:VTIMEZONE') { inTz = false; continue; } + if (!inTz) nonTzLines.push(lines[i]); + } + const nonTzContent = nonTzLines.join('\n'); + + // Check each VTIMEZONE for references + const orphaned = tzBlocks.filter(tz => { + if (!tz.tzid) return false; + // Check for ;TZID= references in any property + return !nonTzContent.includes(`;TZID=${tz.tzid}`); + }); + + // Remove orphaned blocks in reverse order + for (let i = orphaned.length - 1; i >= 0; i--) { + lines.splice(orphaned[i].start, orphaned[i].end - orphaned[i].start + 1); + } + + return lines.join(lineEnding); +} + +/** + * Remove exception VEVENT blocks whose RECURRENCE-ID matches one of the orphaned dates. + * Operates on the full iCal string. Never touches the master VEVENT (no RECURRENCE-ID). + */ +export function removeExceptionVEvents(icalData: string, orphanedRecurrenceIds: Date[]): string { + if (orphanedRecurrenceIds.length === 0) return icalData; + + const lineEnding = detectLineEnding(icalData); + const lines = icalData.split(/\r?\n/); + + // Find all VEVENT blocks + const veventBlocks: Array<{ start: number; end: number; recurrenceId?: string }> = []; + for (let i = 0; i < lines.length; i++) { + if (lines[i].trim() === 'BEGIN:VEVENT') { + const blockStart = i; + for (let j = i + 1; j < lines.length; j++) { + if (lines[j].trim() === 'END:VEVENT') { + // Extract RECURRENCE-ID using parseICalValue for consistency + // with the orphan detection code path (handles unfolding) + const veventText = lines.slice(blockStart, j + 1).join('\n'); + const recId = parseICalValue(veventText, 'RECURRENCE-ID'); + veventBlocks.push({ start: blockStart, end: j, recurrenceId: recId }); + i = j; + break; + } + } + } + } + + // Only remove exception VEVENTs (those with RECURRENCE-ID) that are orphaned. + // Compare on ISO date strings (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS) to avoid + // timezone interpretation issues (floating vs UTC) with millisecond comparison. + const orphanedDateStrings = orphanedRecurrenceIds.map(d => { + // Normalize to ISO date string for comparison + return d.toISOString().replace(/\.\d{3}Z$/, 'Z'); + }); + const toRemove = veventBlocks.filter(block => { + if (!block.recurrenceId) return false; // master VEVENT — never remove + const recIdFormatted = formatICalDate(block.recurrenceId); + if (!recIdFormatted) return false; + // Compare as UTC ISO string — handles both date-only and datetime + const recDate = new Date(recIdFormatted); + const recDateStr = recDate.toISOString().replace(/\.\d{3}Z$/, 'Z'); + return orphanedDateStrings.includes(recDateStr); + }); + + // Remove in reverse order + for (let i = toRemove.length - 1; i >= 0; i--) { + lines.splice(toRemove[i].start, toRemove[i].end - toRemove[i].start + 1); + } + + return lines.join(lineEnding); +} + +/** + * Parse an iCalendar DURATION value and compute end datetime. + * RFC 5545 §3.3.6: [+/-]P[nW | nDTnHnMnS] + * Returns ISO 8601 end datetime, or undefined for malformed input. + */ +export function parseICalDuration(duration: string, start: string): string | undefined { + const m = duration.match(/^([+-])?P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/); + if (!m) return undefined; + + const [, sign, weeks, days, hours, minutes, seconds] = m; + + // At least one component must be present (reject bare "P") + if (!weeks && !days && !hours && !minutes && !seconds) return undefined; + // If T is present in input, at least one time component must exist (reject "P1DT") + if (duration.includes('T') && !hours && !minutes && !seconds) return undefined; + + const ms = + (parseInt(weeks || '0', 10) * 7 * 86400000) + + (parseInt(days || '0', 10) * 86400000) + + (parseInt(hours || '0', 10) * 3600000) + + (parseInt(minutes || '0', 10) * 60000) + + (parseInt(seconds || '0', 10) * 1000); + + const startDate = new Date(start); + if (isNaN(startDate.getTime())) return undefined; + + const endMs = sign === '-' ? startDate.getTime() - ms : startDate.getTime() + ms; + const endDate = new Date(endMs); + + // Return in same format as input start + if (/^\d{4}-\d{2}-\d{2}$/.test(start)) { + // Date-only: return date-only + return endDate.toISOString().slice(0, 10); + } + + // Floating time (no Z, no offset): return floating to match start format. + // new Date() interprets floating as local, so we add the duration in ms + // and format back as floating by doing manual arithmetic instead of toISOString(). + const isFloating = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(start); + if (isFloating) { + // Parse start components directly to avoid local-time interpretation + const [datePart, timePart] = start.split('T'); + const [y, mo, d] = datePart.split('-').map(Number); + const [h, mi, s] = timePart.split(':').map(Number); + const utcStart = Date.UTC(y, mo - 1, d, h, mi, s); + const utcEnd = sign === '-' ? utcStart - ms : utcStart + ms; + const e = new Date(utcEnd); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${e.getUTCFullYear()}-${pad(e.getUTCMonth() + 1)}-${pad(e.getUTCDate())}T${pad(e.getUTCHours())}:${pad(e.getUTCMinutes())}:${pad(e.getUTCSeconds())}`; + } + + return endDate.toISOString().replace(/\.\d{3}Z$/, 'Z'); +} + +export function parseCalendarObject(obj: DAVCalendarObject, options?: { includeParticipants?: boolean }): CalendarEvent { const vevent = extractVEvent(obj.data || ''); + if (!vevent) { + // No VEVENT found — return minimal event + return { + id: obj.url || '', + url: obj.url || '', + title: 'Untitled', + }; + } + const title = parseICalValue(vevent, 'SUMMARY') || 'Untitled'; const description = parseICalValue(vevent, 'DESCRIPTION'); const rawStart = parseICalValue(vevent, 'DTSTART'); - const rawEnd = parseICalValue(vevent, 'DTEND'); + let rawEnd = parseICalValue(vevent, 'DTEND'); const location = parseICalValue(vevent, 'LOCATION'); const uid = parseICalValue(vevent, 'UID') || obj.url || ''; - return { + // DURATION parsing: compute end from start + duration if DTEND absent + if (!rawEnd && rawStart) { + const rawDuration = parseICalValue(vevent, 'DURATION'); + if (rawDuration) { + const startIso = formatICalDate(rawStart); + if (startIso) { + const computedEnd = parseICalDuration(rawDuration, startIso); + if (computedEnd) { + // computedEnd is already ISO format, return it directly + const event: CalendarEvent = { + id: uid, + url: obj.url || '', + title: unescapeICalText(title), + description: description ? unescapeICalText(description) : undefined, + start: formatICalDate(rawStart), + end: computedEnd, + location: location ? unescapeICalText(location) : undefined, + }; + if (options?.includeParticipants) { + addParticipantsToEvent(event, vevent); + } + return event; + } + } + } + } + + const event: CalendarEvent = { id: uid, url: obj.url || '', title: unescapeICalText(title), @@ -110,6 +651,23 @@ export function parseCalendarObject(obj: DAVCalendarObject): CalendarEvent { end: formatICalDate(rawEnd), location: location ? unescapeICalText(location) : undefined, }; + + if (options?.includeParticipants) { + addParticipantsToEvent(event, vevent); + } + + return event; +} + +function addParticipantsToEvent(event: CalendarEvent, vevent: string): void { + const attendeeLines = parseAllICalProperties(vevent, 'ATTENDEE'); + if (attendeeLines.length > 0) { + event.participants = attendeeLines.map(parseAttendee); + } + const organizerLines = parseAllICalProperties(vevent, 'ORGANIZER'); + if (organizerLines.length > 0) { + event.organizer = parseAttendee(organizerLines[0]); + } } /** @@ -136,6 +694,135 @@ export function escapeICalText(value: string): string { .replace(/\r?\n/g, '\\n'); } +/** + * Validate an email address for use in ATTENDEE lines. + * Prevents iCal property injection via malicious email values. + */ +export function validateAttendeeEmail(email: string): void { + if (!email || typeof email !== 'string') { + throw new Error('Participant email is required'); + } + if (!/^[^@]+@[^@]+$/.test(email)) { + throw new Error(`Invalid participant email: ${email}`); + } + if (/[\r\n:;"\\]|\s/.test(email)) { + throw new Error(`Invalid participant email (contains illegal characters): ${email}`); + } +} + +/** + * Quote a CN parameter value per RFC 5545 §3.2. + * Uses DQUOTE quoting (NOT escapeICalText backslash escaping). + * Literal DQUOTEs in the value are replaced with single quotes since + * RFC 5545 has no escape mechanism for DQUOTE inside quoted parameter values. + * RFC 6868 caret encoding (^') exists but is poorly adopted; + * single-quote replacement matches Python icalendar/Outlook behavior. + */ +export function quoteParamValue(value: string): string { + // Strip newlines to prevent iCal property injection via CN values + let cleaned = value.replace(/[\r\n]+/g, ' '); + // Replace literal double quotes with single quotes + cleaned = cleaned.replace(/"/g, "'"); + // Quote if contains comma, semicolon, colon, or if the original had double quotes + if (/[,;:]/.test(cleaned) || value.includes('"')) { + return `"${cleaned}"`; + } + return cleaned; +} + +/** + * Format a start/end input value into the correct iCal property line. + * Handles three cases: + * 1. Date-only (2026-04-01) → DTXXX;VALUE=DATE:20260401 + * 2. Floating time (2026-03-20T09:30:00) → preserve original TZID + * 3. UTC/offset (2026-03-20T09:30:00Z) → DTXXX:20260320T093000Z + */ +function formatDateTimeProperty( + propName: string, + value: string, + originalVevent: string | null, + lineEnding: string +): { line: string; isDateOnly: boolean } { + // Date-only + if (/^\d{4}-\d{2}-\d{2}$/.test(value)) { + const d = new Date(value); + if (isNaN(d.getTime()) || !d.toISOString().startsWith(value)) { + throw new Error(`Invalid date: ${value}`); + } + const icalDate = value.replace(/-/g, ''); + return { line: foldICalLine(`${propName};VALUE=DATE:${icalDate}`, lineEnding), isDateOnly: true }; + } + + // Floating time (no offset, no Z) + if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(value)) { + const icalTime = value.replace(/[-:]/g, ''); + // Try to preserve original TZID + if (originalVevent) { + const rawLines = parseAllICalProperties(originalVevent, propName); + if (rawLines.length > 0) { + const tzMatch = rawLines[0].match(/;TZID=([^;:]+)/); + if (tzMatch) { + return { line: foldICalLine(`${propName};TZID=${tzMatch[1]}:${icalTime}`, lineEnding), isDateOnly: false }; + } + } + // If propName is DTEND and no TZID found (DURATION-based), fall back to DTSTART's TZID + if (propName === 'DTEND') { + const startLines = parseAllICalProperties(originalVevent, 'DTSTART'); + if (startLines.length > 0) { + const tzMatch = startLines[0].match(/;TZID=([^;:]+)/); + if (tzMatch) { + return { line: foldICalLine(`${propName};TZID=${tzMatch[1]}:${icalTime}`, lineEnding), isDateOnly: false }; + } + } + } + } + // No TZID to preserve — emit as floating + return { line: foldICalLine(`${propName}:${icalTime}`, lineEnding), isDateOnly: false }; + } + + // UTC or offset — convert to UTC + return { line: foldICalLine(`${propName}:${toICalUTC(value)}`, lineEnding), isDateOnly: false }; +} + +/** + * Check if a raw iCal property line represents a date-only value (VALUE=DATE). + */ +function isDateOnlyProperty(rawLine: string): boolean { + return /;VALUE=DATE[;:]/.test(rawLine) || /;VALUE=DATE$/.test(rawLine); +} + +/** + * Validate that DTSTART and DTEND have consistent value types and DTEND > DTSTART. + */ +function validateDateConsistency( + startIsDateOnly: boolean | null, + endIsDateOnly: boolean | null, + startValue?: string, + endValue?: string +): void { + if (startIsDateOnly !== null && endIsDateOnly !== null) { + if (startIsDateOnly !== endIsDateOnly) { + throw new Error('DTSTART and DTEND must have the same value type (both date-only or both datetime) per RFC 5545 §3.6.1'); + } + } + + // Validate DTEND > DTSTART when both are date-only + if (startValue && endValue && startIsDateOnly && endIsDateOnly) { + if (startValue >= endValue) { + throw new Error( + `DTEND is exclusive per RFC 5545 — for a one-day event on ${startValue}, ` + + `pass end: '${nextDay(startValue)}'` + ); + } + } +} + +function nextDay(dateStr: string): string { + const d = new Date(dateStr); + d.setUTCDate(d.getUTCDate() + 1); + return d.toISOString().slice(0, 10); +} + export class CalDAVCalendarClient { private config: CalDAVConfig; private client: DAVClient | null = null; @@ -217,7 +904,7 @@ export class CalDAVCalendarClient { return allEvents.slice(0, limit); } - async getCalendarEventById(eventId: string): Promise { + private async findCalendarObjectByUID(eventId: string): Promise { const client = await this.getClient(); if (!this.calendars) { @@ -228,9 +915,10 @@ export class CalDAVCalendarClient { const objects = await client.fetchCalendarObjects({ calendar: cal }); for (const obj of objects) { const vevent = extractVEvent(obj.data || ''); + if (!vevent) continue; const uid = parseICalValue(vevent, 'UID'); if (uid === eventId || obj.url === eventId) { - return parseCalendarObject(obj); + return obj; } } } @@ -238,6 +926,11 @@ export class CalDAVCalendarClient { return null; } + async getCalendarEventById(eventId: string): Promise { + const obj = await this.findCalendarObjectByUID(eventId); + return obj ? parseCalendarObject(obj, { includeParticipants: true }) : null; + } + async createCalendarEvent(event: { calendarId: string; title: string; @@ -245,6 +938,7 @@ export class CalDAVCalendarClient { start: string; end: string; location?: string; + participants?: Array<{ email: string; name?: string }>; }): Promise { const client = await this.getClient(); @@ -261,21 +955,67 @@ export class CalDAVCalendarClient { const uid = `${Date.now()}-${Math.random().toString(36).slice(2)}@fastmail-mcp`; const now = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, ''); - const ical = [ + + // Format start/end with all-day event support + const startResult = formatDateTimeProperty('DTSTART', event.start, null, '\r\n'); + const endResult = formatDateTimeProperty('DTEND', event.end, null, '\r\n'); + + // Value type consistency check + validateDateConsistency( + startResult.isDateOnly, + endResult.isDateOnly, + event.start, + event.end + ); + + const icalLines = [ 'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//fastmail-mcp//CalDAV//EN', 'BEGIN:VEVENT', `UID:${uid}`, `DTSTAMP:${now}`, - `DTSTART:${event.start.replace(/[-:]/g, '')}`, - `DTEND:${event.end.replace(/[-:]/g, '')}`, - `SUMMARY:${escapeICalText(event.title)}`, - event.description ? `DESCRIPTION:${escapeICalText(event.description)}` : '', - event.location ? `LOCATION:${escapeICalText(event.location)}` : '', - 'END:VEVENT', - 'END:VCALENDAR', - ].filter(Boolean).join('\r\n'); + `LAST-MODIFIED:${now}`, + startResult.line, + endResult.line, + foldICalLine(`SUMMARY:${escapeICalText(event.title)}`), + ]; + + if (event.description) { + icalLines.push(foldICalLine(`DESCRIPTION:${escapeICalText(event.description)}`)); + } + if (event.location) { + icalLines.push(foldICalLine(`LOCATION:${escapeICalText(event.location)}`)); + } + + // Participant support + if (event.participants && event.participants.length > 0) { + // Validate all emails first + for (const p of event.participants) { + validateAttendeeEmail(p.email); + } + + // ORGANIZER required when ATTENDEEs present + const caldavUsername = this.config.username; + if (!caldavUsername.includes('@')) { + throw new Error('Cannot add participants: CalDAV username is not an email address, required for ORGANIZER'); + } + const displayName = process.env.FASTMAIL_CALDAV_DISPLAY_NAME || caldavUsername; + const cnPart = `;CN=${quoteParamValue(displayName)}`; + icalLines.push(foldICalLine(`ORGANIZER${cnPart}:mailto:${caldavUsername}`)); + + // ATTENDEE lines — do NOT emit RSVP=TRUE by default (RFC 5545 §3.2.17 defaults to FALSE) + for (const p of event.participants) { + const cnParam = p.name ? `;CN=${quoteParamValue(p.name)}` : ''; + icalLines.push(foldICalLine(`ATTENDEE${cnParam}:mailto:${p.email}`)); + } + } + + icalLines.push('END:VEVENT'); + icalLines.push('END:VCALENDAR'); + + // Trailing CRLF per RFC 5545 §3.1 + const ical = icalLines.join('\r\n') + '\r\n'; await client.createCalendarObject({ calendar: targetCal, @@ -285,4 +1025,228 @@ export class CalDAVCalendarClient { return uid; } + + async updateCalendarEvent(eventId: string, fields: { + title?: string; + description?: string; + start?: string; + end?: string; + location?: string; + participants?: Array<{ email: string; name?: string }>; + confirmRecurring?: boolean; + }): Promise { + const client = await this.getClient(); + const obj = await this.findCalendarObjectByUID(eventId); + if (!obj) { + throw new Error(`Calendar event not found: ${eventId}`); + } + + if (!obj.data || !obj.data.includes('BEGIN:VEVENT')) { + throw new Error('Cannot update event: no iCal data found'); + } + + // Validate date inputs early before any processing + const datePattern = /^\d{4}-\d{2}-\d{2}$/; + const dateTimePattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/; + if (fields.start !== undefined && !datePattern.test(fields.start) && !dateTimePattern.test(fields.start)) { + throw new Error(`Invalid start date format: ${fields.start}. Expected ISO 8601 (e.g. 2026-04-07T14:00:00Z or 2026-04-07)`); + } + if (fields.end !== undefined && !datePattern.test(fields.end) && !dateTimePattern.test(fields.end)) { + throw new Error(`Invalid end date format: ${fields.end}. Expected ISO 8601 (e.g. 2026-04-07T14:00:00Z or 2026-04-07)`); + } + + const lineEnding = detectLineEnding(obj.data); + const fold = (line: string) => foldICalLine(line, lineEnding); + + // Capture original VEVENT before any patching for reads + const originalVevent = extractVEvent(obj.data); + if (!originalVevent) { + throw new Error('Cannot update event: no VEVENT block found'); + } + + const existingUid = parseICalValue(originalVevent, 'UID') || eventId; + let data = obj.data; + + // --- Recurring event guard --- + const hasRRule = /^RRULE[;:]/m.test(originalVevent); + const isTimeChange = fields.start !== undefined || fields.end !== undefined; + + if (hasRRule && isTimeChange) { + // Find exception VEVENTs (same UID, have RECURRENCE-ID) + const allVevents = data.match(/BEGIN:VEVENT[\s\S]*?END:VEVENT/g) || []; + const exceptions = allVevents.filter((v: string) => /^RECURRENCE-ID[;:]/m.test(v)); + + if (exceptions.length > 0) { + // Check which exceptions would be orphaned + const rruleLine = parseICalValue(originalVevent, 'RRULE'); + const existingStart = parseICalValue(originalVevent, 'DTSTART'); + const newStartRaw = fields.start || (existingStart ? formatICalDate(existingStart) : undefined); + + if (rruleLine && newStartRaw) { + try { + // Convert offset-bearing timestamps to UTC before passing to rrule + let dtStartForRrule: string; + if (/[+-]\d{2}:\d{2}$/.test(newStartRaw)) { + dtStartForRrule = toICalUTC(newStartRaw).replace(/Z$/, ''); + } else { + dtStartForRrule = newStartRaw.replace(/[-:]/g, '').replace(/Z$/, ''); + } + const rruleString = `RRULE:${rruleLine}\nDTSTART:${dtStartForRrule}`; + const rule = rrulestr(rruleString, { forceset: false }); + + const orphanedDates: Date[] = []; + const validDates: Date[] = []; + for (const excVevent of exceptions) { + const recIdRaw = parseICalValue(excVevent, 'RECURRENCE-ID'); + if (!recIdRaw) continue; + const recIdFormatted = formatICalDate(recIdRaw); + if (!recIdFormatted) continue; + const recDate = new Date(recIdFormatted); + // Check if this recurrence-id still matches an occurrence + const matches = rule.between( + new Date(recDate.getTime() - 1000), + new Date(recDate.getTime() + 1000), + true + ); + if (matches.length === 0) { + orphanedDates.push(recDate); + } else { + validDates.push(recDate); + } + } + + if (orphanedDates.length > 0 && !fields.confirmRecurring) { + // List the orphaned exceptions + const dateList = orphanedDates.map(d => d.toISOString().slice(0, 10)).join(', '); + throw new Error( + `This recurring event has ${exceptions.length} exception(s). ` + + `Changing start/end will orphan ${orphanedDates.length} of them (${dateList}). ` + + `These will be removed to prevent server errors. Pass confirmRecurring: true to proceed.` + ); + } + + // If confirmRecurring, remove orphaned exceptions after patching + if (orphanedDates.length > 0 && fields.confirmRecurring) { + data = removeExceptionVEvents(data, orphanedDates); + } + } catch (e) { + if (e instanceof Error && e.message.includes('confirmRecurring')) throw e; + // If RRULE parsing fails, proceed without pruning (best effort) + } + } + } + } + + // --- Patch fields --- + let startIsDateOnly: boolean | null = null; + let endIsDateOnly: boolean | null = null; + let timeChanged = false; + + if (fields.title !== undefined) { + data = replaceICalProperty(data, 'SUMMARY', fold(`SUMMARY:${escapeICalText(fields.title)}`)); + } + + if (fields.description !== undefined) { + data = replaceICalProperty(data, 'DESCRIPTION', fold(`DESCRIPTION:${escapeICalText(fields.description)}`)); + } + + if (fields.start !== undefined) { + const result = formatDateTimeProperty('DTSTART', fields.start, originalVevent, lineEnding); + data = replaceICalProperty(data, 'DTSTART', result.line); + startIsDateOnly = result.isDateOnly; + timeChanged = true; + } + + if (fields.end !== undefined) { + const result = formatDateTimeProperty('DTEND', fields.end, originalVevent, lineEnding); + data = replaceICalProperty(data, 'DTEND', result.line); + endIsDateOnly = result.isDateOnly; + // Remove DURATION — DTEND and DURATION are mutually exclusive (RFC 5545 §3.6.1) + data = removeAllICalProperties(data, 'DURATION'); + timeChanged = true; + } + + // Value type consistency: check against existing properties when only one is provided + if (fields.start !== undefined && fields.end === undefined) { + const existingEndLines = parseAllICalProperties(originalVevent, 'DTEND'); + if (existingEndLines.length > 0) { + endIsDateOnly = isDateOnlyProperty(existingEndLines[0]); + validateDateConsistency(startIsDateOnly, endIsDateOnly); + } + } else if (fields.end !== undefined && fields.start === undefined) { + const existingStartLines = parseAllICalProperties(originalVevent, 'DTSTART'); + if (existingStartLines.length > 0) { + startIsDateOnly = isDateOnlyProperty(existingStartLines[0]); + validateDateConsistency(startIsDateOnly, endIsDateOnly); + } + } else if (fields.start !== undefined && fields.end !== undefined) { + validateDateConsistency(startIsDateOnly, endIsDateOnly, fields.start, fields.end); + } + + if (fields.location !== undefined) { + data = replaceICalProperty(data, 'LOCATION', fold(`LOCATION:${escapeICalText(fields.location)}`)); + } + + if (fields.participants !== undefined) { + // Validate emails + for (const p of fields.participants) { + validateAttendeeEmail(p.email); + } + // Remove all existing ATTENDEE lines (ORGANIZER is preserved) + data = removeAllICalProperties(data, 'ATTENDEE'); + // Build and insert all ATTENDEE lines in one pass + if (fields.participants.length > 0) { + const attendeeLines = fields.participants.map(p => { + const cnParam = p.name ? `;CN=${quoteParamValue(p.name)}` : ''; + return fold(`ATTENDEE${cnParam}:mailto:${p.email}`); + }).join(lineEnding); + data = insertBeforeEndVEvent(data, attendeeLines); + } + // Add ORGANIZER if absent and participants are being added (RFC 5545 §3.8.4.1) + if (fields.participants.length > 0 && !/^ORGANIZER[;:]/m.test(extractVEvent(data) || '')) { + const caldavUsername = this.config.username; + if (!caldavUsername.includes('@')) { + throw new Error('Cannot add participants: CalDAV username is not an email address, required for ORGANIZER'); + } + const displayName = process.env.FASTMAIL_CALDAV_DISPLAY_NAME || caldavUsername; + const cnPart = displayName ? `;CN=${quoteParamValue(displayName)}` : ''; + data = replaceICalProperty(data, 'ORGANIZER', fold(`ORGANIZER${cnPart}:mailto:${caldavUsername}`)); + } + } + + // --- SEQUENCE increment --- + const hasAttendees = /^ATTENDEE[;:]/m.test(originalVevent); + const schedulingSignificant = fields.start !== undefined || fields.end !== undefined || + fields.participants !== undefined || fields.location !== undefined; + + if (hasAttendees && schedulingSignificant) { + const existingSeq = parseInt(parseICalValue(originalVevent, 'SEQUENCE') || '0', 10) || 0; + data = replaceICalProperty(data, 'SEQUENCE', `SEQUENCE:${existingSeq + 1}`); + } + + // --- Update DTSTAMP and LAST-MODIFIED --- + const now = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, ''); + data = replaceICalProperty(data, 'DTSTAMP', `DTSTAMP:${now}`); + data = replaceICalProperty(data, 'LAST-MODIFIED', `LAST-MODIFIED:${now}`); + + // --- Orphaned VTIMEZONE cleanup (LAST — after all modifications) --- + if (timeChanged) { + data = removeOrphanedVTimezones(data); + } + + obj.data = data; + await client.updateCalendarObject({ calendarObject: obj }); + + return existingUid; + } + + async deleteCalendarEvent(eventId: string): Promise { + const client = await this.getClient(); + const obj = await this.findCalendarObjectByUID(eventId); + if (!obj) { + throw new Error(`Calendar event not found: ${eventId}`); + } + + await client.deleteCalendarObject({ calendarObject: obj }); + } } diff --git a/src/contacts-calendar.ts b/src/contacts-calendar.ts index 600e7f9..c6190d7 100644 --- a/src/contacts-calendar.ts +++ b/src/contacts-calendar.ts @@ -227,15 +227,19 @@ export class ContactsCalendarClient extends JmapClient { const session = await this.getSession(); - const eventObject = { + const eventObject: Record = { calendarId: event.calendarId, title: event.title, description: event.description || '', start: event.start, end: event.end, location: event.location || '', - participants: event.participants || [] }; + // TODO: participants should be an RFC 8984 object/map, not an array. + // TODO: startDate/endDate not passed through to JMAP. + if (event.participants?.length) { + eventObject.participants = event.participants; + } const request: JmapRequest = { using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:calendars'], diff --git a/src/index.ts b/src/index.ts index 4e7bc04..83c0703 100644 --- a/src/index.ts +++ b/src/index.ts @@ -505,7 +505,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { }, { name: 'get_calendar_event', - description: 'Get a specific calendar event by ID', + description: 'Get a specific calendar event by ID. Returns organizer and participants when available.', inputSchema: { type: 'object', properties: { @@ -519,7 +519,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { }, { name: 'create_calendar_event', - description: 'Create a new calendar event', + description: 'Create a new calendar event. Supports date-only (e.g. 2026-04-01) for all-day events. DTEND is exclusive per RFC 5545 — a one-day event on April 1 needs end: 2026-04-02.', inputSchema: { type: 'object', properties: { @@ -537,11 +537,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { }, start: { type: 'string', - description: 'Start time in ISO 8601 format', + description: 'Start time in ISO 8601 format (e.g. 2026-04-07T14:00:00Z) or date-only for all-day events (e.g. 2026-04-07)', }, end: { type: 'string', - description: 'End time in ISO 8601 format', + description: 'End time in ISO 8601 format. For all-day events, DTEND is exclusive — a one-day event on April 1 requires end: 2026-04-02', }, location: { type: 'string', @@ -552,16 +552,81 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { items: { type: 'object', properties: { - email: { type: 'string' }, - name: { type: 'string' } - } + email: { type: 'string', description: 'Participant email address' }, + name: { type: 'string', description: 'Participant display name (optional)' } + }, + required: ['email'], }, - description: 'Event participants (optional)', + description: 'Event participants (optional). Automatically adds ORGANIZER from CalDAV username.', }, }, required: ['calendarId', 'title', 'start', 'end'], }, }, + { + name: 'update_calendar_event', + description: 'Update an existing calendar event. Preserves all existing data (attendees, reminders, recurrence rules, etc.) not being changed. Floating times preserve the original timezone; explicit UTC/offset times convert to UTC. WARNING: providing participants replaces ALL existing attendee data (acceptance status, roles, etc.). participants: [] removes all attendees.', + inputSchema: { + type: 'object', + properties: { + eventId: { + type: 'string', + description: 'ID of the event to update', + }, + title: { + type: 'string', + description: 'New event title', + }, + description: { + type: 'string', + description: 'New event description', + }, + start: { + type: 'string', + description: 'New start time in ISO 8601 format. Floating times (no Z/offset) preserve original timezone', + }, + end: { + type: 'string', + description: 'New end time in ISO 8601 format. DTEND is exclusive per RFC 5545', + }, + location: { + type: 'string', + description: 'New event location', + }, + participants: { + type: 'array', + items: { + type: 'object', + properties: { + email: { type: 'string', description: 'Participant email address' }, + name: { type: 'string', description: 'Participant display name (optional)' } + }, + required: ['email'], + }, + description: 'Replaces ALL existing attendees. Empty array removes all attendees. Omit to preserve existing attendees.', + }, + confirmRecurring: { + type: 'boolean', + description: 'Required when changing start/end on a recurring event with exceptions. Acknowledges that orphaned exception overrides will be removed.', + }, + }, + required: ['eventId'], + }, + }, + { + name: 'delete_calendar_event', + description: 'Delete a calendar event by ID', + inputSchema: { + type: 'object', + properties: { + eventId: { + type: 'string', + description: 'ID of the event to delete', + }, + }, + required: ['eventId'], + }, + }, { name: 'list_identities', description: 'List sending identities (email addresses that can be used for sending)', @@ -1284,36 +1349,34 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { }; } + // Calendar operations use CalDAV directly. + // JMAP Calendars: spec not yet finalized, Fastmail has not enabled JMAP calendar support. + // Existing JMAP calendar code in contacts-calendar.ts has known bugs and must not be used. + // When Fastmail enables JMAP calendars: re-enable the path, fix to match finalized spec, + // do a parity pass with CalDAV implementation, and test against live Fastmail. + // CalDAV tests should be structured so they can serve as a basis for JMAP tests later. + case 'list_calendars': { - try { - const contactsClient = initializeContactsCalendarClient(); - const calendars = await contactsClient.getCalendars(); - return { content: [{ type: 'text', text: JSON.stringify(calendars, null, 2) }] }; - } catch { - // JMAP calendars not available, try CalDAV - const davClient = initializeCalDAVClient(); - if (!davClient) { - throw new McpError(ErrorCode.InvalidRequest, 'JMAP calendars not available and CalDAV not configured. Set FASTMAIL_CALDAV_USERNAME and FASTMAIL_CALDAV_PASSWORD to use CalDAV.'); - } - const calendars = await davClient.getCalendars(); - return { content: [{ type: 'text', text: JSON.stringify(calendars, null, 2) }] }; + const davClient = initializeCalDAVClient(); + if (!davClient) { + throw new McpError(ErrorCode.InvalidRequest, 'CalDAV not configured. Set FASTMAIL_CALDAV_USERNAME and FASTMAIL_CALDAV_PASSWORD.'); } + const calendars = await davClient.getCalendars(); + return { content: [{ type: 'text', text: JSON.stringify(calendars, null, 2) }] }; } case 'list_calendar_events': { const { calendarId, limit = 50, startDate, endDate } = args as any; - try { - const contactsClient = initializeContactsCalendarClient(); - const events = await contactsClient.getCalendarEvents(calendarId, limit); - return { content: [{ type: 'text', text: JSON.stringify(events, null, 2) }] }; - } catch { - const davClient = initializeCalDAVClient(); - if (!davClient) { - throw new McpError(ErrorCode.InvalidRequest, 'JMAP calendars not available and CalDAV not configured. Set FASTMAIL_CALDAV_USERNAME and FASTMAIL_CALDAV_PASSWORD to use CalDAV.'); - } - const events = await davClient.getCalendarEvents(calendarId, limit, startDate, endDate); - return { content: [{ type: 'text', text: JSON.stringify(events, null, 2) }] }; + // JMAP Calendars: disabled — spec not yet finalized, Fastmail has not enabled support. + // Existing JMAP calendar code in contacts-calendar.ts has known bugs. + // When Fastmail enables JMAP calendars: re-enable, fix to match finalized spec, + // do a parity pass with CalDAV implementation, and test against live Fastmail. + const davClient = initializeCalDAVClient(); + if (!davClient) { + throw new McpError(ErrorCode.InvalidRequest, 'CalDAV not configured. Set FASTMAIL_CALDAV_USERNAME and FASTMAIL_CALDAV_PASSWORD.'); } + const events = await davClient.getCalendarEvents(calendarId, limit, startDate, endDate); + return { content: [{ type: 'text', text: JSON.stringify(events, null, 2) }] }; } case 'get_calendar_event': { @@ -1321,18 +1384,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { if (!eventId) { throw new McpError(ErrorCode.InvalidParams, 'eventId is required'); } - try { - const contactsClient = initializeContactsCalendarClient(); - const event = await contactsClient.getCalendarEventById(eventId); - return { content: [{ type: 'text', text: JSON.stringify(event, null, 2) }] }; - } catch { - const davClient = initializeCalDAVClient(); - if (!davClient) { - throw new McpError(ErrorCode.InvalidRequest, 'JMAP calendars not available and CalDAV not configured. Set FASTMAIL_CALDAV_USERNAME and FASTMAIL_CALDAV_PASSWORD to use CalDAV.'); - } - const event = await davClient.getCalendarEventById(eventId); - return { content: [{ type: 'text', text: JSON.stringify(event, null, 2) }] }; + const davClient = initializeCalDAVClient(); + if (!davClient) { + throw new McpError(ErrorCode.InvalidRequest, 'CalDAV not configured. Set FASTMAIL_CALDAV_USERNAME and FASTMAIL_CALDAV_PASSWORD.'); } + const event = await davClient.getCalendarEventById(eventId); + return { content: [{ type: 'text', text: JSON.stringify(event, null, 2) }] }; } case 'create_calendar_event': { @@ -1340,22 +1397,44 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { if (!calendarId || !title || !start || !end) { throw new McpError(ErrorCode.InvalidParams, 'calendarId, title, start, and end are required'); } - try { - const contactsClient = initializeContactsCalendarClient(); - const eventId = await contactsClient.createCalendarEvent({ - calendarId, title, description, start, end, location, participants, - }); - return { content: [{ type: 'text', text: `Calendar event created successfully. Event ID: ${eventId}` }] }; - } catch { - const davClient = initializeCalDAVClient(); - if (!davClient) { - throw new McpError(ErrorCode.InvalidRequest, 'JMAP calendars not available and CalDAV not configured. Set FASTMAIL_CALDAV_USERNAME and FASTMAIL_CALDAV_PASSWORD to use CalDAV.'); - } - const eventId = await davClient.createCalendarEvent({ - calendarId, title, description, start, end, location, - }); - return { content: [{ type: 'text', text: `Calendar event created via CalDAV. Event ID: ${eventId}` }] }; + const davClient = initializeCalDAVClient(); + if (!davClient) { + throw new McpError(ErrorCode.InvalidRequest, 'CalDAV not configured. Set FASTMAIL_CALDAV_USERNAME and FASTMAIL_CALDAV_PASSWORD.'); + } + const eventId = await davClient.createCalendarEvent({ + calendarId, title, description, start, end, location, participants, + }); + return { content: [{ type: 'text', text: `Calendar event created. Event ID: ${eventId}` }] }; + } + + case 'update_calendar_event': { + const { eventId, title, description, start, end, location, participants, confirmRecurring } = args as any; + if (!eventId) { + throw new McpError(ErrorCode.InvalidParams, 'eventId is required'); + } + if (title === undefined && description === undefined && start === undefined && end === undefined && location === undefined && participants === undefined) { + throw new McpError(ErrorCode.InvalidParams, 'At least one field to update must be provided (title, description, start, end, location, or participants)'); + } + const davClient = initializeCalDAVClient(); + if (!davClient) { + throw new McpError(ErrorCode.InvalidRequest, 'CalDAV not configured. Set FASTMAIL_CALDAV_USERNAME and FASTMAIL_CALDAV_PASSWORD.'); + } + const fields = { title, description, start, end, location, participants, confirmRecurring }; + await davClient.updateCalendarEvent(eventId, fields); + return { content: [{ type: 'text', text: `Calendar event updated. Event ID: ${eventId}` }] }; + } + + case 'delete_calendar_event': { + const { eventId } = args as any; + if (!eventId) { + throw new McpError(ErrorCode.InvalidParams, 'eventId is required'); + } + const davClient = initializeCalDAVClient(); + if (!davClient) { + throw new McpError(ErrorCode.InvalidRequest, 'CalDAV not configured. Set FASTMAIL_CALDAV_USERNAME and FASTMAIL_CALDAV_PASSWORD.'); } + await davClient.deleteCalendarEvent(eventId); + return { content: [{ type: 'text', text: `Calendar event deleted. Event ID: ${eventId}` }] }; } case 'list_identities': { From fd38a8f3b9688947ac93723b13b9480ca635e15a Mon Sep 17 00:00:00 2001 From: Jonathan Godley Date: Fri, 3 Apr 2026 22:26:37 +1100 Subject: [PATCH 2/3] Docs: document rrule dependency rationale and fix env-dependent test --- src/caldav-client.test.ts | 3 ++- src/caldav-client.ts | 6 ++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/caldav-client.test.ts b/src/caldav-client.test.ts index f289ad4..c4ecd45 100644 --- a/src/caldav-client.test.ts +++ b/src/caldav-client.test.ts @@ -1600,7 +1600,8 @@ describe('CalDAVCalendarClient.createCalendarEvent with participants', () => { }); const ical = mockDAVClient.createCalendarObject.mock.calls[0].arguments[0].iCalString; - assert.ok(ical.includes('ORGANIZER;CN=me@fastmail.com:mailto:me@fastmail.com')); + assert.ok(ical.includes(':mailto:me@fastmail.com'), 'ORGANIZER should have mailto URI'); + assert.ok(/ORGANIZER;CN=.+:mailto:me@fastmail.com/.test(ical), 'ORGANIZER should have CN parameter'); assert.ok(ical.includes('ATTENDEE;CN=Alice:mailto:alice@example.com')); assert.ok(ical.includes('ATTENDEE;CN=Bob:mailto:bob@example.com')); }); diff --git a/src/caldav-client.ts b/src/caldav-client.ts index 4f64973..5fc6893 100644 --- a/src/caldav-client.ts +++ b/src/caldav-client.ts @@ -1,4 +1,10 @@ import { DAVClient, DAVCalendar, DAVCalendarObject } from 'tsdav'; +// rrule is used for RRULE expansion when detecting orphaned exception VEVENTs +// during recurring event time changes. This allows selective pruning — only +// exceptions whose RECURRENCE-ID no longer matches a valid occurrence are removed. +// If this dependency is undesirable, the alternative is to remove ALL exception +// VEVENTs when start/end changes on a recurring event (matching Google Calendar +// behavior). The rrule package has a single transitive dependency (tslib). import rruleLib from 'rrule'; const { rrulestr } = rruleLib; From de56612ab259a0ce9b803587f1c553aeb5465906 Mon Sep 17 00:00:00 2001 From: Jonathan Godley Date: Mon, 22 Jun 2026 18:18:34 +1000 Subject: [PATCH 3/3] Fix: reject empty-string field overwrites + add clearFields; strip orphaned ORGANIZER (#56) update_calendar_event previously overwrote title/description/location to empty when passed "" (guarding only on !== undefined), and would crash on null. Empty, whitespace-only, and null values on those fields are now loud-rejected with a per-field error ("omit the field to leave it unchanged"). Dates already threw on bad input and are unchanged. To deliberately delete an optional field, pass it in the new clearFields array (allowed: description, location). Setting and clearing the same field in one call is rejected. Also fixes a related malformed-VEVENT bug: participants: [] removed all ATTENDEEs but left a dangling ORGANIZER. It now strips ORGANIZER too, gated on the empty case so the participant-add path still emits an ORGANIZER. Adds requireNonEmpty / validateClearFields helpers (exported, unit-tested) and flips the existing "preserves ORGANIZER" test to assert removal. Closes #56. --- README.md | 4 +- src/caldav-client.test.ts | 112 +++++++++++++++++++++++++++++++++++++- src/caldav-client.ts | 64 ++++++++++++++++++++-- src/index.ts | 16 ++++-- 4 files changed, 183 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 8392cc7..8291c44 100644 --- a/README.md +++ b/README.md @@ -219,8 +219,8 @@ You can install this server as a Desktop Extension for Claude Desktop using the - Parameters: `eventId` (required) - **create_calendar_event**: Create a new calendar event. Supports date-only (e.g. `2026-04-01`) for all-day events. DTEND is exclusive per RFC 5545 — a one-day event on April 1 needs `end: "2026-04-02"`. - Parameters: `calendarId` (required), `title` (required), `description` (optional), `start` (required, ISO 8601 or date-only), `end` (required, ISO 8601 or date-only), `location` (optional), `participants` (optional array of `{email, name?}`) -- **update_calendar_event**: Patch an existing calendar event. Preserves all existing data (attendees, reminders, recurrence rules, etc.) not being changed. Floating times (no Z/offset) preserve the original timezone. WARNING: providing `participants` replaces ALL existing attendee data; `participants: []` removes all attendees. - - Parameters: `eventId` (required), `title`, `description`, `start`, `end`, `location`, `participants` (array of `{email, name?}`), `confirmRecurring` (boolean) +- **update_calendar_event**: Patch an existing calendar event. Preserves all existing data (attendees, reminders, recurrence rules, etc.) not being changed. Omit a field to leave it unchanged; passing an empty or whitespace-only string for `title`, `description`, or `location` is rejected (it won't silently blank the property). To delete `description` or `location`, list them in `clearFields`. Floating times (no Z/offset) preserve the original timezone. WARNING: providing `participants` replaces ALL existing attendee data; `participants: []` removes all attendees (and the now-orphaned ORGANIZER). + - Parameters: `eventId` (required), `title`, `description`, `start`, `end`, `location`, `participants` (array of `{email, name?}`), `clearFields` (array of `"description"`/`"location"` to delete), `confirmRecurring` (boolean) - **delete_calendar_event**: Delete a calendar event - Parameters: `eventId` (required) diff --git a/src/caldav-client.test.ts b/src/caldav-client.test.ts index c4ecd45..5a1e698 100644 --- a/src/caldav-client.test.ts +++ b/src/caldav-client.test.ts @@ -21,9 +21,48 @@ import { insertBeforeEndVEvent, validateAttendeeEmail, quoteParamValue, + requireNonEmpty, + validateClearFields, CalDAVCalendarClient, } from './caldav-client.js'; +describe('requireNonEmpty', () => { + it('returns the trimmed value for a normal string', () => { + assert.equal(requireNonEmpty(' hello ', 'title'), 'hello'); + }); + + it('throws for empty, whitespace-only, null, and undefined, naming the field', () => { + assert.throws(() => requireNonEmpty('', 'title'), /title cannot be empty/); + assert.throws(() => requireNonEmpty(' ', 'title'), /title cannot be empty/); + assert.throws(() => requireNonEmpty(null, 'location'), /location cannot be empty; omit the field to leave it unchanged/); + assert.throws(() => requireNonEmpty(undefined, 'description'), /description cannot be empty/); + }); +}); + +describe('validateClearFields', () => { + const allowed = new Set(['description', 'location']); + + it('no-ops on empty or undefined input', () => { + assert.doesNotThrow(() => validateClearFields([], allowed, new Set())); + assert.doesNotThrow(() => validateClearFields(undefined, allowed, new Set())); + }); + + it('accepts an allowed field that is not also being set', () => { + assert.doesNotThrow(() => validateClearFields(['location'], allowed, new Set(['title']))); + }); + + it('rejects a field not in the allowed set, listing the allowed set', () => { + assert.throws(() => validateClearFields(['title'], allowed, new Set()), /description, location/); + }); + + it('rejects a field that is both set and cleared', () => { + assert.throws( + () => validateClearFields(['description'], allowed, new Set(['description'])), + /cannot both set and clear description/ + ); + }); +}); + describe('extractVEvent', () => { it('extracts VEVENT block from iCalendar data', () => { const ical = [ @@ -1247,7 +1286,7 @@ describe('CalDAVCalendarClient.updateCalendarEvent (patch-based)', () => { assert.ok(updatedData.includes('DURATION:PT2H')); }); - it('participants: [] removes all ATTENDEEs, preserves ORGANIZER', async () => { + it('participants: [] removes all ATTENDEEs and the now-orphaned ORGANIZER', async () => { const ical = makeRichIcal('evt5@fm'); const objects = [{ data: ical, url: '/cal/evt5.ics' }]; const { client, mockDAVClient } = createMockedPatchClient(objects); @@ -1256,7 +1295,76 @@ describe('CalDAVCalendarClient.updateCalendarEvent (patch-based)', () => { const updatedData = mockDAVClient.updateCalendarObject.mock.calls[0].arguments[0].calendarObject.data; assert.ok(!updatedData.includes('ATTENDEE')); - assert.ok(updatedData.includes('ORGANIZER')); + // An ORGANIZER with no ATTENDEEs is a malformed scheduling VEVENT, so it is stripped too. + assert.ok(!updatedData.includes('ORGANIZER')); + }); + + it('rejects empty/whitespace/null title, description, location without writing', async () => { + for (const fields of [ + { title: '' }, + { title: ' ' }, + { title: null as any }, + { description: '' }, + { description: ' ' }, + { location: '' }, + ]) { + const ical = makeRichIcal('evtA@fm'); + const objects = [{ data: ical, url: '/cal/evtA.ics' }]; + const { client, mockDAVClient } = createMockedPatchClient(objects); + await assert.rejects(() => client.updateCalendarEvent('evtA@fm', fields), /cannot be empty/); + assert.equal(mockDAVClient.updateCalendarObject.mock.calls.length, 0); + } + }); + + it('updating only participants preserves SUMMARY/DESCRIPTION/LOCATION', async () => { + const ical = makeRichIcal('evtB@fm'); + const objects = [{ data: ical, url: '/cal/evtB.ics' }]; + const { client, mockDAVClient } = createMockedPatchClient(objects); + + await client.updateCalendarEvent('evtB@fm', { participants: [{ email: 'carol@example.com' }] }); + + const updatedData = mockDAVClient.updateCalendarObject.mock.calls[0].arguments[0].calendarObject.data; + assert.ok(updatedData.includes('SUMMARY:Original Title')); + assert.ok(updatedData.includes('DESCRIPTION:Original description')); + assert.ok(updatedData.includes('LOCATION:Room A')); + }); + + it('clearFields: ["location"] removes the LOCATION line', async () => { + const ical = makeRichIcal('evtC@fm'); + const objects = [{ data: ical, url: '/cal/evtC.ics' }]; + const { client, mockDAVClient } = createMockedPatchClient(objects); + + await client.updateCalendarEvent('evtC@fm', { clearFields: ['location'] }); + + const updatedData = mockDAVClient.updateCalendarObject.mock.calls[0].arguments[0].calendarObject.data; + assert.ok(!updatedData.includes('LOCATION:')); + // Other content untouched + assert.ok(updatedData.includes('SUMMARY:Original Title')); + assert.ok(updatedData.includes('DESCRIPTION:Original description')); + }); + + it('clearFields rejects a non-clearable field (title) without writing', async () => { + const ical = makeRichIcal('evtD@fm'); + const objects = [{ data: ical, url: '/cal/evtD.ics' }]; + const { client, mockDAVClient } = createMockedPatchClient(objects); + + await assert.rejects( + () => client.updateCalendarEvent('evtD@fm', { clearFields: ['title'] }), + /Cannot clear "title"/ + ); + assert.equal(mockDAVClient.updateCalendarObject.mock.calls.length, 0); + }); + + it('rejects setting and clearing the same field', async () => { + const ical = makeRichIcal('evtE@fm'); + const objects = [{ data: ical, url: '/cal/evtE.ics' }]; + const { client, mockDAVClient } = createMockedPatchClient(objects); + + await assert.rejects( + () => client.updateCalendarEvent('evtE@fm', { description: 'x', clearFields: ['description'] }), + /cannot both set and clear description/ + ); + assert.equal(mockDAVClient.updateCalendarObject.mock.calls.length, 0); }); it('floating end time preserves DTEND TZID', async () => { diff --git a/src/caldav-client.ts b/src/caldav-client.ts index 5fc6893..001db8f 100644 --- a/src/caldav-client.ts +++ b/src/caldav-client.ts @@ -700,6 +700,36 @@ export function escapeICalText(value: string): string { .replace(/\r?\n/g, '\\n'); } +/** + * Loud-reject a settable string field that was provided but is empty, + * whitespace-only, or null. Callers invoke this only for fields that were + * actually present (!== undefined), so silently omitting a field stays distinct + * from explicitly blanking it. Returns the trimmed value. + */ +export function requireNonEmpty(value: unknown, fieldName: string): string { + if (typeof value !== 'string' || value.trim() === '') { + throw new Error(`${fieldName} cannot be empty; omit the field to leave it unchanged`); + } + return value.trim(); +} + +/** + * Validate a clearFields list: every entry must be in the allowed set, and no + * entry may also appear as a settable param (can't both set and clear a field). + * No-op when clearFields is empty/undefined. + */ +export function validateClearFields(clearFields: string[] | undefined, allowed: Set, provided: Set): void { + if (!clearFields || clearFields.length === 0) return; + for (const field of clearFields) { + if (!allowed.has(field)) { + throw new Error(`Cannot clear "${field}"; clearable fields are: ${[...allowed].join(', ')}`); + } + if (provided.has(field)) { + throw new Error(`cannot both set and clear ${field}; pass it as a value or in clearFields, not both`); + } + } +} + /** * Validate an email address for use in ATTENDEE lines. * Prevents iCal property injection via malicious email values. @@ -1039,6 +1069,7 @@ export class CalDAVCalendarClient { end?: string; location?: string; participants?: Array<{ email: string; name?: string }>; + clearFields?: string[]; confirmRecurring?: boolean; }): Promise { const client = await this.getClient(); @@ -1061,6 +1092,14 @@ export class CalDAVCalendarClient { throw new Error(`Invalid end date format: ${fields.end}. Expected ISO 8601 (e.g. 2026-04-07T14:00:00Z or 2026-04-07)`); } + // Validate clearFields: only the optional, string-settable, not-otherwise- + // clearable fields may be cleared, and a field can't be both set and cleared. + const CLEARABLE_FIELDS = new Set(['description', 'location']); + const providedStringFields = new Set(); + if (fields.description !== undefined) providedStringFields.add('description'); + if (fields.location !== undefined) providedStringFields.add('location'); + validateClearFields(fields.clearFields, CLEARABLE_FIELDS, providedStringFields); + const lineEnding = detectLineEnding(obj.data); const fold = (line: string) => foldICalLine(line, lineEnding); @@ -1149,11 +1188,13 @@ export class CalDAVCalendarClient { let timeChanged = false; if (fields.title !== undefined) { - data = replaceICalProperty(data, 'SUMMARY', fold(`SUMMARY:${escapeICalText(fields.title)}`)); + const title = requireNonEmpty(fields.title, 'title'); + data = replaceICalProperty(data, 'SUMMARY', fold(`SUMMARY:${escapeICalText(title)}`)); } if (fields.description !== undefined) { - data = replaceICalProperty(data, 'DESCRIPTION', fold(`DESCRIPTION:${escapeICalText(fields.description)}`)); + const description = requireNonEmpty(fields.description, 'description'); + data = replaceICalProperty(data, 'DESCRIPTION', fold(`DESCRIPTION:${escapeICalText(description)}`)); } if (fields.start !== undefined) { @@ -1190,7 +1231,16 @@ export class CalDAVCalendarClient { } if (fields.location !== undefined) { - data = replaceICalProperty(data, 'LOCATION', fold(`LOCATION:${escapeICalText(fields.location)}`)); + const location = requireNonEmpty(fields.location, 'location'); + data = replaceICalProperty(data, 'LOCATION', fold(`LOCATION:${escapeICalText(location)}`)); + } + + // Clear requested fields by removing the property line entirely. + if (fields.clearFields && fields.clearFields.length > 0) { + const KEY_BY_FIELD: Record = { description: 'DESCRIPTION', location: 'LOCATION' }; + for (const field of fields.clearFields) { + data = replaceICalProperty(data, KEY_BY_FIELD[field], null); + } } if (fields.participants !== undefined) { @@ -1198,8 +1248,14 @@ export class CalDAVCalendarClient { for (const p of fields.participants) { validateAttendeeEmail(p.email); } - // Remove all existing ATTENDEE lines (ORGANIZER is preserved) + // Remove all existing ATTENDEE lines data = removeAllICalProperties(data, 'ATTENDEE'); + // Clearing all participants must also strip ORGANIZER — an ORGANIZER with + // no ATTENDEEs is a malformed scheduling VEVENT (RFC 5545 §3.8.4.3). On the + // length>0 path below the ORGANIZER is re-added, so this is gated to ===0. + if (fields.participants.length === 0) { + data = removeAllICalProperties(data, 'ORGANIZER'); + } // Build and insert all ATTENDEE lines in one pass if (fields.participants.length > 0) { const attendeeLines = fields.participants.map(p => { diff --git a/src/index.ts b/src/index.ts index 83c0703..bceb410 100644 --- a/src/index.ts +++ b/src/index.ts @@ -565,7 +565,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { }, { name: 'update_calendar_event', - description: 'Update an existing calendar event. Preserves all existing data (attendees, reminders, recurrence rules, etc.) not being changed. Floating times preserve the original timezone; explicit UTC/offset times convert to UTC. WARNING: providing participants replaces ALL existing attendee data (acceptance status, roles, etc.). participants: [] removes all attendees.', + description: 'Update an existing calendar event. Preserves all existing data (attendees, reminders, recurrence rules, etc.) not being changed. Omit a field to leave it unchanged; passing an empty/whitespace string for title, description, or location is rejected (use clearFields to delete description/location). Floating times preserve the original timezone; explicit UTC/offset times convert to UTC. WARNING: providing participants replaces ALL existing attendee data (acceptance status, roles, etc.). participants: [] removes all attendees.', inputSchema: { type: 'object', properties: { @@ -605,6 +605,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { }, description: 'Replaces ALL existing attendees. Empty array removes all attendees. Omit to preserve existing attendees.', }, + clearFields: { + type: 'array', + items: { type: 'string', enum: ['description', 'location'] }, + description: 'Property names to delete from the event. Allowed: description, location. Cannot also pass the same field as a value.', + }, confirmRecurring: { type: 'boolean', description: 'Required when changing start/end on a recurring event with exceptions. Acknowledges that orphaned exception overrides will be removed.', @@ -1408,18 +1413,19 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { } case 'update_calendar_event': { - const { eventId, title, description, start, end, location, participants, confirmRecurring } = args as any; + const { eventId, title, description, start, end, location, participants, clearFields, confirmRecurring } = args as any; if (!eventId) { throw new McpError(ErrorCode.InvalidParams, 'eventId is required'); } - if (title === undefined && description === undefined && start === undefined && end === undefined && location === undefined && participants === undefined) { - throw new McpError(ErrorCode.InvalidParams, 'At least one field to update must be provided (title, description, start, end, location, or participants)'); + const hasClearFields = Array.isArray(clearFields) && clearFields.length > 0; + if (title === undefined && description === undefined && start === undefined && end === undefined && location === undefined && participants === undefined && !hasClearFields) { + throw new McpError(ErrorCode.InvalidParams, 'At least one field to update must be provided (title, description, start, end, location, participants, or clearFields)'); } const davClient = initializeCalDAVClient(); if (!davClient) { throw new McpError(ErrorCode.InvalidRequest, 'CalDAV not configured. Set FASTMAIL_CALDAV_USERNAME and FASTMAIL_CALDAV_PASSWORD.'); } - const fields = { title, description, start, end, location, participants, confirmRecurring }; + const fields = { title, description, start, end, location, participants, clearFields, confirmRecurring }; await davClient.updateCalendarEvent(eventId, fields); return { content: [{ type: 'text', text: `Calendar event updated. Event ID: ${eventId}` }] }; }