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

@ -22,10 +22,6 @@ export type Scalars = {
Upload: any;
};
export type AccountType = {
type: Scalars['String'];
};
export type ActivateWorkspaceInput = {
displayName?: InputMaybe<Scalars['String']>;
};
@ -435,6 +431,7 @@ export type ConnectionParameters = {
password: Scalars['String'];
port: Scalars['Float'];
secure?: InputMaybe<Scalars['Boolean']>;
username?: InputMaybe<Scalars['String']>;
};
export type ConnectionParametersOutput = {
@ -443,6 +440,7 @@ export type ConnectionParametersOutput = {
password: Scalars['String'];
port: Scalars['Float'];
secure?: Maybe<Scalars['Boolean']>;
username?: Maybe<Scalars['String']>;
};
export type CreateApiKeyDto = {
@ -673,6 +671,12 @@ export type EditSsoOutput = {
type: IdentityProviderType;
};
export type EmailAccountConnectionParameters = {
CALDAV?: InputMaybe<ConnectionParameters>;
IMAP?: InputMaybe<ConnectionParameters>;
SMTP?: InputMaybe<ConnectionParameters>;
};
export type EmailPasswordResetLink = {
__typename?: 'EmailPasswordResetLink';
/** Boolean that confirms query was dispatched */
@ -1117,7 +1121,7 @@ export type Mutation = {
resendWorkspaceInvitation: SendInvitationsOutput;
revokeApiKey?: Maybe<ApiKey>;
runWorkflowVersion: WorkflowRun;
saveImapSmtpCaldav: ImapSmtpCaldavConnectionSuccess;
saveImapSmtpCaldavAccount: ImapSmtpCaldavConnectionSuccess;
sendInvitations: SendInvitationsOutput;
signIn: AvailableWorkspacesAndAccessTokensOutput;
signUp: AvailableWorkspacesAndAccessTokensOutput;
@ -1436,10 +1440,9 @@ export type MutationRunWorkflowVersionArgs = {
};
export type MutationSaveImapSmtpCaldavArgs = {
export type MutationSaveImapSmtpCaldavAccountArgs = {
accountOwnerId: Scalars['String'];
accountType: AccountType;
connectionParameters: ConnectionParameters;
connectionParameters: EmailAccountConnectionParameters;
handle: Scalars['String'];
id?: InputMaybe<Scalars['String']>;
};
@ -3291,23 +3294,22 @@ export type SkipSyncEmailOnboardingStepMutationVariables = Exact<{ [key: string]
export type SkipSyncEmailOnboardingStepMutation = { __typename?: 'Mutation', skipSyncEmailOnboardingStep: { __typename?: 'OnboardingStepSuccess', success: boolean } };
export type SaveImapSmtpCaldavMutationVariables = Exact<{
export type SaveImapSmtpCaldavAccountMutationVariables = Exact<{
accountOwnerId: Scalars['String'];
handle: Scalars['String'];
accountType: AccountType;
connectionParameters: ConnectionParameters;
connectionParameters: EmailAccountConnectionParameters;
id?: InputMaybe<Scalars['String']>;
}>;
export type SaveImapSmtpCaldavMutation = { __typename?: 'Mutation', saveImapSmtpCaldav: { __typename?: 'ImapSmtpCaldavConnectionSuccess', success: boolean } };
export type SaveImapSmtpCaldavAccountMutation = { __typename?: 'Mutation', saveImapSmtpCaldavAccount: { __typename?: 'ImapSmtpCaldavConnectionSuccess', success: boolean } };
export type GetConnectedImapSmtpCaldavAccountQueryVariables = Exact<{
id: Scalars['String'];
}>;
export type GetConnectedImapSmtpCaldavAccountQuery = { __typename?: 'Query', getConnectedImapSmtpCaldavAccount: { __typename?: 'ConnectedImapSmtpCaldavAccount', id: string, handle: string, provider: string, accountOwnerId: string, connectionParameters?: { __typename?: 'ImapSmtpCaldavConnectionParameters', IMAP?: { __typename?: 'ConnectionParametersOutput', host: string, port: number, secure?: boolean | null, password: string } | null, SMTP?: { __typename?: 'ConnectionParametersOutput', host: string, port: number, secure?: boolean | null, password: string } | null, CALDAV?: { __typename?: 'ConnectionParametersOutput', host: string, port: number, secure?: boolean | null, password: string } | null } | null } };
export type GetConnectedImapSmtpCaldavAccountQuery = { __typename?: 'Query', getConnectedImapSmtpCaldavAccount: { __typename?: 'ConnectedImapSmtpCaldavAccount', id: string, handle: string, provider: string, accountOwnerId: string, connectionParameters?: { __typename?: 'ImapSmtpCaldavConnectionParameters', IMAP?: { __typename?: 'ConnectionParametersOutput', host: string, port: number, secure?: boolean | null, password: string } | null, SMTP?: { __typename?: 'ConnectionParametersOutput', host: string, port: number, secure?: boolean | null, password: string } | null, CALDAV?: { __typename?: 'ConnectionParametersOutput', host: string, username?: string | null, password: string } | null } | null } };
export type CreateDatabaseConfigVariableMutationVariables = Exact<{
key: Scalars['String'];
@ -6061,12 +6063,11 @@ export function useSkipSyncEmailOnboardingStepMutation(baseOptions?: Apollo.Muta
export type SkipSyncEmailOnboardingStepMutationHookResult = ReturnType<typeof useSkipSyncEmailOnboardingStepMutation>;
export type SkipSyncEmailOnboardingStepMutationResult = Apollo.MutationResult<SkipSyncEmailOnboardingStepMutation>;
export type SkipSyncEmailOnboardingStepMutationOptions = Apollo.BaseMutationOptions<SkipSyncEmailOnboardingStepMutation, SkipSyncEmailOnboardingStepMutationVariables>;
export const SaveImapSmtpCaldavDocument = gql`
mutation SaveImapSmtpCaldav($accountOwnerId: String!, $handle: String!, $accountType: AccountType!, $connectionParameters: ConnectionParameters!, $id: String) {
saveImapSmtpCaldav(
export const SaveImapSmtpCaldavAccountDocument = gql`
mutation SaveImapSmtpCaldavAccount($accountOwnerId: String!, $handle: String!, $connectionParameters: EmailAccountConnectionParameters!, $id: String) {
saveImapSmtpCaldavAccount(
accountOwnerId: $accountOwnerId
handle: $handle
accountType: $accountType
connectionParameters: $connectionParameters
id: $id
) {
@ -6074,36 +6075,35 @@ export const SaveImapSmtpCaldavDocument = gql`
}
}
`;
export type SaveImapSmtpCaldavMutationFn = Apollo.MutationFunction<SaveImapSmtpCaldavMutation, SaveImapSmtpCaldavMutationVariables>;
export type SaveImapSmtpCaldavAccountMutationFn = Apollo.MutationFunction<SaveImapSmtpCaldavAccountMutation, SaveImapSmtpCaldavAccountMutationVariables>;
/**
* __useSaveImapSmtpCaldavMutation__
* __useSaveImapSmtpCaldavAccountMutation__
*
* To run a mutation, you first call `useSaveImapSmtpCaldavMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useSaveImapSmtpCaldavMutation` returns a tuple that includes:
* To run a mutation, you first call `useSaveImapSmtpCaldavAccountMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useSaveImapSmtpCaldavAccountMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [saveImapSmtpCaldavMutation, { data, loading, error }] = useSaveImapSmtpCaldavMutation({
* const [saveImapSmtpCaldavAccountMutation, { data, loading, error }] = useSaveImapSmtpCaldavAccountMutation({
* variables: {
* accountOwnerId: // value for 'accountOwnerId'
* handle: // value for 'handle'
* accountType: // value for 'accountType'
* connectionParameters: // value for 'connectionParameters'
* id: // value for 'id'
* },
* });
*/
export function useSaveImapSmtpCaldavMutation(baseOptions?: Apollo.MutationHookOptions<SaveImapSmtpCaldavMutation, SaveImapSmtpCaldavMutationVariables>) {
export function useSaveImapSmtpCaldavAccountMutation(baseOptions?: Apollo.MutationHookOptions<SaveImapSmtpCaldavAccountMutation, SaveImapSmtpCaldavAccountMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<SaveImapSmtpCaldavMutation, SaveImapSmtpCaldavMutationVariables>(SaveImapSmtpCaldavDocument, options);
return Apollo.useMutation<SaveImapSmtpCaldavAccountMutation, SaveImapSmtpCaldavAccountMutationVariables>(SaveImapSmtpCaldavAccountDocument, options);
}
export type SaveImapSmtpCaldavMutationHookResult = ReturnType<typeof useSaveImapSmtpCaldavMutation>;
export type SaveImapSmtpCaldavMutationResult = Apollo.MutationResult<SaveImapSmtpCaldavMutation>;
export type SaveImapSmtpCaldavMutationOptions = Apollo.BaseMutationOptions<SaveImapSmtpCaldavMutation, SaveImapSmtpCaldavMutationVariables>;
export type SaveImapSmtpCaldavAccountMutationHookResult = ReturnType<typeof useSaveImapSmtpCaldavAccountMutation>;
export type SaveImapSmtpCaldavAccountMutationResult = Apollo.MutationResult<SaveImapSmtpCaldavAccountMutation>;
export type SaveImapSmtpCaldavAccountMutationOptions = Apollo.BaseMutationOptions<SaveImapSmtpCaldavAccountMutation, SaveImapSmtpCaldavAccountMutationVariables>;
export const GetConnectedImapSmtpCaldavAccountDocument = gql`
query GetConnectedImapSmtpCaldavAccount($id: String!) {
getConnectedImapSmtpCaldavAccount(id: $id) {
@ -6126,8 +6126,7 @@ export const GetConnectedImapSmtpCaldavAccountDocument = gql`
}
CALDAV {
host
port
secure
username
password
}
}

View File

@ -22,10 +22,6 @@ export type Scalars = {
Upload: any;
};
export type AccountType = {
type: Scalars['String'];
};
export type ActivateWorkspaceInput = {
displayName?: InputMaybe<Scalars['String']>;
};
@ -435,6 +431,7 @@ export type ConnectionParameters = {
password: Scalars['String'];
port: Scalars['Float'];
secure?: InputMaybe<Scalars['Boolean']>;
username?: InputMaybe<Scalars['String']>;
};
export type ConnectionParametersOutput = {
@ -443,6 +440,7 @@ export type ConnectionParametersOutput = {
password: Scalars['String'];
port: Scalars['Float'];
secure?: Maybe<Scalars['Boolean']>;
username?: Maybe<Scalars['String']>;
};
export type CreateApiKeyDto = {
@ -637,6 +635,12 @@ export type EditSsoOutput = {
type: IdentityProviderType;
};
export type EmailAccountConnectionParameters = {
CALDAV?: InputMaybe<ConnectionParameters>;
IMAP?: InputMaybe<ConnectionParameters>;
SMTP?: InputMaybe<ConnectionParameters>;
};
export type EmailPasswordResetLink = {
__typename?: 'EmailPasswordResetLink';
/** Boolean that confirms query was dispatched */
@ -1072,7 +1076,7 @@ export type Mutation = {
resendWorkspaceInvitation: SendInvitationsOutput;
revokeApiKey?: Maybe<ApiKey>;
runWorkflowVersion: WorkflowRun;
saveImapSmtpCaldav: ImapSmtpCaldavConnectionSuccess;
saveImapSmtpCaldavAccount: ImapSmtpCaldavConnectionSuccess;
sendInvitations: SendInvitationsOutput;
signIn: AvailableWorkspacesAndAccessTokensOutput;
signUp: AvailableWorkspacesAndAccessTokensOutput;
@ -1367,10 +1371,9 @@ export type MutationRunWorkflowVersionArgs = {
};
export type MutationSaveImapSmtpCaldavArgs = {
export type MutationSaveImapSmtpCaldavAccountArgs = {
accountOwnerId: Scalars['String'];
accountType: AccountType;
connectionParameters: ConnectionParameters;
connectionParameters: EmailAccountConnectionParameters;
handle: Scalars['String'];
id?: InputMaybe<Scalars['String']>;
};

View File

@ -291,6 +291,22 @@ export const SettingsAccountsConnectionForm = ({
)}
/>
<Controller
name="CALDAV.username"
control={control}
render={({ field, fieldState }) => (
<TextInput
instanceId="caldav-username-connection-form"
label={t`CalDAV Username`}
placeholder={t`john.doe`}
required={false}
value={field.value || ''}
onChange={field.onChange}
error={fieldState.error?.message}
/>
)}
/>
<Controller
name="CALDAV.password"
control={control}
@ -306,47 +322,6 @@ export const SettingsAccountsConnectionForm = ({
/>
)}
/>
<StyledFieldRow>
<StyledFieldGroup>
<Controller
name="CALDAV.port"
control={control}
render={({ field, fieldState }) => (
<TextInput
instanceId="caldav-port-connection-form"
label={t`CalDAV Port`}
type="number"
placeholder="443"
value={field?.value ? field.value : 443}
onChange={(value) =>
field.onChange(handlePortChange(value))
}
error={fieldState.error?.message}
/>
)}
/>
</StyledFieldGroup>
<StyledFieldGroup>
<Controller
name="CALDAV.secure"
control={control}
render={({ field }) => (
<Select
label={t`CalDAV Encryption`}
options={[
{ label: 'SSL/TLS', value: true },
{ label: 'None', value: false },
]}
value={field.value}
onChange={field.onChange}
dropdownId="caldav-secure-dropdown"
/>
)}
/>
</StyledFieldGroup>
</StyledFieldRow>
</StyledConnectionSection>
</StyledFormContainer>
</Section>

View File

@ -1,17 +1,15 @@
import gql from 'graphql-tag';
export const SAVE_IMAP_SMTP_CALDAV_CONNECTION = gql`
mutation SaveImapSmtpCaldav(
export const SAVE_IMAP_SMTP_CALDAV_ACCOUNT = gql`
mutation SaveImapSmtpCaldavAccount(
$accountOwnerId: String!
$handle: String!
$accountType: AccountType!
$connectionParameters: ConnectionParameters!
$connectionParameters: EmailAccountConnectionParameters!
$id: String
) {
saveImapSmtpCaldav(
saveImapSmtpCaldavAccount(
accountOwnerId: $accountOwnerId
handle: $handle
accountType: $accountType
connectionParameters: $connectionParameters
id: $id
) {

View File

@ -22,8 +22,7 @@ export const GET_CONNECTED_IMAP_SMTP_CALDAV_ACCOUNT = gql`
}
CALDAV {
host
port
secure
username
password
}
}

View File

@ -10,7 +10,7 @@ import { SettingsPath } from '@/types/SettingsPath';
import { t } from '@lingui/core/macro';
import {
ConnectionParameters,
useSaveImapSmtpCaldavMutation,
useSaveImapSmtpCaldavAccountMutation,
} from '~/generated-metadata/graphql';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
@ -49,7 +49,13 @@ export const useImapSmtpCaldavConnectionForm = ({
handle: '',
IMAP: { host: '', port: 993, password: '', secure: true },
SMTP: { host: '', port: 587, password: '', secure: true },
CALDAV: { host: '', port: 443, password: '', secure: true },
CALDAV: {
host: '',
port: 443,
password: '',
secure: true,
username: undefined,
},
},
});
@ -76,7 +82,7 @@ export const useImapSmtpCaldavConnectionForm = ({
);
const [saveConnection, { loading: saveLoading }] =
useSaveImapSmtpCaldavMutation();
useSaveImapSmtpCaldavAccountMutation();
const watchedValues = watch();
@ -102,47 +108,39 @@ export const useImapSmtpCaldavConnectionForm = ({
);
}, [getConfiguredProtocols, watchedValues.handle]);
const saveIndividualConnection = useCallback(
async (
protocol: keyof ImapSmtpCaldavAccount,
formValues: ConnectionFormData,
): Promise<void> => {
const handleSave = useCallback(
async (formValues: ConnectionFormData): Promise<void> => {
if (!currentWorkspaceMember?.id) {
throw new Error('Workspace member ID is missing');
}
const protocolConfig = formValues[protocol];
if (!protocolConfig) {
throw new Error(`${protocol} configuration is missing`);
}
await saveConnection({
variables: {
...(isEditing && connectedAccountId
? { id: connectedAccountId }
: {}),
accountOwnerId: currentWorkspaceMember.id,
handle: formValues.handle,
accountType: {
type: protocol,
},
connectionParameters: protocolConfig,
},
});
},
[saveConnection, isEditing, connectedAccountId, currentWorkspaceMember?.id],
);
const handleSave = useCallback(
async (formValues: ConnectionFormData): Promise<void> => {
const configuredProtocols = getConfiguredProtocols(formValues);
if (configuredProtocols.length === 0) {
throw new Error('At least one protocol must be configured');
}
const connectionParameters: Partial<
Record<keyof ImapSmtpCaldavAccount, ConnectionParameters>
> = {};
configuredProtocols.forEach((protocol) => {
const protocolConfig = formValues[protocol];
if (isDefined(protocolConfig)) {
connectionParameters[protocol] = protocolConfig;
}
});
try {
await Promise.all(
configuredProtocols.map((protocol) =>
saveIndividualConnection(protocol, formValues),
),
);
await saveConnection({
variables: {
...(isEditing && connectedAccountId
? { id: connectedAccountId }
: {}),
accountOwnerId: currentWorkspaceMember.id,
handle: formValues.handle,
connectionParameters,
},
});
const successMessage = isEditing
? t`Connection successfully updated`
@ -160,12 +158,14 @@ export const useImapSmtpCaldavConnectionForm = ({
}
},
[
currentWorkspaceMember?.id,
getConfiguredProtocols,
saveIndividualConnection,
saveConnection,
isEditing,
connectedAccountId,
enqueueSuccessSnackBar,
enqueueErrorSnackBar,
navigate,
enqueueErrorSnackBar,
],
);

View File

@ -6,6 +6,7 @@ const connectionParameters = z
.object({
host: z.string().default(''),
port: z.number().int().nullable().default(null),
username: z.string().optional(),
password: z.string().default(''),
secure: z.boolean().default(true),
})

View File

@ -54,6 +54,7 @@
"lodash.uniqby": "^4.7.0",
"monaco-editor": "^0.51.0",
"monaco-editor-auto-typings": "^0.4.5",
"node-ical": "^0.20.1",
"openid-client": "^5.7.0",
"otplib": "^12.0.1",
"passport": "^0.7.0",
@ -61,6 +62,7 @@
"redis": "^4.7.0",
"ts-morph": "^24.0.0",
"tsconfig-paths": "^4.2.0",
"tsdav": "^2.1.5",
"typeorm": "patch:typeorm@0.3.20#./patches/typeorm+0.3.20.patch",
"unzipper": "^0.12.3",
"zod-to-json-schema": "^3.23.1"

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,
},
);
}
}
}

View File

@ -27625,7 +27625,7 @@ __metadata:
languageName: node
linkType: hard
"axios@npm:^1.6.2":
"axios@npm:^1.6.2, axios@npm:^1.7.7":
version: 1.10.0
resolution: "axios@npm:1.10.0"
dependencies:
@ -28826,6 +28826,13 @@ __metadata:
languageName: node
linkType: hard
"base-64@npm:1.0.0":
version: 1.0.0
resolution: "base-64@npm:1.0.0"
checksum: 10c0/d886cb3236cee0bed9f7075675748b59b32fad623ddb8ce1793c790306aa0f76a03238cad4b3fb398abda6527ce08a5588388533a4ccade0b97e82b9da660e28
languageName: node
linkType: hard
"base64-js@npm:0.0.8":
version: 0.0.8
resolution: "base64-js@npm:0.0.8"
@ -31760,6 +31767,15 @@ __metadata:
languageName: node
linkType: hard
"cross-fetch@npm:4.1.0":
version: 4.1.0
resolution: "cross-fetch@npm:4.1.0"
dependencies:
node-fetch: "npm:^2.7.0"
checksum: 10c0/628b134ea27cfcada67025afe6ef1419813fffc5d63d175553efa75a2334522d450300a0f3f0719029700da80e96327930709d5551cf6deb39bb62f1d536642e
languageName: node
linkType: hard
"cross-fetch@npm:^3.0.4, cross-fetch@npm:^3.1.5":
version: 3.1.8
resolution: "cross-fetch@npm:3.1.8"
@ -32577,6 +32593,18 @@ __metadata:
languageName: node
linkType: hard
"debug@npm:4.4.1":
version: 4.4.1
resolution: "debug@npm:4.4.1"
dependencies:
ms: "npm:^2.1.3"
peerDependenciesMeta:
supports-color:
optional: true
checksum: 10c0/d2b44bc1afd912b49bb7ebb0d50a860dc93a4dd7d946e8de94abc957bb63726b7dd5aa48c18c2386c379ec024c46692e15ed3ed97d481729f929201e671fcd55
languageName: node
linkType: hard
"debug@npm:^3.2.7":
version: 3.2.7
resolution: "debug@npm:3.2.7"
@ -46345,7 +46373,16 @@ __metadata:
languageName: node
linkType: hard
"moment@npm:^2.22.1":
"moment-timezone@npm:^0.5.45":
version: 0.5.48
resolution: "moment-timezone@npm:0.5.48"
dependencies:
moment: "npm:^2.29.4"
checksum: 10c0/ab14ec9d94bc33f29ac18e5417b7f8aca0b17130b952c5cc9697b8fea839e5ece9313af5fd3c9703a05db472b1560ddbfc7ad2aa24aac9afd047d6da6c3c6033
languageName: node
linkType: hard
"moment@npm:^2.22.1, moment@npm:^2.29.4":
version: 2.30.1
resolution: "moment@npm:2.30.1"
checksum: 10c0/865e4279418c6de666fca7786607705fd0189d8a7b7624e2e56be99290ac846f90878a6f602e34b4e0455c549b85385b1baf9966845962b313699e7cb847543a
@ -47093,7 +47130,7 @@ __metadata:
languageName: node
linkType: hard
"node-fetch@npm:^2, node-fetch@npm:^2.0.0, node-fetch@npm:^2.6.1, node-fetch@npm:^2.6.12, node-fetch@npm:^2.6.7, node-fetch@npm:^2.6.9":
"node-fetch@npm:^2, node-fetch@npm:^2.0.0, node-fetch@npm:^2.6.1, node-fetch@npm:^2.6.12, node-fetch@npm:^2.6.7, node-fetch@npm:^2.6.9, node-fetch@npm:^2.7.0":
version: 2.7.0
resolution: "node-fetch@npm:2.7.0"
dependencies:
@ -47199,6 +47236,18 @@ __metadata:
languageName: node
linkType: hard
"node-ical@npm:^0.20.1":
version: 0.20.1
resolution: "node-ical@npm:0.20.1"
dependencies:
axios: "npm:^1.7.7"
moment-timezone: "npm:^0.5.45"
rrule: "npm:2.8.1"
uuid: "npm:^10.0.0"
checksum: 10c0/3e95ece63f0420f96b611913e0134660120bf2e87da8b1afbc73b6db2cbe2ad20d9b76ad912fa80b20923c64f0002869d98edaa53d143aa5989d223ff12ef621
languageName: node
linkType: hard
"node-int64@npm:^0.4.0":
version: 0.4.0
resolution: "node-int64@npm:0.4.0"
@ -53194,6 +53243,15 @@ __metadata:
languageName: node
linkType: hard
"rrule@npm:2.8.1":
version: 2.8.1
resolution: "rrule@npm:2.8.1"
dependencies:
tslib: "npm:^2.4.0"
checksum: 10c0/c9350620bbd57d0cdf99320b576121a2d7ed579fda4ae50891e2779c7dfd6dc7b174b558b6598adefb1f0e053549dc977740e1a265f77dcf0827aaeea60b45e7
languageName: node
linkType: hard
"rrweb-cssom@npm:^0.6.0":
version: 0.6.0
resolution: "rrweb-cssom@npm:0.6.0"
@ -56654,6 +56712,18 @@ __metadata:
languageName: node
linkType: hard
"tsdav@npm:^2.1.5":
version: 2.1.5
resolution: "tsdav@npm:2.1.5"
dependencies:
base-64: "npm:1.0.0"
cross-fetch: "npm:4.1.0"
debug: "npm:4.4.1"
xml-js: "npm:1.6.11"
checksum: 10c0/ff04bb9b61413c07d4532926f9d4a8b6788af77c6e3aa87813c797a06e59348398d2d9ecb15ef3e82e4ae013fe764c73511c3bbfaff5fb6c4484f5f15099499f
languageName: node
linkType: hard
"tslib@npm:2, tslib@npm:^2.8.0":
version: 2.8.1
resolution: "tslib@npm:2.8.1"
@ -57002,6 +57072,7 @@ __metadata:
lodash.uniqby: "npm:^4.7.0"
monaco-editor: "npm:^0.51.0"
monaco-editor-auto-typings: "npm:^0.4.5"
node-ical: "npm:^0.20.1"
openid-client: "npm:^5.7.0"
otplib: "npm:^12.0.1"
passport: "npm:^0.7.0"
@ -57010,6 +57081,7 @@ __metadata:
rimraf: "npm:^5.0.5"
ts-morph: "npm:^24.0.0"
tsconfig-paths: "npm:^4.2.0"
tsdav: "npm:^2.1.5"
twenty-emails: "workspace:*"
twenty-shared: "workspace:*"
typeorm: "patch:typeorm@0.3.20#./patches/typeorm+0.3.20.patch"
@ -58805,6 +58877,15 @@ __metadata:
languageName: node
linkType: hard
"uuid@npm:^10.0.0":
version: 10.0.0
resolution: "uuid@npm:10.0.0"
bin:
uuid: dist/bin/uuid
checksum: 10c0/eab18c27fe4ab9fb9709a5d5f40119b45f2ec8314f8d4cf12ce27e4c6f4ffa4a6321dc7db6c515068fa373c075b49691ba969f0010bf37f44c37ca40cd6bf7fe
languageName: node
linkType: hard
"uuid@npm:^3.3.2":
version: 3.4.0
resolution: "uuid@npm:3.4.0"
@ -60309,7 +60390,7 @@ __metadata:
languageName: node
linkType: hard
"xml-js@npm:^1.6.8":
"xml-js@npm:1.6.11, xml-js@npm:^1.6.8":
version: 1.6.11
resolution: "xml-js@npm:1.6.11"
dependencies: