CalDAV refactor (#20180)

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

- Splits the monolithic driver into isolated utilities with test
coverage

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

Real testing report

| Provider  | Server            | Sync method          | Auth   |
| --------- | ----------------- | -------------------- | ------ |
| iCloud    | Apple's CalDAV    | sync-collection      | Basic  |
| Nextcloud | sabre/dav         | sync-collection      | Basic  |
| all-inkl  | sabre/dav (older) | ctag + etag fallback | Digest |
This commit is contained in:
neo773
2026-05-05 19:45:05 +05:30
committed by GitHub
parent 65ba36d475
commit 4dd08097ce
26 changed files with 1453 additions and 704 deletions
@@ -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],
@@ -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');
});
});
});
@@ -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<ConnectedAccountEntity>,
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<boolean> {
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.`,