Compare commits

...
Author SHA1 Message Date
neo773 5486db4527 wip 2026-04-30 15:59:38 +05:30
neo773 3529eb0c0b wip 2026-04-30 15:50:55 +05:30
neo773 ce519d62b0 add legacy sync 2026-04-30 15:35:24 +05:30
5 changed files with 612 additions and 140 deletions
@@ -11,8 +11,7 @@ jest.mock(
'src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/caldav.client',
() => ({
CalDAVClient: jest.fn().mockImplementation(() => ({
listCalendars: jest.fn().mockResolvedValue([]),
validateSyncCollectionSupport: jest.fn().mockResolvedValue(undefined),
detectServerCapability: jest.fn().mockResolvedValue('sync-collection'),
})),
}),
);
@@ -141,20 +141,13 @@ export class ImapSmtpCaldavService {
});
try {
await client.listCalendars();
await client.validateSyncCollectionSupport();
await client.detectServerCapability();
} catch (error) {
this.logger.error(
`CALDAV connection failed: ${error.message}`,
error.stack,
);
if (error.message?.includes('CALDAV_SYNC_COLLECTION_NOT_SUPPORTED')) {
throw new UserInputError(`CALDAV connection failed: ${error.message}`, {
userFriendlyMessage: msg`Your CalDAV server does not support incremental sync (RFC 6578). Please use a compatible provider such as Nextcloud, iCloud, or Fastmail.`,
});
}
if (error.code === 'FailedToOpenSocket') {
throw new UserInputError(`CALDAV connection failed: ${error.message}`, {
userFriendlyMessage: msg`We couldn't connect to your CalDAV server. Please check your server settings and try again.`,
@@ -0,0 +1,321 @@
jest.mock('tsdav', () => {
const actual = jest.requireActual('tsdav');
return {
...actual,
createAccount: jest.fn(),
fetchCalendars: jest.fn(),
syncCollection: jest.fn(),
calendarMultiGet: jest.fn(),
propfind: jest.fn(),
};
});
jest.mock(
'src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/auth/create-basic-digest-auth-fetch',
() => ({
createBasicDigestAuthFetch: jest.fn(() => globalThis.fetch),
}),
);
import {
calendarMultiGet,
createAccount,
fetchCalendars,
propfind,
syncCollection,
} from 'tsdav';
import { CalDAVClient } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/caldav.client';
const mockCreateAccount = createAccount as jest.MockedFunction<
typeof createAccount
>;
const mockFetchCalendars = fetchCalendars as jest.MockedFunction<
typeof fetchCalendars
>;
const mockSyncCollection = syncCollection as jest.MockedFunction<
typeof syncCollection
>;
const mockCalendarMultiGet = calendarMultiGet as jest.MockedFunction<
typeof calendarMultiGet
>;
const mockPropfind = propfind as jest.MockedFunction<typeof propfind>;
const CALENDAR_URL = 'https://caldav.example.com/calendars/user/default/';
const FIRST_HREF = `${CALENDAR_URL}event-1.ics`;
const SECOND_HREF = `${CALENDAR_URL}event-2.ics`;
const buildICalData = (uid: string, summary: string) =>
[
'BEGIN:VCALENDAR',
'VERSION:2.0',
'BEGIN:VEVENT',
`UID:${uid}`,
`SUMMARY:${summary}`,
'DTSTART:20260501T100000Z',
'DTEND:20260501T110000Z',
'STATUS:CONFIRMED',
'END:VEVENT',
'END:VCALENDAR',
].join('\r\n');
const buildClient = () =>
new CalDAVClient({
serverUrl: 'https://caldav.example.com',
username: 'user@example.com',
password: 'password',
});
const buildLegacyCalendar = (ctag: string) => ({
url: CALENDAR_URL,
components: ['VEVENT'],
reports: [],
ctag,
});
const buildSyncCollectionCalendar = () => ({
url: CALENDAR_URL,
components: ['VEVENT'],
reports: ['syncCollection'],
syncToken: 'token-1',
});
describe('CalDAVClient — CTag/ETag fallback', () => {
beforeEach(() => {
jest.clearAllMocks();
mockCreateAccount.mockResolvedValue({
serverUrl: 'https://caldav.example.com',
accountType: 'caldav',
rootUrl: 'https://caldav.example.com/',
principalUrl: 'https://caldav.example.com/principal/',
homeUrl: 'https://caldav.example.com/calendars/user/',
} as Awaited<ReturnType<typeof createAccount>>);
});
it('returns no events and preserves stored etags when the calendar CTag is unchanged', async () => {
mockFetchCalendars.mockResolvedValue([buildLegacyCalendar('ctag-v1')]);
const client = buildClient();
const previousEtags = {
[FIRST_HREF]: '"etag-1"',
[SECOND_HREF]: '"etag-2"',
};
const result = await client.getEvents({
startDate: new Date('2026-01-01'),
endDate: new Date('2027-01-01'),
syncCursor: {
syncTokens: {},
ctags: { [CALENDAR_URL]: 'ctag-v1' },
etags: { [CALENDAR_URL]: previousEtags },
},
});
expect(result.events).toEqual([]);
expect(result.syncCursor.ctags).toEqual({ [CALENDAR_URL]: 'ctag-v1' });
expect(result.syncCursor.etags).toEqual({ [CALENDAR_URL]: previousEtags });
expect(mockPropfind).not.toHaveBeenCalled();
expect(mockCalendarMultiGet).not.toHaveBeenCalled();
});
it('fetches every event on the first sync when no etags are stored', async () => {
mockFetchCalendars.mockResolvedValue([buildLegacyCalendar('ctag-v1')]);
mockPropfind.mockResolvedValue([
{
href: FIRST_HREF,
status: 207,
statusText: 'OK',
ok: true,
props: { getetag: '"etag-1"' },
},
{
href: SECOND_HREF,
status: 207,
statusText: 'OK',
ok: true,
props: { getetag: '"etag-2"' },
},
]);
mockCalendarMultiGet.mockResolvedValue([
{
href: FIRST_HREF,
status: 207,
statusText: 'OK',
ok: true,
props: {
getetag: '"etag-1"',
calendarData: buildICalData('uid-1', 'Event One'),
},
},
{
href: SECOND_HREF,
status: 207,
statusText: 'OK',
ok: true,
props: {
getetag: '"etag-2"',
calendarData: buildICalData('uid-2', 'Event Two'),
},
},
]);
const client = buildClient();
const result = await client.getEvents({
startDate: new Date('2026-01-01'),
endDate: new Date('2027-01-01'),
syncCursor: { syncTokens: {} },
});
expect(result.events.map((event) => event.iCalUid).sort()).toEqual([
'uid-1',
'uid-2',
]);
expect(result.events.every((event) => !event.isCanceled)).toBe(true);
expect(result.syncCursor.ctags).toEqual({ [CALENDAR_URL]: 'ctag-v1' });
expect(result.syncCursor.etags).toEqual({
[CALENDAR_URL]: {
[FIRST_HREF]: '"etag-1"',
[SECOND_HREF]: '"etag-2"',
},
});
expect(mockCalendarMultiGet).toHaveBeenCalledTimes(1);
expect(mockCalendarMultiGet).toHaveBeenCalledWith(
expect.objectContaining({
url: CALENDAR_URL,
objectUrls: [FIRST_HREF, SECOND_HREF],
}),
);
});
it('only fetches changed hrefs and emits cancelled stub events for vanished hrefs when CTag differs', async () => {
mockFetchCalendars.mockResolvedValue([buildLegacyCalendar('ctag-v2')]);
mockPropfind.mockResolvedValue([
{
href: FIRST_HREF,
status: 207,
statusText: 'OK',
ok: true,
props: { getetag: '"etag-1-updated"' },
},
]);
mockCalendarMultiGet.mockResolvedValue([
{
href: FIRST_HREF,
status: 207,
statusText: 'OK',
ok: true,
props: {
getetag: '"etag-1-updated"',
calendarData: buildICalData('uid-1', 'Event One Updated'),
},
},
]);
const client = buildClient();
const result = await client.getEvents({
startDate: new Date('2026-01-01'),
endDate: new Date('2027-01-01'),
syncCursor: {
syncTokens: {},
ctags: { [CALENDAR_URL]: 'ctag-v1' },
etags: {
[CALENDAR_URL]: {
[FIRST_HREF]: '"etag-1"',
[SECOND_HREF]: '"etag-2"',
},
},
},
});
const liveEvents = result.events.filter((event) => !event.isCanceled);
const cancelledEvents = result.events.filter((event) => event.isCanceled);
expect(liveEvents.map((event) => event.iCalUid)).toEqual(['uid-1']);
expect(cancelledEvents.map((event) => event.id)).toEqual([SECOND_HREF]);
expect(cancelledEvents[0].status).toBe('CANCELLED');
expect(result.syncCursor.ctags).toEqual({ [CALENDAR_URL]: 'ctag-v2' });
expect(result.syncCursor.etags).toEqual({
[CALENDAR_URL]: { [FIRST_HREF]: '"etag-1-updated"' },
});
expect(mockCalendarMultiGet).toHaveBeenCalledWith(
expect.objectContaining({
url: CALENDAR_URL,
objectUrls: [FIRST_HREF],
}),
);
});
it('treats a numeric CTag as an opaque string so SabreDAV-style sequence numbers still trigger the unchanged-calendar skip', async () => {
mockFetchCalendars.mockResolvedValue([
{
url: CALENDAR_URL,
components: ['VEVENT'],
reports: [],
ctag: 1004 as unknown as string,
},
]);
const client = buildClient();
const result = await client.getEvents({
startDate: new Date('2026-01-01'),
endDate: new Date('2027-01-01'),
syncCursor: {
syncTokens: {},
ctags: { [CALENDAR_URL]: '1004' },
etags: { [CALENDAR_URL]: { [FIRST_HREF]: '"keep"' } },
},
});
expect(result.events).toEqual([]);
expect(result.syncCursor.ctags).toEqual({ [CALENDAR_URL]: '1004' });
expect(mockPropfind).not.toHaveBeenCalled();
});
it('takes the sync-collection branch when the calendar advertises it', async () => {
mockFetchCalendars.mockResolvedValue([buildSyncCollectionCalendar()]);
mockSyncCollection.mockResolvedValue([
{
href: FIRST_HREF,
status: 207,
statusText: 'OK',
ok: true,
props: { getetag: '"etag-1"' },
},
]);
mockCalendarMultiGet.mockResolvedValue([
{
href: FIRST_HREF,
status: 207,
statusText: 'OK',
ok: true,
props: {
getetag: '"etag-1"',
calendarData: buildICalData('uid-1', 'Event One'),
},
},
]);
const client = buildClient();
const result = await client.getEvents({
startDate: new Date('2026-01-01'),
endDate: new Date('2027-01-01'),
});
expect(mockSyncCollection).toHaveBeenCalledTimes(1);
expect(mockPropfind).not.toHaveBeenCalled();
expect(result.events.map((event) => event.iCalUid)).toEqual(['uid-1']);
});
});
@@ -1,5 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import { isNonEmptyString } from '@sniptt/guards';
import * as ical from 'node-ical';
import {
calendarMultiGet,
@@ -7,10 +8,12 @@ import {
type DAVAccount,
type DAVCalendar,
DAVNamespaceShort,
type DAVObject,
type DAVResponse,
fetchCalendars,
propfind,
syncCollection,
} from 'tsdav';
import { isDefined } from 'twenty-shared/utils';
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';
@@ -20,8 +23,7 @@ import {
type FetchedCalendarEvent,
type FetchedCalendarEventParticipant,
} from 'src/modules/calendar/common/types/fetched-calendar-event';
const DEFAULT_CALENDAR_TYPE = 'caldav';
import { DEFAULT_CALENDAR_TYPE } from './constants/default-calendar-type.constant';
type CalendarCredentials = {
username: string;
@@ -44,15 +46,23 @@ type FetchEventsOptions = {
syncCursor?: CalDAVSyncCursor;
};
type EtagsByHref = Record<string, string>;
type CalDAVSyncResult = {
events: FetchedCalendarEvent[];
newSyncToken?: string;
newCtag?: string;
newEtags?: EtagsByHref;
};
type CalDAVSyncCursor = {
syncTokens: Record<string, string>;
ctags?: Record<string, string>;
etags?: Record<string, EtagsByHref>;
};
export type CalDAVServerCapability = 'sync-collection' | 'ctag-etag';
type CalDAVGetEventsResponse = {
events: FetchedCalendarEvent[];
syncCursor: CalDAVSyncCursor;
@@ -147,7 +157,7 @@ export class CalDAVClient {
}
}
async validateSyncCollectionSupport(): Promise<void> {
async detectServerCapability(): Promise<CalDAVServerCapability> {
const account = await this.getAccount();
const calendars = await fetchCalendars({
@@ -163,14 +173,9 @@ export class CalDAVClient {
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)',
);
}
return eventCalendar.reports?.includes('syncCollection')
? 'sync-collection'
: 'ctag-etag';
}
/**
@@ -344,138 +349,297 @@ export class CalDAVClient {
async getEvents(
options: FetchEventsOptions,
): Promise<CalDAVGetEventsResponse> {
const calendars = await this.listCalendars();
const account = await this.getAccount();
const calendars = (
await fetchCalendars({ account, fetch: this.fetchOverride })
).filter((calendar) => calendar.components?.includes('VEVENT'));
const results = new Map<string, CalDAVSyncResult>();
const syncPromises = calendars.map(async (calendar) => {
try {
const syncToken =
options.syncCursor?.syncTokens[calendar.url] ||
calendar.syncToken?.toString();
const supportsSyncCollection =
calendar.reports?.includes('syncCollection') ?? false;
const syncResult = await syncCollection({
url: calendar.url,
props: {
[`${DAVNamespaceShort.DAV}:getetag`]: {},
[`${DAVNamespaceShort.CALDAV}:calendar-data`]: {},
},
syncLevel: 1,
...(syncToken ? { syncToken } : {}),
fetch: this.fetchOverride,
});
const result = supportsSyncCollection
? await this.fetchEventsViaSyncCollection(calendar, options)
: await this.fetchEventsViaCtagEtag(calendar, options);
const allEvents: FetchedCalendarEvent[] = [];
results.set(calendar.url, result);
} catch (error) {
this.logger.error(
`Error in ${CalDavGetEventsService.name} - getEvents`,
error,
);
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],
newCtag: options.syncCursor?.ctags?.[calendar.url],
});
}
});
await Promise.all(syncPromises);
const allEvents = Array.from(results.values())
.map((result) => result.events)
.flat();
const allEvents = Array.from(results.values()).flatMap(
(result) => result.events,
);
const syncTokens: Record<string, string> = {};
const ctags: Record<string, string> = {};
const etags: Record<string, EtagsByHref> = {};
for (const [calendarUrl, result] of results) {
if (result.newSyncToken) {
syncTokens[calendarUrl] = result.newSyncToken;
}
if (result.newCtag) {
ctags[calendarUrl] = result.newCtag;
}
if (result.newEtags) {
etags[calendarUrl] = result.newEtags;
}
}
return {
events: allEvents,
syncCursor: { syncTokens },
syncCursor: {
syncTokens,
...(Object.keys(ctags).length > 0 ? { ctags } : {}),
...(Object.keys(etags).length > 0 ? { etags } : {}),
},
};
}
private async fetchEventsViaSyncCollection(
calendar: DAVCalendar,
options: FetchEventsOptions,
): Promise<CalDAVSyncResult> {
const previousSyncToken =
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,
...(previousSyncToken ? { syncToken: previousSyncToken } : {}),
fetch: this.fetchOverride,
});
const objectUrls = syncResult
.map((entry) => entry.href)
.filter((href): href is string => !!href && this.isValidFormat(href));
const events = await this.fetchAndParseEvents(
calendar.url,
objectUrls,
options,
);
const newSyncToken = await this.refreshSyncToken(
calendar.url,
previousSyncToken,
);
return {
events,
newSyncToken,
};
}
private async refreshSyncToken(
calendarUrl: string,
fallbackToken: string | undefined,
): Promise<string | undefined> {
try {
const account = await this.getAccount();
const refreshed = await fetchCalendars({
account,
fetch: this.fetchOverride,
});
const refreshedCalendar = refreshed.find(
(candidate) => candidate.url === calendarUrl,
);
return refreshedCalendar?.syncToken
? refreshedCalendar.syncToken.toString()
: fallbackToken;
} catch (refreshError) {
this.logger.error(
`Error in ${CalDavGetEventsService.name} - refreshSyncToken`,
refreshError,
);
return fallbackToken;
}
}
/**
* Fallback sync path for legacy CalDAV servers that don't support
* RFC 6578 sync-collection.
*/
private async fetchEventsViaCtagEtag(
calendar: DAVCalendar,
options: FetchEventsOptions,
): Promise<CalDAVSyncResult> {
const storedEtags = options.syncCursor?.etags?.[calendar.url] ?? {};
// Treat ctag as opaque text since some servers return it as a number.
const newCtag = isDefined(calendar.ctag)
? String(calendar.ctag)
: undefined;
const storedCtag = options.syncCursor?.ctags?.[calendar.url];
if (isDefined(newCtag) && isDefined(storedCtag) && newCtag === storedCtag) {
return {
events: [],
newCtag,
newEtags: storedEtags,
};
}
const currentEtags = await this.fetchEtagsByHref(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(
calendar.url,
changedHrefs,
options,
);
const cancelledStubs = cancelledHrefs.map((href) =>
this.buildCancelledEvent(href),
);
return {
events: [...fetchedEvents, ...cancelledStubs],
newCtag,
newEtags: currentEtags,
};
}
private buildCancelledEvent(href: string): FetchedCalendarEvent {
return {
id: href,
title: '',
iCalUid: '',
description: '',
startsAt: '',
endsAt: '',
location: '',
isFullDay: false,
isCanceled: true,
conferenceLinkLabel: '',
conferenceLinkUrl: '',
externalCreatedAt: '',
externalUpdatedAt: '',
conferenceSolution: '',
participants: [],
status: 'CANCELLED',
};
}
private async fetchEtagsByHref(calendarUrl: string): Promise<EtagsByHref> {
const responses = await propfind({
url: calendarUrl,
props: { [`${DAVNamespaceShort.DAV}:getetag`]: {} },
depth: '1',
fetch: this.fetchOverride,
});
return responses.reduce<EtagsByHref>((map, response: DAVResponse) => {
const href = response.href;
const etag = response.props?.getetag;
if (
!isNonEmptyString(href) ||
!isNonEmptyString(etag) ||
!this.isValidFormat(href)
) {
return map;
}
map[href] = etag;
return map;
}, {});
}
private async fetchAndParseEvents(
calendarUrl: string,
objectUrls: string[],
options: FetchEventsOptions,
): Promise<FetchedCalendarEvent[]> {
if (objectUrls.length === 0) {
return [];
}
try {
const calendarObjects = await calendarMultiGet({
url: calendarUrl,
props: {
[`${DAVNamespaceShort.DAV}:getetag`]: {},
[`${DAVNamespaceShort.CALDAV}:calendar-data`]: {},
},
objectUrls,
depth: '1',
fetch: this.fetchOverride,
});
const events: FetchedCalendarEvent[] = [];
for (const calendarObject of calendarObjects) {
if (!calendarObject.props?.calendarData) {
continue;
}
const iCalData = this.extractICalData(
calendarObject.props.calendarData,
);
if (!iCalData) {
continue;
}
const event = this.parseICalData(iCalData, calendarObject.href || '');
if (
!event ||
!this.isEventInTimeRange(event, options.startDate, options.endDate)
) {
continue;
}
events.push(event);
}
return events;
} catch (fetchError) {
this.logger.error(
`Error in ${CalDavGetEventsService.name} - fetchAndParseEvents`,
fetchError,
);
return [];
}
}
/**
* 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.
@@ -506,25 +670,18 @@ export class CalDAVClient {
}
private isEventInTimeRange(
davObject: DAVObject,
startDate: Date,
endDate: Date,
event: FetchedCalendarEvent,
windowStart: Date,
windowEnd: Date,
): boolean {
try {
if (!davObject.data) return false;
if (!event.startsAt || !event.endsAt) return false;
const parsed = ical.parseICS(davObject.data);
const events = Object.values(parsed).filter(
(item) => item.type === 'VEVENT',
);
const eventStart = new Date(event.startsAt);
const eventEnd = new Date(event.endsAt);
if (events.length === 0) return false;
const startsBeforeWindowCloses = eventStart < windowEnd;
const endsAfterWindowOpens = eventEnd > windowStart;
const event = events[0] as ical.VEvent;
return event.start < endDate && event.end > startDate;
} catch {
return true;
}
return startsBeforeWindowCloses && endsAfterWindowOpens;
}
}