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:
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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']>;
|
||||
};
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
) {
|
||||
|
||||
@ -22,8 +22,7 @@ export const GET_CONNECTED_IMAP_SMTP_CALDAV_ACCOUNT = gql`
|
||||
}
|
||||
CALDAV {
|
||||
host
|
||||
port
|
||||
secure
|
||||
username
|
||||
password
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@ -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),
|
||||
})
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
});
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
export type ConnectionParameters = {
|
||||
host: string;
|
||||
port: number;
|
||||
username?: string;
|
||||
password: string;
|
||||
secure?: boolean;
|
||||
};
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 {}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
);
|
||||
};
|
||||
@ -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`,
|
||||
|
||||
@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
89
yarn.lock
89
yarn.lock
@ -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:
|
||||
|
||||
Reference in New Issue
Block a user