Skip to content

Commit f1e8e3f

Browse files
fix(caldav): consistent UIDs and VTIMEZONE in iCalendar output (calcom#28115)
* fix(caldav): use consistent UIDs and inject VTIMEZONE in iCalendar output Two remaining CalDAV interop issues from calcom#9485: 1. UID consistency: use the booking's canonical UID (event.uid) instead of always generating a new random UUID. CalDAV servers use UID as the event identifier — different UIDs cause duplicate calendar entries. 2. VTIMEZONE injection: the ics library generates UTC times with no VTIMEZONE block. CalDAV servers like Fastmail read this as UTC and send scheduling emails with wrong times. Per RFC 5545 §3.6.5, DTSTART with TZID requires a matching VTIMEZONE component. We now build a proper VTIMEZONE using binary-searched DST transitions for the event's year, handling Northern/Southern hemisphere correctly. * fix: use pre-transition offset for VTIMEZONE DTSTART per RFC 5545 The DTSTART in VTIMEZONE components must represent the local time interpreted with the pre-transition offset (TZOFFSETFROM), not the post-transition offset. For example, US Eastern spring forward DTSTART should be 02:00 (EST), not 03:00 (EDT). * Remove comments on UID handling in createEvent Removed comments about UID handling for calendar events. * Revise injectVTimezone documentation Update injectVTimezone function documentation to clarify UTC handling. --------- Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
1 parent 4dbe044 commit f1e8e3f

2 files changed

Lines changed: 597 additions & 29 deletions

File tree

packages/lib/CalendarService.test.ts

Lines changed: 355 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { describe, it, expect, vi, beforeEach } from "vitest";
21
import { createEvent as createIcsEvent } from "ics";
32
import { createCalendarObject, updateCalendarObject } from "tsdav";
3+
import { beforeEach, describe, expect, it, vi } from "vitest";
44

55
vi.mock("ics", () => ({
66
createEvent: vi.fn(),
@@ -41,7 +41,6 @@ vi.mock("./CalEventParser", () => ({
4141
}));
4242

4343
import type { CalendarServiceEvent } from "@calcom/types/Calendar";
44-
4544
import BaseCalendarService from "./CalendarService";
4645

4746
const createMockEvent = (overrides: Partial<CalendarServiceEvent> = {}): CalendarServiceEvent => ({
@@ -102,6 +101,359 @@ class TestCalendarService extends BaseCalendarService {
102101
}
103102
}
104103

104+
describe("CalendarService - UID Consistency", () => {
105+
beforeEach(() => {
106+
vi.clearAllMocks();
107+
});
108+
109+
it("should use event.uid when provided, not generate a new UUID", async () => {
110+
const service = new TestCalendarService();
111+
const bookingUid = "booking-uid-from-database-abc123";
112+
const mockIcsOutput = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:${bookingUid}\r\nDTSTART:20230615T150000Z\r\nDTEND:20230615T160000Z\r\nEND:VEVENT\r\nEND:VCALENDAR`;
113+
114+
vi.mocked(createIcsEvent).mockReturnValue({
115+
error: null as unknown as Error,
116+
value: mockIcsOutput,
117+
});
118+
119+
const event = createMockEvent({ uid: bookingUid });
120+
const result = await service.createEvent(event, 1);
121+
122+
expect(result.uid).toBe(bookingUid);
123+
expect(result.id).toBe(bookingUid);
124+
125+
const calledArg = vi.mocked(createCalendarObject).mock.calls[0][0];
126+
expect(calledArg.filename).toBe(`${bookingUid}.ics`);
127+
128+
const icsCallArg = vi.mocked(createIcsEvent).mock.calls[0][0];
129+
expect(icsCallArg.uid).toBe(bookingUid);
130+
});
131+
132+
it("should generate a new UUID when event.uid is not provided", async () => {
133+
const service = new TestCalendarService();
134+
const mockIcsOutput = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:generated-uuid\r\nDTSTART:20230615T150000Z\r\nDTEND:20230615T160000Z\r\nEND:VEVENT\r\nEND:VCALENDAR`;
135+
136+
vi.mocked(createIcsEvent).mockReturnValue({
137+
error: null as unknown as Error,
138+
value: mockIcsOutput,
139+
});
140+
141+
const event = createMockEvent({ uid: undefined });
142+
const result = await service.createEvent(event, 1);
143+
144+
expect(result.uid).toBeTruthy();
145+
expect(typeof result.uid).toBe("string");
146+
expect(result.uid.length).toBeGreaterThan(0);
147+
});
148+
149+
it("should use the same uid for CalDAV filename and ics UID property", async () => {
150+
const service = new TestCalendarService();
151+
const bookingUid = "consistent-uid-xyz789";
152+
const mockIcsOutput = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:${bookingUid}\r\nDTSTART:20230615T150000Z\r\nDTEND:20230615T160000Z\r\nEND:VEVENT\r\nEND:VCALENDAR`;
153+
154+
vi.mocked(createIcsEvent).mockReturnValue({
155+
error: null as unknown as Error,
156+
value: mockIcsOutput,
157+
});
158+
159+
const event = createMockEvent({ uid: bookingUid });
160+
await service.createEvent(event, 1);
161+
162+
const icsCallArg = vi.mocked(createIcsEvent).mock.calls[0][0];
163+
const calledArg = vi.mocked(createCalendarObject).mock.calls[0][0];
164+
165+
expect(icsCallArg.uid).toBe(bookingUid);
166+
expect(calledArg.filename).toBe(`${bookingUid}.ics`);
167+
expect(icsCallArg.uid).toBe(calledArg.filename?.replace(".ics", ""));
168+
});
169+
});
170+
171+
describe("CalendarService - VTIMEZONE Generation", () => {
172+
beforeEach(() => {
173+
vi.clearAllMocks();
174+
});
175+
176+
it("should include VTIMEZONE block in created CalDAV event", async () => {
177+
const service = new TestCalendarService();
178+
const mockIcsOutput = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:test-uid\r\nDTSTART:20230615T150000Z\r\nDURATION:PT1H\r\nEND:VEVENT\r\nEND:VCALENDAR`;
179+
180+
vi.mocked(createIcsEvent).mockReturnValue({
181+
error: null as unknown as Error,
182+
value: mockIcsOutput,
183+
});
184+
185+
const event = createMockEvent({
186+
startTime: "2023-06-15T15:00:00Z",
187+
endTime: "2023-06-15T16:00:00Z",
188+
organizer: {
189+
name: "Test",
190+
email: "test@example.com",
191+
timeZone: "America/Chicago",
192+
language: { translate: ((key: string) => key) as never, locale: "en" },
193+
},
194+
});
195+
196+
await service.createEvent(event, 1);
197+
198+
const calledArg = vi.mocked(createCalendarObject).mock.calls[0][0];
199+
const iCalString = calledArg.iCalString;
200+
201+
expect(iCalString).toContain("BEGIN:VTIMEZONE");
202+
expect(iCalString).toContain("END:VTIMEZONE");
203+
expect(iCalString).toContain("TZID:America/Chicago");
204+
});
205+
206+
it("should use TZID in DTSTART instead of UTC Z suffix", async () => {
207+
const service = new TestCalendarService();
208+
const mockIcsOutput = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:test-uid\r\nDTSTART:20230615T150000Z\r\nDURATION:PT1H\r\nEND:VEVENT\r\nEND:VCALENDAR`;
209+
210+
vi.mocked(createIcsEvent).mockReturnValue({
211+
error: null as unknown as Error,
212+
value: mockIcsOutput,
213+
});
214+
215+
const event = createMockEvent({
216+
startTime: "2023-06-15T15:00:00Z",
217+
endTime: "2023-06-15T16:00:00Z",
218+
organizer: {
219+
name: "Test",
220+
email: "test@example.com",
221+
timeZone: "America/Chicago",
222+
language: { translate: ((key: string) => key) as never, locale: "en" },
223+
},
224+
});
225+
226+
await service.createEvent(event, 1);
227+
228+
const calledArg = vi.mocked(createCalendarObject).mock.calls[0][0];
229+
const iCalString = calledArg.iCalString;
230+
231+
expect(iCalString).toContain("DTSTART;TZID=America/Chicago:");
232+
const unfolded = iCalString.replace(/\r?\n[ \t]/g, "");
233+
expect(unfolded).not.toMatch(/^DTSTART:[0-9]{8}T[0-9]{6}Z/m);
234+
});
235+
236+
it("should convert UTC time to local time correctly for America/Chicago", async () => {
237+
const service = new TestCalendarService();
238+
// 2023-01-15T15:00:00Z = 2023-01-15T09:00:00 in America/Chicago (UTC-6 in January)
239+
const mockIcsOutput = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:test-uid\r\nDTSTART:20230115T150000Z\r\nDURATION:PT1H\r\nEND:VEVENT\r\nEND:VCALENDAR`;
240+
241+
vi.mocked(createIcsEvent).mockReturnValue({
242+
error: null as unknown as Error,
243+
value: mockIcsOutput,
244+
});
245+
246+
const event = createMockEvent({
247+
startTime: "2023-01-15T15:00:00Z",
248+
endTime: "2023-01-15T16:00:00Z",
249+
organizer: {
250+
name: "Test",
251+
email: "test@example.com",
252+
timeZone: "America/Chicago",
253+
language: { translate: ((key: string) => key) as never, locale: "en" },
254+
},
255+
});
256+
257+
await service.createEvent(event, 1);
258+
259+
const calledArg = vi.mocked(createCalendarObject).mock.calls[0][0];
260+
const iCalString = calledArg.iCalString;
261+
const unfolded = iCalString.replace(/\r?\n[ \t]/g, "");
262+
263+
expect(unfolded).toContain("DTSTART;TZID=America/Chicago:20230115T090000");
264+
});
265+
266+
it("should place VTIMEZONE block before BEGIN:VEVENT", async () => {
267+
const service = new TestCalendarService();
268+
const mockIcsOutput = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:test-uid\r\nDTSTART:20230615T150000Z\r\nDURATION:PT1H\r\nEND:VEVENT\r\nEND:VCALENDAR`;
269+
270+
vi.mocked(createIcsEvent).mockReturnValue({
271+
error: null as unknown as Error,
272+
value: mockIcsOutput,
273+
});
274+
275+
const event = createMockEvent({
276+
startTime: "2023-06-15T15:00:00Z",
277+
endTime: "2023-06-15T16:00:00Z",
278+
organizer: {
279+
name: "Test",
280+
email: "test@example.com",
281+
timeZone: "America/Chicago",
282+
language: { translate: ((key: string) => key) as never, locale: "en" },
283+
},
284+
});
285+
await service.createEvent(event, 1);
286+
287+
const calledArg = vi.mocked(createCalendarObject).mock.calls[0][0];
288+
const iCalString = calledArg.iCalString;
289+
290+
const vtimezoneIdx = iCalString.indexOf("BEGIN:VTIMEZONE");
291+
const veventIdx = iCalString.indexOf("BEGIN:VEVENT");
292+
293+
expect(vtimezoneIdx).toBeGreaterThan(-1);
294+
expect(veventIdx).toBeGreaterThan(-1);
295+
expect(vtimezoneIdx).toBeLessThan(veventIdx);
296+
});
297+
298+
it("should produce valid 8-digit DTSTART dates in VTIMEZONE blocks", async () => {
299+
const service = new TestCalendarService();
300+
const mockIcsOutput = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:test-uid\r\nDTSTART:20230615T120000Z\r\nDURATION:PT1H\r\nEND:VEVENT\r\nEND:VCALENDAR`;
301+
302+
vi.mocked(createIcsEvent).mockReturnValue({
303+
error: null as unknown as Error,
304+
value: mockIcsOutput,
305+
});
306+
307+
const event = createMockEvent({
308+
startTime: "2023-06-15T12:00:00Z",
309+
endTime: "2023-06-15T13:00:00Z",
310+
organizer: {
311+
name: "Test",
312+
email: "test@example.com",
313+
timeZone: "America/New_York",
314+
language: { translate: ((key: string) => key) as never, locale: "en" },
315+
},
316+
});
317+
318+
await service.createEvent(event, 1);
319+
320+
const calledArg = vi.mocked(createCalendarObject).mock.calls[0][0];
321+
const iCalString = calledArg.iCalString;
322+
323+
const vtimezoneBlock = iCalString.slice(
324+
iCalString.indexOf("BEGIN:VTIMEZONE"),
325+
iCalString.indexOf("END:VTIMEZONE") + 13
326+
);
327+
const dtStartMatches = vtimezoneBlock.match(/DTSTART:(\d+)T/g);
328+
expect(dtStartMatches).not.toBeNull();
329+
for (const match of dtStartMatches ?? []) {
330+
const dateStr = match.replace("DTSTART:", "").replace("T", "");
331+
expect(dateStr).toHaveLength(8); // YYYYMMDD = 8 chars
332+
}
333+
});
334+
335+
it("should use pre-transition local time for VTIMEZONE DTSTART (RFC 5545)", async () => {
336+
const service = new TestCalendarService();
337+
const mockIcsOutput = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:test-uid\r\nDTSTART:20230615T120000Z\r\nDURATION:PT1H\r\nEND:VEVENT\r\nEND:VCALENDAR`;
338+
339+
vi.mocked(createIcsEvent).mockReturnValue({
340+
error: null as unknown as Error,
341+
value: mockIcsOutput,
342+
});
343+
344+
const event = createMockEvent({
345+
startTime: "2023-06-15T12:00:00Z",
346+
endTime: "2023-06-15T13:00:00Z",
347+
organizer: {
348+
name: "Test",
349+
email: "test@example.com",
350+
timeZone: "America/New_York",
351+
language: { translate: ((key: string) => key) as never, locale: "en" },
352+
},
353+
});
354+
355+
await service.createEvent(event, 1);
356+
357+
const calledArg = vi.mocked(createCalendarObject).mock.calls[0][0];
358+
const iCalString = calledArg.iCalString;
359+
360+
const vtimezoneBlock = iCalString.slice(
361+
iCalString.indexOf("BEGIN:VTIMEZONE"),
362+
iCalString.indexOf("END:VTIMEZONE") + 13
363+
);
364+
365+
// America/New_York 2023: spring forward March 12 at 2:00 AM EST,
366+
// fall back November 5 at 2:00 AM EDT.
367+
// RFC 5545 requires DTSTART to use pre-transition local time (02:00),
368+
// not the post-transition time (03:00 for spring forward, 01:00 for fall back).
369+
const daylightBlock = vtimezoneBlock.slice(
370+
vtimezoneBlock.indexOf("BEGIN:DAYLIGHT"),
371+
vtimezoneBlock.indexOf("END:DAYLIGHT")
372+
);
373+
expect(daylightBlock).toContain("DTSTART:20230312T020000");
374+
375+
const standardBlock = vtimezoneBlock.slice(
376+
vtimezoneBlock.indexOf("BEGIN:STANDARD"),
377+
vtimezoneBlock.indexOf("END:STANDARD")
378+
);
379+
expect(standardBlock).toContain("DTSTART:20231105T020000");
380+
});
381+
382+
it("should use correct DST rules for Southern Hemisphere (Australia/Sydney)", async () => {
383+
const service = new TestCalendarService();
384+
const mockIcsOutput = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:test-uid\r\nDTSTART:20230115T020000Z\r\nDURATION:PT1H\r\nEND:VEVENT\r\nEND:VCALENDAR`;
385+
386+
vi.mocked(createIcsEvent).mockReturnValue({
387+
error: null as unknown as Error,
388+
value: mockIcsOutput,
389+
});
390+
391+
const event = createMockEvent({
392+
startTime: "2023-01-15T02:00:00Z",
393+
endTime: "2023-01-15T03:00:00Z",
394+
organizer: {
395+
name: "Test",
396+
email: "test@example.com",
397+
timeZone: "Australia/Sydney",
398+
language: { translate: ((key: string) => key) as never, locale: "en" },
399+
},
400+
});
401+
402+
await service.createEvent(event, 1);
403+
404+
const calledArg = vi.mocked(createCalendarObject).mock.calls[0][0];
405+
const iCalString = calledArg.iCalString;
406+
407+
expect(iCalString).toContain("BEGIN:VTIMEZONE");
408+
expect(iCalString).toContain("TZID:Australia/Sydney");
409+
const vtimezoneBlock = iCalString.slice(
410+
iCalString.indexOf("BEGIN:VTIMEZONE"),
411+
iCalString.indexOf("END:VTIMEZONE") + 13
412+
);
413+
expect(vtimezoneBlock).toContain("BEGIN:DAYLIGHT");
414+
expect(vtimezoneBlock).toContain("BEGIN:STANDARD");
415+
});
416+
417+
it("should apply timezone fix to updateEvent as well", async () => {
418+
const service = new TestCalendarService();
419+
const mockIcsOutput = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:test-uid\r\nDTSTART:20230615T150000Z\r\nATTENDEE;CN=Guest:mailto:guest@example.com\r\nDURATION:PT1H\r\nEND:VEVENT\r\nEND:VCALENDAR`;
420+
421+
vi.mocked(createIcsEvent).mockReturnValue({
422+
error: null as unknown as Error,
423+
value: mockIcsOutput,
424+
});
425+
426+
(service as unknown as Record<string, unknown>).getEventsByUID = vi.fn().mockResolvedValue([
427+
{
428+
uid: "test-uid",
429+
url: "https://caldav.example.com/calendar/test.ics",
430+
etag: '"etag123"',
431+
},
432+
]);
433+
434+
const event = createMockEvent({
435+
uid: "test-uid",
436+
startTime: "2023-06-15T15:00:00Z",
437+
endTime: "2023-06-15T16:00:00Z",
438+
organizer: {
439+
name: "Test",
440+
email: "test@example.com",
441+
timeZone: "America/Chicago",
442+
language: { translate: ((key: string) => key) as never, locale: "en" },
443+
},
444+
});
445+
446+
await service.testUpdateEvent("test-uid", event, "https://caldav.example.com/calendar/");
447+
448+
const calledArg = vi.mocked(updateCalendarObject).mock.calls[0][0];
449+
const data = calledArg.calendarObject.data;
450+
451+
expect(data).toContain("BEGIN:VTIMEZONE");
452+
expect(data).toContain("TZID:America/Chicago");
453+
expect(data).toContain("DTSTART;TZID=America/Chicago:");
454+
});
455+
});
456+
105457
describe("CalendarService - SCHEDULE-AGENT injection", () => {
106458
beforeEach(() => {
107459
vi.clearAllMocks();
@@ -366,7 +718,7 @@ describe("CalendarService - SCHEDULE-AGENT injection", () => {
366718

367719
it("should preserve other iCal properties unchanged", async () => {
368720
const service = new TestCalendarService();
369-
const mockIcsOutput = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Cal.com//NONSGML//EN\r\nATTENDEE;CN=Guest:mailto:guest@example.com\r\nDTSTART:20230101T100000Z\r\nDTEND:20230101T110000Z\r\nEND:VCALENDAR`;
721+
const mockIcsOutput = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Cal.com//NONSGML//EN\r\nBEGIN:VEVENT\r\nATTENDEE;CN=Guest:mailto:guest@example.com\r\nDTSTART:20230101T100000Z\r\nDTEND:20230101T110000Z\r\nEND:VEVENT\r\nEND:VCALENDAR`;
370722
vi.mocked(createIcsEvent).mockReturnValue({
371723
error: null as unknown as Error,
372724
value: mockIcsOutput,
@@ -381,8 +733,6 @@ describe("CalendarService - SCHEDULE-AGENT injection", () => {
381733

382734
expect(iCalString).toContain("VERSION:2.0");
383735
expect(iCalString).toContain("PRODID:-//Cal.com//NONSGML//EN");
384-
expect(iCalString).toContain("DTSTART:20230101T100000Z");
385-
expect(iCalString).toContain("DTEND:20230101T110000Z");
386736
});
387737

388738
it("should handle empty iCalString gracefully", async () => {

0 commit comments

Comments
 (0)