From f1e8e3fa584be7fb55cfbf0c427e5a5f280c4e89 Mon Sep 17 00:00:00 2001 From: Yuvraj Angad Singh <36276913+yuvrajangadsingh@users.noreply.github.com> Date: Sat, 21 Feb 2026 20:08:56 +0530 Subject: [PATCH] fix(caldav): consistent UIDs and VTIMEZONE in iCalendar output (#28115) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(caldav): use consistent UIDs and inject VTIMEZONE in iCalendar output Two remaining CalDAV interop issues from #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> --- packages/lib/CalendarService.test.ts | 360 ++++++++++++++++++++++++++- packages/lib/CalendarService.ts | 266 ++++++++++++++++++-- 2 files changed, 597 insertions(+), 29 deletions(-) diff --git a/packages/lib/CalendarService.test.ts b/packages/lib/CalendarService.test.ts index 55e1f8d7af..1e78920013 100644 --- a/packages/lib/CalendarService.test.ts +++ b/packages/lib/CalendarService.test.ts @@ -1,6 +1,6 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; import { createEvent as createIcsEvent } from "ics"; import { createCalendarObject, updateCalendarObject } from "tsdav"; +import { beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("ics", () => ({ createEvent: vi.fn(), @@ -41,7 +41,6 @@ vi.mock("./CalEventParser", () => ({ })); import type { CalendarServiceEvent } from "@calcom/types/Calendar"; - import BaseCalendarService from "./CalendarService"; const createMockEvent = (overrides: Partial = {}): CalendarServiceEvent => ({ @@ -102,6 +101,359 @@ class TestCalendarService extends BaseCalendarService { } } +describe("CalendarService - UID Consistency", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should use event.uid when provided, not generate a new UUID", async () => { + const service = new TestCalendarService(); + const bookingUid = "booking-uid-from-database-abc123"; + 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`; + + vi.mocked(createIcsEvent).mockReturnValue({ + error: null as unknown as Error, + value: mockIcsOutput, + }); + + const event = createMockEvent({ uid: bookingUid }); + const result = await service.createEvent(event, 1); + + expect(result.uid).toBe(bookingUid); + expect(result.id).toBe(bookingUid); + + const calledArg = vi.mocked(createCalendarObject).mock.calls[0][0]; + expect(calledArg.filename).toBe(`${bookingUid}.ics`); + + const icsCallArg = vi.mocked(createIcsEvent).mock.calls[0][0]; + expect(icsCallArg.uid).toBe(bookingUid); + }); + + it("should generate a new UUID when event.uid is not provided", async () => { + const service = new TestCalendarService(); + 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`; + + vi.mocked(createIcsEvent).mockReturnValue({ + error: null as unknown as Error, + value: mockIcsOutput, + }); + + const event = createMockEvent({ uid: undefined }); + const result = await service.createEvent(event, 1); + + expect(result.uid).toBeTruthy(); + expect(typeof result.uid).toBe("string"); + expect(result.uid.length).toBeGreaterThan(0); + }); + + it("should use the same uid for CalDAV filename and ics UID property", async () => { + const service = new TestCalendarService(); + const bookingUid = "consistent-uid-xyz789"; + 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`; + + vi.mocked(createIcsEvent).mockReturnValue({ + error: null as unknown as Error, + value: mockIcsOutput, + }); + + const event = createMockEvent({ uid: bookingUid }); + await service.createEvent(event, 1); + + const icsCallArg = vi.mocked(createIcsEvent).mock.calls[0][0]; + const calledArg = vi.mocked(createCalendarObject).mock.calls[0][0]; + + expect(icsCallArg.uid).toBe(bookingUid); + expect(calledArg.filename).toBe(`${bookingUid}.ics`); + expect(icsCallArg.uid).toBe(calledArg.filename?.replace(".ics", "")); + }); +}); + +describe("CalendarService - VTIMEZONE Generation", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should include VTIMEZONE block in created CalDAV event", async () => { + const service = new TestCalendarService(); + 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`; + + vi.mocked(createIcsEvent).mockReturnValue({ + error: null as unknown as Error, + value: mockIcsOutput, + }); + + const event = createMockEvent({ + startTime: "2023-06-15T15:00:00Z", + endTime: "2023-06-15T16:00:00Z", + organizer: { + name: "Test", + email: "test@example.com", + timeZone: "America/Chicago", + language: { translate: ((key: string) => key) as never, locale: "en" }, + }, + }); + + await service.createEvent(event, 1); + + const calledArg = vi.mocked(createCalendarObject).mock.calls[0][0]; + const iCalString = calledArg.iCalString; + + expect(iCalString).toContain("BEGIN:VTIMEZONE"); + expect(iCalString).toContain("END:VTIMEZONE"); + expect(iCalString).toContain("TZID:America/Chicago"); + }); + + it("should use TZID in DTSTART instead of UTC Z suffix", async () => { + const service = new TestCalendarService(); + 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`; + + vi.mocked(createIcsEvent).mockReturnValue({ + error: null as unknown as Error, + value: mockIcsOutput, + }); + + const event = createMockEvent({ + startTime: "2023-06-15T15:00:00Z", + endTime: "2023-06-15T16:00:00Z", + organizer: { + name: "Test", + email: "test@example.com", + timeZone: "America/Chicago", + language: { translate: ((key: string) => key) as never, locale: "en" }, + }, + }); + + await service.createEvent(event, 1); + + const calledArg = vi.mocked(createCalendarObject).mock.calls[0][0]; + const iCalString = calledArg.iCalString; + + expect(iCalString).toContain("DTSTART;TZID=America/Chicago:"); + const unfolded = iCalString.replace(/\r?\n[ \t]/g, ""); + expect(unfolded).not.toMatch(/^DTSTART:[0-9]{8}T[0-9]{6}Z/m); + }); + + it("should convert UTC time to local time correctly for America/Chicago", async () => { + const service = new TestCalendarService(); + // 2023-01-15T15:00:00Z = 2023-01-15T09:00:00 in America/Chicago (UTC-6 in January) + 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`; + + vi.mocked(createIcsEvent).mockReturnValue({ + error: null as unknown as Error, + value: mockIcsOutput, + }); + + const event = createMockEvent({ + startTime: "2023-01-15T15:00:00Z", + endTime: "2023-01-15T16:00:00Z", + organizer: { + name: "Test", + email: "test@example.com", + timeZone: "America/Chicago", + language: { translate: ((key: string) => key) as never, locale: "en" }, + }, + }); + + await service.createEvent(event, 1); + + const calledArg = vi.mocked(createCalendarObject).mock.calls[0][0]; + const iCalString = calledArg.iCalString; + const unfolded = iCalString.replace(/\r?\n[ \t]/g, ""); + + expect(unfolded).toContain("DTSTART;TZID=America/Chicago:20230115T090000"); + }); + + it("should place VTIMEZONE block before BEGIN:VEVENT", async () => { + const service = new TestCalendarService(); + 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`; + + vi.mocked(createIcsEvent).mockReturnValue({ + error: null as unknown as Error, + value: mockIcsOutput, + }); + + const event = createMockEvent({ + startTime: "2023-06-15T15:00:00Z", + endTime: "2023-06-15T16:00:00Z", + organizer: { + name: "Test", + email: "test@example.com", + timeZone: "America/Chicago", + language: { translate: ((key: string) => key) as never, locale: "en" }, + }, + }); + await service.createEvent(event, 1); + + const calledArg = vi.mocked(createCalendarObject).mock.calls[0][0]; + const iCalString = calledArg.iCalString; + + const vtimezoneIdx = iCalString.indexOf("BEGIN:VTIMEZONE"); + const veventIdx = iCalString.indexOf("BEGIN:VEVENT"); + + expect(vtimezoneIdx).toBeGreaterThan(-1); + expect(veventIdx).toBeGreaterThan(-1); + expect(vtimezoneIdx).toBeLessThan(veventIdx); + }); + + it("should produce valid 8-digit DTSTART dates in VTIMEZONE blocks", async () => { + const service = new TestCalendarService(); + 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`; + + vi.mocked(createIcsEvent).mockReturnValue({ + error: null as unknown as Error, + value: mockIcsOutput, + }); + + const event = createMockEvent({ + startTime: "2023-06-15T12:00:00Z", + endTime: "2023-06-15T13:00:00Z", + organizer: { + name: "Test", + email: "test@example.com", + timeZone: "America/New_York", + language: { translate: ((key: string) => key) as never, locale: "en" }, + }, + }); + + await service.createEvent(event, 1); + + const calledArg = vi.mocked(createCalendarObject).mock.calls[0][0]; + const iCalString = calledArg.iCalString; + + const vtimezoneBlock = iCalString.slice( + iCalString.indexOf("BEGIN:VTIMEZONE"), + iCalString.indexOf("END:VTIMEZONE") + 13 + ); + const dtStartMatches = vtimezoneBlock.match(/DTSTART:(\d+)T/g); + expect(dtStartMatches).not.toBeNull(); + for (const match of dtStartMatches ?? []) { + const dateStr = match.replace("DTSTART:", "").replace("T", ""); + expect(dateStr).toHaveLength(8); // YYYYMMDD = 8 chars + } + }); + + it("should use pre-transition local time for VTIMEZONE DTSTART (RFC 5545)", async () => { + const service = new TestCalendarService(); + 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`; + + vi.mocked(createIcsEvent).mockReturnValue({ + error: null as unknown as Error, + value: mockIcsOutput, + }); + + const event = createMockEvent({ + startTime: "2023-06-15T12:00:00Z", + endTime: "2023-06-15T13:00:00Z", + organizer: { + name: "Test", + email: "test@example.com", + timeZone: "America/New_York", + language: { translate: ((key: string) => key) as never, locale: "en" }, + }, + }); + + await service.createEvent(event, 1); + + const calledArg = vi.mocked(createCalendarObject).mock.calls[0][0]; + const iCalString = calledArg.iCalString; + + const vtimezoneBlock = iCalString.slice( + iCalString.indexOf("BEGIN:VTIMEZONE"), + iCalString.indexOf("END:VTIMEZONE") + 13 + ); + + // America/New_York 2023: spring forward March 12 at 2:00 AM EST, + // fall back November 5 at 2:00 AM EDT. + // RFC 5545 requires DTSTART to use pre-transition local time (02:00), + // not the post-transition time (03:00 for spring forward, 01:00 for fall back). + const daylightBlock = vtimezoneBlock.slice( + vtimezoneBlock.indexOf("BEGIN:DAYLIGHT"), + vtimezoneBlock.indexOf("END:DAYLIGHT") + ); + expect(daylightBlock).toContain("DTSTART:20230312T020000"); + + const standardBlock = vtimezoneBlock.slice( + vtimezoneBlock.indexOf("BEGIN:STANDARD"), + vtimezoneBlock.indexOf("END:STANDARD") + ); + expect(standardBlock).toContain("DTSTART:20231105T020000"); + }); + + it("should use correct DST rules for Southern Hemisphere (Australia/Sydney)", async () => { + const service = new TestCalendarService(); + 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`; + + vi.mocked(createIcsEvent).mockReturnValue({ + error: null as unknown as Error, + value: mockIcsOutput, + }); + + const event = createMockEvent({ + startTime: "2023-01-15T02:00:00Z", + endTime: "2023-01-15T03:00:00Z", + organizer: { + name: "Test", + email: "test@example.com", + timeZone: "Australia/Sydney", + language: { translate: ((key: string) => key) as never, locale: "en" }, + }, + }); + + await service.createEvent(event, 1); + + const calledArg = vi.mocked(createCalendarObject).mock.calls[0][0]; + const iCalString = calledArg.iCalString; + + expect(iCalString).toContain("BEGIN:VTIMEZONE"); + expect(iCalString).toContain("TZID:Australia/Sydney"); + const vtimezoneBlock = iCalString.slice( + iCalString.indexOf("BEGIN:VTIMEZONE"), + iCalString.indexOf("END:VTIMEZONE") + 13 + ); + expect(vtimezoneBlock).toContain("BEGIN:DAYLIGHT"); + expect(vtimezoneBlock).toContain("BEGIN:STANDARD"); + }); + + it("should apply timezone fix to updateEvent as well", async () => { + const service = new TestCalendarService(); + 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`; + + vi.mocked(createIcsEvent).mockReturnValue({ + error: null as unknown as Error, + value: mockIcsOutput, + }); + + (service as unknown as Record).getEventsByUID = vi.fn().mockResolvedValue([ + { + uid: "test-uid", + url: "https://caldav.example.com/calendar/test.ics", + etag: '"etag123"', + }, + ]); + + const event = createMockEvent({ + uid: "test-uid", + startTime: "2023-06-15T15:00:00Z", + endTime: "2023-06-15T16:00:00Z", + organizer: { + name: "Test", + email: "test@example.com", + timeZone: "America/Chicago", + language: { translate: ((key: string) => key) as never, locale: "en" }, + }, + }); + + await service.testUpdateEvent("test-uid", event, "https://caldav.example.com/calendar/"); + + const calledArg = vi.mocked(updateCalendarObject).mock.calls[0][0]; + const data = calledArg.calendarObject.data; + + expect(data).toContain("BEGIN:VTIMEZONE"); + expect(data).toContain("TZID:America/Chicago"); + expect(data).toContain("DTSTART;TZID=America/Chicago:"); + }); +}); + describe("CalendarService - SCHEDULE-AGENT injection", () => { beforeEach(() => { vi.clearAllMocks(); @@ -366,7 +718,7 @@ describe("CalendarService - SCHEDULE-AGENT injection", () => { it("should preserve other iCal properties unchanged", async () => { const service = new TestCalendarService(); - 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`; + 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`; vi.mocked(createIcsEvent).mockReturnValue({ error: null as unknown as Error, value: mockIcsOutput, @@ -381,8 +733,6 @@ describe("CalendarService - SCHEDULE-AGENT injection", () => { expect(iCalString).toContain("VERSION:2.0"); expect(iCalString).toContain("PRODID:-//Cal.com//NONSGML//EN"); - expect(iCalString).toContain("DTSTART:20230101T100000Z"); - expect(iCalString).toContain("DTEND:20230101T110000Z"); }); it("should handle empty iCalString gracefully", async () => { diff --git a/packages/lib/CalendarService.ts b/packages/lib/CalendarService.ts index 1dbd875235..a0c3b8c183 100644 --- a/packages/lib/CalendarService.ts +++ b/packages/lib/CalendarService.ts @@ -1,5 +1,22 @@ /* eslint-disable @typescript-eslint/triple-slash-reference */ /// + +import process from "node:process"; +import dayjs from "@calcom/dayjs"; +import sanitizeCalendarObject from "@calcom/lib/sanitizeCalendarObject"; +import type { + Person as AttendeeInCalendarEvent, + Calendar, + CalendarEvent, + CalendarEventType, + CalendarServiceEvent, + EventBusyDate, + GetAvailabilityParams, + IntegrationCalendar, + NewCalendarEventType, + TeamMember, +} from "@calcom/types/Calendar"; +import type { CredentialPayload } from "@calcom/types/Credential"; import ICAL from "ical.js"; import type { Attendee, DateArray, DurationObject } from "ics"; import { createEvent } from "ics"; @@ -14,23 +31,6 @@ import { updateCalendarObject, } from "tsdav"; import { v4 as uuidv4 } from "uuid"; - -import dayjs from "@calcom/dayjs"; -import sanitizeCalendarObject from "@calcom/lib/sanitizeCalendarObject"; -import type { Person as AttendeeInCalendarEvent } from "@calcom/types/Calendar"; -import type { - Calendar, - CalendarServiceEvent, - CalendarEvent, - CalendarEventType, - EventBusyDate, - GetAvailabilityParams, - IntegrationCalendar, - NewCalendarEventType, - TeamMember, -} from "@calcom/types/Calendar"; -import type { CredentialPayload } from "@calcom/types/Credential"; - import { getLocation, getRichDescription } from "./CalEventParser"; import { symmetricDecrypt } from "./crypto"; import logger from "./logger"; @@ -172,6 +172,216 @@ const injectScheduleAgent = (iCalString: string): string => { return result; }; +/** + * Finds the DST transition moment for a given year by binary-searching + * when the UTC offset changes between two months. + */ +const findDSTTransition = ( + timezone: string, + year: number, + fromMonth: number, + toMonth: number +): ReturnType | null => { + let low = dayjs.utc(new Date(year, fromMonth, 1)).valueOf(); + let high = dayjs.utc(new Date(year, toMonth, 1)).valueOf(); + const lowOffset = dayjs(low).tz(timezone).utcOffset(); + const highOffset = dayjs(high).tz(timezone).utcOffset(); + + if (lowOffset === highOffset) return null; + + while (high - low > 60 * 1000) { + const mid = Math.floor((low + high) / 2); + const midOffset = dayjs(mid).tz(timezone).utcOffset(); + if (midOffset === lowOffset) { + low = mid; + } else { + high = mid; + } + } + + // RFC 5545 requires DTSTART to be the local time interpreted with the + // pre-transition offset (TZOFFSETFROM). Apply the pre-transition offset + // to the UTC transition moment to get this local time. + const preTransitionOffsetMs = lowOffset * 60 * 1000; + return dayjs.utc(high + preTransitionOffsetMs); +}; + +/** Formats a transition moment as iCal DTSTART (e.g., 20260308T020000). */ +const formatTransitionDtstart = (transition: ReturnType): string => { + const year = String(transition.year()); + const month = String(transition.month() + 1).padStart(2, "0"); + const day = String(transition.date()).padStart(2, "0"); + const hour = String(transition.hour()).padStart(2, "0"); + const minute = String(transition.minute()).padStart(2, "0"); + return `${year}${month}${day}T${hour}${minute}00`; +}; + +/** Generates BYDAY RRULE value (e.g., "2SU") for nth weekday occurrence in month. */ +const getBydayRule = (d: ReturnType): string => { + const dayNames = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"]; + const dayOfWeek = dayNames[d.day()]; + const dayOfMonth = d.date(); + const weekNum = Math.ceil(dayOfMonth / 7); + const daysInMonth = d.daysInMonth(); + if (dayOfMonth > daysInMonth - 7) { + return `-1${dayOfWeek}`; + } + return `${weekNum}${dayOfWeek}`; +}; + +/** + * Builds a VTIMEZONE component for the given IANA timezone. + * Uses the event's year to compute actual DST transitions (not 1970), + * ensuring correct BYDAY/BYMONTH for zones that changed rules post-1970. + * RFC 5545 §3.6.5 requires VTIMEZONE when DTSTART uses TZID. + */ +const buildVTimezone = (timezone: string, eventStart: string): string => { + const eventYear = dayjs(eventStart).tz(timezone).year(); + // Construct dates from scratch in the target timezone to get correct offsets. + // Using .month() on an existing dayjs object may not recalculate the tz offset. + const winterMoment = dayjs.tz(`${eventYear}-01-15T12:00:00`, timezone); + const summerMoment = dayjs.tz(`${eventYear}-07-15T12:00:00`, timezone); + + const formatOffset = (d: ReturnType): string => { + const offsetMinutes = d.utcOffset(); + const sign = offsetMinutes >= 0 ? "+" : "-"; + const abs = Math.abs(offsetMinutes); + const hours = String(Math.floor(abs / 60)).padStart(2, "0"); + const mins = String(abs % 60).padStart(2, "0"); + return `${sign}${hours}${mins}`; + }; + + const standardOffset = formatOffset(winterMoment); + const daylightOffset = formatOffset(summerMoment); + const hasDST = standardOffset !== daylightOffset; + + const lines: string[] = ["BEGIN:VTIMEZONE", `TZID:${timezone}`]; + + if (hasDST) { + const springTransition = findDSTTransition(timezone, eventYear, 0, 6); + const fallTransition = findDSTTransition(timezone, eventYear, 6, 11); + + const winterUtcOffset = winterMoment.utcOffset(); + const summerUtcOffset = summerMoment.utcOffset(); + const trueStandardOffset = winterUtcOffset < summerUtcOffset ? standardOffset : daylightOffset; + const trueDaylightOffset = winterUtcOffset < summerUtcOffset ? daylightOffset : standardOffset; + const springIsDaylight = summerUtcOffset > winterUtcOffset; + + if (springTransition) { + const dtstart = formatTransitionDtstart(springTransition); + const byday = getBydayRule(springTransition); + const bymonth = springTransition.month() + 1; + + if (springIsDaylight) { + lines.push( + "BEGIN:DAYLIGHT", + `TZOFFSETFROM:${trueStandardOffset}`, + `TZOFFSETTO:${trueDaylightOffset}`, + "TZNAME:DST", + `DTSTART:${dtstart}`, + `RRULE:FREQ=YEARLY;BYMONTH=${bymonth};BYDAY=${byday}`, + "END:DAYLIGHT" + ); + } else { + lines.push( + "BEGIN:STANDARD", + `TZOFFSETFROM:${trueDaylightOffset}`, + `TZOFFSETTO:${trueStandardOffset}`, + "TZNAME:ST", + `DTSTART:${dtstart}`, + `RRULE:FREQ=YEARLY;BYMONTH=${bymonth};BYDAY=${byday}`, + "END:STANDARD" + ); + } + } else { + lines.push( + "BEGIN:DAYLIGHT", + `TZOFFSETFROM:${trueStandardOffset}`, + `TZOFFSETTO:${trueDaylightOffset}`, + "TZNAME:DST", + "DTSTART:19700101T000000", + "END:DAYLIGHT" + ); + } + + if (fallTransition) { + const dtstart = formatTransitionDtstart(fallTransition); + const byday = getBydayRule(fallTransition); + const bymonth = fallTransition.month() + 1; + + if (springIsDaylight) { + lines.push( + "BEGIN:STANDARD", + `TZOFFSETFROM:${trueDaylightOffset}`, + `TZOFFSETTO:${trueStandardOffset}`, + "TZNAME:ST", + `DTSTART:${dtstart}`, + `RRULE:FREQ=YEARLY;BYMONTH=${bymonth};BYDAY=${byday}`, + "END:STANDARD" + ); + } else { + lines.push( + "BEGIN:DAYLIGHT", + `TZOFFSETFROM:${trueStandardOffset}`, + `TZOFFSETTO:${trueDaylightOffset}`, + "TZNAME:DST", + `DTSTART:${dtstart}`, + `RRULE:FREQ=YEARLY;BYMONTH=${bymonth};BYDAY=${byday}`, + "END:DAYLIGHT" + ); + } + } else { + lines.push( + "BEGIN:STANDARD", + `TZOFFSETFROM:${trueDaylightOffset}`, + `TZOFFSETTO:${trueStandardOffset}`, + "TZNAME:ST", + "DTSTART:19700101T000000", + "END:STANDARD" + ); + } + } else { + lines.push( + "BEGIN:STANDARD", + `TZOFFSETFROM:${standardOffset}`, + `TZOFFSETTO:${standardOffset}`, + `TZNAME:${timezone}`, + "DTSTART:19700101T000000", + "END:STANDARD" + ); + } + + lines.push("END:VTIMEZONE"); + return lines.join("\r\n"); +}; + +/** + * Injects a VTIMEZONE block and rewrites DTSTART/DTEND from UTC to timezone-local. + */ +const injectVTimezone = ( + iCalString: string, + timezone: string, + startTime: string, + endTime: string +): string => { + const formatLocalDateTime = (isoString: string, tz: string): string => { + const local = dayjs(isoString).tz(tz); + return local.format("YYYYMMDDTHHmmss"); + }; + + const localStart = formatLocalDateTime(startTime, timezone); + const localEnd = formatLocalDateTime(endTime, timezone); + + let result = iCalString + .replace(/^DTSTART:[^\r\n]+/m, `DTSTART;TZID=${timezone}:${localStart}`) + .replace(/^DTEND:[^\r\n]+/m, `DTEND;TZID=${timezone}:${localEnd}`); + + const vtimezone = buildVTimezone(timezone, startTime); + result = result.replace(/^BEGIN:VEVENT/m, `${vtimezone}\r\nBEGIN:VEVENT`); + + return result; +}; + const mapAttendees = (attendees: AttendeeInCalendarEvent[] | TeamMember[]): Attendee[] => attendees.map(({ email, name }) => ({ name, email, partstat: "NEEDS-ACTION" })); @@ -217,7 +427,7 @@ export default abstract class BaseCalendarService implements Calendar { async createEvent(event: CalendarServiceEvent, credentialId: number): Promise { try { const calendars = await this.listCalendars(event); - const uid = uuidv4(); + const uid = event.uid || uuidv4(); // We create local ICS files const { error, value: iCalString } = createEvent({ @@ -242,6 +452,11 @@ export default abstract class BaseCalendarService implements Calendar { if (error || !iCalString) throw new Error(`Error creating iCalString:=> ${error?.message} : ${error?.name} `); + // Inject VTIMEZONE and rewrite DTSTART/DTEND to organizer's local timezone. + // Without this, CalDAV servers send scheduling emails with UTC times. + const timezone = event.organizer.timeZone; + const iCalStringWithTimezone = injectVTimezone(iCalString, timezone, event.startTime, event.endTime); + const mainHostDestinationCalendar = event.destinationCalendar ? (event.destinationCalendar.find((cal) => cal.credentialId === credentialId) ?? event.destinationCalendar[0]) @@ -261,7 +476,7 @@ export default abstract class BaseCalendarService implements Calendar { url: calendar.externalId, }, filename: `${uid}.ics`, - iCalString: injectScheduleAgent(iCalString), + iCalString: injectScheduleAgent(iCalStringWithTimezone), headers: this.headers, }) ) @@ -320,6 +535,12 @@ export default abstract class BaseCalendarService implements Calendar { additionalInfo: {}, }; } + // Apply timezone fix to updated events as well + const updateTimezone = event.organizer.timeZone; + const iCalStringWithTimezone = iCalString + ? injectVTimezone(iCalString, updateTimezone, event.startTime, event.endTime) + : ""; + let calendarEvent: CalendarEventType; const eventsToUpdate = events.filter((e) => e.uid === uid); return Promise.all( @@ -328,7 +549,7 @@ export default abstract class BaseCalendarService implements Calendar { return updateCalendarObject({ calendarObject: { url: calendarEvent.url, - data: injectScheduleAgent(iCalString ?? ""), + data: injectScheduleAgent(iCalStringWithTimezone ?? ""), etag: calendarEvent?.etag, }, headers: this.headers, @@ -695,10 +916,7 @@ export default abstract class BaseCalendarService implements Calendar { (promise): promise is PromiseFulfilledResult<(DAVObject | undefined)[]> => promise.status === "fulfilled" ); - const flatResult = fulfilledPromises - .map((promise) => promise.value) - .flat() - .filter((obj) => obj !== null); + const flatResult = fulfilledPromises.flatMap((promise) => promise.value).filter((obj) => obj !== null); return flatResult as DAVObject[]; }