CalDAV refactor (#20180)

Original CalDAV driver was written almost a year ago and code quality,
patterns were not up to the mark including having no test coverage, this
PR does the following:

- Splits the monolithic driver into isolated utilities with test
coverage

- Adds support for syncing legacy servers by checking if server supports
`syncCollection` and branches into two sync methods
`fetchEventsViaSyncCollection` or `fetchEventsViaCtagEtag` with this I
believe our driver is feature complete

Real testing report

| Provider  | Server            | Sync method          | Auth   |
| --------- | ----------------- | -------------------- | ------ |
| iCloud    | Apple's CalDAV    | sync-collection      | Basic  |
| Nextcloud | sabre/dav         | sync-collection      | Basic  |
| all-inkl  | sabre/dav (older) | ctag + etag fallback | Digest |
This commit is contained in:
neo773
2026-05-05 14:15:05 +00:00
committed by GitHub
parent 65ba36d475
commit 4dd08097ce
26 changed files with 1453 additions and 704 deletions
@@ -2,12 +2,21 @@ import { Module } from '@nestjs/common';
import { SecureHttpClientModule } from 'src/engine/core-modules/secure-http-client/secure-http-client.module';
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
import { CalDavClientProvider } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/providers/caldav.provider';
import { CalDavClientService } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/services/caldav-client.service';
import { CalDavFetchEventsService } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/services/caldav-fetch-events.service';
import { CalDavGetEventsService } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/services/caldav-get-events.service';
@Module({
imports: [SecureHttpClientModule, TwentyConfigModule],
providers: [CalDavClientProvider, CalDavGetEventsService],
exports: [CalDavGetEventsService],
providers: [
CalDavClientService,
CalDavFetchEventsService,
CalDavGetEventsService,
],
exports: [
CalDavClientService,
CalDavFetchEventsService,
CalDavGetEventsService,
],
})
export class CalDavDriverModule {}
@@ -1,530 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import * as ical from 'node-ical';
import {
calendarMultiGet,
createAccount,
type DAVAccount,
type DAVCalendar,
DAVNamespaceShort,
type DAVObject,
fetchCalendars,
syncCollection,
} from 'tsdav';
import { createBasicDigestAuthFetch } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/auth/create-basic-digest-auth-fetch';
import { icalDataExtractPropertyValue } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/utils/icalDataExtractPropertyValue';
import { CalDavGetEventsService } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/services/caldav-get-events.service';
import { CalendarEventParticipantResponseStatus } from 'src/modules/calendar/common/standard-objects/calendar-event-participant.workspace-entity';
import {
type FetchedCalendarEvent,
type FetchedCalendarEventParticipant,
} from 'src/modules/calendar/common/types/fetched-calendar-event';
const DEFAULT_CALENDAR_TYPE = 'caldav';
type CalendarCredentials = {
username: string;
password: string;
serverUrl: string;
fetch?: typeof globalThis.fetch;
};
type SimpleCalendar = {
id: string;
name: string;
url: string;
isPrimary?: boolean;
syncToken?: string | number;
};
type FetchEventsOptions = {
startDate: Date;
endDate: Date;
syncCursor?: CalDAVSyncCursor;
};
type CalDAVSyncResult = {
events: FetchedCalendarEvent[];
newSyncToken?: string;
};
type CalDAVSyncCursor = {
syncTokens: Record<string, string>;
};
type CalDAVGetEventsResponse = {
events: FetchedCalendarEvent[];
syncCursor: CalDAVSyncCursor;
};
@Injectable()
export class CalDAVClient {
private credentials: CalendarCredentials;
private logger: Logger;
private fetchOverride: typeof fetch;
constructor(credentials: CalendarCredentials) {
this.credentials = credentials;
this.logger = new Logger(CalDAVClient.name);
this.fetchOverride = createBasicDigestAuthFetch(
credentials.username,
credentials.password,
credentials.fetch ?? globalThis.fetch,
);
}
private hasFileExtension(url: string): boolean {
const fileName = url.substring(url.lastIndexOf('/') + 1);
return (
fileName.includes('.') &&
!fileName.substring(fileName.lastIndexOf('.')).includes('/')
);
}
private getFileExtension(url: string): string {
if (!this.hasFileExtension(url)) return 'ics';
const fileName = url.substring(url.lastIndexOf('/') + 1);
return fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase();
}
private isValidFormat(url: string): boolean {
const allowedExtensions = ['eml', 'ics'];
return allowedExtensions.includes(this.getFileExtension(url));
}
private async getAccount(): Promise<DAVAccount> {
return createAccount({
account: {
serverUrl: this.credentials.serverUrl,
accountType: DEFAULT_CALENDAR_TYPE,
credentials: {
username: this.credentials.username,
password: this.credentials.password,
},
},
fetch: this.fetchOverride,
});
}
async listCalendars(): Promise<SimpleCalendar[]> {
try {
const account = await this.getAccount();
const calendars = (await fetchCalendars({
account,
fetch: this.fetchOverride,
})) as (Omit<DAVCalendar, 'displayName'> & {
displayName?: string | Record<string, unknown>;
})[];
return calendars.reduce<SimpleCalendar[]>((result, calendar) => {
if (!calendar.components?.includes('VEVENT')) return result;
result.push({
id: calendar.url,
url: calendar.url,
name:
typeof calendar.displayName === 'string'
? calendar.displayName
: 'Unnamed Calendar',
isPrimary: false,
});
return result;
}, []);
} catch (error) {
this.logger.error(
`Error in ${CalDavGetEventsService.name} - getCalendarEvents`,
error.code,
error,
);
throw error;
}
}
async validateSyncCollectionSupport(): Promise<void> {
const account = await this.getAccount();
const calendars = await fetchCalendars({
account,
fetch: this.fetchOverride,
});
const eventCalendar = calendars.find((calendar) =>
calendar.components?.includes('VEVENT'),
);
if (!eventCalendar) {
throw new Error('No calendar with event support found');
}
const supportsSyncCollection =
eventCalendar.reports?.includes('syncCollection') ?? false;
if (!supportsSyncCollection) {
throw new Error(
'CALDAV_SYNC_COLLECTION_NOT_SUPPORTED: Your CalDAV server does not support incremental sync (RFC 6578)',
);
}
}
/**
* Determines if an event is a full-day event by checking the raw iCal data.
* Full-day events use VALUE=DATE parameter in DTSTART/DTEND properties.
* Since node-ical converts all dates to JavaScript Date objects, we must check the raw data.
* @see https://tools.ietf.org/html/rfc5545#section-3.3.4 (DATE Value Type)
* @see https://tools.ietf.org/html/rfc5545#section-3.3.5 (DATE-TIME Value Type)
* @see https://tools.ietf.org/html/rfc5545#section-3.2.20 (VALUE Parameter)
*/
private isFullDayEvent(rawICalData: string): boolean {
const lines = rawICalData.split(/\r?\n/);
for (const line of lines) {
const trimmedLine = line.trim();
if (
trimmedLine.startsWith('DTSTART') &&
trimmedLine.includes('VALUE=DATE')
) {
return true;
}
}
return false;
}
private extractOrganizerFromEvent(
event: ical.VEvent,
): FetchedCalendarEventParticipant | null {
if (!event.organizer) {
return null;
}
const organizerEmail =
// @ts-expect-error - limitation of node-ical typing
event.organizer.val?.replace(/^mailto:/i, '') || '';
return {
displayName:
// @ts-expect-error - limitation of node-ical typing
event.organizer.params?.CN || organizerEmail || 'Unknown',
responseStatus: CalendarEventParticipantResponseStatus.ACCEPTED,
handle: organizerEmail,
isOrganizer: true,
};
}
private mapPartStatToResponseStatus(
partStat: ical.AttendeePartStat,
): CalendarEventParticipantResponseStatus {
switch (partStat) {
case 'ACCEPTED':
return CalendarEventParticipantResponseStatus.ACCEPTED;
case 'DECLINED':
return CalendarEventParticipantResponseStatus.DECLINED;
case 'TENTATIVE':
return CalendarEventParticipantResponseStatus.TENTATIVE;
case 'NEEDS-ACTION':
default:
return CalendarEventParticipantResponseStatus.NEEDS_ACTION;
}
}
private extractAttendeesFromEvent(
event: ical.VEvent,
): FetchedCalendarEventParticipant[] {
if (!event.attendee) {
return [];
}
const attendees = Array.isArray(event.attendee)
? event.attendee
: [event.attendee];
return attendees.map((attendee: ical.Attendee) => {
// @ts-expect-error - limitation of node-ical typing
const handle = attendee.val?.replace(/^mailto:/i, '') || '';
// @ts-expect-error - limitation of node-ical typing
const displayName = attendee.params?.CN || handle || 'Unknown';
// @ts-expect-error - limitation of node-ical typing
const partStat = attendee.params?.PARTSTAT || 'NEEDS_ACTION';
return {
displayName,
responseStatus: this.mapPartStatToResponseStatus(partStat),
handle,
isOrganizer: false,
};
});
}
private extractParticipantsFromEvent(
event: ical.VEvent,
): FetchedCalendarEventParticipant[] {
const participants: FetchedCalendarEventParticipant[] = [];
const organizer = this.extractOrganizerFromEvent(event);
if (organizer) {
participants.push(organizer);
}
const attendees = this.extractAttendeesFromEvent(event);
participants.push(...attendees);
return participants;
}
private parseICalData(
rawData: string,
objectUrl: string,
): FetchedCalendarEvent | null {
try {
const parsed = ical.parseICS(rawData);
const events = Object.values(parsed).filter(
(item) => item.type === 'VEVENT',
);
if (events.length === 0) {
return null;
}
const event = events[0] as ical.VEvent;
const participants = this.extractParticipantsFromEvent(event);
const title = icalDataExtractPropertyValue(
event.summary,
'Untitled Event',
);
const description = icalDataExtractPropertyValue(event.description);
const location = icalDataExtractPropertyValue(event.location);
const conferenceLinkUrl = icalDataExtractPropertyValue(event.url);
return {
id: objectUrl,
title,
iCalUid: event.uid || '',
description,
startsAt: event.start.toISOString(),
endsAt: event.end.toISOString(),
location,
isFullDay: this.isFullDayEvent(rawData),
isCanceled: event.status === 'CANCELLED',
conferenceLinkLabel: '',
conferenceLinkUrl,
externalCreatedAt:
event.created?.toISOString() || new Date().toISOString(),
externalUpdatedAt:
event.lastmodified?.toISOString() ||
event.created?.toISOString() ||
new Date().toISOString(),
conferenceSolution: '',
recurringEventExternalId: event.recurrenceid
? String(event.recurrenceid)
: undefined,
participants,
status: event.status || 'CONFIRMED',
};
} catch (error) {
this.logger.error(
`Error in ${CalDavGetEventsService.name} - parseICalData`,
error,
);
return null;
}
}
async getEvents(
options: FetchEventsOptions,
): Promise<CalDAVGetEventsResponse> {
const calendars = await this.listCalendars();
const results = new Map<string, CalDAVSyncResult>();
const syncPromises = calendars.map(async (calendar) => {
try {
const syncToken =
options.syncCursor?.syncTokens[calendar.url] ||
calendar.syncToken?.toString();
const syncResult = await syncCollection({
url: calendar.url,
props: {
[`${DAVNamespaceShort.DAV}:getetag`]: {},
[`${DAVNamespaceShort.CALDAV}:calendar-data`]: {},
},
syncLevel: 1,
...(syncToken ? { syncToken } : {}),
fetch: this.fetchOverride,
});
const allEvents: FetchedCalendarEvent[] = [];
const objectUrls = syncResult
.map((event) => event.href)
.filter((href): href is string => !!href && this.isValidFormat(href));
if (objectUrls.length > 0) {
try {
const calendarObjects = await calendarMultiGet({
url: calendar.url,
props: {
[`${DAVNamespaceShort.DAV}:getetag`]: {},
[`${DAVNamespaceShort.CALDAV}:calendar-data`]: {},
},
objectUrls: objectUrls,
depth: '1',
fetch: this.fetchOverride,
});
for (const calendarObject of calendarObjects) {
if (calendarObject.props?.calendarData) {
const iCalData = this.extractICalData(
calendarObject.props?.calendarData,
);
if (!iCalData) {
continue;
}
const event = this.parseICalData(
iCalData,
calendarObject.href || '',
);
if (
event &&
this.isEventInTimeRange(
{
url: calendarObject.href || '',
data: calendarObject.props.calendarData,
etag: calendarObject.props.getetag,
},
options.startDate,
options.endDate,
)
) {
allEvents.push(event);
}
}
}
} catch (fetchError) {
this.logger.error(
`Error in ${CalDavGetEventsService.name} - getEvents`,
fetchError,
);
}
}
let newSyncToken = syncToken;
try {
const account = await this.getAccount();
const updatedCalendars = await fetchCalendars({
account,
fetch: this.fetchOverride,
});
const updatedCalendar = updatedCalendars.find(
(cal) => cal.url === calendar.url,
);
if (updatedCalendar?.syncToken) {
newSyncToken = updatedCalendar.syncToken.toString();
}
} catch (syncTokenError) {
this.logger.error(
`Error in ${CalDavGetEventsService.name} - getEvents`,
syncTokenError,
);
}
results.set(calendar.url, {
events: allEvents,
newSyncToken,
});
} catch {
results.set(calendar.url, {
events: [],
newSyncToken: options.syncCursor?.syncTokens[calendar.url],
});
}
});
await Promise.all(syncPromises);
const allEvents = Array.from(results.values())
.map((result) => result.events)
.flat();
const syncTokens: Record<string, string> = {};
for (const [calendarUrl, result] of results) {
if (result.newSyncToken) {
syncTokens[calendarUrl] = result.newSyncToken;
}
}
return {
events: allEvents,
syncCursor: { syncTokens },
};
}
/**
* Extracts iCal data from various CalDAV server response formats.
* Some servers return data directly as a string, others nest it under _cdata or some other properties.
*/
private extractICalData(
calendarData: string | Record<string, unknown>,
): string | null {
if (!calendarData) return null;
if (
typeof calendarData === 'string' &&
calendarData.includes('VCALENDAR')
) {
return calendarData;
}
if (typeof calendarData === 'object' && calendarData !== null) {
for (const key in calendarData) {
const result = this.extractICalData(
calendarData[key] as string | Record<string, unknown>,
);
if (result) return result;
}
}
return null;
}
private isEventInTimeRange(
davObject: DAVObject,
startDate: Date,
endDate: Date,
): boolean {
try {
if (!davObject.data) return false;
const parsed = ical.parseICS(davObject.data);
const events = Object.values(parsed).filter(
(item) => item.type === 'VEVENT',
);
if (events.length === 0) return false;
const event = events[0] as ical.VEvent;
return event.start < endDate && event.end > startDate;
} catch {
return true;
}
}
}
@@ -1,52 +0,0 @@
import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service';
import { CalDavClientProvider } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/providers/caldav.provider';
import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
jest.mock(
'src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/caldav.client',
() => ({
CalDAVClient: jest.fn().mockImplementation((creds) => creds),
}),
);
import { CalDAVClient } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/caldav.client';
const MockCalDAVClient = jest.mocked(CalDAVClient);
describe('CalDavClientProvider', () => {
describe('getCalDavCalendarClient', () => {
it('should pass SSRF-safe fetch to CalDAVClient', async () => {
const fakeFetch = jest.fn();
const mockSecureHttpClientService = {
createSsrfSafeFetch: jest.fn().mockReturnValue(fakeFetch),
} as unknown as SecureHttpClientService;
const provider = new CalDavClientProvider(mockSecureHttpClientService);
const connectedAccount = {
id: 'account-1',
provider: 'IMAP_SMTP_CALDAV',
handle: 'user@example.com',
connectionParameters: {
CALDAV: {
host: 'https://caldav.example.com',
password: 'secret',
username: 'caldav-user',
},
},
} as unknown as Pick<
ConnectedAccountEntity,
'id' | 'provider' | 'connectionParameters' | 'handle'
>;
await provider.getCalDavCalendarClient(connectedAccount);
expect(
mockSecureHttpClientService.createSsrfSafeFetch,
).toHaveBeenCalledTimes(1);
expect(MockCalDAVClient).toHaveBeenCalledWith(
expect.objectContaining({ fetch: fakeFetch }),
);
});
});
});
@@ -1,41 +0,0 @@
import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service';
import { CalDAVClient } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/caldav.client';
import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
@Injectable()
export class CalDavClientProvider {
constructor(
private readonly secureHttpClientService: SecureHttpClientService,
) {}
public async getCalDavCalendarClient(
connectedAccount: Pick<
ConnectedAccountEntity,
'id' | 'provider' | 'connectionParameters' | 'handle'
>,
): Promise<CalDAVClient> {
if (
!connectedAccount.connectionParameters?.CALDAV?.password ||
!connectedAccount.connectionParameters?.CALDAV?.host ||
!isDefined(connectedAccount.handle)
) {
throw new Error('Missing required CalDAV connection parameters');
}
const serverUrl = connectedAccount.connectionParameters.CALDAV.host;
const ssrfSafeFetch = this.secureHttpClientService.createSsrfSafeFetch();
return new CalDAVClient({
username:
connectedAccount.connectionParameters.CALDAV.username ??
connectedAccount.handle,
password: connectedAccount.connectionParameters.CALDAV.password,
serverUrl,
fetch: ssrfSafeFetch,
});
}
}
@@ -0,0 +1,42 @@
import { Injectable } from '@nestjs/common';
import { DAVClient } from 'tsdav';
import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service';
import { createBasicDigestAuthFetch } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/auth/create-basic-digest-auth-fetch';
type CalDavConnectionParams = {
serverUrl: string;
username: string;
password: string;
};
@Injectable()
export class CalDavClientService {
constructor(
private readonly secureHttpClientService: SecureHttpClientService,
) {}
async getClient(input: CalDavConnectionParams): Promise<DAVClient> {
const ssrfSafeFetch = this.secureHttpClientService.createSsrfSafeFetch();
const fetch = createBasicDigestAuthFetch(
input.username,
input.password,
ssrfSafeFetch,
);
const client = new DAVClient({
serverUrl: input.serverUrl,
credentials: { username: input.username, password: input.password },
authMethod: 'Custom',
// our fetch handles Basic+Digest itself; no-op authFunction so tsdav doesn't add its own header on top
authFunction: async () => ({}),
defaultAccountType: 'caldav',
fetch,
});
await client.login();
return client;
}
}
@@ -0,0 +1,336 @@
import { type DAVClient } from 'tsdav';
import { CalDavFetchEventsService } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/services/caldav-fetch-events.service';
const PRIMARY_URL = 'https://caldav.example.com/calendars/user/primary/';
const PERSONAL_URL = 'https://caldav.example.com/calendars/user/personal/';
const HREF_A = `${PRIMARY_URL}event-a.ics`;
const HREF_B = `${PRIMARY_URL}event-b.ics`;
const buildICal = (uid: string) =>
[
'BEGIN:VCALENDAR',
'VERSION:2.0',
'BEGIN:VEVENT',
`UID:${uid}`,
`SUMMARY:${uid}`,
'DTSTART:20260601T100000Z',
'DTEND:20260601T110000Z',
'STATUS:CONFIRMED',
'END:VEVENT',
'END:VCALENDAR',
].join('\r\n');
const inWindow = {
startDate: new Date('2026-01-01'),
endDate: new Date('2027-01-01'),
};
const buildClient = () => {
const fetchCalendars = jest.fn();
const syncCollection = jest.fn();
const calendarMultiGet = jest.fn();
const propfind = jest.fn();
return {
client: {
fetchCalendars,
syncCollection,
calendarMultiGet,
propfind,
} as unknown as DAVClient,
fetchCalendars,
syncCollection,
calendarMultiGet,
propfind,
};
};
describe('CalDavFetchEventsService', () => {
let service: CalDavFetchEventsService;
beforeEach(() => {
service = new CalDavFetchEventsService();
});
describe('per-calendar tier dispatch', () => {
it('runs Tier-1 on calendars advertising sync-collection and Tier-2/3 on the rest, in parallel', async () => {
const c = buildClient();
c.fetchCalendars.mockResolvedValue([
{
url: PRIMARY_URL,
components: ['VEVENT'],
reports: ['syncCollection'],
},
{ url: PERSONAL_URL, components: ['VEVENT'], reports: [], ctag: 'c-1' },
]);
c.syncCollection.mockResolvedValue([
{ href: HREF_A, status: 207, statusText: 'OK', ok: true, props: {} },
]);
c.propfind.mockResolvedValue([
{
href: HREF_B,
status: 207,
statusText: 'OK',
ok: true,
props: { getetag: '"etag-b"' },
},
]);
c.calendarMultiGet
.mockResolvedValueOnce([
{
href: HREF_A,
status: 207,
statusText: 'OK',
ok: true,
props: { calendarData: buildICal('uid-a') },
},
])
.mockResolvedValueOnce([
{
href: HREF_B,
status: 207,
statusText: 'OK',
ok: true,
props: { calendarData: buildICal('uid-b') },
},
]);
const result = await service.fetchEvents(c.client, inWindow);
expect(result.events.map((event) => event.iCalUid).sort()).toEqual([
'uid-a',
'uid-b',
]);
expect(c.syncCollection).toHaveBeenCalledTimes(1);
expect(c.propfind).toHaveBeenCalledTimes(1);
});
});
describe('Tier-2/3 ctag short-circuit', () => {
it('skips network entirely when the server CTag matches the stored CTag', async () => {
const c = buildClient();
c.fetchCalendars.mockResolvedValue([
{
url: PRIMARY_URL,
components: ['VEVENT'],
reports: [],
ctag: 'unchanged',
},
]);
const storedEtags = { [HREF_A]: '"etag-a"' };
const result = await service.fetchEvents(c.client, {
...inWindow,
syncCursor: {
syncTokens: {},
ctags: { [PRIMARY_URL]: 'unchanged' },
etags: { [PRIMARY_URL]: storedEtags },
},
});
expect(result.events).toEqual([]);
expect(c.propfind).not.toHaveBeenCalled();
expect(c.calendarMultiGet).not.toHaveBeenCalled();
expect(result.syncCursor.etags).toEqual({ [PRIMARY_URL]: storedEtags });
});
});
describe('Tier-2/3 etag diff', () => {
it('fetches only changed hrefs and emits cancelled stubs for hrefs vanished from the server', async () => {
const c = buildClient();
c.fetchCalendars.mockResolvedValue([
{
url: PRIMARY_URL,
components: ['VEVENT'],
reports: [],
ctag: 'new-ctag',
},
]);
c.propfind.mockResolvedValue([
{
href: HREF_A,
status: 207,
statusText: 'OK',
ok: true,
props: { getetag: '"etag-a-updated"' },
},
]);
c.calendarMultiGet.mockResolvedValue([
{
href: HREF_A,
status: 207,
statusText: 'OK',
ok: true,
props: { calendarData: buildICal('uid-a') },
},
]);
const result = await service.fetchEvents(c.client, {
...inWindow,
syncCursor: {
syncTokens: {},
ctags: { [PRIMARY_URL]: 'old-ctag' },
etags: {
[PRIMARY_URL]: { [HREF_A]: '"etag-a"', [HREF_B]: '"etag-b"' },
},
},
});
expect(c.calendarMultiGet).toHaveBeenCalledWith(
expect.objectContaining({ objectUrls: [HREF_A] }),
);
const live = result.events.filter((event) => !event.isCanceled);
const cancelled = result.events.filter((event) => event.isCanceled);
expect(live.map((event) => event.iCalUid)).toEqual(['uid-a']);
expect(cancelled.map((event) => event.id)).toEqual([HREF_B]);
});
});
describe('per-calendar error isolation', () => {
it('preserves the prior cursor entry (token + ctag + etags) for the failing calendar without aborting siblings', async () => {
const c = buildClient();
c.fetchCalendars.mockResolvedValue([
{
url: PRIMARY_URL,
components: ['VEVENT'],
reports: ['syncCollection'],
},
{
url: PERSONAL_URL,
components: ['VEVENT'],
reports: [],
ctag: 'c-new',
},
]);
c.syncCollection.mockRejectedValue(new Error('network blip'));
c.propfind.mockRejectedValue(new Error('propfind blip'));
const priorEtags = { [HREF_A]: '"etag-a"' };
const result = await service.fetchEvents(c.client, {
...inWindow,
syncCursor: {
syncTokens: { [PRIMARY_URL]: 'token-prior' },
ctags: { [PERSONAL_URL]: 'c-prior' },
etags: { [PERSONAL_URL]: priorEtags },
},
});
expect(result.events).toEqual([]);
expect(result.syncCursor.syncTokens[PRIMARY_URL]).toBe('token-prior');
expect(result.syncCursor.ctags?.[PERSONAL_URL]).toBe('c-prior');
expect(result.syncCursor.etags?.[PERSONAL_URL]).toEqual(priorEtags);
});
});
describe('cursor shape', () => {
it('omits ctags and etags entirely when only sync-collection calendars exist', async () => {
const c = buildClient();
c.fetchCalendars.mockResolvedValue([
{
url: PRIMARY_URL,
components: ['VEVENT'],
reports: ['syncCollection'],
},
]);
c.syncCollection.mockResolvedValue([
{
status: 207,
statusText: 'OK',
ok: true,
raw: { multistatus: { syncToken: 'token-fresh' } },
},
]);
c.calendarMultiGet.mockResolvedValue([]);
const result = await service.fetchEvents(c.client, inWindow);
expect(result.syncCursor).toEqual({
syncTokens: { [PRIMARY_URL]: 'token-fresh' },
});
});
});
describe('initial sync (no stored cursor)', () => {
it('omits the sync-token on the first run so the server returns a full listing (RFC 6578 §3.4)', async () => {
const c = buildClient();
c.fetchCalendars.mockResolvedValue([
{
url: PRIMARY_URL,
components: ['VEVENT'],
reports: ['syncCollection'],
syncToken: 'server-current-token',
},
]);
c.syncCollection.mockResolvedValue([
{ href: HREF_A, status: 207, statusText: 'OK', ok: true, props: {} },
]);
c.calendarMultiGet.mockResolvedValue([
{
href: HREF_A,
status: 207,
statusText: 'OK',
ok: true,
props: { calendarData: buildICal('uid-a') },
},
]);
await service.fetchEvents(c.client, inWindow);
expect(c.syncCollection).toHaveBeenCalledWith(
expect.not.objectContaining({ syncToken: expect.anything() }),
);
});
});
describe('time-window filtering', () => {
it('drops events that fall outside the requested [startDate, endDate] window', async () => {
const c = buildClient();
c.fetchCalendars.mockResolvedValue([
{
url: PRIMARY_URL,
components: ['VEVENT'],
reports: ['syncCollection'],
},
]);
c.syncCollection.mockResolvedValue([
{ href: HREF_A, status: 207, statusText: 'OK', ok: true, props: {} },
]);
c.calendarMultiGet.mockResolvedValue([
{
href: HREF_A,
status: 207,
statusText: 'OK',
ok: true,
props: { calendarData: buildICal('uid-a') },
},
]);
const result = await service.fetchEvents(c.client, {
startDate: new Date('2030-01-01'),
endDate: new Date('2030-12-31'),
});
expect(result.events).toEqual([]);
});
});
});
@@ -0,0 +1,290 @@
import { Injectable, Logger } from '@nestjs/common';
import { isNonEmptyString } from '@sniptt/guards';
import {
type DAVCalendar,
type DAVClient,
type DAVResponse,
DAVNamespaceShort,
} from 'tsdav';
import { isDefined } from 'twenty-shared/utils';
import { type CalDavSyncCursor } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/types/caldav-sync-cursor';
import { buildCancelledCalDavEvent } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/build-cancelled-event.util';
import { extractICalData } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/extract-ical-data.util';
import { isEventInTimeRange } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/is-event-in-time-range.util';
import { isInvalidSyncTokenResponse } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/is-invalid-sync-token-response.util';
import { isValidCalDavHref } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/is-valid-caldav-href.util';
import { parseICalEvents } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/parse-ical-event.util';
import { type FetchedCalendarEvent } from 'src/modules/calendar/common/types/fetched-calendar-event';
type CalendarSyncResult = {
calendarUrl: string;
events: FetchedCalendarEvent[];
newSyncToken?: string;
newCtag?: string;
newEtags?: Record<string, string>;
};
type FetchEventsOptions = {
startDate: Date;
endDate: Date;
syncCursor?: CalDavSyncCursor;
};
@Injectable()
export class CalDavFetchEventsService {
private readonly logger = new Logger(CalDavFetchEventsService.name);
async listEventCalendars(client: DAVClient): Promise<DAVCalendar[]> {
const calendars = await client.fetchCalendars();
return calendars.filter((calendar) =>
calendar.components?.includes('VEVENT'),
);
}
async fetchEvents(
client: DAVClient,
options: FetchEventsOptions,
): Promise<{ events: FetchedCalendarEvent[]; syncCursor: CalDavSyncCursor }> {
const calendars = await this.listEventCalendars(client);
const results = await Promise.all(
calendars.map((calendar) => this.syncCalendar(client, calendar, options)),
);
return {
events: results.flatMap((result) => result.events),
syncCursor: this.mergeSyncCursor(results),
};
}
private async syncCalendar(
client: DAVClient,
calendar: DAVCalendar,
options: FetchEventsOptions,
): Promise<CalendarSyncResult> {
const supportsSyncCollection =
calendar.reports?.includes('syncCollection') ?? false;
try {
return supportsSyncCollection
? await this.fetchEventsViaSyncCollection(client, calendar, options)
: await this.fetchEventsViaCtagEtag(client, calendar, options);
} catch (error) {
this.logger.error(`Per-calendar sync failed for ${calendar.url}`, error);
return {
calendarUrl: calendar.url,
events: [],
newSyncToken: options.syncCursor?.syncTokens[calendar.url],
newCtag: options.syncCursor?.ctags?.[calendar.url],
newEtags: options.syncCursor?.etags?.[calendar.url],
};
}
}
private async fetchEventsViaSyncCollection(
client: DAVClient,
calendar: DAVCalendar,
options: FetchEventsOptions,
): Promise<CalendarSyncResult> {
const previousSyncToken = options.syncCursor?.syncTokens[calendar.url];
const syncResult = await this.runSyncCollection(
client,
calendar.url,
previousSyncToken,
);
const memberResponses = syncResult.filter(
(entry): entry is DAVResponse & { href: string } =>
isNonEmptyString(entry.href) && isValidCalDavHref(entry.href),
);
const changedHrefs = memberResponses
.filter((entry) => entry.status !== 404)
.map((entry) => entry.href);
const cancelledHrefs = memberResponses
.filter((entry) => entry.status === 404)
.map((entry) => entry.href);
const fetchedEvents = await this.fetchAndParseEvents(
client,
calendar.url,
changedHrefs,
options,
);
const rawSyncToken = syncResult[0]?.raw?.multistatus?.syncToken;
const newSyncToken = isNonEmptyString(rawSyncToken)
? rawSyncToken
: previousSyncToken;
return {
calendarUrl: calendar.url,
events: [
...fetchedEvents,
...cancelledHrefs.map(buildCancelledCalDavEvent),
],
newSyncToken,
};
}
private async runSyncCollection(
client: DAVClient,
url: string,
previousSyncToken: string | undefined,
): Promise<DAVResponse[]> {
const send = (token: string | undefined) =>
client.syncCollection({
url,
props: {
[`${DAVNamespaceShort.DAV}:getetag`]: {},
[`${DAVNamespaceShort.CALDAV}:calendar-data`]: {},
},
syncLevel: 1,
...(isNonEmptyString(token) ? { syncToken: token } : {}),
});
const result = await send(previousSyncToken);
if (
isNonEmptyString(previousSyncToken) &&
isInvalidSyncTokenResponse(result)
) {
this.logger.warn(
`Sync-token invalidated for ${url}; falling back to full re-sync`,
);
return send(undefined);
}
return result;
}
private async fetchEventsViaCtagEtag(
client: DAVClient,
calendar: DAVCalendar,
options: FetchEventsOptions,
): Promise<CalendarSyncResult> {
const storedEtags = options.syncCursor?.etags?.[calendar.url] ?? {};
const newCtag = isDefined(calendar.ctag)
? String(calendar.ctag)
: undefined;
const storedCtag = options.syncCursor?.ctags?.[calendar.url];
if (isDefined(newCtag) && isDefined(storedCtag) && newCtag === storedCtag) {
return {
calendarUrl: calendar.url,
events: [],
newCtag,
newEtags: storedEtags,
};
}
const currentEtags = await this.fetchEtagsByHref(client, calendar.url);
const changedHrefs = Object.keys(currentEtags).filter(
(href) => storedEtags[href] !== currentEtags[href],
);
const cancelledHrefs = Object.keys(storedEtags).filter(
(href) => !(href in currentEtags),
);
const fetchedEvents = await this.fetchAndParseEvents(
client,
calendar.url,
changedHrefs,
options,
);
return {
calendarUrl: calendar.url,
events: [
...fetchedEvents,
...cancelledHrefs.map(buildCancelledCalDavEvent),
],
newCtag,
newEtags: currentEtags,
};
}
private mergeSyncCursor(results: CalendarSyncResult[]): CalDavSyncCursor {
const syncTokens: Record<string, string> = {};
const ctags: Record<string, string> = {};
const etags: Record<string, Record<string, string>> = {};
for (const result of results) {
if (result.newSyncToken)
syncTokens[result.calendarUrl] = result.newSyncToken;
if (result.newCtag) ctags[result.calendarUrl] = result.newCtag;
if (result.newEtags) etags[result.calendarUrl] = result.newEtags;
}
return {
syncTokens,
ctags: Object.keys(ctags).length > 0 ? ctags : undefined,
etags: Object.keys(etags).length > 0 ? etags : undefined,
};
}
private async fetchEtagsByHref(
client: DAVClient,
calendarUrl: string,
): Promise<Record<string, string>> {
const responses = await client.propfind({
url: calendarUrl,
props: { [`${DAVNamespaceShort.DAV}:getetag`]: {} },
depth: '1',
});
return responses.reduce<Record<string, string>>((map, response) => {
const href = response.href;
const etag = response.props?.getetag;
if (
!isNonEmptyString(href) ||
!isNonEmptyString(etag) ||
!isValidCalDavHref(href)
) {
return map;
}
map[href] = etag;
return map;
}, {});
}
private async fetchAndParseEvents(
client: DAVClient,
calendarUrl: string,
objectUrls: string[],
options: { startDate: Date; endDate: Date },
): Promise<FetchedCalendarEvent[]> {
if (objectUrls.length === 0) return [];
const calendarObjects = await client.calendarMultiGet({
url: calendarUrl,
props: {
[`${DAVNamespaceShort.DAV}:getetag`]: {},
[`${DAVNamespaceShort.CALDAV}:calendar-data`]: {},
},
objectUrls,
depth: '1',
});
return calendarObjects.flatMap((calendarObject) => {
const iCalData = extractICalData(calendarObject.props?.calendarData);
if (!iCalData) return [];
return parseICalEvents(iCalData, calendarObject.href || '').filter(
(event) =>
isEventInTimeRange(event, options.startDate, options.endDate),
);
});
}
}
@@ -1,9 +1,14 @@
import { Injectable, Logger } from '@nestjs/common';
import { CalDavClientProvider } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/providers/caldav.provider';
import { isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { CalDavClientService } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/services/caldav-client.service';
import { CalDavFetchEventsService } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/services/caldav-fetch-events.service';
import { type CalDavSyncCursor } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/types/caldav-sync-cursor';
import { parseCalDAVError } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/parse-caldav-error.util';
import { type GetCalendarEventsResponse } from 'src/modules/calendar/calendar-event-import-manager/services/calendar-get-events.service';
import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
@Injectable()
export class CalDavGetEventsService {
@@ -13,10 +18,11 @@ export class CalDavGetEventsService {
private static readonly FUTURE_DAYS_WINDOW = 365;
constructor(
private readonly caldavCalendarClientProvider: CalDavClientProvider,
private readonly clientService: CalDavClientService,
private readonly fetchEventsService: CalDavFetchEventsService,
) {}
public async getCalendarEvents(
async getCalendarEvents(
connectedAccount: Pick<
ConnectedAccountEntity,
'provider' | 'id' | 'connectionParameters' | 'handle'
@@ -26,10 +32,21 @@ export class CalDavGetEventsService {
this.logger.debug(`Getting calendar events for ${connectedAccount.handle}`);
try {
const caldavCalendarClient =
await this.caldavCalendarClientProvider.getCalDavCalendarClient(
connectedAccount,
);
const params = connectedAccount.connectionParameters?.CALDAV;
if (
!isNonEmptyString(params?.host) ||
!isNonEmptyString(params?.password) ||
!isDefined(connectedAccount.handle)
) {
throw new Error('Missing required CalDAV connection parameters');
}
const client = await this.clientService.getClient({
serverUrl: params.host,
username: params.username ?? connectedAccount.handle,
password: params.password,
});
const startDate = new Date(
Date.now() -
@@ -40,10 +57,12 @@ export class CalDavGetEventsService {
CalDavGetEventsService.FUTURE_DAYS_WINDOW * 24 * 60 * 60 * 1000,
);
const result = await caldavCalendarClient.getEvents({
const result = await this.fetchEventsService.fetchEvents(client, {
startDate,
endDate,
syncCursor: syncCursor ? JSON.parse(syncCursor) : undefined,
syncCursor: syncCursor
? (JSON.parse(syncCursor) as CalDavSyncCursor)
: undefined,
});
this.logger.debug(
@@ -56,17 +75,12 @@ export class CalDavGetEventsService {
nextSyncCursor: JSON.stringify(result.syncCursor),
};
} catch (error) {
this.handleError(error as Error);
throw error;
this.logger.error(
`Error in ${CalDavGetEventsService.name} - getCalendarEvents`,
error,
);
throw parseCalDAVError(error as Error);
}
}
private handleError(error: Error) {
this.logger.error(
`Error in ${CalDavGetEventsService.name} - getCalendarEvents`,
error,
);
throw parseCalDAVError(error);
}
}
@@ -0,0 +1,5 @@
export type CalDavSyncCursor = {
syncTokens: Record<string, string>;
ctags?: Record<string, string>;
etags?: Record<string, Record<string, string>>;
};
@@ -0,0 +1,34 @@
import { extractICalData } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/extract-ical-data.util';
const VCALENDAR_PAYLOAD = 'BEGIN:VCALENDAR\r\nEND:VCALENDAR';
describe('extractICalData', () => {
it('returns the string as-is when it already contains VCALENDAR', () => {
expect(extractICalData(VCALENDAR_PAYLOAD)).toBe(VCALENDAR_PAYLOAD);
});
it('unwraps nested CDATA-style wrappers', () => {
expect(extractICalData({ _cdata: VCALENDAR_PAYLOAD })).toBe(
VCALENDAR_PAYLOAD,
);
});
it('recurses through arbitrarily nested objects', () => {
expect(
extractICalData({
outer: { inner: { deeper: VCALENDAR_PAYLOAD } },
}),
).toBe(VCALENDAR_PAYLOAD);
});
it('returns null when no VCALENDAR block is found', () => {
expect(extractICalData('not a calendar')).toBeNull();
expect(extractICalData({ random: 'payload' })).toBeNull();
});
it.each([null, undefined, ''])('returns null for empty input %s', (value) => {
expect(
extractICalData(value as unknown as string | Record<string, unknown>),
).toBeNull();
});
});
@@ -0,0 +1,66 @@
import { isEventInTimeRange } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/is-event-in-time-range.util';
import { type FetchedCalendarEvent } from 'src/modules/calendar/common/types/fetched-calendar-event';
const event = (startsAt: string, endsAt: string) =>
({ startsAt, endsAt }) as unknown as FetchedCalendarEvent;
const WINDOW_START = new Date('2026-01-01');
const WINDOW_END = new Date('2026-12-31');
describe('isEventInTimeRange', () => {
it('returns false when the event lacks start or end', () => {
expect(isEventInTimeRange(event('', ''), WINDOW_START, WINDOW_END)).toBe(
false,
);
});
it('includes events fully inside the window', () => {
expect(
isEventInTimeRange(
event('2026-06-01T10:00:00Z', '2026-06-01T11:00:00Z'),
WINDOW_START,
WINDOW_END,
),
).toBe(true);
});
it('excludes events entirely before the window', () => {
expect(
isEventInTimeRange(
event('2025-06-01T10:00:00Z', '2025-06-01T11:00:00Z'),
WINDOW_START,
WINDOW_END,
),
).toBe(false);
});
it('excludes events entirely after the window', () => {
expect(
isEventInTimeRange(
event('2027-06-01T10:00:00Z', '2027-06-01T11:00:00Z'),
WINDOW_START,
WINDOW_END,
),
).toBe(false);
});
it('includes events that straddle the start boundary', () => {
expect(
isEventInTimeRange(
event('2025-12-31T22:00:00Z', '2026-01-01T02:00:00Z'),
WINDOW_START,
WINDOW_END,
),
).toBe(true);
});
it('includes events that straddle the end boundary', () => {
expect(
isEventInTimeRange(
event('2026-12-30T22:00:00Z', '2027-01-01T02:00:00Z'),
WINDOW_START,
WINDOW_END,
),
).toBe(true);
});
});
@@ -0,0 +1,30 @@
import { isValidCalDavHref } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/is-valid-caldav-href.util';
describe('isValidCalDavHref', () => {
it.each([
'https://caldav.example.com/calendars/user/event.ics',
'https://caldav.example.com/calendars/user/event.ICS',
'https://caldav.example.com/calendars/user/message.eml',
])('accepts CalDAV-format href %s', (href) => {
expect(isValidCalDavHref(href)).toBe(true);
});
it.each([
'https://caldav.example.com/calendars/user/notes.txt',
'https://caldav.example.com/calendars/user/data.json',
])('rejects non-CalDAV extension %s', (href) => {
expect(isValidCalDavHref(href)).toBe(false);
});
it('treats hrefs without an extension as ics (some servers omit it)', () => {
expect(
isValidCalDavHref('https://caldav.example.com/calendars/user/abc123'),
).toBe(true);
});
it('treats trailing slash as no-extension', () => {
expect(
isValidCalDavHref('https://caldav.example.com/calendars/user/'),
).toBe(true);
});
});
@@ -0,0 +1,47 @@
import {
CalendarEventImportDriverException,
CalendarEventImportDriverExceptionCode,
} from 'src/modules/calendar/calendar-event-import-manager/drivers/exceptions/calendar-event-import-driver.exception';
import { parseCalDAVError } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/parse-caldav-error.util';
describe('parseCalDAVError', () => {
it('maps tsdav auth-failure messages to INSUFFICIENT_PERMISSIONS', () => {
for (const message of [
'no account for fetchCalendars',
'Must have account before syncCalendars',
'Invalid credentials',
'Invalid auth method',
]) {
const result = parseCalDAVError(new Error(message));
expect(result).toBeInstanceOf(CalendarEventImportDriverException);
expect(result.code).toBe(
CalendarEventImportDriverExceptionCode.INSUFFICIENT_PERMISSIONS,
);
}
});
it('maps tsdav not-found messages to NOT_FOUND', () => {
for (const message of [
'Collection does not exist on server',
'cannot find homeUrl',
'cannot fetchCalendarObjects for undefined calendar',
]) {
expect(parseCalDAVError(new Error(message)).code).toBe(
CalendarEventImportDriverExceptionCode.NOT_FOUND,
);
}
});
it('falls back to UNKNOWN for unrecognised errors', () => {
expect(parseCalDAVError(new Error('TLS handshake failed')).code).toBe(
CalendarEventImportDriverExceptionCode.UNKNOWN,
);
});
it('forwards the original error message untouched', () => {
const result = parseCalDAVError(new Error('Invalid credentials'));
expect(result.message).toBe('Invalid credentials');
});
});
@@ -0,0 +1,257 @@
import { CalendarEventParticipantResponseStatus } from 'src/modules/calendar/common/standard-objects/calendar-event-participant.workspace-entity';
import { parseICalEvents } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/parse-ical-event.util';
const HREF = 'https://caldav.example.com/calendars/user/event-1.ics';
const buildVCalendar = (vevents: string[][]) =>
[
'BEGIN:VCALENDAR',
'VERSION:2.0',
...vevents.flatMap((lines) => ['BEGIN:VEVENT', ...lines, 'END:VEVENT']),
'END:VCALENDAR',
].join('\r\n');
const buildVEvent = (lines: string[]) => buildVCalendar([lines]);
describe('parseICalEvents', () => {
it('returns an empty array when the payload contains no VEVENT', () => {
const ics = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'END:VCALENDAR'].join(
'\r\n',
);
expect(parseICalEvents(ics, HREF)).toEqual([]);
});
it('extracts the canonical VEVENT fields', () => {
const ics = buildVEvent([
'UID:abc123',
'SUMMARY:Quarterly review',
'DESCRIPTION:Discuss Q2 numbers',
'LOCATION:Room 4',
'DTSTART:20260601T100000Z',
'DTEND:20260601T110000Z',
]);
const [event] = parseICalEvents(ics, HREF);
expect(event).toMatchObject({
id: HREF,
iCalUid: 'abc123',
title: 'Quarterly review',
description: 'Discuss Q2 numbers',
location: 'Room 4',
startsAt: '2026-06-01T10:00:00.000Z',
endsAt: '2026-06-01T11:00:00.000Z',
isCanceled: false,
status: 'CONFIRMED',
});
});
it('falls back to "Untitled Event" when SUMMARY is missing', () => {
const ics = buildVEvent([
'UID:abc',
'DTSTART:20260601T100000Z',
'DTEND:20260601T110000Z',
]);
expect(parseICalEvents(ics, HREF)[0].title).toBe('Untitled Event');
});
it('flags isFullDay when DTSTART carries VALUE=DATE', () => {
const ics = buildVEvent([
'UID:abc',
'SUMMARY:Holiday',
'DTSTART;VALUE=DATE:20260704',
'DTEND;VALUE=DATE:20260705',
]);
expect(parseICalEvents(ics, HREF)[0].isFullDay).toBe(true);
});
it('classifies isFullDay per-event when a full-day master has a timed recurrence override', () => {
const ics = buildVCalendar([
[
'UID:series-fullday',
'SUMMARY:Daily standup',
'DTSTART;VALUE=DATE:20260601',
'DTEND;VALUE=DATE:20260602',
'RRULE:FREQ=DAILY',
],
[
'UID:series-fullday',
'SUMMARY:Daily standup (rescheduled to a meeting)',
'DTSTART:20260605T140000Z',
'DTEND:20260605T143000Z',
'RECURRENCE-ID;VALUE=DATE:20260605',
],
]);
const [master, override] = parseICalEvents(ics, HREF);
expect(master.isFullDay).toBe(true);
expect(override.isFullDay).toBe(false);
});
it('flags isCanceled when STATUS:CANCELLED is present', () => {
const ics = buildVEvent([
'UID:abc',
'SUMMARY:Off',
'DTSTART:20260601T100000Z',
'DTEND:20260601T110000Z',
'STATUS:CANCELLED',
]);
const [event] = parseICalEvents(ics, HREF);
expect(event.isCanceled).toBe(true);
expect(event.status).toBe('CANCELLED');
});
it('attaches recurringEventExternalId in ISO format when RECURRENCE-ID is present', () => {
const ics = buildVEvent([
'UID:series-1',
'SUMMARY:Standup',
'DTSTART:20260601T100000Z',
'DTEND:20260601T101500Z',
'RECURRENCE-ID:20260601T100000Z',
]);
expect(parseICalEvents(ics, HREF)[0].recurringEventExternalId).toBe(
'2026-06-01T10:00:00.000Z',
);
});
it('returns master + override events as separate entries with unique ids (RFC 5545 §3.8.4.4)', () => {
const ics = buildVCalendar([
[
'UID:series-1',
'SUMMARY:Standup',
'DTSTART:20260601T100000Z',
'DTEND:20260601T101500Z',
'RRULE:FREQ=WEEKLY',
],
[
'UID:series-1',
'SUMMARY:Standup (rescheduled)',
'DTSTART:20260609T110000Z',
'DTEND:20260609T111500Z',
'RECURRENCE-ID:20260608T100000Z',
],
]);
const events = parseICalEvents(ics, HREF);
expect(events).toHaveLength(2);
expect(events[0]).toMatchObject({
id: HREF,
iCalUid: 'series-1',
recurringEventExternalId: undefined,
});
expect(events[1]).toMatchObject({
id: `${HREF}#recurrence=2026-06-08T10:00:00.000Z`,
iCalUid: 'series-1',
title: 'Standup (rescheduled)',
recurringEventExternalId: '2026-06-08T10:00:00.000Z',
});
});
it('handles bare ORGANIZER (plain mailto, no CN) — node-ical returns a string', () => {
const ics = buildVEvent([
'UID:abc',
'SUMMARY:Sync',
'DTSTART:20260601T100000Z',
'DTEND:20260601T110000Z',
'ORGANIZER:mailto:bare@example.com',
]);
expect(parseICalEvents(ics, HREF)[0].participants[0]).toMatchObject({
handle: 'bare@example.com',
displayName: 'bare@example.com',
isOrganizer: true,
});
});
it('handles bare ATTENDEE (plain mailto, no params) — node-ical returns a string', () => {
const ics = buildVEvent([
'UID:abc',
'SUMMARY:Sync',
'DTSTART:20260601T100000Z',
'DTEND:20260601T110000Z',
'ATTENDEE:mailto:bare@example.com',
]);
const attendee = parseICalEvents(ics, HREF)[0].participants.find(
(p) => !p.isOrganizer,
);
expect(attendee).toMatchObject({
handle: 'bare@example.com',
displayName: 'bare@example.com',
isOrganizer: false,
});
});
it('extracts the organizer with mailto stripped and ACCEPTED status', () => {
const ics = buildVEvent([
'UID:abc',
'SUMMARY:Sync',
'DTSTART:20260601T100000Z',
'DTEND:20260601T110000Z',
'ORGANIZER;CN=Alice Org:mailto:alice@example.com',
]);
expect(parseICalEvents(ics, HREF)[0].participants[0]).toMatchObject({
handle: 'alice@example.com',
displayName: 'Alice Org',
isOrganizer: true,
responseStatus: CalendarEventParticipantResponseStatus.ACCEPTED,
});
});
it('maps each ATTENDEE PARTSTAT to the matching response status', () => {
const ics = buildVEvent([
'UID:abc',
'SUMMARY:Sync',
'DTSTART:20260601T100000Z',
'DTEND:20260601T110000Z',
'ATTENDEE;CN=Bob;PARTSTAT=ACCEPTED:mailto:bob@example.com',
'ATTENDEE;CN=Carol;PARTSTAT=DECLINED:mailto:carol@example.com',
'ATTENDEE;CN=Dan;PARTSTAT=TENTATIVE:mailto:dan@example.com',
'ATTENDEE;CN=Eve;PARTSTAT=NEEDS-ACTION:mailto:eve@example.com',
'ATTENDEE;CN=Frank;PARTSTAT=DELEGATED:mailto:frank@example.com',
]);
const [event] = parseICalEvents(ics, HREF);
const byHandle = Object.fromEntries(
event.participants.map((p) => [p.handle, p.responseStatus]),
);
expect(byHandle).toEqual({
'bob@example.com': CalendarEventParticipantResponseStatus.ACCEPTED,
'carol@example.com': CalendarEventParticipantResponseStatus.DECLINED,
'dan@example.com': CalendarEventParticipantResponseStatus.TENTATIVE,
'eve@example.com': CalendarEventParticipantResponseStatus.NEEDS_ACTION,
'frank@example.com': CalendarEventParticipantResponseStatus.NEEDS_ACTION,
});
});
it('returns an empty array and does not throw when iCal data is malformed', () => {
expect(parseICalEvents('not a calendar', HREF)).toEqual([]);
});
it('skips a VEVENT missing DTSTART without dropping its siblings', () => {
const ics = buildVCalendar([
['UID:no-start', 'SUMMARY:Broken'],
[
'UID:healthy',
'SUMMARY:Healthy',
'DTSTART:20260601T100000Z',
'DTEND:20260601T110000Z',
],
]);
const events = parseICalEvents(ics, HREF);
expect(events.map((event) => event.iCalUid)).toEqual(['healthy']);
});
});
@@ -0,0 +1,22 @@
import { type FetchedCalendarEvent } from 'src/modules/calendar/common/types/fetched-calendar-event';
export const buildCancelledCalDavEvent = (
href: string,
): FetchedCalendarEvent => ({
id: href,
title: '',
iCalUid: '',
description: '',
startsAt: '',
endsAt: '',
location: '',
isFullDay: false,
isCanceled: true,
conferenceLinkLabel: '',
conferenceLinkUrl: '',
externalCreatedAt: '',
externalUpdatedAt: '',
conferenceSolution: '',
participants: [],
status: 'CANCELLED',
});
@@ -0,0 +1,32 @@
import { isString } from '@sniptt/guards';
import type * as ical from 'node-ical';
import { isDefined } from 'twenty-shared/utils';
import { mapPartStatToResponseStatus } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/map-partstat-to-response-status.util';
import { type FetchedCalendarEventParticipant } from 'src/modules/calendar/common/types/fetched-calendar-event';
export const extractAttendeesFromEvent = (
event: ical.VEvent,
): FetchedCalendarEventParticipant[] => {
if (!isDefined(event.attendee)) return [];
const attendees = Array.isArray(event.attendee)
? event.attendee
: [event.attendee];
return attendees.map((attendee) => {
const rawValue = isString(attendee) ? attendee : attendee.val;
const params = isString(attendee) ? undefined : attendee.params;
const handle = rawValue.replace(/^mailto:/i, '');
const partStat = params?.PARTSTAT ?? 'NEEDS_ACTION';
return {
displayName: params?.CN || handle || 'Unknown',
responseStatus: mapPartStatToResponseStatus(
partStat as ical.AttendeePartStat,
),
handle,
isOrganizer: false,
};
});
};
@@ -0,0 +1,24 @@
import { isString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
// Some CalDAV servers return calendar-data as a string, others nest it under
// _cdata or other shapes. Recurse until a VCALENDAR block is found.
export const extractICalData = (
calendarData: string | Record<string, unknown> | null | undefined,
): string | null => {
if (!isDefined(calendarData)) return null;
if (isString(calendarData) && calendarData.includes('VCALENDAR')) {
return calendarData;
}
if (typeof calendarData === 'object') {
for (const value of Object.values(calendarData)) {
const result = extractICalData(value as string | Record<string, unknown>);
if (isDefined(result)) return result;
}
}
return null;
};
@@ -0,0 +1,25 @@
import { isString } from '@sniptt/guards';
import type * as ical from 'node-ical';
import { isDefined } from 'twenty-shared/utils';
import { CalendarEventParticipantResponseStatus } from 'src/modules/calendar/common/standard-objects/calendar-event-participant.workspace-entity';
import { type FetchedCalendarEventParticipant } from 'src/modules/calendar/common/types/fetched-calendar-event';
export const extractOrganizerFromEvent = (
event: ical.VEvent,
): FetchedCalendarEventParticipant | null => {
const organizer = event.organizer;
if (!isDefined(organizer)) return null;
const rawValue = isString(organizer) ? organizer : organizer.val;
const commonName = isString(organizer) ? undefined : organizer.params?.CN;
const handle = rawValue.replace(/^mailto:/i, '');
return {
displayName: commonName || handle || 'Unknown',
responseStatus: CalendarEventParticipantResponseStatus.ACCEPTED,
handle,
isOrganizer: true,
};
};
@@ -0,0 +1,17 @@
import { isNonEmptyString } from '@sniptt/guards';
import { type FetchedCalendarEvent } from 'src/modules/calendar/common/types/fetched-calendar-event';
export const isEventInTimeRange = (
event: FetchedCalendarEvent,
windowStart: Date,
windowEnd: Date,
): boolean => {
if (!isNonEmptyString(event.startsAt) || !isNonEmptyString(event.endsAt))
return false;
const eventStart = new Date(event.startsAt);
const eventEnd = new Date(event.endsAt);
return eventStart < windowEnd && eventEnd > windowStart;
};
@@ -0,0 +1,17 @@
import { isNonEmptyString } from '@sniptt/guards';
import { type DAVResponse } from 'tsdav';
import { isDefined } from 'twenty-shared/utils';
export const isInvalidSyncTokenResponse = (
responses: DAVResponse[],
): boolean => {
const DAVResponse = responses[0];
if (!isDefined(DAVResponse) || DAVResponse.status !== 403) return false;
const body = isNonEmptyString(DAVResponse.raw)
? DAVResponse.raw
: JSON.stringify(DAVResponse.raw ?? {});
return body.includes('valid-sync-token') || body.includes('validSyncToken');
};
@@ -0,0 +1,6 @@
import { extname } from 'node:path';
const ALLOWED_EXTENSIONS = new Set(['', '.ics', '.eml']);
export const isValidCalDavHref = (url: string): boolean =>
ALLOWED_EXTENSIONS.has(extname(url).toLowerCase());
@@ -0,0 +1,20 @@
import type * as ical from 'node-ical';
import { CalendarEventParticipantResponseStatus } from 'src/modules/calendar/common/standard-objects/calendar-event-participant.workspace-entity';
export const mapPartStatToResponseStatus = (
partStat: ical.AttendeePartStat,
): CalendarEventParticipantResponseStatus => {
switch (partStat) {
case 'ACCEPTED':
return CalendarEventParticipantResponseStatus.ACCEPTED;
case 'DECLINED':
return CalendarEventParticipantResponseStatus.DECLINED;
case 'TENTATIVE':
return CalendarEventParticipantResponseStatus.TENTATIVE;
case 'DELEGATED':
case 'NEEDS-ACTION':
default:
return CalendarEventParticipantResponseStatus.NEEDS_ACTION;
}
};
@@ -0,0 +1,57 @@
import * as ical from 'node-ical';
import { icalDataExtractPropertyValue } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/utils/icalDataExtractPropertyValue';
import { extractAttendeesFromEvent } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/extract-attendees-from-event.util';
import { extractOrganizerFromEvent } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/extract-organizer-from-event.util';
import { type FetchedCalendarEvent } from 'src/modules/calendar/common/types/fetched-calendar-event';
export const parseICalEvents = (
rawData: string,
objectUrl: string,
): FetchedCalendarEvent[] => {
try {
const events = Object.values(ical.parseICS(rawData))
.filter(
(calendarComponent): calendarComponent is ical.VEvent =>
calendarComponent.type === 'VEVENT',
)
.flatMap((event) => [event, ...Object.values(event.recurrences ?? {})])
.filter(
(event) => event.start instanceof Date && event.end instanceof Date,
);
return events.map((event) => {
const organizer = extractOrganizerFromEvent(event);
const attendees = extractAttendeesFromEvent(event);
const recurrenceIso =
event.recurrenceid instanceof Date
? event.recurrenceid.toISOString()
: undefined;
const createdIso =
event.created?.toISOString() ?? new Date().toISOString();
return {
id: recurrenceIso
? `${objectUrl}#recurrence=${recurrenceIso}`
: objectUrl,
iCalUid: event.uid || '',
title: icalDataExtractPropertyValue(event.summary, 'Untitled Event'),
description: icalDataExtractPropertyValue(event.description),
location: icalDataExtractPropertyValue(event.location),
startsAt: event.start.toISOString(),
endsAt: event.end.toISOString(),
isFullDay: event.datetype === 'date',
isCanceled: event.status === 'CANCELLED',
status: event.status || 'CONFIRMED',
recurringEventExternalId: recurrenceIso,
conferenceLinkLabel: '',
conferenceLinkUrl: icalDataExtractPropertyValue(event.url),
conferenceSolution: '',
externalCreatedAt: createdIso,
externalUpdatedAt: event.lastmodified?.toISOString() ?? createdIso,
participants: organizer ? [organizer, ...attendees] : attendees,
};
});
} catch {
return [];
}
};