feat: CalDav Driver (#13170)

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
neo773
2025-07-15 21:11:23 +05:30
committed by GitHub
parent c5a74b8e92
commit 3e8fa3120d
22 changed files with 1210 additions and 339 deletions

View File

@ -14,6 +14,9 @@ export class ConnectionParameters {
@Field(() => Number)
port: number;
@Field(() => String, { nullable: true })
username?: string;
/**
* Note: This field is stored in plain text in the database.
* While encrypting it could provide an extra layer of defense, we have decided not to,
@ -26,6 +29,18 @@ export class ConnectionParameters {
secure?: boolean;
}
@InputType()
export class EmailAccountConnectionParameters {
@Field(() => ConnectionParameters, { nullable: true })
IMAP?: ConnectionParameters;
@Field(() => ConnectionParameters, { nullable: true })
SMTP?: ConnectionParameters;
@Field(() => ConnectionParameters, { nullable: true })
CALDAV?: ConnectionParameters;
}
@ObjectType()
export class ConnectionParametersOutput {
@Field(() => String)
@ -34,6 +49,9 @@ export class ConnectionParametersOutput {
@Field(() => Number)
port: number;
@Field(() => String, { nullable: true })
username?: string;
@Field(() => String)
password: string;

View File

@ -16,10 +16,7 @@ import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/re
import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { ConnectedImapSmtpCaldavAccount } from 'src/engine/core-modules/imap-smtp-caldav-connection/dtos/imap-smtp-caldav-connected-account.dto';
import { ImapSmtpCaldavConnectionSuccess } from 'src/engine/core-modules/imap-smtp-caldav-connection/dtos/imap-smtp-caldav-connection-success.dto';
import {
AccountType,
ConnectionParameters,
} from 'src/engine/core-modules/imap-smtp-caldav-connection/dtos/imap-smtp-caldav-connection.dto';
import { EmailAccountConnectionParameters } from 'src/engine/core-modules/imap-smtp-caldav-connection/dtos/imap-smtp-caldav-connection.dto';
import { ImapSmtpCaldavValidatorService } from 'src/engine/core-modules/imap-smtp-caldav-connection/services/imap-smtp-caldav-connection-validator.service';
import { ImapSmtpCaldavService } from 'src/engine/core-modules/imap-smtp-caldav-connection/services/imap-smtp-caldav-connection.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
@ -78,12 +75,11 @@ export class ImapSmtpCaldavResolver {
@Mutation(() => ImapSmtpCaldavConnectionSuccess)
@UseGuards(WorkspaceAuthGuard)
async saveImapSmtpCaldav(
async saveImapSmtpCaldavAccount(
@Args('accountOwnerId') accountOwnerId: string,
@Args('handle') handle: string,
@Args('accountType') accountType: AccountType,
@Args('connectionParameters')
connectionParameters: ConnectionParameters,
connectionParameters: EmailAccountConnectionParameters,
@AuthWorkspace() workspace: Workspace,
@Args('id', { nullable: true }) id?: string,
): Promise<ImapSmtpCaldavConnectionSuccess> {
@ -100,23 +96,16 @@ export class ImapSmtpCaldavResolver {
);
}
const validatedParams =
this.mailConnectionValidatorService.validateProtocolConnectionParams(
connectionParameters,
);
await this.ImapSmtpCaldavConnectionService.testImapSmtpCaldav(
const validatedParams = await this.validateAndTestConnectionParameters(
connectionParameters,
handle,
validatedParams,
accountType.type,
);
await this.imapSmtpCaldavApisService.setupConnectedAccount({
await this.imapSmtpCaldavApisService.setupCompleteAccount({
handle,
workspaceMemberId: accountOwnerId,
workspaceId: workspace.id,
connectionParams: validatedParams,
accountType: accountType.type,
connectionParameters: validatedParams,
connectedAccountId: id,
});
@ -124,4 +113,34 @@ export class ImapSmtpCaldavResolver {
success: true,
};
}
private async validateAndTestConnectionParameters(
connectionParameters: EmailAccountConnectionParameters,
handle: string,
): Promise<EmailAccountConnectionParameters> {
const validatedParams: EmailAccountConnectionParameters = {};
const protocols = ['IMAP', 'SMTP', 'CALDAV'] as const;
for (const protocol of protocols) {
const params = connectionParameters[protocol];
if (params) {
validatedParams[protocol] =
this.mailConnectionValidatorService.validateProtocolConnectionParams(
params,
);
const validatedProtocolParams = validatedParams[protocol];
if (validatedProtocolParams) {
await this.ImapSmtpCaldavConnectionService.testImapSmtpCaldav(
handle,
validatedProtocolParams,
protocol,
);
}
}
}
return validatedParams;
}
}

View File

@ -10,6 +10,7 @@ export class ImapSmtpCaldavValidatorService {
private readonly protocolConnectionSchema = z.object({
host: z.string().min(1, 'Host is required'),
port: z.number().int().positive('Port must be a positive number'),
username: z.string().optional(),
password: z.string().min(1, 'Password is required'),
secure: z.boolean().optional(),
});

View File

@ -10,6 +10,7 @@ import {
ConnectionParameters,
} from 'src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { CalDAVClient } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/caldav.client';
import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
@Injectable()
@ -121,7 +122,31 @@ export class ImapSmtpCaldavService {
handle: string,
params: ConnectionParameters,
): Promise<boolean> {
this.logger.log('CALDAV connection testing not yet implemented', params);
const client = new CalDAVClient({
serverUrl: params.host,
username: params.username ?? handle,
password: params.password,
});
try {
await client.listCalendars();
} catch (error) {
this.logger.error(
`CALDAV connection failed: ${error.message}`,
error.stack,
);
if (error.code === 'FailedToOpenSocket') {
throw new UserInputError(`CALDAV connection failed: ${error.message}`, {
userFriendlyMessage:
"We couldn't connect to your CalDAV server. Please check your server settings and try again.",
});
}
throw new UserInputError(`CALDAV connection failed: ${error.message}`, {
userFriendlyMessage:
'Invalid credentials. Please check your username and password.',
});
}
return true;
}

View File

@ -1,6 +1,7 @@
export type ConnectionParameters = {
host: string;
port: number;
username?: string;
password: string;
secure?: boolean;
};

View File

@ -16,6 +16,7 @@ import { CalendarOngoingStaleCronCommand } from 'src/modules/calendar/calendar-e
import { CalendarEventListFetchCronJob } from 'src/modules/calendar/calendar-event-import-manager/crons/jobs/calendar-event-list-fetch.cron.job';
import { CalendarEventsImportCronJob } from 'src/modules/calendar/calendar-event-import-manager/crons/jobs/calendar-events-import.cron.job';
import { CalendarOngoingStaleCronJob } from 'src/modules/calendar/calendar-event-import-manager/crons/jobs/calendar-ongoing-stale.cron.job';
import { CalDavDriverModule } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/caldav-driver.module';
import { GoogleCalendarDriverModule } from 'src/modules/calendar/calendar-event-import-manager/drivers/google-calendar/google-calendar-driver.module';
import { MicrosoftCalendarDriverModule } from 'src/modules/calendar/calendar-event-import-manager/drivers/microsoft-calendar/microsoft-calendar-driver.module';
import { CalendarEventListFetchJob } from 'src/modules/calendar/calendar-event-import-manager/jobs/calendar-event-list-fetch.job';
@ -43,6 +44,7 @@ import { RefreshTokensManagerModule } from 'src/modules/connected-account/refres
WorkspaceDataSourceModule,
CalendarEventCleanerModule,
GoogleCalendarDriverModule,
CalDavDriverModule,
MicrosoftCalendarDriverModule,
BillingModule,
RefreshTokensManagerModule,

View File

@ -0,0 +1,12 @@
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 { CalDavGetEventsService } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/services/caldav-get-events.service';
@Module({
imports: [TwentyConfigModule],
providers: [CalDavClientProvider, CalDavGetEventsService],
exports: [CalDavGetEventsService],
})
export class CalDavDriverModule {}

View File

@ -0,0 +1,493 @@
import { Injectable, Logger } from '@nestjs/common';
import * as ical from 'node-ical';
import {
calendarMultiGet,
createAccount,
DAVAccount,
DAVCalendar,
DAVNamespaceShort,
DAVObject,
fetchCalendars,
getBasicAuthHeaders,
syncCollection,
} from 'tsdav';
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 {
FetchedCalendarEvent,
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);
}
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;
}
}
/**
* 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);
return {
id: objectUrl,
title: event.summary || 'Untitled Event',
iCalUID: event.uid || '',
description: event.description || '',
startsAt: event.start.toISOString(),
endsAt: event.end.toISOString(),
location: event.location || '',
isFullDay: this.isFullDayEvent(rawData),
isCanceled: event.status === 'CANCELLED',
conferenceLinkLabel: '',
conferenceLinkUrl: 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',
};
} 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 (error) {
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 (error) {
return true;
}
}
}

View File

@ -0,0 +1,30 @@
import { Injectable } from '@nestjs/common';
import { CalDAVClient } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/caldav.client';
import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
@Injectable()
export class CalDavClientProvider {
public async getCalDavCalendarClient(
connectedAccount: Pick<
ConnectedAccountWorkspaceEntity,
'id' | 'provider' | 'connectionParameters' | 'handle'
>,
): Promise<CalDAVClient> {
if (
!connectedAccount.connectionParameters?.CALDAV?.password ||
!connectedAccount.connectionParameters?.CALDAV?.host
) {
throw new Error('Missing required CalDAV connection parameters');
}
const caldavClient = new CalDAVClient({
username:
connectedAccount.connectionParameters.CALDAV.username ??
connectedAccount.handle,
password: connectedAccount.connectionParameters.CALDAV.password,
serverUrl: connectedAccount.connectionParameters.CALDAV.host,
});
return caldavClient;
}
}

View File

@ -0,0 +1,72 @@
import { Injectable, Logger } from '@nestjs/common';
import { CalDavClientProvider } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/providers/caldav.provider';
import { parseCalDAVError } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/parse-caldav-error.util';
import { GetCalendarEventsResponse } from 'src/modules/calendar/calendar-event-import-manager/services/calendar-get-events.service';
import { 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,
) {}
public async getCalendarEvents(
connectedAccount: Pick<
ConnectedAccountWorkspaceEntity,
'provider' | 'id' | 'connectionParameters' | 'handle'
>,
syncCursor?: string,
): Promise<GetCalendarEventsResponse> {
this.logger.log(`Getting calendar events for ${connectedAccount.handle}`);
try {
const caldavCalendarClient =
await this.caldavCalendarClientProvider.getCalDavCalendarClient(
connectedAccount,
);
const startDate = new Date(
Date.now() -
CalDavGetEventsService.PAST_DAYS_WINDOW * 24 * 60 * 60 * 1000,
);
const endDate = new Date(
Date.now() +
CalDavGetEventsService.FUTURE_DAYS_WINDOW * 24 * 60 * 60 * 1000,
);
const result = await caldavCalendarClient.getEvents({
startDate,
endDate,
syncCursor: syncCursor ? JSON.parse(syncCursor) : undefined,
});
this.logger.log(
`Found ${result.events.length} calendar events for ${connectedAccount.handle}`,
);
return {
fullEvents: true,
calendarEvents: result.events,
nextSyncCursor: JSON.stringify(result.syncCursor),
};
} catch (error) {
this.handleError(error as Error);
throw error;
}
}
private handleError(error: Error) {
this.logger.error(
`Error in ${CalDavGetEventsService.name} - getCalendarEvents`,
error,
);
throw parseCalDAVError(error);
}
}

View File

@ -0,0 +1,53 @@
import {
CalendarEventImportDriverException,
CalendarEventImportDriverExceptionCode,
} from 'src/modules/calendar/calendar-event-import-manager/drivers/exceptions/calendar-event-import-driver.exception';
export const parseCalDAVError = (
error: Error,
): CalendarEventImportDriverException => {
const { message } = error;
switch (message) {
case 'Collection does not exist on server':
return new CalendarEventImportDriverException(
message,
CalendarEventImportDriverExceptionCode.NOT_FOUND,
);
case 'no account for smartCollectionSync':
case 'no account for fetchAddressBooks':
case 'no account for fetchCalendars':
case 'Must have account before syncCalendars':
return new CalendarEventImportDriverException(
message,
CalendarEventImportDriverExceptionCode.INSUFFICIENT_PERMISSIONS,
);
case 'cannot fetchVCards for undefined addressBook':
case 'cannot find calendarUserAddresses':
case 'cannot fetchCalendarObjects for undefined calendar':
case 'cannot find homeUrl':
return new CalendarEventImportDriverException(
message,
CalendarEventImportDriverExceptionCode.NOT_FOUND,
);
case 'Invalid credentials':
return new CalendarEventImportDriverException(
message,
CalendarEventImportDriverExceptionCode.INSUFFICIENT_PERMISSIONS,
);
case 'Invalid auth method':
return new CalendarEventImportDriverException(
message,
CalendarEventImportDriverExceptionCode.INSUFFICIENT_PERMISSIONS,
);
}
return new CalendarEventImportDriverException(
message,
CalendarEventImportDriverExceptionCode.UNKNOWN,
);
};

View File

@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { CalDavGetEventsService } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/services/caldav-get-events.service';
import { GoogleCalendarGetEventsService } from 'src/modules/calendar/calendar-event-import-manager/drivers/google-calendar/services/google-calendar-get-events.service';
import { MicrosoftCalendarGetEventsService } from 'src/modules/calendar/calendar-event-import-manager/drivers/microsoft-calendar/services/microsoft-calendar-get-events.service';
import {
@ -23,12 +24,13 @@ export class CalendarGetCalendarEventsService {
constructor(
private readonly googleCalendarGetEventsService: GoogleCalendarGetEventsService,
private readonly microsoftCalendarGetEventsService: MicrosoftCalendarGetEventsService,
private readonly caldavCalendarGetEventsService: CalDavGetEventsService,
) {}
public async getCalendarEvents(
connectedAccount: Pick<
ConnectedAccountWorkspaceEntity,
'provider' | 'refreshToken' | 'id'
'provider' | 'refreshToken' | 'id' | 'connectionParameters' | 'handle'
>,
syncCursor?: string,
): Promise<GetCalendarEventsResponse> {
@ -43,6 +45,11 @@ export class CalendarGetCalendarEventsService {
connectedAccount,
syncCursor,
);
case ConnectedAccountProvider.IMAP_SMTP_CALDAV:
return this.caldavCalendarGetEventsService.getCalendarEvents(
connectedAccount,
syncCursor,
);
default:
throw new CalendarEventImportException(
`Provider ${connectedAccount.provider} is not supported`,

View File

@ -2,22 +2,26 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { Repository } from 'typeorm';
import { v4 } from 'uuid';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import {
AccountType,
ConnectionParameters,
} from 'src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type';
import { EmailAccountConnectionParameters } from 'src/engine/core-modules/imap-smtp-caldav-connection/dtos/imap-smtp-caldav-connection.dto';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
import {
CalendarEventListFetchJob,
CalendarEventListFetchJobData,
} from 'src/modules/calendar/calendar-event-import-manager/jobs/calendar-event-list-fetch.job';
import {
CalendarChannelSyncStage,
CalendarChannelSyncStatus,
CalendarChannelWorkspaceEntity,
} from 'src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity';
import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import {
MessageChannelSyncStage,
@ -36,28 +40,22 @@ export class ImapSmtpCalDavAPIService {
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
@InjectMessageQueue(MessageQueue.messagingQueue)
private readonly messageQueueService: MessageQueueService,
private readonly twentyConfigService: TwentyConfigService,
@InjectMessageQueue(MessageQueue.calendarQueue)
private readonly calendarQueueService: MessageQueueService,
private readonly workspaceEventEmitter: WorkspaceEventEmitter,
@InjectRepository(ObjectMetadataEntity, 'core')
private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>,
private readonly featureFlagService: FeatureFlagService,
private readonly objectMetadataRepository: WorkspaceRepository<ObjectMetadataEntity>,
) {}
async setupConnectedAccount(input: {
async setupCompleteAccount(input: {
handle: string;
workspaceMemberId: string;
workspaceId: string;
accountType: AccountType;
connectionParams: ConnectionParameters;
connectionParameters: EmailAccountConnectionParameters;
connectedAccountId?: string;
}) {
const {
handle,
workspaceId,
workspaceMemberId,
connectionParams,
connectedAccountId,
} = input;
const { handle, workspaceId, workspaceMemberId, connectedAccountId } =
input;
const connectedAccountRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<ConnectedAccountWorkspaceEntity>(
@ -65,7 +63,19 @@ export class ImapSmtpCalDavAPIService {
'connectedAccount',
);
const connectedAccount = connectedAccountId
const messageChannelRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<MessageChannelWorkspaceEntity>(
workspaceId,
'messageChannel',
);
const calendarChannelRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<CalendarChannelWorkspaceEntity>(
workspaceId,
'calendarChannel',
);
const existingAccount = connectedAccountId
? await connectedAccountRepository.findOne({
where: { id: connectedAccountId },
})
@ -73,191 +83,251 @@ export class ImapSmtpCalDavAPIService {
where: { handle, accountOwnerId: workspaceMemberId },
});
const existingAccountId = connectedAccount?.id;
const newOrExistingConnectedAccountId =
existingAccountId ?? connectedAccountId ?? v4();
const messageChannelRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<MessageChannelWorkspaceEntity>(
workspaceId,
'messageChannel',
);
const accountId = existingAccount?.id ?? connectedAccountId ?? v4();
const workspaceDataSource =
await this.twentyORMGlobalManager.getDataSourceForWorkspace({
workspaceId,
});
let shouldEnableSync = false;
if (connectedAccount) {
const hadOnlySmtp =
connectedAccount.connectionParameters?.SMTP &&
!connectedAccount.connectionParameters?.IMAP &&
!connectedAccount.connectionParameters?.CALDAV;
const isAddingImapOrCaldav =
input.accountType === 'IMAP' || input.accountType === 'CALDAV';
shouldEnableSync = Boolean(hadOnlySmtp && isAddingImapOrCaldav);
}
let createdMessageChannel: MessageChannelWorkspaceEntity | null = null;
let createdCalendarChannel: CalendarChannelWorkspaceEntity | null = null;
await workspaceDataSource.transaction(async () => {
if (!existingAccountId) {
const newConnectedAccount = await connectedAccountRepository.save(
{
id: newOrExistingConnectedAccountId,
handle,
provider: ConnectedAccountProvider.IMAP_SMTP_CALDAV,
connectionParameters: {
[input.accountType]: connectionParams,
},
accountOwnerId: workspaceMemberId,
},
{},
);
await this.upsertConnectedAccount(
input,
accountId,
existingAccount,
connectedAccountRepository,
);
const connectedAccountMetadata =
await this.objectMetadataRepository.findOneOrFail({
where: { nameSingular: 'connectedAccount', workspaceId },
});
createdMessageChannel = await this.setupMessageChannels(
input,
accountId,
messageChannelRepository,
);
this.workspaceEventEmitter.emitDatabaseBatchEvent({
objectMetadataNameSingular: 'connectedAccount',
action: DatabaseEventAction.CREATED,
events: [
{
recordId: newConnectedAccount.id,
objectMetadata: connectedAccountMetadata,
properties: {
after: newConnectedAccount,
},
},
],
workspaceId,
});
const newMessageChannel = await messageChannelRepository.save(
{
id: v4(),
connectedAccountId: newOrExistingConnectedAccountId,
type: MessageChannelType.EMAIL,
handle,
isSyncEnabled: shouldEnableSync,
syncStatus: shouldEnableSync
? MessageChannelSyncStatus.ONGOING
: MessageChannelSyncStatus.NOT_SYNCED,
},
{},
);
const messageChannelMetadata =
await this.objectMetadataRepository.findOneOrFail({
where: { nameSingular: 'messageChannel', workspaceId },
});
this.workspaceEventEmitter.emitDatabaseBatchEvent({
objectMetadataNameSingular: 'messageChannel',
action: DatabaseEventAction.CREATED,
events: [
{
recordId: newMessageChannel.id,
objectMetadata: messageChannelMetadata,
properties: {
after: newMessageChannel,
},
},
],
workspaceId,
});
} else {
const updatedConnectedAccount = await connectedAccountRepository.update(
{
id: newOrExistingConnectedAccountId,
},
{
connectionParameters: {
...connectedAccount.connectionParameters,
[input.accountType]: connectionParams,
},
},
);
const connectedAccountMetadata =
await this.objectMetadataRepository.findOneOrFail({
where: { nameSingular: 'connectedAccount', workspaceId },
});
this.workspaceEventEmitter.emitDatabaseBatchEvent({
objectMetadataNameSingular: 'connectedAccount',
action: DatabaseEventAction.UPDATED,
events: [
{
recordId: newOrExistingConnectedAccountId,
objectMetadata: connectedAccountMetadata,
properties: {
before: connectedAccount,
after: {
...connectedAccount,
...updatedConnectedAccount.raw[0],
},
},
},
],
workspaceId,
});
const messageChannels = await messageChannelRepository.find({
where: { connectedAccountId: newOrExistingConnectedAccountId },
});
const messageChannelUpdates = await messageChannelRepository.update(
{
connectedAccountId: newOrExistingConnectedAccountId,
},
{
syncStage: MessageChannelSyncStage.FULL_MESSAGE_LIST_FETCH_PENDING,
syncStatus: shouldEnableSync
? MessageChannelSyncStatus.ONGOING
: MessageChannelSyncStatus.NOT_SYNCED,
syncCursor: '',
syncStageStartedAt: null,
isSyncEnabled: shouldEnableSync,
},
);
const messageChannelMetadata =
await this.objectMetadataRepository.findOneOrFail({
where: { nameSingular: 'messageChannel', workspaceId },
});
this.workspaceEventEmitter.emitDatabaseBatchEvent({
objectMetadataNameSingular: 'messageChannel',
action: DatabaseEventAction.UPDATED,
events: messageChannels.map((messageChannel) => ({
recordId: messageChannel.id,
objectMetadata: messageChannelMetadata,
properties: {
before: messageChannel,
after: { ...messageChannel, ...messageChannelUpdates.raw[0] },
},
})),
workspaceId,
});
}
createdCalendarChannel = await this.setupCalendarChannels(
input,
accountId,
calendarChannelRepository,
);
});
if (!shouldEnableSync) {
return;
await this.enqueueSyncJobs(
input,
accountId,
workspaceId,
createdMessageChannel,
createdCalendarChannel,
);
}
private async upsertConnectedAccount(
input: {
handle: string;
workspaceMemberId: string;
workspaceId: string;
connectionParameters: EmailAccountConnectionParameters;
},
accountId: string,
existingAccount: ConnectedAccountWorkspaceEntity | null,
connectedAccountRepository: WorkspaceRepository<ConnectedAccountWorkspaceEntity>,
) {
const accountData = {
id: accountId,
handle: input.handle,
provider: ConnectedAccountProvider.IMAP_SMTP_CALDAV,
connectionParameters: input.connectionParameters,
accountOwnerId: input.workspaceMemberId,
};
const savedAccount = await connectedAccountRepository.save(accountData, {});
const connectedAccountMetadata =
await this.objectMetadataRepository.findOneOrFail({
where: {
nameSingular: 'connectedAccount',
workspaceId: input.workspaceId,
},
});
if (existingAccount) {
this.workspaceEventEmitter.emitDatabaseBatchEvent({
objectMetadataNameSingular: 'connectedAccount',
action: DatabaseEventAction.UPDATED,
events: [
{
recordId: savedAccount.id,
objectMetadata: connectedAccountMetadata,
properties: {
before: existingAccount,
after: savedAccount,
},
},
],
workspaceId: input.workspaceId,
});
} else {
this.workspaceEventEmitter.emitDatabaseBatchEvent({
objectMetadataNameSingular: 'connectedAccount',
action: DatabaseEventAction.CREATED,
events: [
{
recordId: savedAccount.id,
objectMetadata: connectedAccountMetadata,
properties: {
after: savedAccount,
},
},
],
workspaceId: input.workspaceId,
});
}
}
private async setupMessageChannels(
input: {
handle: string;
workspaceId: string;
connectionParameters: EmailAccountConnectionParameters;
},
accountId: string,
messageChannelRepository: WorkspaceRepository<MessageChannelWorkspaceEntity>,
): Promise<MessageChannelWorkspaceEntity | null> {
const existingChannels = await messageChannelRepository.find({
where: { connectedAccountId: accountId },
});
if (existingChannels.length > 0) {
await messageChannelRepository.delete({
connectedAccountId: accountId,
});
}
const messageChannels = await messageChannelRepository.find({
where: {
connectedAccountId: newOrExistingConnectedAccountId,
const shouldEnableSync = Boolean(input.connectionParameters.IMAP);
const newMessageChannel = await messageChannelRepository.save(
{
id: v4(),
connectedAccountId: accountId,
type: MessageChannelType.EMAIL,
handle: input.handle,
isSyncEnabled: shouldEnableSync,
syncStatus: shouldEnableSync
? MessageChannelSyncStatus.ONGOING
: MessageChannelSyncStatus.NOT_SYNCED,
syncStage: shouldEnableSync
? MessageChannelSyncStage.FULL_MESSAGE_LIST_FETCH_PENDING
: undefined,
syncCursor: '',
syncStageStartedAt: null,
},
{},
);
const messageChannelMetadata =
await this.objectMetadataRepository.findOneOrFail({
where: {
nameSingular: 'messageChannel',
workspaceId: input.workspaceId,
},
});
this.workspaceEventEmitter.emitDatabaseBatchEvent({
objectMetadataNameSingular: 'messageChannel',
action: DatabaseEventAction.CREATED,
events: [
{
recordId: newMessageChannel.id,
objectMetadata: messageChannelMetadata,
properties: {
after: newMessageChannel,
},
},
],
workspaceId: input.workspaceId,
});
for (const messageChannel of messageChannels) {
return shouldEnableSync ? newMessageChannel : null;
}
private async setupCalendarChannels(
input: {
handle: string;
workspaceId: string;
connectionParameters: EmailAccountConnectionParameters;
},
accountId: string,
calendarChannelRepository: WorkspaceRepository<CalendarChannelWorkspaceEntity>,
): Promise<CalendarChannelWorkspaceEntity | null> {
const existingChannels = await calendarChannelRepository.find({
where: { connectedAccountId: accountId },
});
if (existingChannels.length > 0) {
await calendarChannelRepository.delete({
connectedAccountId: accountId,
});
}
const shouldEnableSync = Boolean(input.connectionParameters.CALDAV);
if (shouldEnableSync) {
const newCalendarChannel = await calendarChannelRepository.save(
{
id: v4(),
connectedAccountId: accountId,
handle: input.handle,
isSyncEnabled: shouldEnableSync,
syncStatus: CalendarChannelSyncStatus.ONGOING,
syncStage:
CalendarChannelSyncStage.FULL_CALENDAR_EVENT_LIST_FETCH_PENDING,
syncCursor: '',
syncStageStartedAt: null,
},
{},
);
const calendarChannelMetadata =
await this.objectMetadataRepository.findOneOrFail({
where: {
nameSingular: 'calendarChannel',
workspaceId: input.workspaceId,
},
});
this.workspaceEventEmitter.emitDatabaseBatchEvent({
objectMetadataNameSingular: 'calendarChannel',
action: DatabaseEventAction.CREATED,
events: [
{
recordId: newCalendarChannel.id,
objectMetadata: calendarChannelMetadata,
properties: {
after: newCalendarChannel,
},
},
],
workspaceId: input.workspaceId,
});
return newCalendarChannel;
}
return null;
}
private async enqueueSyncJobs(
input: {
connectionParameters: EmailAccountConnectionParameters;
},
accountId: string,
workspaceId: string,
messageChannel: MessageChannelWorkspaceEntity | null,
calendarChannel: CalendarChannelWorkspaceEntity | null,
) {
if (input.connectionParameters.IMAP && messageChannel) {
await this.messageQueueService.add<MessagingMessageListFetchJobData>(
MessagingMessageListFetchJob.name,
{
@ -266,5 +336,15 @@ export class ImapSmtpCalDavAPIService {
},
);
}
if (input.connectionParameters.CALDAV && calendarChannel) {
await this.calendarQueueService.add<CalendarEventListFetchJobData>(
CalendarEventListFetchJob.name,
{
workspaceId,
calendarChannelId: calendarChannel.id,
},
);
}
}
}