Compare commits

...
Author SHA1 Message Date
neo773 f5717752be wip 2026-02-12 02:38:33 +05:30
neo773 24796e9355 refactor caldav 2026-02-12 01:42:59 +05:30
31 changed files with 1301 additions and 594 deletions
@@ -12,7 +12,7 @@ import {
} from 'src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { CalDAVClient } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/caldav.client';
import { CalDAVClient } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/services/caldav-client';
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
@Injectable()
@@ -1,7 +1,7 @@
import { Module } from '@nestjs/common';
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 { CalDavClientProvider } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/providers/caldav-client.provider';
import { CalDavGetEventsService } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/services/caldav-get-events.service';
@Module({
@@ -0,0 +1,9 @@
export class CalDavHttpException extends Error {
public readonly status: number;
constructor(status: number, statusText: string) {
super(`CalDAV HTTP error ${status}: ${statusText}`);
this.name = 'CalDavHttpException';
this.status = status;
}
}
@@ -1,528 +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,
getBasicAuthHeaders,
syncCollection,
} from 'tsdav';
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;
};
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 headers: Record<string, string>;
constructor(credentials: CalendarCredentials) {
this.credentials = credentials;
this.logger = new Logger(CalDAVClient.name);
this.headers = getBasicAuthHeaders({
username: credentials.username,
password: credentials.password,
});
}
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,
},
},
headers: this.headers,
});
}
async listCalendars(): Promise<SimpleCalendar[]> {
try {
const account = await this.getAccount();
const calendars = (await fetchCalendars({
account,
headers: this.headers,
})) 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,
headers: this.headers,
});
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 } : {}),
headers: this.headers,
});
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',
headers: this.headers,
});
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,
headers: this.headers,
});
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;
}
}
}
@@ -2,17 +2,17 @@ import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { CalDAVClient } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/caldav.client';
import { CalDAVClient } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/services/caldav-client';
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
@Injectable()
export class CalDavClientProvider {
public async getCalDavCalendarClient(
public getCalDavCalendarClient(
connectedAccount: Pick<
ConnectedAccountWorkspaceEntity,
'id' | 'provider' | 'connectionParameters' | 'handle'
>,
): Promise<CalDAVClient> {
): CalDAVClient {
if (
!connectedAccount.connectionParameters?.CALDAV?.password ||
!connectedAccount.connectionParameters?.CALDAV?.host ||
@@ -20,14 +20,13 @@ export class CalDavClientProvider {
) {
throw new Error('Missing required CalDAV connection parameters');
}
const caldavClient = new CalDAVClient({
return new CalDAVClient({
username:
connectedAccount.connectionParameters.CALDAV.username ??
connectedAccount.handle,
password: connectedAccount.connectionParameters.CALDAV.password,
serverUrl: connectedAccount.connectionParameters.CALDAV.host,
});
return caldavClient;
}
}
@@ -0,0 +1,210 @@
import { Logger } from '@nestjs/common';
import {
calendarMultiGet,
createAccount,
type DAVAccount,
type DAVCalendar,
DAVNamespaceShort,
type DAVResponse,
fetchCalendars,
getBasicAuthHeaders,
syncCollection,
} from 'tsdav';
import { CalDavHttpException } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/exceptions/caldav-http.exception';
import { type CalDavCalendar } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/types/caldav-calendar.type';
import { type CalDavCredentials } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/types/caldav-credentials.type';
const DEFAULT_CALENDAR_TYPE = 'caldav';
const ALLOWED_EXTENSIONS = ['eml', 'ics'];
export class CalDAVClient {
private readonly credentials: CalDavCredentials;
private readonly logger = new Logger(CalDAVClient.name);
private readonly headers: Record<string, string>;
constructor(credentials: CalDavCredentials) {
this.credentials = credentials;
this.headers = getBasicAuthHeaders({
username: credentials.username,
password: credentials.password,
});
}
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,
},
},
headers: this.headers,
});
}
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();
}
isValidCalendarObjectUrl(url: string): boolean {
return ALLOWED_EXTENSIONS.includes(this.getFileExtension(url));
}
private throwOnHttpError(responses: DAVResponse[]): void {
const failedResponse = responses.find((response) => !response.ok);
if (failedResponse) {
throw new CalDavHttpException(
failedResponse.status,
failedResponse.statusText,
);
}
}
async listCalendars(): Promise<CalDavCalendar[]> {
const account = await this.getAccount();
const calendars = (await fetchCalendars({
account,
headers: this.headers,
})) as (Omit<DAVCalendar, 'displayName'> & {
displayName?: string | Record<string, unknown>;
})[];
return calendars.reduce<CalDavCalendar[]>((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;
}, []);
}
async validateSyncCollectionSupport(): Promise<void> {
const account = await this.getAccount();
const calendars = await fetchCalendars({
account,
headers: this.headers,
});
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)',
);
}
}
async syncCalendarCollection(
calendarUrl: string,
syncToken?: string,
): Promise<{ href: string }[]> {
const syncResult = await syncCollection({
url: calendarUrl,
props: {
[`${DAVNamespaceShort.DAV}:getetag`]: {},
[`${DAVNamespaceShort.CALDAV}:calendar-data`]: {},
},
syncLevel: 1,
...(syncToken ? { syncToken } : {}),
headers: this.headers,
});
this.throwOnHttpError(syncResult);
return syncResult
.map((item) => ({ href: item.href || '' }))
.filter((item) => item.href && this.isValidCalendarObjectUrl(item.href));
}
async fetchCalendarObjects(
calendarUrl: string,
objectUrls: string[],
): Promise<
{
href: string;
calendarData: string | Record<string, unknown>;
etag: string;
}[]
> {
const calendarObjects = await calendarMultiGet({
url: calendarUrl,
props: {
[`${DAVNamespaceShort.DAV}:getetag`]: {},
[`${DAVNamespaceShort.CALDAV}:calendar-data`]: {},
},
objectUrls,
depth: '1',
headers: this.headers,
});
this.throwOnHttpError(calendarObjects);
return calendarObjects
.filter((obj) => obj.props?.calendarData)
.map((obj) => ({
href: obj.href || '',
calendarData: obj.props!.calendarData,
etag: obj.props!.getetag || '',
}));
}
async fetchAllCalendarSyncTokens(): Promise<Record<string, string>> {
try {
const account = await this.getAccount();
const calendars = await fetchCalendars({
account,
headers: this.headers,
});
const syncTokens: Record<string, string> = {};
for (const calendar of calendars) {
if (calendar.syncToken) {
syncTokens[calendar.url] = calendar.syncToken.toString();
}
}
return syncTokens;
} catch (error) {
this.logger.error('Failed to fetch calendar sync tokens', error);
return {};
}
}
}
@@ -1,20 +1,25 @@
import { Injectable, Logger } from '@nestjs/common';
import { CalDavClientProvider } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/providers/caldav.provider';
import { CALDAV_FUTURE_DAYS_WINDOW } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/constants/caldav-future-days-window.constant';
import { CALDAV_PAST_DAYS_WINDOW } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/constants/caldav-past-days-window.constant';
import { CalDavHttpException } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/exceptions/caldav-http.exception';
import { CalDavClientProvider } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/providers/caldav-client.provider';
import { type CalDAVClient } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/services/caldav-client';
import { type CalDavCalendar } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/types/caldav-calendar.type';
import { type CalDavSyncCursor } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/types/caldav-sync-cursor.type';
import { extractICalData } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/extract-ical-data.util';
import { formatCalDavCalendarEvent } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/format-caldav-calendar-event.util';
import { isEventInTimeRange } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/is-event-in-time-range.util';
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 FetchedCalendarEvent } from 'src/modules/calendar/common/types/fetched-calendar-event';
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
@Injectable()
export class CalDavGetEventsService {
private readonly logger = new Logger(CalDavGetEventsService.name);
private static readonly PAST_DAYS_WINDOW = 365 * 5;
private static readonly FUTURE_DAYS_WINDOW = 365;
constructor(
private readonly caldavCalendarClientProvider: CalDavClientProvider,
) {}
constructor(private readonly caldavClientProvider: CalDavClientProvider) {}
public async getCalendarEvents(
connectedAccount: Pick<
@@ -26,47 +31,175 @@ export class CalDavGetEventsService {
this.logger.debug(`Getting calendar events for ${connectedAccount.handle}`);
try {
const caldavCalendarClient =
await this.caldavCalendarClientProvider.getCalDavCalendarClient(
connectedAccount,
);
const client =
this.caldavClientProvider.getCalDavCalendarClient(connectedAccount);
const parsedSyncCursor: CalDavSyncCursor | undefined = syncCursor
? JSON.parse(syncCursor)
: undefined;
const startDate = new Date(
Date.now() -
CalDavGetEventsService.PAST_DAYS_WINDOW * 24 * 60 * 60 * 1000,
Date.now() - CALDAV_PAST_DAYS_WINDOW * 24 * 60 * 60 * 1000,
);
const endDate = new Date(
Date.now() +
CalDavGetEventsService.FUTURE_DAYS_WINDOW * 24 * 60 * 60 * 1000,
Date.now() + CALDAV_FUTURE_DAYS_WINDOW * 24 * 60 * 60 * 1000,
);
const result = await caldavCalendarClient.getEvents({
startDate,
endDate,
syncCursor: syncCursor ? JSON.parse(syncCursor) : undefined,
});
const calendars = await client.listCalendars();
const syncResults = await Promise.all(
calendars.map((calendar) =>
this.syncCalendar(
client,
calendar,
startDate,
endDate,
parsedSyncCursor,
),
),
);
const allEvents = syncResults.flatMap((result) => result.events);
const updatedSyncTokens = await client.fetchAllCalendarSyncTokens();
const syncTokens: Record<string, string> = {};
for (const result of syncResults) {
const updatedToken = updatedSyncTokens[result.calendarUrl];
if (updatedToken) {
syncTokens[result.calendarUrl] = updatedToken;
} else if (result.fallbackSyncToken) {
syncTokens[result.calendarUrl] = result.fallbackSyncToken;
}
}
this.logger.debug(
`Found ${result.events.length} calendar events for ${connectedAccount.handle}`,
`Found ${allEvents.length} calendar events for ${connectedAccount.handle}`,
);
return {
fullEvents: true,
calendarEvents: result.events,
nextSyncCursor: JSON.stringify(result.syncCursor),
calendarEvents: allEvents,
nextSyncCursor: JSON.stringify({ syncTokens }),
};
} catch (error) {
this.handleError(error as Error);
throw error;
this.logger.error(
`Failed to get calendar events for ${connectedAccount.handle}`,
error,
);
throw parseCalDAVError(error as Error);
}
}
private handleError(error: Error) {
this.logger.error(
`Error in ${CalDavGetEventsService.name} - getCalendarEvents`,
error,
);
private async syncCalendar(
client: CalDAVClient,
calendar: CalDavCalendar,
startDate: Date,
endDate: Date,
parsedSyncCursor?: CalDavSyncCursor,
): Promise<{
calendarUrl: string;
events: FetchedCalendarEvent[];
fallbackSyncToken?: string;
}> {
const existingSyncToken =
parsedSyncCursor?.syncTokens[calendar.url] ||
calendar.syncToken?.toString();
throw parseCalDAVError(error);
try {
const changedObjects = await client.syncCalendarCollection(
calendar.url,
existingSyncToken,
);
const objectUrls = changedObjects.map((obj) => obj.href);
const events =
objectUrls.length > 0
? await this.fetchEventsFromChangedObjects(
client,
calendar.url,
objectUrls,
startDate,
endDate,
)
: [];
return {
calendarUrl: calendar.url,
events,
fallbackSyncToken: existingSyncToken,
};
} catch (error) {
if (error instanceof CalDavHttpException) {
throw error;
}
this.logger.error(`Failed to sync calendar ${calendar.url}`, error);
return {
calendarUrl: calendar.url,
events: [],
fallbackSyncToken: parsedSyncCursor?.syncTokens[calendar.url],
};
}
}
private async fetchEventsFromChangedObjects(
client: CalDAVClient,
calendarUrl: string,
objectUrls: string[],
startDate: Date,
endDate: Date,
): Promise<FetchedCalendarEvent[]> {
try {
const calendarObjects = await client.fetchCalendarObjects(
calendarUrl,
objectUrls,
);
return calendarObjects
.map((calendarObject) => {
const iCalData = extractICalData(calendarObject.calendarData);
if (!iCalData) {
return null;
}
const event = formatCalDavCalendarEvent(
iCalData,
calendarObject.href,
);
if (!event) {
this.logger.debug(
`Could not parse calendar object ${calendarObject.href}`,
);
return null;
}
if (!isEventInTimeRange(event, startDate, endDate)) {
return null;
}
return event;
})
.filter((event): event is FetchedCalendarEvent => event !== null);
} catch (error) {
if (error instanceof CalDavHttpException) {
throw error;
}
this.logger.error(
`Failed to fetch calendar objects for ${calendarUrl}`,
error,
);
return [];
}
}
}
@@ -0,0 +1,7 @@
export type CalDavCalendar = {
id: string;
name: string;
url: string;
isPrimary?: boolean;
syncToken?: string | number;
};
@@ -0,0 +1,5 @@
export type CalDavCredentials = {
username: string;
password: string;
serverUrl: string;
};
@@ -0,0 +1,3 @@
export type CalDavSyncCursor = {
syncTokens: Record<string, string>;
};
@@ -0,0 +1,49 @@
import { extractICalData } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/extract-ical-data.util';
describe('extractICalData', () => {
it('should return a plain string containing VCALENDAR as-is', () => {
const data =
'BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nEND:VEVENT\r\nEND:VCALENDAR';
expect(extractICalData(data)).toBe(data);
});
it('should extract iCal data nested under _cdata', () => {
const nested = {
_cdata: 'BEGIN:VCALENDAR\r\nEND:VCALENDAR',
};
expect(extractICalData(nested)).toBe('BEGIN:VCALENDAR\r\nEND:VCALENDAR');
});
it('should extract from deeply nested objects', () => {
const deeplyNested = {
wrapper: {
inner: {
content: 'BEGIN:VCALENDAR\r\nEND:VCALENDAR',
},
},
};
expect(extractICalData(deeplyNested)).toBe(
'BEGIN:VCALENDAR\r\nEND:VCALENDAR',
);
});
it('should return null for a string without VCALENDAR', () => {
expect(extractICalData('just some text')).toBeNull();
});
it('should return null for empty input', () => {
expect(extractICalData('')).toBeNull();
});
it('should return null when depth limit is exceeded', () => {
const nested = {
a: { b: { c: { d: 'BEGIN:VCALENDAR\r\nEND:VCALENDAR' } } },
};
expect(extractICalData(nested, 4)).toBeNull();
expect(extractICalData(nested, 5)).toBe('BEGIN:VCALENDAR\r\nEND:VCALENDAR');
});
});
@@ -1,6 +1,6 @@
import { icalDataExtractPropertyValue } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/utils/icalDataExtractPropertyValue';
import { extractICalPropertyValue } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/extract-ical-property-value.util';
describe('icalDataExtractPropertyValue', () => {
describe('extractICalPropertyValue', () => {
describe('properties with parameters (RFC 5545 Section 3.2)', () => {
it('should extract value from property object with val and params', () => {
const property = {
@@ -8,7 +8,7 @@ describe('icalDataExtractPropertyValue', () => {
params: { LANGUAGE: 'en-US' },
};
const result = icalDataExtractPropertyValue(property);
const result = extractICalPropertyValue(property);
expect(result).toBe('Meeting Title');
});
@@ -18,7 +18,7 @@ describe('icalDataExtractPropertyValue', () => {
val: 'Conference Room A',
};
const result = icalDataExtractPropertyValue(property);
const result = extractICalPropertyValue(property);
expect(result).toBe('Conference Room A');
});
@@ -29,7 +29,7 @@ describe('icalDataExtractPropertyValue', () => {
params: { TYPE: 'INTEGER' },
} as any;
const result = icalDataExtractPropertyValue(property);
const result = extractICalPropertyValue(property);
expect(result).toBe('12345');
});
@@ -40,7 +40,7 @@ describe('icalDataExtractPropertyValue', () => {
params: { LANGUAGE: 'de-DE' },
};
const result = icalDataExtractPropertyValue(property, 'default');
const result = extractICalPropertyValue(property, 'default');
expect(result).toBe('');
});
@@ -50,7 +50,7 @@ describe('icalDataExtractPropertyValue', () => {
it('should join multiple string values with comma and space', () => {
const property = ['Value 1', 'Value 2', 'Value 3'] as any;
const result = icalDataExtractPropertyValue(property);
const result = extractICalPropertyValue(property);
expect(result).toBe('Value 1, Value 2, Value 3');
});
@@ -61,7 +61,7 @@ describe('icalDataExtractPropertyValue', () => {
{ val: 'Second Value', params: { LANGUAGE: 'fr' } },
] as any;
const result = icalDataExtractPropertyValue(property);
const result = extractICalPropertyValue(property);
expect(result).toBe('First Value, Second Value');
});
@@ -69,7 +69,7 @@ describe('icalDataExtractPropertyValue', () => {
it('should filter out empty values from array', () => {
const property = ['Value 1', '', 'Value 3', { val: '' }] as any;
const result = icalDataExtractPropertyValue(property);
const result = extractICalPropertyValue(property);
expect(result).toBe('Value 1, Value 3');
});
@@ -77,7 +77,7 @@ describe('icalDataExtractPropertyValue', () => {
it('should return default value when array contains only empty values', () => {
const property = ['', { val: '' }, null] as any;
const result = icalDataExtractPropertyValue(property, 'No values');
const result = extractICalPropertyValue(property, 'No values');
expect(result).toBe('No values');
});
@@ -89,7 +89,7 @@ describe('icalDataExtractPropertyValue', () => {
'Another String',
] as any;
const result = icalDataExtractPropertyValue(property);
const result = extractICalPropertyValue(property);
expect(result).toBe('Plain String, Object Value, Another String');
});
@@ -0,0 +1,117 @@
import * as ical from 'node-ical';
import { extractParticipantsFromEvent } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/extract-participants-from-event.util';
import { CalendarEventParticipantResponseStatus } from 'src/modules/calendar/common/standard-objects/calendar-event-participant.workspace-entity';
const parseFirstEvent = (icsData: string): ical.VEvent => {
const parsed = ical.parseICS(icsData);
const events = Object.values(parsed).filter((item) => item.type === 'VEVENT');
return events[0] as ical.VEvent;
};
describe('extractParticipantsFromEvent', () => {
it('should extract organizer and attendees', () => {
const event = parseFirstEvent(
[
'BEGIN:VCALENDAR',
'BEGIN:VEVENT',
'UID:test@example.com',
'DTSTART:20230615T090000Z',
'DTEND:20230615T100000Z',
'ORGANIZER;CN=Alice:mailto:alice@example.com',
'ATTENDEE;CN=Bob;PARTSTAT=ACCEPTED:mailto:bob@example.com',
'END:VEVENT',
'END:VCALENDAR',
].join('\r\n'),
);
const participants = extractParticipantsFromEvent(event);
expect(participants).toHaveLength(2);
const organizer = participants.find((p) => p.isOrganizer);
expect(organizer).toBeDefined();
expect(organizer!.handle).toBe('alice@example.com');
expect(organizer!.responseStatus).toBe(
CalendarEventParticipantResponseStatus.ACCEPTED,
);
const attendee = participants.find((p) => !p.isOrganizer);
expect(attendee).toBeDefined();
expect(attendee!.handle).toBe('bob@example.com');
});
it('should return empty array when no organizer and no attendees', () => {
const event = parseFirstEvent(
[
'BEGIN:VCALENDAR',
'BEGIN:VEVENT',
'UID:solo@example.com',
'DTSTART:20230615T090000Z',
'DTEND:20230615T100000Z',
'END:VEVENT',
'END:VCALENDAR',
].join('\r\n'),
);
const participants = extractParticipantsFromEvent(event);
expect(participants).toHaveLength(0);
});
it('should strip mailto: prefix from attendee handles', () => {
const event = parseFirstEvent(
[
'BEGIN:VCALENDAR',
'BEGIN:VEVENT',
'UID:mailto-test@example.com',
'DTSTART:20230615T090000Z',
'DTEND:20230615T100000Z',
'ATTENDEE;PARTSTAT=ACCEPTED:mailto:charlie@example.com',
'END:VEVENT',
'END:VCALENDAR',
].join('\r\n'),
);
const participants = extractParticipantsFromEvent(event);
expect(participants[0].handle).toBe('charlie@example.com');
});
it('should map each PARTSTAT value to the correct response status', () => {
const event = parseFirstEvent(
[
'BEGIN:VCALENDAR',
'BEGIN:VEVENT',
'UID:partstat@example.com',
'DTSTART:20230615T090000Z',
'DTEND:20230615T100000Z',
'ATTENDEE;PARTSTAT=ACCEPTED:mailto:a@test.com',
'ATTENDEE;PARTSTAT=DECLINED:mailto:d@test.com',
'ATTENDEE;PARTSTAT=TENTATIVE:mailto:t@test.com',
'ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:n@test.com',
'END:VEVENT',
'END:VCALENDAR',
].join('\r\n'),
);
const participants = extractParticipantsFromEvent(event);
const byHandle = new Map(participants.map((p) => [p.handle, p]));
expect(byHandle.get('a@test.com')!.responseStatus).toBe(
CalendarEventParticipantResponseStatus.ACCEPTED,
);
expect(byHandle.get('d@test.com')!.responseStatus).toBe(
CalendarEventParticipantResponseStatus.DECLINED,
);
expect(byHandle.get('t@test.com')!.responseStatus).toBe(
CalendarEventParticipantResponseStatus.TENTATIVE,
);
expect(byHandle.get('n@test.com')!.responseStatus).toBe(
CalendarEventParticipantResponseStatus.NEEDS_ACTION,
);
});
});
@@ -0,0 +1,167 @@
import { formatCalDavCalendarEvent } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/format-caldav-calendar-event.util';
import { CalendarEventParticipantResponseStatus } from 'src/modules/calendar/common/standard-objects/calendar-event-participant.workspace-entity';
const buildICalEvent = (overrides: Record<string, string> = {}): string => {
const defaults: Record<string, string> = {
UID: 'test-uid-123@example.com',
SUMMARY: 'Team Standup',
DESCRIPTION: 'Daily sync meeting',
LOCATION: 'Conference Room B',
DTSTART: '20230615T090000Z',
DTEND: '20230615T093000Z',
STATUS: 'CONFIRMED',
CREATED: '20230601T120000Z',
'LAST-MODIFIED': '20230610T120000Z',
ORGANIZER: 'ORGANIZER;CN=Alice:mailto:alice@example.com',
ATTENDEE: 'ATTENDEE;CN=Bob;PARTSTAT=ACCEPTED:mailto:bob@example.com',
};
const merged = { ...defaults, ...overrides };
const lines = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'BEGIN:VEVENT'];
for (const [key, value] of Object.entries(merged)) {
if (key === 'ORGANIZER' || key === 'ATTENDEE') {
lines.push(value);
} else {
lines.push(`${key}:${value}`);
}
}
lines.push('END:VEVENT', 'END:VCALENDAR');
return lines.join('\r\n');
};
describe('formatCalDavCalendarEvent', () => {
it('should format a standard event with all fields populated', () => {
const rawData = buildICalEvent();
const result = formatCalDavCalendarEvent(rawData, '/cal/event1.ics');
expect(result).not.toBeNull();
expect(result!.id).toBe('/cal/event1.ics');
expect(result!.title).toBe('Team Standup');
expect(result!.description).toBe('Daily sync meeting');
expect(result!.location).toBe('Conference Room B');
expect(result!.iCalUid).toBe('test-uid-123@example.com');
expect(result!.isCanceled).toBe(false);
expect(result!.isFullDay).toBe(false);
expect(result!.status).toBe('CONFIRMED');
expect(result!.participants).toHaveLength(2);
});
it('should handle an event with only required fields', () => {
const rawData = buildICalEvent({
SUMMARY: '',
DESCRIPTION: '',
LOCATION: '',
ORGANIZER: '',
ATTENDEE: '',
});
const result = formatCalDavCalendarEvent(rawData, '/cal/minimal.ics');
expect(result).not.toBeNull();
expect(result!.title).toBe('Untitled Event');
expect(result!.description).toBe('');
expect(result!.location).toBe('');
expect(result!.participants).toHaveLength(0);
});
it('should map cancelled status to isCanceled', () => {
const rawData = buildICalEvent({ STATUS: 'CANCELLED' });
const result = formatCalDavCalendarEvent(rawData, '/cal/cancelled.ics');
expect(result).not.toBeNull();
expect(result!.isCanceled).toBe(true);
});
it('should detect full-day events using VALUE=DATE', () => {
const rawData = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'BEGIN:VEVENT',
'UID:fullday@example.com',
'SUMMARY:All Day Off',
'DTSTART;VALUE=DATE:20230615',
'DTEND;VALUE=DATE:20230616',
'STATUS:CONFIRMED',
'CREATED:20230601T120000Z',
'END:VEVENT',
'END:VCALENDAR',
].join('\r\n');
const result = formatCalDavCalendarEvent(rawData, '/cal/fullday.ics');
expect(result).not.toBeNull();
expect(result!.isFullDay).toBe(true);
});
it('should sanitize null bytes in string fields', () => {
const rawData = buildICalEvent({
SUMMARY: 'Meeting\u0000Title',
});
const result = formatCalDavCalendarEvent(rawData, '/cal/dirty.ics');
expect(result).not.toBeNull();
expect(result!.title).not.toContain('\u0000');
});
it('should return null for non-VEVENT data', () => {
const rawData = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'BEGIN:VTODO',
'UID:todo@example.com',
'SUMMARY:Buy groceries',
'END:VTODO',
'END:VCALENDAR',
].join('\r\n');
const result = formatCalDavCalendarEvent(rawData, '/cal/todo.ics');
expect(result).toBeNull();
});
it('should map attendee PARTSTAT to correct response status', () => {
const rawData = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'BEGIN:VEVENT',
'UID:partstat@example.com',
'SUMMARY:Status Test',
'DTSTART:20230615T090000Z',
'DTEND:20230615T100000Z',
'STATUS:CONFIRMED',
'CREATED:20230601T120000Z',
'ATTENDEE;CN=Accepted;PARTSTAT=ACCEPTED:mailto:a@example.com',
'ATTENDEE;CN=Declined;PARTSTAT=DECLINED:mailto:d@example.com',
'ATTENDEE;CN=Tentative;PARTSTAT=TENTATIVE:mailto:t@example.com',
'ATTENDEE;CN=NeedsAction;PARTSTAT=NEEDS-ACTION:mailto:n@example.com',
'END:VEVENT',
'END:VCALENDAR',
].join('\r\n');
const result = formatCalDavCalendarEvent(rawData, '/cal/partstat.ics');
expect(result).not.toBeNull();
expect(result!.participants).toHaveLength(4);
const statusMap = new Map(
result!.participants.map((p) => [p.handle, p.responseStatus]),
);
expect(statusMap.get('a@example.com')).toBe(
CalendarEventParticipantResponseStatus.ACCEPTED,
);
expect(statusMap.get('d@example.com')).toBe(
CalendarEventParticipantResponseStatus.DECLINED,
);
expect(statusMap.get('t@example.com')).toBe(
CalendarEventParticipantResponseStatus.TENTATIVE,
);
expect(statusMap.get('n@example.com')).toBe(
CalendarEventParticipantResponseStatus.NEEDS_ACTION,
);
});
});
@@ -0,0 +1,69 @@
import { isEventInTimeRange } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/is-event-in-time-range.util';
describe('isEventInTimeRange', () => {
const rangeStart = new Date('2023-06-01T00:00:00Z');
const rangeEnd = new Date('2023-06-30T23:59:59Z');
it('should return true when event is fully within range', () => {
const event = {
startsAt: '2023-06-10T09:00:00.000Z',
endsAt: '2023-06-10T10:00:00.000Z',
};
expect(isEventInTimeRange(event, rangeStart, rangeEnd)).toBe(true);
});
it('should return false when event ends before range starts', () => {
const event = {
startsAt: '2023-05-01T09:00:00.000Z',
endsAt: '2023-05-01T10:00:00.000Z',
};
expect(isEventInTimeRange(event, rangeStart, rangeEnd)).toBe(false);
});
it('should return false when event starts after range ends', () => {
const event = {
startsAt: '2023-07-15T09:00:00.000Z',
endsAt: '2023-07-15T10:00:00.000Z',
};
expect(isEventInTimeRange(event, rangeStart, rangeEnd)).toBe(false);
});
it('should return true when event overlaps the start of the range', () => {
const event = {
startsAt: '2023-05-30T09:00:00.000Z',
endsAt: '2023-06-02T10:00:00.000Z',
};
expect(isEventInTimeRange(event, rangeStart, rangeEnd)).toBe(true);
});
it('should return true when event overlaps the end of the range', () => {
const event = {
startsAt: '2023-06-29T09:00:00.000Z',
endsAt: '2023-07-05T10:00:00.000Z',
};
expect(isEventInTimeRange(event, rangeStart, rangeEnd)).toBe(true);
});
it('should return true when event spans the entire range', () => {
const event = {
startsAt: '2023-05-01T00:00:00.000Z',
endsAt: '2023-07-31T23:59:59.000Z',
};
expect(isEventInTimeRange(event, rangeStart, rangeEnd)).toBe(true);
});
it('should return false when event ends exactly at range start', () => {
const event = {
startsAt: '2023-05-31T09:00:00.000Z',
endsAt: '2023-06-01T00:00:00.000Z',
};
expect(isEventInTimeRange(event, rangeStart, rangeEnd)).toBe(false);
});
});
@@ -0,0 +1,46 @@
import { isFullDayEvent } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/is-full-day-event.util';
describe('isFullDayEvent', () => {
it('should return true for VALUE=DATE in DTSTART', () => {
const rawData = [
'BEGIN:VEVENT',
'DTSTART;VALUE=DATE:20230615',
'DTEND;VALUE=DATE:20230616',
'END:VEVENT',
].join('\r\n');
expect(isFullDayEvent(rawData)).toBe(true);
});
it('should return false for datetime DTSTART', () => {
const rawData = [
'BEGIN:VEVENT',
'DTSTART:20230615T090000Z',
'DTEND:20230615T100000Z',
'END:VEVENT',
].join('\r\n');
expect(isFullDayEvent(rawData)).toBe(false);
});
it('should return false for DTSTART with TZID', () => {
const rawData = [
'BEGIN:VEVENT',
'DTSTART;TZID=America/New_York:20230615T090000',
'DTEND;TZID=America/New_York:20230615T100000',
'END:VEVENT',
].join('\r\n');
expect(isFullDayEvent(rawData)).toBe(false);
});
it('should handle LF-only line endings', () => {
const rawData = [
'BEGIN:VEVENT',
'DTSTART;VALUE=DATE:20230615',
'END:VEVENT',
].join('\n');
expect(isFullDayEvent(rawData)).toBe(true);
});
});
@@ -0,0 +1,115 @@
import { CalDavHttpException } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/exceptions/caldav-http.exception';
import { parseCalDAVError } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/parse-caldav-error.util';
import { CalendarEventImportDriverExceptionCode } from 'src/modules/calendar/calendar-event-import-manager/drivers/exceptions/calendar-event-import-driver.exception';
describe('parseCalDAVError', () => {
it('should map "Invalid credentials" to INSUFFICIENT_PERMISSIONS', () => {
const error = new Error('Invalid credentials');
expect(parseCalDAVError(error).code).toBe(
CalendarEventImportDriverExceptionCode.INSUFFICIENT_PERMISSIONS,
);
});
it('should map "Collection does not exist on server" to NOT_FOUND', () => {
const error = new Error('Collection does not exist on server');
expect(parseCalDAVError(error).code).toBe(
CalendarEventImportDriverExceptionCode.NOT_FOUND,
);
});
it('should map "no account for fetchCalendars" to INSUFFICIENT_PERMISSIONS', () => {
const error = new Error('no account for fetchCalendars');
expect(parseCalDAVError(error).code).toBe(
CalendarEventImportDriverExceptionCode.INSUFFICIENT_PERMISSIONS,
);
});
it('should map network errors to TEMPORARY_ERROR', () => {
const error = Object.assign(new Error('Connection reset'), {
code: 'ECONNRESET',
});
expect(parseCalDAVError(error).code).toBe(
CalendarEventImportDriverExceptionCode.TEMPORARY_ERROR,
);
});
it('should map ETIMEDOUT to TEMPORARY_ERROR', () => {
const error = Object.assign(new Error('Timed out'), {
code: 'ETIMEDOUT',
});
expect(parseCalDAVError(error).code).toBe(
CalendarEventImportDriverExceptionCode.TEMPORARY_ERROR,
);
});
it('should map HTTP 401 to INSUFFICIENT_PERMISSIONS', () => {
const error = new CalDavHttpException(401, 'Unauthorized');
expect(parseCalDAVError(error).code).toBe(
CalendarEventImportDriverExceptionCode.INSUFFICIENT_PERMISSIONS,
);
});
it('should map HTTP 403 to INSUFFICIENT_PERMISSIONS', () => {
const error = new CalDavHttpException(403, 'Forbidden');
expect(parseCalDAVError(error).code).toBe(
CalendarEventImportDriverExceptionCode.INSUFFICIENT_PERMISSIONS,
);
});
it('should map HTTP 404 to NOT_FOUND', () => {
const error = new CalDavHttpException(404, 'Not Found');
expect(parseCalDAVError(error).code).toBe(
CalendarEventImportDriverExceptionCode.NOT_FOUND,
);
});
it('should map HTTP 429 to TEMPORARY_ERROR', () => {
const error = new CalDavHttpException(429, 'Too Many Requests');
expect(parseCalDAVError(error).code).toBe(
CalendarEventImportDriverExceptionCode.TEMPORARY_ERROR,
);
});
it('should map HTTP 500 to TEMPORARY_ERROR', () => {
const error = new CalDavHttpException(500, 'Internal Server Error');
expect(parseCalDAVError(error).code).toBe(
CalendarEventImportDriverExceptionCode.TEMPORARY_ERROR,
);
});
it('should map unhandled HTTP status to UNKNOWN', () => {
const error = new CalDavHttpException(418, "I'm a Teapot");
expect(parseCalDAVError(error).code).toBe(
CalendarEventImportDriverExceptionCode.UNKNOWN,
);
});
it('should map CALDAV_SYNC_COLLECTION_NOT_SUPPORTED to CHANNEL_MISCONFIGURED', () => {
const error = new Error(
'CALDAV_SYNC_COLLECTION_NOT_SUPPORTED: Your CalDAV server does not support incremental sync (RFC 6578)',
);
expect(parseCalDAVError(error).code).toBe(
CalendarEventImportDriverExceptionCode.CHANNEL_MISCONFIGURED,
);
});
it('should map unknown errors to UNKNOWN', () => {
const error = new Error('Something unexpected happened');
expect(parseCalDAVError(error).code).toBe(
CalendarEventImportDriverExceptionCode.UNKNOWN,
);
});
});
@@ -0,0 +1,18 @@
import { type VEvent } from 'node-ical';
import { parseAttendee } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/parse-attendee.util';
import { type FetchedCalendarEventParticipant } from 'src/modules/calendar/common/types/fetched-calendar-event';
export const extractAttendeesFromEvent = (
event: VEvent,
): FetchedCalendarEventParticipant[] => {
if (!event.attendee) {
return [];
}
const attendees = Array.isArray(event.attendee)
? event.attendee
: [event.attendee];
return attendees.map(parseAttendee);
};
@@ -0,0 +1,25 @@
const MAX_EXTRACTION_DEPTH = 10;
export const extractICalData = (
calendarData: string | Record<string, unknown>,
depth: number = MAX_EXTRACTION_DEPTH,
): string | null => {
if (!calendarData || depth <= 0) return null;
if (typeof calendarData === 'string' && calendarData.includes('VCALENDAR')) {
return calendarData;
}
if (typeof calendarData === 'object' && calendarData !== null) {
for (const key in calendarData) {
const result = extractICalData(
calendarData[key] as string | Record<string, unknown>,
depth - 1,
);
if (result) return result;
}
}
return null;
};
@@ -1,5 +1,5 @@
import { isNonEmptyString, isString } from '@sniptt/guards';
import { isDefined } from 'class-validator';
import { isDefined } from 'twenty-shared/utils';
/**
* Extracts the string value from an iCal property that may have parameters.
@@ -11,7 +11,7 @@ import { isDefined } from 'class-validator';
* @see https://datatracker.ietf.org/doc/html/rfc5545#section-3.2 (Property Parameters)
* @see https://datatracker.ietf.org/doc/html/rfc5545#section-3.1.2 (Multiple Values)
*/
export const icalDataExtractPropertyValue = (
export const extractICalPropertyValue = (
property:
| string
| { val?: string; params?: Record<string, unknown> }
@@ -0,0 +1,32 @@
import { type VEvent } from 'node-ical';
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: VEvent,
): FetchedCalendarEventParticipant | null => {
if (!event.organizer) {
return null;
}
if (typeof event.organizer === 'string') {
const email = event.organizer.replace(/^mailto:/i, '');
return {
displayName: email || 'Unknown',
responseStatus: CalendarEventParticipantResponseStatus.ACCEPTED,
handle: email,
isOrganizer: true,
};
}
const organizerEmail = event.organizer.val?.replace(/^mailto:/i, '') || '';
return {
displayName: event.organizer.params?.CN || organizerEmail || 'Unknown',
responseStatus: CalendarEventParticipantResponseStatus.ACCEPTED,
handle: organizerEmail,
isOrganizer: true,
};
};
@@ -0,0 +1,23 @@
import { type VEvent } from 'node-ical';
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 FetchedCalendarEventParticipant } from 'src/modules/calendar/common/types/fetched-calendar-event';
export const extractParticipantsFromEvent = (
event: VEvent,
): FetchedCalendarEventParticipant[] => {
const participants: FetchedCalendarEventParticipant[] = [];
const organizer = extractOrganizerFromEvent(event);
if (organizer) {
participants.push(organizer);
}
const attendees = extractAttendeesFromEvent(event);
participants.push(...attendees);
return participants;
};
@@ -0,0 +1,73 @@
import * as ical from 'node-ical';
import { extractICalPropertyValue } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/extract-ical-property-value.util';
import { extractParticipantsFromEvent } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/extract-participants-from-event.util';
import { isFullDayEvent } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/is-full-day-event.util';
import { sanitizeCalendarEvent } from 'src/modules/calendar/calendar-event-import-manager/drivers/utils/sanitizeCalendarEvent';
import { type FetchedCalendarEvent } from 'src/modules/calendar/common/types/fetched-calendar-event';
const PROPERTIES_TO_SANITIZE: (keyof FetchedCalendarEvent)[] = [
'title',
'startsAt',
'endsAt',
'id',
'externalCreatedAt',
'externalUpdatedAt',
'description',
'location',
'iCalUid',
'conferenceSolution',
'conferenceLinkLabel',
'conferenceLinkUrl',
'recurringEventExternalId',
'status',
];
export const formatCalDavCalendarEvent = (
rawData: string,
objectUrl: string,
): FetchedCalendarEvent | null => {
try {
const parsed = ical.parseICS(rawData);
const vevents = Object.values(parsed).filter(
(item) => item.type === 'VEVENT',
);
if (vevents.length === 0) {
return null;
}
const event = vevents[0] as ical.VEvent;
const participants = extractParticipantsFromEvent(event);
const calendarEvent: FetchedCalendarEvent = {
id: objectUrl,
title: extractICalPropertyValue(event.summary, 'Untitled Event'),
iCalUid: event.uid || '',
description: extractICalPropertyValue(event.description),
startsAt: event.start.toISOString(),
endsAt: event.end.toISOString(),
location: extractICalPropertyValue(event.location),
isFullDay: isFullDayEvent(rawData),
isCanceled: event.status === 'CANCELLED',
conferenceLinkLabel: '',
conferenceLinkUrl: extractICalPropertyValue(event.url),
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',
};
return sanitizeCalendarEvent(calendarEvent, PROPERTIES_TO_SANITIZE);
} catch {
return null;
}
};
@@ -0,0 +1,10 @@
export const isEventInTimeRange = (
event: { startsAt: string; endsAt: string },
startDate: Date,
endDate: Date,
): boolean => {
const eventStart = new Date(event.startsAt);
const eventEnd = new Date(event.endsAt);
return eventStart < endDate && eventEnd > startDate;
};
@@ -0,0 +1,18 @@
// node-ical converts all dates to JavaScript Date objects, losing the VALUE=DATE
// distinction. We inspect the raw iCal text to detect full-day events.
export const 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;
};
@@ -0,0 +1,19 @@
import { type AttendeePartStat } from 'node-ical';
import { CalendarEventParticipantResponseStatus } from 'src/modules/calendar/common/standard-objects/calendar-event-participant.workspace-entity';
export const mapPartStatToResponseStatus = (
partStat: 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;
}
};
@@ -0,0 +1,30 @@
import { type Attendee } from 'node-ical';
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 parseAttendee = (
attendee: Attendee,
): FetchedCalendarEventParticipant => {
if (typeof attendee === 'string') {
const handle = attendee.replace(/^mailto:/i, '');
return {
displayName: handle || 'Unknown',
responseStatus: mapPartStatToResponseStatus('NEEDS-ACTION'),
handle,
isOrganizer: false,
};
}
const handle = attendee.val?.replace(/^mailto:/i, '') || '';
const displayName = attendee.params?.CN || handle || 'Unknown';
const partStat = attendee.params?.PARTSTAT || 'NEEDS-ACTION';
return {
displayName,
responseStatus: mapPartStatToResponseStatus(partStat),
handle,
isOrganizer: false,
};
};
@@ -1,13 +1,41 @@
import { CalDavHttpException } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/exceptions/caldav-http.exception';
import {
CalendarEventImportDriverException,
CalendarEventImportDriverExceptionCode,
} from 'src/modules/calendar/calendar-event-import-manager/drivers/exceptions/calendar-event-import-driver.exception';
import { parseCalDAVHttpStatusError } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/parse-caldav-http-status-error.util';
const NETWORK_ERROR_CODES = [
'ECONNRESET',
'ENOTFOUND',
'ECONNABORTED',
'ETIMEDOUT',
'ERR_NETWORK',
];
export const parseCalDAVError = (
error: Error,
error: Error & { code?: string },
): CalendarEventImportDriverException => {
const { message } = error;
if (error.code && NETWORK_ERROR_CODES.includes(error.code)) {
return new CalendarEventImportDriverException(
message,
CalendarEventImportDriverExceptionCode.TEMPORARY_ERROR,
);
}
if (error instanceof CalDavHttpException) {
return parseCalDAVHttpStatusError(error.status, message);
}
if (message.includes('CALDAV_SYNC_COLLECTION_NOT_SUPPORTED')) {
return new CalendarEventImportDriverException(
message,
CalendarEventImportDriverExceptionCode.CHANNEL_MISCONFIGURED,
);
}
switch (message) {
case 'Collection does not exist on server':
return new CalendarEventImportDriverException(
@@ -19,6 +47,8 @@ export const parseCalDAVError = (
case 'no account for fetchAddressBooks':
case 'no account for fetchCalendars':
case 'Must have account before syncCalendars':
case 'Invalid credentials':
case 'Invalid auth method':
return new CalendarEventImportDriverException(
message,
CalendarEventImportDriverExceptionCode.INSUFFICIENT_PERMISSIONS,
@@ -33,21 +63,10 @@ export const parseCalDAVError = (
CalendarEventImportDriverExceptionCode.NOT_FOUND,
);
case 'Invalid credentials':
default:
return new CalendarEventImportDriverException(
message,
CalendarEventImportDriverExceptionCode.INSUFFICIENT_PERMISSIONS,
);
case 'Invalid auth method':
return new CalendarEventImportDriverException(
message,
CalendarEventImportDriverExceptionCode.INSUFFICIENT_PERMISSIONS,
CalendarEventImportDriverExceptionCode.UNKNOWN,
);
}
return new CalendarEventImportDriverException(
message,
CalendarEventImportDriverExceptionCode.UNKNOWN,
);
};
@@ -0,0 +1,37 @@
import {
CalendarEventImportDriverException,
CalendarEventImportDriverExceptionCode,
} from 'src/modules/calendar/calendar-event-import-manager/drivers/exceptions/calendar-event-import-driver.exception';
export const parseCalDAVHttpStatusError = (
statusCode: number,
message: string,
): CalendarEventImportDriverException => {
switch (statusCode) {
case 401:
case 403:
return new CalendarEventImportDriverException(
message,
CalendarEventImportDriverExceptionCode.INSUFFICIENT_PERMISSIONS,
);
case 404:
return new CalendarEventImportDriverException(
message,
CalendarEventImportDriverExceptionCode.NOT_FOUND,
);
case 429:
case 500:
case 502:
case 503:
case 504:
return new CalendarEventImportDriverException(
message,
CalendarEventImportDriverExceptionCode.TEMPORARY_ERROR,
);
default:
return new CalendarEventImportDriverException(
message,
CalendarEventImportDriverExceptionCode.UNKNOWN,
);
}
};