diff --git a/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/imap-smtp-caldav-connection.module.ts b/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/imap-smtp-caldav-connection.module.ts index 297fd3bc04d..51159a902bd 100644 --- a/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/imap-smtp-caldav-connection.module.ts +++ b/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/imap-smtp-caldav-connection.module.ts @@ -8,6 +8,7 @@ import { MessageQueueModule } from 'src/engine/core-modules/message-queue/messag import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module'; +import { CalDavDriverModule } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/caldav-driver.module'; import { ConnectedAccountModule } from 'src/modules/connected-account/connected-account.module'; import { IMAPAPIsModule } from 'src/modules/connected-account/imap-api/imap-apis.module'; import { MessagingIMAPDriverModule } from 'src/modules/messaging/message-import-manager/drivers/imap/messaging-imap-driver.module'; @@ -30,6 +31,7 @@ import { ImapSmtpCaldavService } from './services/imap-smtp-caldav-connection.se ImapSmtpCaldavValidatorModule, PermissionsModule, SecureHttpClientModule, + CalDavDriverModule, ], providers: [ImapSmtpCaldavResolver, ImapSmtpCaldavService], exports: [ImapSmtpCaldavService], diff --git a/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/services/__tests__/imap-smtp-caldav-connection.service.spec.ts b/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/services/__tests__/imap-smtp-caldav-connection.service.spec.ts index dbd7e492355..e23d911d3c3 100644 --- a/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/services/__tests__/imap-smtp-caldav-connection.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/services/__tests__/imap-smtp-caldav-connection.service.spec.ts @@ -1,55 +1,49 @@ import { Test, type TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; -import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service'; +import { type DAVClient } from 'tsdav'; + import { ImapSmtpCaldavService } from 'src/engine/core-modules/imap-smtp-caldav-connection/services/imap-smtp-caldav-connection.service'; import { type ConnectionParameters } from 'src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type'; +import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service'; import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity'; import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; - -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), - })), - }), -); - -import { CalDAVClient } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/caldav.client'; - -const MockCalDAVClient = jest.mocked(CalDAVClient); +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'; describe('ImapSmtpCaldavService', () => { let service: ImapSmtpCaldavService; - const mockSsrfSafeFetch = jest.fn(); + const mockClient = {} as DAVClient; - const mockSecureHttpClientService = { - createSsrfSafeFetch: jest.fn().mockReturnValue(mockSsrfSafeFetch), + const mockCalDavClientService = { + getClient: jest.fn(), + }; + + const mockCalDavFetchEventsService = { + listEventCalendars: jest.fn(), }; beforeEach(async () => { jest.clearAllMocks(); - mockSecureHttpClientService.createSsrfSafeFetch.mockReturnValue( - mockSsrfSafeFetch, - ); + mockCalDavClientService.getClient.mockResolvedValue(mockClient); + mockCalDavFetchEventsService.listEventCalendars.mockResolvedValue([ + { url: 'https://caldav.example.com/calendars/user/default/' }, + ]); const module: TestingModule = await Test.createTestingModule({ providers: [ ImapSmtpCaldavService, + { provide: GlobalWorkspaceOrmManager, useValue: {} }, + { provide: getRepositoryToken(ConnectedAccountEntity), useValue: {} }, + { provide: SecureHttpClientService, useValue: {} }, { - provide: GlobalWorkspaceOrmManager, - useValue: {}, + provide: CalDavClientService, + useValue: mockCalDavClientService, }, { - provide: getRepositoryToken(ConnectedAccountEntity), - useValue: {}, - }, - { - provide: SecureHttpClientService, - useValue: mockSecureHttpClientService, + provide: CalDavFetchEventsService, + useValue: mockCalDavFetchEventsService, }, ], }).compile(); @@ -58,22 +52,43 @@ describe('ImapSmtpCaldavService', () => { }); describe('testCaldavConnection', () => { - it('should pass SSRF-safe fetch to CalDAVClient', async () => { - const params: ConnectionParameters = { - host: 'https://caldav.example.com', - port: 443, - username: 'user@example.com', - password: 'password123', - }; + const params: ConnectionParameters = { + host: 'https://caldav.example.com', + port: 443, + username: 'user@example.com', + password: 'password123', + }; + it('builds a CalDAV client and lists its event calendars', async () => { await service.testCaldavConnection('user@example.com', params); + expect(mockCalDavClientService.getClient).toHaveBeenCalledWith({ + serverUrl: 'https://caldav.example.com', + username: 'user@example.com', + password: 'password123', + }); expect( - mockSecureHttpClientService.createSsrfSafeFetch, - ).toHaveBeenCalledTimes(1); - expect(MockCalDAVClient).toHaveBeenCalledWith( - expect.objectContaining({ fetch: mockSsrfSafeFetch }), + mockCalDavFetchEventsService.listEventCalendars, + ).toHaveBeenCalledWith(mockClient); + }); + + it('falls back to the handle when CALDAV.username is missing', async () => { + await service.testCaldavConnection('handle@example.com', { + ...params, + username: undefined, + }); + + expect(mockCalDavClientService.getClient).toHaveBeenCalledWith( + expect.objectContaining({ username: 'handle@example.com' }), ); }); + + it('throws when no event calendars are found', async () => { + mockCalDavFetchEventsService.listEventCalendars.mockResolvedValue([]); + + await expect( + service.testCaldavConnection('user@example.com', params), + ).rejects.toThrow('No calendar with event support found'); + }); }); }); diff --git a/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/services/imap-smtp-caldav-connection.service.ts b/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/services/imap-smtp-caldav-connection.service.ts index e9396ef7b48..5408778f972 100644 --- a/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/services/imap-smtp-caldav-connection.service.ts +++ b/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/services/imap-smtp-caldav-connection.service.ts @@ -16,7 +16,8 @@ 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 { 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'; @Injectable() export class ImapSmtpCaldavService { @@ -27,6 +28,8 @@ export class ImapSmtpCaldavService { @InjectRepository(ConnectedAccountEntity) private readonly connectedAccountRepository: Repository, private readonly secureHttpClientService: SecureHttpClientService, + private readonly caldavClientService: CalDavClientService, + private readonly caldavFetchEventsService: CalDavFetchEventsService, ) {} async testImapConnection( @@ -132,29 +135,31 @@ export class ImapSmtpCaldavService { handle: string, params: ConnectionParameters, ): Promise { - const ssrfSafeFetch = this.secureHttpClientService.createSsrfSafeFetch(); - const client = new CalDAVClient({ - serverUrl: params.host, - username: params.username ?? handle, - password: params.password, - fetch: ssrfSafeFetch, - }); - try { - await client.listCalendars(); - await client.validateSyncCollectionSupport(); + const client = await this.caldavClientService.getClient({ + serverUrl: params.host, + username: params.username ?? handle, + password: params.password, + }); + + const calendars = + await this.caldavFetchEventsService.listEventCalendars(client); + + if (calendars.length === 0) { + throw new UserInputError('No calendar with event support found', { + userFriendlyMessage: msg`We couldn't find any calendars on your CalDAV server. Please make sure your account has at least one calendar.`, + }); + } } catch (error) { + if (error instanceof UserInputError) { + throw 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.`, diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/caldav-driver.module.ts b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/caldav-driver.module.ts index 6478ccd6809..1b1cb1f1f7d 100644 --- a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/caldav-driver.module.ts +++ b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/caldav-driver.module.ts @@ -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 {} diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/caldav.client.ts b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/caldav.client.ts deleted file mode 100644 index 1e1aca1c2c7..00000000000 --- a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/caldav.client.ts +++ /dev/null @@ -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; -}; - -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 { - 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 { - try { - const account = await this.getAccount(); - - const calendars = (await fetchCalendars({ - account, - fetch: this.fetchOverride, - })) as (Omit & { - displayName?: string | Record; - })[]; - - return calendars.reduce((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 { - 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 { - const calendars = await this.listCalendars(); - const results = new Map(); - - 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 = {}; - - 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 | 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, - ); - - 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; - } - } -} diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/providers/__tests__/caldav.provider.spec.ts b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/providers/__tests__/caldav.provider.spec.ts deleted file mode 100644 index 83edb322fb1..00000000000 --- a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/providers/__tests__/caldav.provider.spec.ts +++ /dev/null @@ -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 }), - ); - }); - }); -}); diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/providers/caldav.provider.ts b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/providers/caldav.provider.ts deleted file mode 100644 index c455bc88f04..00000000000 --- a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/providers/caldav.provider.ts +++ /dev/null @@ -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 { - 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, - }); - } -} diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/services/caldav-client.service.ts b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/services/caldav-client.service.ts new file mode 100644 index 00000000000..6ad0503145b --- /dev/null +++ b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/services/caldav-client.service.ts @@ -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 { + 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; + } +} diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/services/caldav-fetch-events.service.spec.ts b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/services/caldav-fetch-events.service.spec.ts new file mode 100644 index 00000000000..ae515ccd686 --- /dev/null +++ b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/services/caldav-fetch-events.service.spec.ts @@ -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([]); + }); + }); +}); diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/services/caldav-fetch-events.service.ts b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/services/caldav-fetch-events.service.ts new file mode 100644 index 00000000000..c8b65458350 --- /dev/null +++ b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/services/caldav-fetch-events.service.ts @@ -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; +}; + +type FetchEventsOptions = { + startDate: Date; + endDate: Date; + syncCursor?: CalDavSyncCursor; +}; + +@Injectable() +export class CalDavFetchEventsService { + private readonly logger = new Logger(CalDavFetchEventsService.name); + + async listEventCalendars(client: DAVClient): Promise { + 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 { + 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 { + 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 { + 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 { + 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 = {}; + const ctags: Record = {}; + const etags: Record> = {}; + + 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> { + const responses = await client.propfind({ + url: calendarUrl, + props: { [`${DAVNamespaceShort.DAV}:getetag`]: {} }, + depth: '1', + }); + + return responses.reduce>((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 { + 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), + ); + }); + } +} diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/services/caldav-get-events.service.ts b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/services/caldav-get-events.service.ts index 6a6c0d3e6cc..cb70f369dbc 100644 --- a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/services/caldav-get-events.service.ts +++ b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/services/caldav-get-events.service.ts @@ -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); - } } diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/types/caldav-sync-cursor.ts b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/types/caldav-sync-cursor.ts new file mode 100644 index 00000000000..70fed76a079 --- /dev/null +++ b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/types/caldav-sync-cursor.ts @@ -0,0 +1,5 @@ +export type CalDavSyncCursor = { + syncTokens: Record; + ctags?: Record; + etags?: Record>; +}; diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/__tests__/extract-ical-data.util.spec.ts b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/__tests__/extract-ical-data.util.spec.ts new file mode 100644 index 00000000000..480ae88574f --- /dev/null +++ b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/__tests__/extract-ical-data.util.spec.ts @@ -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), + ).toBeNull(); + }); +}); diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/__tests__/is-event-in-time-range.util.spec.ts b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/__tests__/is-event-in-time-range.util.spec.ts new file mode 100644 index 00000000000..95ea2f65f0b --- /dev/null +++ b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/__tests__/is-event-in-time-range.util.spec.ts @@ -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); + }); +}); diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/__tests__/is-valid-caldav-href.util.spec.ts b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/__tests__/is-valid-caldav-href.util.spec.ts new file mode 100644 index 00000000000..d30c12c142d --- /dev/null +++ b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/__tests__/is-valid-caldav-href.util.spec.ts @@ -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); + }); +}); diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/__tests__/parse-caldav-error.util.spec.ts b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/__tests__/parse-caldav-error.util.spec.ts new file mode 100644 index 00000000000..2b1d297bf7f --- /dev/null +++ b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/__tests__/parse-caldav-error.util.spec.ts @@ -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'); + }); +}); diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/__tests__/parse-ical-event.util.spec.ts b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/__tests__/parse-ical-event.util.spec.ts new file mode 100644 index 00000000000..38d5aea2640 --- /dev/null +++ b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/__tests__/parse-ical-event.util.spec.ts @@ -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']); + }); +}); diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/build-cancelled-event.util.ts b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/build-cancelled-event.util.ts new file mode 100644 index 00000000000..cd8a7822461 --- /dev/null +++ b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/build-cancelled-event.util.ts @@ -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', +}); diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/extract-attendees-from-event.util.ts b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/extract-attendees-from-event.util.ts new file mode 100644 index 00000000000..767ff8f9759 --- /dev/null +++ b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/extract-attendees-from-event.util.ts @@ -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, + }; + }); +}; diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/extract-ical-data.util.ts b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/extract-ical-data.util.ts new file mode 100644 index 00000000000..a771dac9b8d --- /dev/null +++ b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/extract-ical-data.util.ts @@ -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 | 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); + + if (isDefined(result)) return result; + } + } + + return null; +}; diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/extract-organizer-from-event.util.ts b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/extract-organizer-from-event.util.ts new file mode 100644 index 00000000000..ebac7ff8ad6 --- /dev/null +++ b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/extract-organizer-from-event.util.ts @@ -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, + }; +}; diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/is-event-in-time-range.util.ts b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/is-event-in-time-range.util.ts new file mode 100644 index 00000000000..411754b5118 --- /dev/null +++ b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/is-event-in-time-range.util.ts @@ -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; +}; diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/is-invalid-sync-token-response.util.ts b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/is-invalid-sync-token-response.util.ts new file mode 100644 index 00000000000..665725e5482 --- /dev/null +++ b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/is-invalid-sync-token-response.util.ts @@ -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'); +}; diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/is-valid-caldav-href.util.ts b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/is-valid-caldav-href.util.ts new file mode 100644 index 00000000000..81349f4ba9b --- /dev/null +++ b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/is-valid-caldav-href.util.ts @@ -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()); diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/map-partstat-to-response-status.util.ts b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/map-partstat-to-response-status.util.ts new file mode 100644 index 00000000000..a0fbda2ef98 --- /dev/null +++ b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/map-partstat-to-response-status.util.ts @@ -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; + } +}; diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/parse-ical-event.util.ts b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/parse-ical-event.util.ts new file mode 100644 index 00000000000..f50c87192a7 --- /dev/null +++ b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/parse-ical-event.util.ts @@ -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 []; + } +};