feat: IMAP Driver Integration (#12576)
### Added IMAP integration This PR adds support for connecting email accounts via IMAP protocol, allowing users to sync their emails without OAuth. #### DB Changes: - Added customConnectionParams and connectionType fields to ConnectedAccountWorkspaceEntity #### UI: - Added settings pages for creating and editing IMAP connections with proper validation and connection testing. - Implemented reconnection flows for handling permission issues. #### Backend: - Built ImapConnectionModule with corresponding resolver and service for managing IMAP connections. - Created MessagingIMAPDriverModule to handle IMAP client operations, message fetching/parsing, and error handling. #### Dependencies: Integrated `imapflow` and `mailparser` libraries with their type definitions to handle the IMAP protocol communication. --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Co-authored-by: Félix Malfait <felix.malfait@gmail.com> Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
@ -107,6 +107,7 @@
|
|||||||
"graphql-yoga": "^4.0.4",
|
"graphql-yoga": "^4.0.4",
|
||||||
"hex-rgb": "^5.0.0",
|
"hex-rgb": "^5.0.0",
|
||||||
"iframe-resizer-react": "^1.1.0",
|
"iframe-resizer-react": "^1.1.0",
|
||||||
|
"imapflow": "^1.0.186",
|
||||||
"immer": "^10.0.2",
|
"immer": "^10.0.2",
|
||||||
"jest-mock-extended": "^3.0.4",
|
"jest-mock-extended": "^3.0.4",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
@ -133,6 +134,7 @@
|
|||||||
"lodash.snakecase": "^4.1.1",
|
"lodash.snakecase": "^4.1.1",
|
||||||
"lodash.upperfirst": "^4.3.1",
|
"lodash.upperfirst": "^4.3.1",
|
||||||
"luxon": "^3.3.0",
|
"luxon": "^3.3.0",
|
||||||
|
"mailparser": "^3.7.3",
|
||||||
"microdiff": "^1.3.2",
|
"microdiff": "^1.3.2",
|
||||||
"moize": "^6.1.6",
|
"moize": "^6.1.6",
|
||||||
"nest-commander": "^3.12.0",
|
"nest-commander": "^3.12.0",
|
||||||
@ -251,6 +253,7 @@
|
|||||||
"@types/file-saver": "^2.0.7",
|
"@types/file-saver": "^2.0.7",
|
||||||
"@types/graphql-fields": "^1.3.6",
|
"@types/graphql-fields": "^1.3.6",
|
||||||
"@types/graphql-upload": "^8.0.12",
|
"@types/graphql-upload": "^8.0.12",
|
||||||
|
"@types/imapflow": "^1.0.21",
|
||||||
"@types/js-cookie": "^3.0.3",
|
"@types/js-cookie": "^3.0.3",
|
||||||
"@types/js-levenshtein": "^1.1.3",
|
"@types/js-levenshtein": "^1.1.3",
|
||||||
"@types/lodash.camelcase": "^4.3.7",
|
"@types/lodash.camelcase": "^4.3.7",
|
||||||
@ -269,6 +272,7 @@
|
|||||||
"@types/lodash.snakecase": "^4.1.7",
|
"@types/lodash.snakecase": "^4.1.7",
|
||||||
"@types/lodash.upperfirst": "^4.3.7",
|
"@types/lodash.upperfirst": "^4.3.7",
|
||||||
"@types/luxon": "^3.3.0",
|
"@types/luxon": "^3.3.0",
|
||||||
|
"@types/mailparser": "^3.4.6",
|
||||||
"@types/ms": "^0.7.31",
|
"@types/ms": "^0.7.31",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/passport-google-oauth20": "^2.0.11",
|
"@types/passport-google-oauth20": "^2.0.11",
|
||||||
|
|||||||
@ -9,6 +9,7 @@ module.exports = {
|
|||||||
'!./src/modules/object-metadata/**',
|
'!./src/modules/object-metadata/**',
|
||||||
'!./src/modules/object-record/**',
|
'!./src/modules/object-record/**',
|
||||||
'!./src/modules/settings/serverless-functions/**',
|
'!./src/modules/settings/serverless-functions/**',
|
||||||
|
'!./src/modules/settings/accounts/hooks/**',
|
||||||
'./src/modules/**/*.tsx',
|
'./src/modules/**/*.tsx',
|
||||||
'./src/modules/**/*.ts',
|
'./src/modules/**/*.ts',
|
||||||
'!./src/**/*.test.tsx',
|
'!./src/**/*.test.tsx',
|
||||||
|
|||||||
@ -30,6 +30,10 @@ export type Scalars = {
|
|||||||
Upload: { input: any; output: any; }
|
Upload: { input: any; output: any; }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AccountType = {
|
||||||
|
type: Scalars['String']['input'];
|
||||||
|
};
|
||||||
|
|
||||||
export type ActivateWorkspaceInput = {
|
export type ActivateWorkspaceInput = {
|
||||||
displayName?: InputMaybe<Scalars['String']['input']>;
|
displayName?: InputMaybe<Scalars['String']['input']>;
|
||||||
};
|
};
|
||||||
@ -412,6 +416,32 @@ export type ConfigVariablesOutput = {
|
|||||||
groups: Array<ConfigVariablesGroupData>;
|
groups: Array<ConfigVariablesGroupData>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ConnectedImapSmtpCaldavAccount = {
|
||||||
|
__typename?: 'ConnectedImapSmtpCaldavAccount';
|
||||||
|
accountOwnerId: Scalars['String']['output'];
|
||||||
|
connectionParameters?: Maybe<ImapSmtpCaldavConnectionParameters>;
|
||||||
|
handle: Scalars['String']['output'];
|
||||||
|
id: Scalars['String']['output'];
|
||||||
|
provider: Scalars['String']['output'];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConnectionParameters = {
|
||||||
|
host: Scalars['String']['input'];
|
||||||
|
password: Scalars['String']['input'];
|
||||||
|
port: Scalars['Float']['input'];
|
||||||
|
secure?: InputMaybe<Scalars['Boolean']['input']>;
|
||||||
|
username: Scalars['String']['input'];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConnectionParametersOutput = {
|
||||||
|
__typename?: 'ConnectionParametersOutput';
|
||||||
|
host: Scalars['String']['output'];
|
||||||
|
password: Scalars['String']['output'];
|
||||||
|
port: Scalars['Float']['output'];
|
||||||
|
secure?: Maybe<Scalars['Boolean']['output']>;
|
||||||
|
username: Scalars['String']['output'];
|
||||||
|
};
|
||||||
|
|
||||||
export type CreateAgentInput = {
|
export type CreateAgentInput = {
|
||||||
description?: InputMaybe<Scalars['String']['input']>;
|
description?: InputMaybe<Scalars['String']['input']>;
|
||||||
modelId: Scalars['String']['input'];
|
modelId: Scalars['String']['input'];
|
||||||
@ -662,6 +692,7 @@ export type FeatureFlagDto = {
|
|||||||
export enum FeatureFlagKey {
|
export enum FeatureFlagKey {
|
||||||
IS_AIRTABLE_INTEGRATION_ENABLED = 'IS_AIRTABLE_INTEGRATION_ENABLED',
|
IS_AIRTABLE_INTEGRATION_ENABLED = 'IS_AIRTABLE_INTEGRATION_ENABLED',
|
||||||
IS_AI_ENABLED = 'IS_AI_ENABLED',
|
IS_AI_ENABLED = 'IS_AI_ENABLED',
|
||||||
|
IS_IMAP_ENABLED = 'IS_IMAP_ENABLED',
|
||||||
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
|
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
|
||||||
IS_POSTGRESQL_INTEGRATION_ENABLED = 'IS_POSTGRESQL_INTEGRATION_ENABLED',
|
IS_POSTGRESQL_INTEGRATION_ENABLED = 'IS_POSTGRESQL_INTEGRATION_ENABLED',
|
||||||
IS_STRIPE_INTEGRATION_ENABLED = 'IS_STRIPE_INTEGRATION_ENABLED',
|
IS_STRIPE_INTEGRATION_ENABLED = 'IS_STRIPE_INTEGRATION_ENABLED',
|
||||||
@ -819,6 +850,18 @@ export enum IdentityProviderType {
|
|||||||
SAML = 'SAML'
|
SAML = 'SAML'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ImapSmtpCaldavConnectionParameters = {
|
||||||
|
__typename?: 'ImapSmtpCaldavConnectionParameters';
|
||||||
|
CALDAV?: Maybe<ConnectionParametersOutput>;
|
||||||
|
IMAP?: Maybe<ConnectionParametersOutput>;
|
||||||
|
SMTP?: Maybe<ConnectionParametersOutput>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ImapSmtpCaldavConnectionSuccess = {
|
||||||
|
__typename?: 'ImapSmtpCaldavConnectionSuccess';
|
||||||
|
success: Scalars['Boolean']['output'];
|
||||||
|
};
|
||||||
|
|
||||||
export type ImpersonateOutput = {
|
export type ImpersonateOutput = {
|
||||||
__typename?: 'ImpersonateOutput';
|
__typename?: 'ImpersonateOutput';
|
||||||
loginToken: AuthToken;
|
loginToken: AuthToken;
|
||||||
@ -1010,6 +1053,7 @@ export type Mutation = {
|
|||||||
resendEmailVerificationToken: ResendEmailVerificationTokenOutput;
|
resendEmailVerificationToken: ResendEmailVerificationTokenOutput;
|
||||||
resendWorkspaceInvitation: SendInvitationsOutput;
|
resendWorkspaceInvitation: SendInvitationsOutput;
|
||||||
runWorkflowVersion: WorkflowRun;
|
runWorkflowVersion: WorkflowRun;
|
||||||
|
saveImapSmtpCaldav: ImapSmtpCaldavConnectionSuccess;
|
||||||
sendInvitations: SendInvitationsOutput;
|
sendInvitations: SendInvitationsOutput;
|
||||||
signIn: AvailableWorkspacesAndAccessTokensOutput;
|
signIn: AvailableWorkspacesAndAccessTokensOutput;
|
||||||
signUp: AvailableWorkspacesAndAccessTokensOutput;
|
signUp: AvailableWorkspacesAndAccessTokensOutput;
|
||||||
@ -1294,6 +1338,15 @@ export type MutationRunWorkflowVersionArgs = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export type MutationSaveImapSmtpCaldavArgs = {
|
||||||
|
accountOwnerId: Scalars['String']['input'];
|
||||||
|
accountType: AccountType;
|
||||||
|
connectionParameters: ConnectionParameters;
|
||||||
|
handle: Scalars['String']['input'];
|
||||||
|
id?: InputMaybe<Scalars['String']['input']>;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
export type MutationSendInvitationsArgs = {
|
export type MutationSendInvitationsArgs = {
|
||||||
emails: Array<Scalars['String']['input']>;
|
emails: Array<Scalars['String']['input']>;
|
||||||
};
|
};
|
||||||
@ -1696,6 +1749,7 @@ export type Query = {
|
|||||||
getApprovedAccessDomains: Array<ApprovedAccessDomain>;
|
getApprovedAccessDomains: Array<ApprovedAccessDomain>;
|
||||||
getAvailablePackages: Scalars['JSON']['output'];
|
getAvailablePackages: Scalars['JSON']['output'];
|
||||||
getConfigVariablesGrouped: ConfigVariablesOutput;
|
getConfigVariablesGrouped: ConfigVariablesOutput;
|
||||||
|
getConnectedImapSmtpCaldavAccount: ConnectedImapSmtpCaldavAccount;
|
||||||
getDatabaseConfigVariable: ConfigVariable;
|
getDatabaseConfigVariable: ConfigVariable;
|
||||||
getIndicatorHealthStatus: AdminPanelHealthServiceData;
|
getIndicatorHealthStatus: AdminPanelHealthServiceData;
|
||||||
getMeteredProductsUsage: Array<BillingMeteredProductUsageOutput>;
|
getMeteredProductsUsage: Array<BillingMeteredProductUsageOutput>;
|
||||||
@ -1783,6 +1837,11 @@ export type QueryGetAvailablePackagesArgs = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export type QueryGetConnectedImapSmtpCaldavAccountArgs = {
|
||||||
|
id: Scalars['String']['input'];
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
export type QueryGetDatabaseConfigVariableArgs = {
|
export type QueryGetDatabaseConfigVariableArgs = {
|
||||||
key: Scalars['String']['input'];
|
key: Scalars['String']['input'];
|
||||||
};
|
};
|
||||||
|
|||||||
@ -22,6 +22,10 @@ export type Scalars = {
|
|||||||
Upload: any;
|
Upload: any;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AccountType = {
|
||||||
|
type: Scalars['String'];
|
||||||
|
};
|
||||||
|
|
||||||
export type ActivateWorkspaceInput = {
|
export type ActivateWorkspaceInput = {
|
||||||
displayName?: InputMaybe<Scalars['String']>;
|
displayName?: InputMaybe<Scalars['String']>;
|
||||||
};
|
};
|
||||||
@ -404,6 +408,32 @@ export type ConfigVariablesOutput = {
|
|||||||
groups: Array<ConfigVariablesGroupData>;
|
groups: Array<ConfigVariablesGroupData>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ConnectedImapSmtpCaldavAccount = {
|
||||||
|
__typename?: 'ConnectedImapSmtpCaldavAccount';
|
||||||
|
accountOwnerId: Scalars['String'];
|
||||||
|
connectionParameters?: Maybe<ImapSmtpCaldavConnectionParameters>;
|
||||||
|
handle: Scalars['String'];
|
||||||
|
id: Scalars['String'];
|
||||||
|
provider: Scalars['String'];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConnectionParameters = {
|
||||||
|
host: Scalars['String'];
|
||||||
|
password: Scalars['String'];
|
||||||
|
port: Scalars['Float'];
|
||||||
|
secure?: InputMaybe<Scalars['Boolean']>;
|
||||||
|
username: Scalars['String'];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConnectionParametersOutput = {
|
||||||
|
__typename?: 'ConnectionParametersOutput';
|
||||||
|
host: Scalars['String'];
|
||||||
|
password: Scalars['String'];
|
||||||
|
port: Scalars['Float'];
|
||||||
|
secure?: Maybe<Scalars['Boolean']>;
|
||||||
|
username: Scalars['String'];
|
||||||
|
};
|
||||||
|
|
||||||
export type CreateAgentInput = {
|
export type CreateAgentInput = {
|
||||||
description?: InputMaybe<Scalars['String']>;
|
description?: InputMaybe<Scalars['String']>;
|
||||||
modelId: Scalars['String'];
|
modelId: Scalars['String'];
|
||||||
@ -618,6 +648,7 @@ export type FeatureFlagDto = {
|
|||||||
export enum FeatureFlagKey {
|
export enum FeatureFlagKey {
|
||||||
IS_AIRTABLE_INTEGRATION_ENABLED = 'IS_AIRTABLE_INTEGRATION_ENABLED',
|
IS_AIRTABLE_INTEGRATION_ENABLED = 'IS_AIRTABLE_INTEGRATION_ENABLED',
|
||||||
IS_AI_ENABLED = 'IS_AI_ENABLED',
|
IS_AI_ENABLED = 'IS_AI_ENABLED',
|
||||||
|
IS_IMAP_ENABLED = 'IS_IMAP_ENABLED',
|
||||||
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
|
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
|
||||||
IS_POSTGRESQL_INTEGRATION_ENABLED = 'IS_POSTGRESQL_INTEGRATION_ENABLED',
|
IS_POSTGRESQL_INTEGRATION_ENABLED = 'IS_POSTGRESQL_INTEGRATION_ENABLED',
|
||||||
IS_STRIPE_INTEGRATION_ENABLED = 'IS_STRIPE_INTEGRATION_ENABLED',
|
IS_STRIPE_INTEGRATION_ENABLED = 'IS_STRIPE_INTEGRATION_ENABLED',
|
||||||
@ -768,6 +799,18 @@ export enum IdentityProviderType {
|
|||||||
SAML = 'SAML'
|
SAML = 'SAML'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ImapSmtpCaldavConnectionParameters = {
|
||||||
|
__typename?: 'ImapSmtpCaldavConnectionParameters';
|
||||||
|
CALDAV?: Maybe<ConnectionParametersOutput>;
|
||||||
|
IMAP?: Maybe<ConnectionParametersOutput>;
|
||||||
|
SMTP?: Maybe<ConnectionParametersOutput>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ImapSmtpCaldavConnectionSuccess = {
|
||||||
|
__typename?: 'ImapSmtpCaldavConnectionSuccess';
|
||||||
|
success: Scalars['Boolean'];
|
||||||
|
};
|
||||||
|
|
||||||
export type ImpersonateOutput = {
|
export type ImpersonateOutput = {
|
||||||
__typename?: 'ImpersonateOutput';
|
__typename?: 'ImpersonateOutput';
|
||||||
loginToken: AuthToken;
|
loginToken: AuthToken;
|
||||||
@ -957,6 +1000,7 @@ export type Mutation = {
|
|||||||
resendEmailVerificationToken: ResendEmailVerificationTokenOutput;
|
resendEmailVerificationToken: ResendEmailVerificationTokenOutput;
|
||||||
resendWorkspaceInvitation: SendInvitationsOutput;
|
resendWorkspaceInvitation: SendInvitationsOutput;
|
||||||
runWorkflowVersion: WorkflowRun;
|
runWorkflowVersion: WorkflowRun;
|
||||||
|
saveImapSmtpCaldav: ImapSmtpCaldavConnectionSuccess;
|
||||||
sendInvitations: SendInvitationsOutput;
|
sendInvitations: SendInvitationsOutput;
|
||||||
signIn: AvailableWorkspacesAndAccessTokensOutput;
|
signIn: AvailableWorkspacesAndAccessTokensOutput;
|
||||||
signUp: AvailableWorkspacesAndAccessTokensOutput;
|
signUp: AvailableWorkspacesAndAccessTokensOutput;
|
||||||
@ -1217,6 +1261,15 @@ export type MutationRunWorkflowVersionArgs = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export type MutationSaveImapSmtpCaldavArgs = {
|
||||||
|
accountOwnerId: Scalars['String'];
|
||||||
|
accountType: AccountType;
|
||||||
|
connectionParameters: ConnectionParameters;
|
||||||
|
handle: Scalars['String'];
|
||||||
|
id?: InputMaybe<Scalars['String']>;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
export type MutationSendInvitationsArgs = {
|
export type MutationSendInvitationsArgs = {
|
||||||
emails: Array<Scalars['String']>;
|
emails: Array<Scalars['String']>;
|
||||||
};
|
};
|
||||||
@ -1596,6 +1649,7 @@ export type Query = {
|
|||||||
getApprovedAccessDomains: Array<ApprovedAccessDomain>;
|
getApprovedAccessDomains: Array<ApprovedAccessDomain>;
|
||||||
getAvailablePackages: Scalars['JSON'];
|
getAvailablePackages: Scalars['JSON'];
|
||||||
getConfigVariablesGrouped: ConfigVariablesOutput;
|
getConfigVariablesGrouped: ConfigVariablesOutput;
|
||||||
|
getConnectedImapSmtpCaldavAccount: ConnectedImapSmtpCaldavAccount;
|
||||||
getDatabaseConfigVariable: ConfigVariable;
|
getDatabaseConfigVariable: ConfigVariable;
|
||||||
getIndicatorHealthStatus: AdminPanelHealthServiceData;
|
getIndicatorHealthStatus: AdminPanelHealthServiceData;
|
||||||
getMeteredProductsUsage: Array<BillingMeteredProductUsageOutput>;
|
getMeteredProductsUsage: Array<BillingMeteredProductUsageOutput>;
|
||||||
@ -1657,6 +1711,11 @@ export type QueryGetAvailablePackagesArgs = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export type QueryGetConnectedImapSmtpCaldavAccountArgs = {
|
||||||
|
id: Scalars['String'];
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
export type QueryGetDatabaseConfigVariableArgs = {
|
export type QueryGetDatabaseConfigVariableArgs = {
|
||||||
key: Scalars['String'];
|
key: Scalars['String'];
|
||||||
};
|
};
|
||||||
@ -2816,6 +2875,24 @@ export type SkipSyncEmailOnboardingStepMutationVariables = Exact<{ [key: string]
|
|||||||
|
|
||||||
export type SkipSyncEmailOnboardingStepMutation = { __typename?: 'Mutation', skipSyncEmailOnboardingStep: { __typename?: 'OnboardingStepSuccess', success: boolean } };
|
export type SkipSyncEmailOnboardingStepMutation = { __typename?: 'Mutation', skipSyncEmailOnboardingStep: { __typename?: 'OnboardingStepSuccess', success: boolean } };
|
||||||
|
|
||||||
|
export type SaveImapSmtpCaldavMutationVariables = Exact<{
|
||||||
|
accountOwnerId: Scalars['String'];
|
||||||
|
handle: Scalars['String'];
|
||||||
|
accountType: AccountType;
|
||||||
|
connectionParameters: ConnectionParameters;
|
||||||
|
id?: InputMaybe<Scalars['String']>;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
|
||||||
|
export type SaveImapSmtpCaldavMutation = { __typename?: 'Mutation', saveImapSmtpCaldav: { __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, username: string, password: string } | null, SMTP?: { __typename?: 'ConnectionParametersOutput', host: string, port: number, secure?: boolean | null, username: string, password: string } | null, CALDAV?: { __typename?: 'ConnectionParametersOutput', host: string, port: number, secure?: boolean | null, username: string, password: string } | null } | null } };
|
||||||
|
|
||||||
export type CreateDatabaseConfigVariableMutationVariables = Exact<{
|
export type CreateDatabaseConfigVariableMutationVariables = Exact<{
|
||||||
key: Scalars['String'];
|
key: Scalars['String'];
|
||||||
value: Scalars['JSON'];
|
value: Scalars['JSON'];
|
||||||
@ -4885,6 +4962,110 @@ export function useSkipSyncEmailOnboardingStepMutation(baseOptions?: Apollo.Muta
|
|||||||
export type SkipSyncEmailOnboardingStepMutationHookResult = ReturnType<typeof useSkipSyncEmailOnboardingStepMutation>;
|
export type SkipSyncEmailOnboardingStepMutationHookResult = ReturnType<typeof useSkipSyncEmailOnboardingStepMutation>;
|
||||||
export type SkipSyncEmailOnboardingStepMutationResult = Apollo.MutationResult<SkipSyncEmailOnboardingStepMutation>;
|
export type SkipSyncEmailOnboardingStepMutationResult = Apollo.MutationResult<SkipSyncEmailOnboardingStepMutation>;
|
||||||
export type SkipSyncEmailOnboardingStepMutationOptions = Apollo.BaseMutationOptions<SkipSyncEmailOnboardingStepMutation, SkipSyncEmailOnboardingStepMutationVariables>;
|
export type SkipSyncEmailOnboardingStepMutationOptions = Apollo.BaseMutationOptions<SkipSyncEmailOnboardingStepMutation, SkipSyncEmailOnboardingStepMutationVariables>;
|
||||||
|
export const SaveImapSmtpCaldavDocument = gql`
|
||||||
|
mutation SaveImapSmtpCaldav($accountOwnerId: String!, $handle: String!, $accountType: AccountType!, $connectionParameters: ConnectionParameters!, $id: String) {
|
||||||
|
saveImapSmtpCaldav(
|
||||||
|
accountOwnerId: $accountOwnerId
|
||||||
|
handle: $handle
|
||||||
|
accountType: $accountType
|
||||||
|
connectionParameters: $connectionParameters
|
||||||
|
id: $id
|
||||||
|
) {
|
||||||
|
success
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
export type SaveImapSmtpCaldavMutationFn = Apollo.MutationFunction<SaveImapSmtpCaldavMutation, SaveImapSmtpCaldavMutationVariables>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* __useSaveImapSmtpCaldavMutation__
|
||||||
|
*
|
||||||
|
* 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:
|
||||||
|
* - 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({
|
||||||
|
* 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>) {
|
||||||
|
const options = {...defaultOptions, ...baseOptions}
|
||||||
|
return Apollo.useMutation<SaveImapSmtpCaldavMutation, SaveImapSmtpCaldavMutationVariables>(SaveImapSmtpCaldavDocument, options);
|
||||||
|
}
|
||||||
|
export type SaveImapSmtpCaldavMutationHookResult = ReturnType<typeof useSaveImapSmtpCaldavMutation>;
|
||||||
|
export type SaveImapSmtpCaldavMutationResult = Apollo.MutationResult<SaveImapSmtpCaldavMutation>;
|
||||||
|
export type SaveImapSmtpCaldavMutationOptions = Apollo.BaseMutationOptions<SaveImapSmtpCaldavMutation, SaveImapSmtpCaldavMutationVariables>;
|
||||||
|
export const GetConnectedImapSmtpCaldavAccountDocument = gql`
|
||||||
|
query GetConnectedImapSmtpCaldavAccount($id: String!) {
|
||||||
|
getConnectedImapSmtpCaldavAccount(id: $id) {
|
||||||
|
id
|
||||||
|
handle
|
||||||
|
provider
|
||||||
|
accountOwnerId
|
||||||
|
connectionParameters {
|
||||||
|
IMAP {
|
||||||
|
host
|
||||||
|
port
|
||||||
|
secure
|
||||||
|
username
|
||||||
|
password
|
||||||
|
}
|
||||||
|
SMTP {
|
||||||
|
host
|
||||||
|
port
|
||||||
|
secure
|
||||||
|
username
|
||||||
|
password
|
||||||
|
}
|
||||||
|
CALDAV {
|
||||||
|
host
|
||||||
|
port
|
||||||
|
secure
|
||||||
|
username
|
||||||
|
password
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* __useGetConnectedImapSmtpCaldavAccountQuery__
|
||||||
|
*
|
||||||
|
* To run a query within a React component, call `useGetConnectedImapSmtpCaldavAccountQuery` and pass it any options that fit your needs.
|
||||||
|
* When your component renders, `useGetConnectedImapSmtpCaldavAccountQuery` returns an object from Apollo Client that contains loading, error, and data properties
|
||||||
|
* you can use to render your UI.
|
||||||
|
*
|
||||||
|
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const { data, loading, error } = useGetConnectedImapSmtpCaldavAccountQuery({
|
||||||
|
* variables: {
|
||||||
|
* id: // value for 'id'
|
||||||
|
* },
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
export function useGetConnectedImapSmtpCaldavAccountQuery(baseOptions: Apollo.QueryHookOptions<GetConnectedImapSmtpCaldavAccountQuery, GetConnectedImapSmtpCaldavAccountQueryVariables>) {
|
||||||
|
const options = {...defaultOptions, ...baseOptions}
|
||||||
|
return Apollo.useQuery<GetConnectedImapSmtpCaldavAccountQuery, GetConnectedImapSmtpCaldavAccountQueryVariables>(GetConnectedImapSmtpCaldavAccountDocument, options);
|
||||||
|
}
|
||||||
|
export function useGetConnectedImapSmtpCaldavAccountLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetConnectedImapSmtpCaldavAccountQuery, GetConnectedImapSmtpCaldavAccountQueryVariables>) {
|
||||||
|
const options = {...defaultOptions, ...baseOptions}
|
||||||
|
return Apollo.useLazyQuery<GetConnectedImapSmtpCaldavAccountQuery, GetConnectedImapSmtpCaldavAccountQueryVariables>(GetConnectedImapSmtpCaldavAccountDocument, options);
|
||||||
|
}
|
||||||
|
export type GetConnectedImapSmtpCaldavAccountQueryHookResult = ReturnType<typeof useGetConnectedImapSmtpCaldavAccountQuery>;
|
||||||
|
export type GetConnectedImapSmtpCaldavAccountLazyQueryHookResult = ReturnType<typeof useGetConnectedImapSmtpCaldavAccountLazyQuery>;
|
||||||
|
export type GetConnectedImapSmtpCaldavAccountQueryResult = Apollo.QueryResult<GetConnectedImapSmtpCaldavAccountQuery, GetConnectedImapSmtpCaldavAccountQueryVariables>;
|
||||||
export const CreateDatabaseConfigVariableDocument = gql`
|
export const CreateDatabaseConfigVariableDocument = gql`
|
||||||
mutation CreateDatabaseConfigVariable($key: String!, $value: JSON!) {
|
mutation CreateDatabaseConfigVariable($key: String!, $value: JSON!) {
|
||||||
createDatabaseConfigVariable(key: $key, value: $value)
|
createDatabaseConfigVariable(key: $key, value: $value)
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { CalendarChannel } from '@/accounts/types/CalendarChannel';
|
import { CalendarChannel } from '@/accounts/types/CalendarChannel';
|
||||||
import { MessageChannel } from './MessageChannel';
|
import { ImapSmtpCaldavAccount } from '@/accounts/types/ImapSmtpCaldavAccount';
|
||||||
import { ConnectedAccountProvider } from 'twenty-shared/types';
|
import { ConnectedAccountProvider } from 'twenty-shared/types';
|
||||||
|
import { MessageChannel } from './MessageChannel';
|
||||||
|
|
||||||
export type ConnectedAccount = {
|
export type ConnectedAccount = {
|
||||||
id: string;
|
id: string;
|
||||||
@ -14,5 +15,6 @@ export type ConnectedAccount = {
|
|||||||
messageChannels: MessageChannel[];
|
messageChannels: MessageChannel[];
|
||||||
calendarChannels: CalendarChannel[];
|
calendarChannels: CalendarChannel[];
|
||||||
scopes: string[] | null;
|
scopes: string[] | null;
|
||||||
|
connectionParameters?: ImapSmtpCaldavAccount;
|
||||||
__typename: 'ConnectedAccount';
|
__typename: 'ConnectedAccount';
|
||||||
};
|
};
|
||||||
|
|||||||
@ -0,0 +1,7 @@
|
|||||||
|
import { ConnectionParameters } from '~/generated/graphql';
|
||||||
|
|
||||||
|
export type ImapSmtpCaldavAccount = {
|
||||||
|
IMAP?: ConnectionParameters;
|
||||||
|
SMTP?: ConnectionParameters;
|
||||||
|
CALDAV?: ConnectionParameters;
|
||||||
|
};
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import { MessageChannelVisibility } from '~/generated/graphql';
|
import { ImapSmtpCaldavAccount } from '@/accounts/types/ImapSmtpCaldavAccount';
|
||||||
import { ConnectedAccountProvider } from 'twenty-shared/types';
|
import { ConnectedAccountProvider } from 'twenty-shared/types';
|
||||||
|
import { MessageChannelVisibility } from '~/generated/graphql';
|
||||||
|
|
||||||
export enum MessageChannelContactAutoCreationPolicy {
|
export enum MessageChannelContactAutoCreationPolicy {
|
||||||
SENT_AND_RECEIVED = 'SENT_AND_RECEIVED',
|
SENT_AND_RECEIVED = 'SENT_AND_RECEIVED',
|
||||||
@ -40,6 +41,7 @@ export type MessageChannel = {
|
|||||||
connectedAccount?: {
|
connectedAccount?: {
|
||||||
id: string;
|
id: string;
|
||||||
provider: ConnectedAccountProvider;
|
provider: ConnectedAccountProvider;
|
||||||
|
connectionParameters?: ImapSmtpCaldavAccount;
|
||||||
};
|
};
|
||||||
__typename: 'MessageChannel';
|
__typename: 'MessageChannel';
|
||||||
};
|
};
|
||||||
|
|||||||
@ -64,6 +64,22 @@ const SettingsNewObject = lazy(() =>
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const SettingsNewImapConnection = lazy(() =>
|
||||||
|
import(
|
||||||
|
'@/settings/accounts/components/SettingsAccountsNewImapConnection'
|
||||||
|
).then((module) => ({
|
||||||
|
default: module.SettingsAccountsNewImapConnection,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
const SettingsEditImapConnection = lazy(() =>
|
||||||
|
import(
|
||||||
|
'@/settings/accounts/components/SettingsAccountsEditImapConnection'
|
||||||
|
).then((module) => ({
|
||||||
|
default: module.SettingsAccountsEditImapConnection,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
const SettingsObjectDetailPage = lazy(() =>
|
const SettingsObjectDetailPage = lazy(() =>
|
||||||
import('~/pages/settings/data-model/SettingsObjectDetailPage').then(
|
import('~/pages/settings/data-model/SettingsObjectDetailPage').then(
|
||||||
(module) => ({
|
(module) => ({
|
||||||
@ -358,6 +374,14 @@ export const SettingsRoutes = ({
|
|||||||
path={SettingsPath.AccountsEmails}
|
path={SettingsPath.AccountsEmails}
|
||||||
element={<SettingsAccountsEmails />}
|
element={<SettingsAccountsEmails />}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path={SettingsPath.NewImapConnection}
|
||||||
|
element={<SettingsNewImapConnection />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={SettingsPath.EditImapConnection}
|
||||||
|
element={<SettingsEditImapConnection />}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
element={
|
element={
|
||||||
<SettingsProtectedRouteWrapper
|
<SettingsProtectedRouteWrapper
|
||||||
|
|||||||
@ -30,6 +30,7 @@ export type ClientConfig = {
|
|||||||
isMicrosoftCalendarEnabled: boolean;
|
isMicrosoftCalendarEnabled: boolean;
|
||||||
isMicrosoftMessagingEnabled: boolean;
|
isMicrosoftMessagingEnabled: boolean;
|
||||||
isMultiWorkspaceEnabled: boolean;
|
isMultiWorkspaceEnabled: boolean;
|
||||||
|
isIMAPMessagingEnabled: boolean;
|
||||||
publicFeatureFlags: Array<PublicFeatureFlag>;
|
publicFeatureFlags: Array<PublicFeatureFlag>;
|
||||||
sentry: Sentry;
|
sentry: Sentry;
|
||||||
signInPrefilled: boolean;
|
signInPrefilled: boolean;
|
||||||
|
|||||||
@ -12,8 +12,9 @@ import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
|
|||||||
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
|
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
|
||||||
import { assertUnreachable } from '@/workflow/utils/assertUnreachable';
|
import { assertUnreachable } from '@/workflow/utils/assertUnreachable';
|
||||||
import { ConnectedAccountProvider } from 'twenty-shared/types';
|
import { ConnectedAccountProvider } from 'twenty-shared/types';
|
||||||
import { Button } from 'twenty-ui/input';
|
import { isDefined } from 'twenty-shared/utils';
|
||||||
import { IconArrowBackUp } from 'twenty-ui/display';
|
import { IconArrowBackUp } from 'twenty-ui/display';
|
||||||
|
import { Button } from 'twenty-ui/input';
|
||||||
|
|
||||||
const StyledWrapper = styled.div`
|
const StyledWrapper = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -41,6 +42,12 @@ const StyledButtonContainer = styled.div<{ isMobile: boolean }>`
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const ALLOWED_REPLY_PROVIDERS = [
|
||||||
|
ConnectedAccountProvider.GOOGLE,
|
||||||
|
ConnectedAccountProvider.MICROSOFT,
|
||||||
|
ConnectedAccountProvider.IMAP_SMTP_CALDAV,
|
||||||
|
];
|
||||||
|
|
||||||
export const CommandMenuMessageThreadPage = () => {
|
export const CommandMenuMessageThreadPage = () => {
|
||||||
const setMessageThread = useSetRecoilComponentStateV2(
|
const setMessageThread = useSetRecoilComponentStateV2(
|
||||||
messageThreadComponentState,
|
messageThreadComponentState,
|
||||||
@ -58,6 +65,7 @@ export const CommandMenuMessageThreadPage = () => {
|
|||||||
messageChannelLoading,
|
messageChannelLoading,
|
||||||
connectedAccountProvider,
|
connectedAccountProvider,
|
||||||
lastMessageExternalId,
|
lastMessageExternalId,
|
||||||
|
connectedAccountConnectionParameters,
|
||||||
} = useEmailThreadInCommandMenu();
|
} = useEmailThreadInCommandMenu();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -83,10 +91,14 @@ export const CommandMenuMessageThreadPage = () => {
|
|||||||
return (
|
return (
|
||||||
connectedAccountHandle &&
|
connectedAccountHandle &&
|
||||||
connectedAccountProvider &&
|
connectedAccountProvider &&
|
||||||
|
ALLOWED_REPLY_PROVIDERS.includes(connectedAccountProvider) &&
|
||||||
|
(connectedAccountProvider !== ConnectedAccountProvider.IMAP_SMTP_CALDAV ||
|
||||||
|
isDefined(connectedAccountConnectionParameters?.SMTP)) &&
|
||||||
lastMessage &&
|
lastMessage &&
|
||||||
messageThreadExternalId != null
|
messageThreadExternalId != null
|
||||||
);
|
);
|
||||||
}, [
|
}, [
|
||||||
|
connectedAccountConnectionParameters,
|
||||||
connectedAccountHandle,
|
connectedAccountHandle,
|
||||||
connectedAccountProvider,
|
connectedAccountProvider,
|
||||||
lastMessage,
|
lastMessage,
|
||||||
@ -108,6 +120,8 @@ export const CommandMenuMessageThreadPage = () => {
|
|||||||
url = `https://mail.google.com/mail/?authuser=${connectedAccountHandle}#all/${messageThreadExternalId}`;
|
url = `https://mail.google.com/mail/?authuser=${connectedAccountHandle}#all/${messageThreadExternalId}`;
|
||||||
window.open(url, '_blank');
|
window.open(url, '_blank');
|
||||||
break;
|
break;
|
||||||
|
case ConnectedAccountProvider.IMAP_SMTP_CALDAV:
|
||||||
|
throw new Error('Account provider not supported');
|
||||||
case null:
|
case null:
|
||||||
throw new Error('Account provider not provided');
|
throw new Error('Account provider not provided');
|
||||||
default:
|
default:
|
||||||
|
|||||||
@ -139,6 +139,7 @@ export const useEmailThreadInCommandMenu = () => {
|
|||||||
connectedAccount: {
|
connectedAccount: {
|
||||||
id: true,
|
id: true,
|
||||||
provider: true,
|
provider: true,
|
||||||
|
connectionParameters: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
skip: !lastMessageChannelId,
|
skip: !lastMessageChannelId,
|
||||||
@ -175,12 +176,16 @@ export const useEmailThreadInCommandMenu = () => {
|
|||||||
? messageChannelData[0]?.connectedAccount
|
? messageChannelData[0]?.connectedAccount
|
||||||
: null;
|
: null;
|
||||||
const connectedAccountProvider = connectedAccount?.provider ?? null;
|
const connectedAccountProvider = connectedAccount?.provider ?? null;
|
||||||
|
const connectedAccountConnectionParameters =
|
||||||
|
connectedAccount?.connectionParameters;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
thread,
|
thread,
|
||||||
messages: messagesWithSender,
|
messages: messagesWithSender,
|
||||||
messageThreadExternalId,
|
messageThreadExternalId,
|
||||||
connectedAccountHandle,
|
connectedAccountHandle,
|
||||||
connectedAccountProvider,
|
connectedAccountProvider,
|
||||||
|
connectedAccountConnectionParameters,
|
||||||
threadLoading: messagesLoading,
|
threadLoading: messagesLoading,
|
||||||
messageChannelLoading,
|
messageChannelLoading,
|
||||||
lastMessageExternalId,
|
lastMessageExternalId,
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { InformationBanner } from '@/information-banner/components/InformationBanner';
|
import { InformationBanner } from '@/information-banner/components/InformationBanner';
|
||||||
import { useAccountToReconnect } from '@/information-banner/hooks/useAccountToReconnect';
|
import { useAccountToReconnect } from '@/information-banner/hooks/useAccountToReconnect';
|
||||||
import { InformationBannerKeys } from '@/information-banner/types/InformationBannerKeys';
|
import { InformationBannerKeys } from '@/information-banner/types/InformationBannerKeys';
|
||||||
import { useTriggerApisOAuth } from '@/settings/accounts/hooks/useTriggerApiOAuth';
|
import { useTriggerProviderReconnect } from '@/settings/accounts/hooks/useTriggerProviderReconnect';
|
||||||
import { IconRefresh } from 'twenty-ui/display';
|
import { IconRefresh } from 'twenty-ui/display';
|
||||||
|
|
||||||
export const InformationBannerReconnectAccountEmailAliases = () => {
|
export const InformationBannerReconnectAccountEmailAliases = () => {
|
||||||
@ -9,7 +9,7 @@ export const InformationBannerReconnectAccountEmailAliases = () => {
|
|||||||
InformationBannerKeys.ACCOUNTS_TO_RECONNECT_EMAIL_ALIASES,
|
InformationBannerKeys.ACCOUNTS_TO_RECONNECT_EMAIL_ALIASES,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { triggerApisOAuth } = useTriggerApisOAuth();
|
const { triggerProviderReconnect } = useTriggerProviderReconnect();
|
||||||
|
|
||||||
if (!accountToReconnect) {
|
if (!accountToReconnect) {
|
||||||
return null;
|
return null;
|
||||||
@ -17,10 +17,15 @@ export const InformationBannerReconnectAccountEmailAliases = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<InformationBanner
|
<InformationBanner
|
||||||
message={`Please reconnect your mailbox ${accountToReconnect?.handle} to update your email aliases:`}
|
message={`Please reconnect your mailbox ${accountToReconnect.handle} to update your email aliases:`}
|
||||||
buttonTitle="Reconnect"
|
buttonTitle="Reconnect"
|
||||||
buttonIcon={IconRefresh}
|
buttonIcon={IconRefresh}
|
||||||
buttonOnClick={() => triggerApisOAuth(accountToReconnect.provider)}
|
buttonOnClick={() =>
|
||||||
|
triggerProviderReconnect(
|
||||||
|
accountToReconnect.provider,
|
||||||
|
accountToReconnect.id,
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { InformationBanner } from '@/information-banner/components/InformationBanner';
|
import { InformationBanner } from '@/information-banner/components/InformationBanner';
|
||||||
import { useAccountToReconnect } from '@/information-banner/hooks/useAccountToReconnect';
|
import { useAccountToReconnect } from '@/information-banner/hooks/useAccountToReconnect';
|
||||||
import { InformationBannerKeys } from '@/information-banner/types/InformationBannerKeys';
|
import { InformationBannerKeys } from '@/information-banner/types/InformationBannerKeys';
|
||||||
import { useTriggerApisOAuth } from '@/settings/accounts/hooks/useTriggerApiOAuth';
|
import { useTriggerProviderReconnect } from '@/settings/accounts/hooks/useTriggerProviderReconnect';
|
||||||
import { IconRefresh } from 'twenty-ui/display';
|
import { IconRefresh } from 'twenty-ui/display';
|
||||||
|
|
||||||
export const InformationBannerReconnectAccountInsufficientPermissions = () => {
|
export const InformationBannerReconnectAccountInsufficientPermissions = () => {
|
||||||
@ -9,7 +9,7 @@ export const InformationBannerReconnectAccountInsufficientPermissions = () => {
|
|||||||
InformationBannerKeys.ACCOUNTS_TO_RECONNECT_INSUFFICIENT_PERMISSIONS,
|
InformationBannerKeys.ACCOUNTS_TO_RECONNECT_INSUFFICIENT_PERMISSIONS,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { triggerApisOAuth } = useTriggerApisOAuth();
|
const { triggerProviderReconnect } = useTriggerProviderReconnect();
|
||||||
|
|
||||||
if (!accountToReconnect) {
|
if (!accountToReconnect) {
|
||||||
return null;
|
return null;
|
||||||
@ -17,11 +17,16 @@ export const InformationBannerReconnectAccountInsufficientPermissions = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<InformationBanner
|
<InformationBanner
|
||||||
message={`Sync lost with mailbox ${accountToReconnect?.handle}. Please
|
message={`Sync lost with mailbox ${accountToReconnect.handle}. Please
|
||||||
reconnect for updates:`}
|
reconnect for updates:`}
|
||||||
buttonTitle="Reconnect"
|
buttonTitle="Reconnect"
|
||||||
buttonIcon={IconRefresh}
|
buttonIcon={IconRefresh}
|
||||||
buttonOnClick={() => triggerApisOAuth(accountToReconnect.provider)}
|
buttonOnClick={() =>
|
||||||
|
triggerProviderReconnect(
|
||||||
|
accountToReconnect.provider,
|
||||||
|
accountToReconnect.id,
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -3,14 +3,20 @@ import { SettingsAccountsListEmptyStateCard } from '@/settings/accounts/componen
|
|||||||
import { SettingsPath } from '@/types/SettingsPath';
|
import { SettingsPath } from '@/types/SettingsPath';
|
||||||
|
|
||||||
import { SettingsAccountsConnectedAccountsRowRightContainer } from '@/settings/accounts/components/SettingsAccountsConnectedAccountsRowRightContainer';
|
import { SettingsAccountsConnectedAccountsRowRightContainer } from '@/settings/accounts/components/SettingsAccountsConnectedAccountsRowRightContainer';
|
||||||
|
import { useLingui } from '@lingui/react/macro';
|
||||||
|
import {
|
||||||
|
IconComponent,
|
||||||
|
IconGoogle,
|
||||||
|
IconMail,
|
||||||
|
IconMicrosoft,
|
||||||
|
} from 'twenty-ui/display';
|
||||||
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
|
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
|
||||||
import { SettingsListCard } from '../../components/SettingsListCard';
|
import { SettingsListCard } from '../../components/SettingsListCard';
|
||||||
import { useLingui } from '@lingui/react/macro';
|
|
||||||
import { IconComponent, IconGoogle, IconMicrosoft } from 'twenty-ui/display';
|
|
||||||
|
|
||||||
const ProviderIcons: { [k: string]: IconComponent } = {
|
const ProviderIcons: { [k: string]: IconComponent } = {
|
||||||
google: IconGoogle,
|
google: IconGoogle,
|
||||||
microsoft: IconMicrosoft,
|
microsoft: IconMicrosoft,
|
||||||
|
imap: IconMail,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SettingsAccountsConnectedAccountsListCard = ({
|
export const SettingsAccountsConnectedAccountsListCard = ({
|
||||||
|
|||||||
@ -0,0 +1,93 @@
|
|||||||
|
import { FormProvider } from 'react-hook-form';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { SetttingsAccountsImapConnectionForm } from '@/settings/accounts/components/SetttingsAccountsImapConnectionForm';
|
||||||
|
import { useConnectedImapSmtpCaldavAccount } from '@/settings/accounts/hooks/useConnectedImapSmtpCaldavAccount';
|
||||||
|
import { useImapConnectionForm } from '@/settings/accounts/hooks/useImapConnectionForm';
|
||||||
|
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
|
||||||
|
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
|
||||||
|
import { SettingsPath } from '@/types/SettingsPath';
|
||||||
|
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
|
||||||
|
import styled from '@emotion/styled';
|
||||||
|
import { useLingui } from '@lingui/react/macro';
|
||||||
|
import { Loader } from 'twenty-ui/feedback';
|
||||||
|
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
|
||||||
|
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
|
||||||
|
|
||||||
|
const StyledLoadingContainer = styled.div`
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
height: 200px;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SettingsAccountsEditImapConnection = () => {
|
||||||
|
const { t } = useLingui();
|
||||||
|
const navigate = useNavigateSettings();
|
||||||
|
const { connectedAccountId } = useParams<{ connectedAccountId: string }>();
|
||||||
|
|
||||||
|
const { connectedAccount, loading: accountLoading } =
|
||||||
|
useConnectedImapSmtpCaldavAccount(connectedAccountId);
|
||||||
|
|
||||||
|
const initialData = {
|
||||||
|
handle: connectedAccount?.handle || '',
|
||||||
|
host: connectedAccount?.connectionParameters?.IMAP?.host || '',
|
||||||
|
port: connectedAccount?.connectionParameters?.IMAP?.port || 993,
|
||||||
|
secure: connectedAccount?.connectionParameters?.IMAP?.secure ?? true,
|
||||||
|
password: connectedAccount?.connectionParameters?.IMAP?.password || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const { formMethods, handleSave, handleSubmit, canSave, isSubmitting } =
|
||||||
|
useImapConnectionForm({
|
||||||
|
initialData,
|
||||||
|
isEditing: true,
|
||||||
|
connectedAccountId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { control } = formMethods;
|
||||||
|
|
||||||
|
const renderLoadingState = () => (
|
||||||
|
<StyledLoadingContainer>
|
||||||
|
<Loader />
|
||||||
|
</StyledLoadingContainer>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderForm = () => (
|
||||||
|
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||||
|
<FormProvider {...formMethods}>
|
||||||
|
<SubMenuTopBarContainer
|
||||||
|
title={t`Edit IMAP Connection`}
|
||||||
|
links={[
|
||||||
|
{
|
||||||
|
children: t`Settings`,
|
||||||
|
href: getSettingsPath(SettingsPath.Workspace),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
children: t`Email Connections`,
|
||||||
|
href: getSettingsPath(SettingsPath.Accounts),
|
||||||
|
},
|
||||||
|
{ children: t`Edit IMAP Connection` },
|
||||||
|
]}
|
||||||
|
actionButton={
|
||||||
|
<SaveAndCancelButtons
|
||||||
|
isSaveDisabled={!canSave}
|
||||||
|
isCancelDisabled={isSubmitting}
|
||||||
|
onCancel={() => navigate(SettingsPath.Accounts)}
|
||||||
|
onSave={handleSubmit(handleSave)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SettingsPageContainer>
|
||||||
|
<SetttingsAccountsImapConnectionForm control={control} isEditing />
|
||||||
|
</SettingsPageContainer>
|
||||||
|
</SubMenuTopBarContainer>
|
||||||
|
</FormProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (accountLoading === true) {
|
||||||
|
return renderLoadingState();
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderForm();
|
||||||
|
};
|
||||||
@ -3,13 +3,17 @@ import { isGoogleMessagingEnabledState } from '@/client-config/states/isGoogleMe
|
|||||||
import { isMicrosoftCalendarEnabledState } from '@/client-config/states/isMicrosoftCalendarEnabledState';
|
import { isMicrosoftCalendarEnabledState } from '@/client-config/states/isMicrosoftCalendarEnabledState';
|
||||||
import { isMicrosoftMessagingEnabledState } from '@/client-config/states/isMicrosoftMessagingEnabledState';
|
import { isMicrosoftMessagingEnabledState } from '@/client-config/states/isMicrosoftMessagingEnabledState';
|
||||||
import { useTriggerApisOAuth } from '@/settings/accounts/hooks/useTriggerApiOAuth';
|
import { useTriggerApisOAuth } from '@/settings/accounts/hooks/useTriggerApiOAuth';
|
||||||
|
import { SettingsPath } from '@/types/SettingsPath';
|
||||||
|
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
import { useLingui } from '@lingui/react/macro';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
import { useRecoilValue } from 'recoil';
|
import { useRecoilValue } from 'recoil';
|
||||||
import { ConnectedAccountProvider } from 'twenty-shared/types';
|
import { ConnectedAccountProvider } from 'twenty-shared/types';
|
||||||
|
import { IconGoogle, IconMail, IconMicrosoft } from 'twenty-ui/display';
|
||||||
import { Button } from 'twenty-ui/input';
|
import { Button } from 'twenty-ui/input';
|
||||||
import { Card, CardContent, CardHeader } from 'twenty-ui/layout';
|
import { Card, CardContent, CardHeader } from 'twenty-ui/layout';
|
||||||
import { IconGoogle, IconMicrosoft } from 'twenty-ui/display';
|
import { FeatureFlagKey } from '~/generated-metadata/graphql';
|
||||||
|
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
|
||||||
|
|
||||||
const StyledHeader = styled(CardHeader)`
|
const StyledHeader = styled(CardHeader)`
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -47,6 +51,8 @@ export const SettingsAccountsListEmptyStateCard = ({
|
|||||||
isMicrosoftCalendarEnabledState,
|
isMicrosoftCalendarEnabledState,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isImapEnabled = useIsFeatureEnabled(FeatureFlagKey.IS_IMAP_ENABLED);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<StyledHeader>{label || t`No connected account`}</StyledHeader>
|
<StyledHeader>{label || t`No connected account`}</StyledHeader>
|
||||||
@ -68,6 +74,15 @@ export const SettingsAccountsListEmptyStateCard = ({
|
|||||||
onClick={() => triggerApisOAuth(ConnectedAccountProvider.MICROSOFT)}
|
onClick={() => triggerApisOAuth(ConnectedAccountProvider.MICROSOFT)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isImapEnabled && (
|
||||||
|
<Button
|
||||||
|
Icon={IconMail}
|
||||||
|
title={t`Connect with IMAP`}
|
||||||
|
variant="secondary"
|
||||||
|
to={getSettingsPath(SettingsPath.NewImapConnection)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</StyledBody>
|
</StyledBody>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -0,0 +1,56 @@
|
|||||||
|
import { FormProvider } from 'react-hook-form';
|
||||||
|
|
||||||
|
import { SetttingsAccountsImapConnectionForm } from '@/settings/accounts/components/SetttingsAccountsImapConnectionForm';
|
||||||
|
import { useImapConnectionForm } from '@/settings/accounts/hooks/useImapConnectionForm';
|
||||||
|
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
|
||||||
|
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
|
||||||
|
import { SettingsPath } from '@/types/SettingsPath';
|
||||||
|
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
|
||||||
|
import { useLingui } from '@lingui/react/macro';
|
||||||
|
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
|
||||||
|
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
|
||||||
|
|
||||||
|
export const SettingsAccountsNewImapConnection = () => {
|
||||||
|
const { t } = useLingui();
|
||||||
|
const navigate = useNavigateSettings();
|
||||||
|
|
||||||
|
const { formMethods, handleSave, handleSubmit, canSave, isSubmitting } =
|
||||||
|
useImapConnectionForm();
|
||||||
|
|
||||||
|
const { control } = formMethods;
|
||||||
|
|
||||||
|
return (
|
||||||
|
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||||
|
<FormProvider {...formMethods}>
|
||||||
|
<SubMenuTopBarContainer
|
||||||
|
title={t`New IMAP Connection`}
|
||||||
|
links={[
|
||||||
|
{
|
||||||
|
children: t`Settings`,
|
||||||
|
href: getSettingsPath(SettingsPath.Workspace),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
children: t`Email Connections`,
|
||||||
|
href: getSettingsPath(SettingsPath.Accounts),
|
||||||
|
},
|
||||||
|
{ children: t`New IMAP Connection` },
|
||||||
|
]}
|
||||||
|
actionButton={
|
||||||
|
<SaveAndCancelButtons
|
||||||
|
isSaveDisabled={!canSave}
|
||||||
|
isCancelDisabled={isSubmitting}
|
||||||
|
onCancel={() => navigate(SettingsPath.Accounts)}
|
||||||
|
onSave={handleSubmit(handleSave)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SettingsPageContainer>
|
||||||
|
<SetttingsAccountsImapConnectionForm
|
||||||
|
control={control}
|
||||||
|
isEditing={false}
|
||||||
|
/>
|
||||||
|
</SettingsPageContainer>
|
||||||
|
</SubMenuTopBarContainer>
|
||||||
|
</FormProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import { ConnectedAccount } from '@/accounts/types/ConnectedAccount';
|
import { ConnectedAccount } from '@/accounts/types/ConnectedAccount';
|
||||||
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
||||||
import { useDestroyOneRecord } from '@/object-record/hooks/useDestroyOneRecord';
|
import { useDestroyOneRecord } from '@/object-record/hooks/useDestroyOneRecord';
|
||||||
import { useTriggerApisOAuth } from '@/settings/accounts/hooks/useTriggerApiOAuth';
|
import { useTriggerProviderReconnect } from '@/settings/accounts/hooks/useTriggerProviderReconnect';
|
||||||
import { SettingsPath } from '@/types/SettingsPath';
|
import { SettingsPath } from '@/types/SettingsPath';
|
||||||
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
|
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
|
||||||
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
|
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
|
||||||
@ -40,7 +40,7 @@ export const SettingsAccountsRowDropdownMenu = ({
|
|||||||
const { destroyOneRecord } = useDestroyOneRecord({
|
const { destroyOneRecord } = useDestroyOneRecord({
|
||||||
objectNameSingular: CoreObjectNameSingular.ConnectedAccount,
|
objectNameSingular: CoreObjectNameSingular.ConnectedAccount,
|
||||||
});
|
});
|
||||||
const { triggerApisOAuth } = useTriggerApisOAuth();
|
const { triggerProviderReconnect } = useTriggerProviderReconnect();
|
||||||
|
|
||||||
const deleteAccount = async () => {
|
const deleteAccount = async () => {
|
||||||
await destroyOneRecord(account.id);
|
await destroyOneRecord(account.id);
|
||||||
@ -78,7 +78,7 @@ export const SettingsAccountsRowDropdownMenu = ({
|
|||||||
LeftIcon={IconRefresh}
|
LeftIcon={IconRefresh}
|
||||||
text={t`Reconnect`}
|
text={t`Reconnect`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
triggerApisOAuth(account.provider);
|
triggerProviderReconnect(account.provider, account.id);
|
||||||
closeDropdown();
|
closeDropdown();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -0,0 +1,119 @@
|
|||||||
|
import { Control, Controller } from 'react-hook-form';
|
||||||
|
|
||||||
|
import { Select } from '@/ui/input/components/Select';
|
||||||
|
import { TextInput } from '@/ui/input/components/TextInput';
|
||||||
|
import styled from '@emotion/styled';
|
||||||
|
import { useLingui } from '@lingui/react/macro';
|
||||||
|
import { H2Title } from 'twenty-ui/display';
|
||||||
|
import { Section } from 'twenty-ui/layout';
|
||||||
|
import { ConnectionParameters } from '~/generated/graphql';
|
||||||
|
|
||||||
|
const StyledFormContainer = styled.form`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: ${({ theme }) => theme.spacing(4)};
|
||||||
|
`;
|
||||||
|
|
||||||
|
type SetttingsAccountsImapConnectionFormProps = {
|
||||||
|
control: Control<ConnectionParameters & { handle: string }>;
|
||||||
|
isEditing: boolean;
|
||||||
|
defaultValues?: Partial<ConnectionParameters & { handle: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SetttingsAccountsImapConnectionForm = ({
|
||||||
|
control,
|
||||||
|
isEditing,
|
||||||
|
defaultValues,
|
||||||
|
}: SetttingsAccountsImapConnectionFormProps) => {
|
||||||
|
const { t } = useLingui();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section>
|
||||||
|
<H2Title
|
||||||
|
title={t`IMAP Connection Details`}
|
||||||
|
description={
|
||||||
|
isEditing
|
||||||
|
? t`Update your IMAP email account configuration`
|
||||||
|
: t`Configure your IMAP email account`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<StyledFormContainer>
|
||||||
|
<Controller
|
||||||
|
name="handle"
|
||||||
|
control={control}
|
||||||
|
defaultValue={defaultValues?.handle}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextInput
|
||||||
|
label={t`Email Address`}
|
||||||
|
placeholder={t`john.doe@example.com`}
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
error={fieldState.error?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="host"
|
||||||
|
control={control}
|
||||||
|
defaultValue={defaultValues?.host}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextInput
|
||||||
|
label={t`IMAP Server`}
|
||||||
|
placeholder={t`imap.example.com`}
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
error={fieldState.error?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="port"
|
||||||
|
control={control}
|
||||||
|
defaultValue={defaultValues?.port ?? 993}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextInput
|
||||||
|
label={t`IMAP Port`}
|
||||||
|
type="number"
|
||||||
|
placeholder={t`993`}
|
||||||
|
value={field.value.toString()}
|
||||||
|
onChange={(value) => field.onChange(Number(value))}
|
||||||
|
error={fieldState.error?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="secure"
|
||||||
|
control={control}
|
||||||
|
defaultValue={defaultValues?.secure}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select
|
||||||
|
label={t`Encryption`}
|
||||||
|
options={[
|
||||||
|
{ label: 'SSL/TLS', value: true },
|
||||||
|
{ label: 'None', value: false },
|
||||||
|
]}
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
dropdownId="secure-dropdown"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="password"
|
||||||
|
control={control}
|
||||||
|
defaultValue={defaultValues?.password}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextInput
|
||||||
|
label={t`Password`}
|
||||||
|
placeholder={t`••••••••`}
|
||||||
|
type="password"
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
error={fieldState.error?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</StyledFormContainer>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
import gql from 'graphql-tag';
|
||||||
|
|
||||||
|
export const SAVE_IMAP_SMTP_CALDAV_CONNECTION = gql`
|
||||||
|
mutation SaveImapSmtpCaldav(
|
||||||
|
$accountOwnerId: String!
|
||||||
|
$handle: String!
|
||||||
|
$accountType: AccountType!
|
||||||
|
$connectionParameters: ConnectionParameters!
|
||||||
|
$id: String
|
||||||
|
) {
|
||||||
|
saveImapSmtpCaldav(
|
||||||
|
accountOwnerId: $accountOwnerId
|
||||||
|
handle: $handle
|
||||||
|
accountType: $accountType
|
||||||
|
connectionParameters: $connectionParameters
|
||||||
|
id: $id
|
||||||
|
) {
|
||||||
|
success
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
import gql from 'graphql-tag';
|
||||||
|
|
||||||
|
export const GET_CONNECTED_IMAP_SMTP_CALDAV_ACCOUNT = gql`
|
||||||
|
query GetConnectedImapSmtpCaldavAccount($id: String!) {
|
||||||
|
getConnectedImapSmtpCaldavAccount(id: $id) {
|
||||||
|
id
|
||||||
|
handle
|
||||||
|
provider
|
||||||
|
accountOwnerId
|
||||||
|
connectionParameters {
|
||||||
|
IMAP {
|
||||||
|
host
|
||||||
|
port
|
||||||
|
secure
|
||||||
|
username
|
||||||
|
password
|
||||||
|
}
|
||||||
|
SMTP {
|
||||||
|
host
|
||||||
|
port
|
||||||
|
secure
|
||||||
|
username
|
||||||
|
password
|
||||||
|
}
|
||||||
|
CALDAV {
|
||||||
|
host
|
||||||
|
port
|
||||||
|
secure
|
||||||
|
username
|
||||||
|
password
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
import { useGetConnectedImapSmtpCaldavAccountQuery } from '~/generated/graphql';
|
||||||
|
|
||||||
|
export const useConnectedImapSmtpCaldavAccount = (
|
||||||
|
connectedAccountId: string | undefined,
|
||||||
|
) => {
|
||||||
|
const { data, loading, error } = useGetConnectedImapSmtpCaldavAccountQuery({
|
||||||
|
variables: { id: connectedAccountId ?? '' },
|
||||||
|
skip: !connectedAccountId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
connectedAccount: data?.getConnectedImapSmtpCaldavAccount,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -0,0 +1,140 @@
|
|||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { useRecoilValue } from 'recoil';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { SettingsPath } from '@/types/SettingsPath';
|
||||||
|
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
|
||||||
|
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||||
|
import { useLingui } from '@lingui/react/macro';
|
||||||
|
import {
|
||||||
|
ConnectionParameters,
|
||||||
|
useSaveImapSmtpCaldavMutation,
|
||||||
|
} from '~/generated/graphql';
|
||||||
|
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
|
||||||
|
import { currentWorkspaceMemberState } from '~/modules/auth/states/currentWorkspaceMemberState';
|
||||||
|
import { currentWorkspaceState } from '~/modules/auth/states/currentWorkspaceState';
|
||||||
|
|
||||||
|
const imapConnectionFormSchema = z.object({
|
||||||
|
handle: z.string().email('Invalid email address'),
|
||||||
|
host: z.string().min(1, 'IMAP server is required'),
|
||||||
|
port: z.number().int().positive('Port must be a positive number'),
|
||||||
|
secure: z.boolean(),
|
||||||
|
password: z.string().min(1, 'Password is required'),
|
||||||
|
});
|
||||||
|
|
||||||
|
type ImapConnectionFormValues = z.infer<typeof imapConnectionFormSchema>;
|
||||||
|
|
||||||
|
type UseImapConnectionFormProps = {
|
||||||
|
initialData?: ImapConnectionFormValues;
|
||||||
|
isEditing?: boolean;
|
||||||
|
connectedAccountId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useImapConnectionForm = ({
|
||||||
|
initialData,
|
||||||
|
isEditing = false,
|
||||||
|
connectedAccountId,
|
||||||
|
}: UseImapConnectionFormProps = {}) => {
|
||||||
|
const { t } = useLingui();
|
||||||
|
const navigate = useNavigateSettings();
|
||||||
|
const { enqueueSnackBar } = useSnackBar();
|
||||||
|
const currentWorkspace = useRecoilValue(currentWorkspaceState);
|
||||||
|
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
|
||||||
|
|
||||||
|
const [saveImapConnection, { loading: saveLoading }] =
|
||||||
|
useSaveImapSmtpCaldavMutation();
|
||||||
|
|
||||||
|
const resolver = zodResolver(imapConnectionFormSchema);
|
||||||
|
|
||||||
|
const defaultValues = {
|
||||||
|
handle: initialData?.handle || '',
|
||||||
|
host: initialData?.host || '',
|
||||||
|
port: initialData?.port || 993,
|
||||||
|
secure: initialData?.secure ?? true,
|
||||||
|
password: initialData?.password || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const formMethods = useForm<ConnectionParameters & { handle: string }>({
|
||||||
|
mode: 'onSubmit',
|
||||||
|
resolver,
|
||||||
|
defaultValues,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { handleSubmit, formState } = formMethods;
|
||||||
|
const { isValid, isSubmitting } = formState;
|
||||||
|
const canSave = isValid && !isSubmitting;
|
||||||
|
const loading = saveLoading;
|
||||||
|
|
||||||
|
const handleSave = async (
|
||||||
|
formValues: ConnectionParameters & { handle: string },
|
||||||
|
) => {
|
||||||
|
if (!currentWorkspace?.id) {
|
||||||
|
enqueueSnackBar('Workspace ID is missing', {
|
||||||
|
variant: SnackBarVariant.Error,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentWorkspaceMember?.id) {
|
||||||
|
enqueueSnackBar('Workspace member ID is missing', {
|
||||||
|
variant: SnackBarVariant.Error,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const variables = {
|
||||||
|
...(isEditing && connectedAccountId ? { id: connectedAccountId } : {}),
|
||||||
|
accountOwnerId: currentWorkspaceMember.id,
|
||||||
|
handle: formValues.handle,
|
||||||
|
host: formValues.host,
|
||||||
|
port: formValues.port,
|
||||||
|
secure: formValues.secure,
|
||||||
|
password: formValues.password,
|
||||||
|
};
|
||||||
|
|
||||||
|
await saveImapConnection({
|
||||||
|
variables: {
|
||||||
|
accountOwnerId: variables.accountOwnerId,
|
||||||
|
handle: variables.handle,
|
||||||
|
accountType: {
|
||||||
|
type: 'IMAP',
|
||||||
|
},
|
||||||
|
connectionParameters: {
|
||||||
|
host: variables.host,
|
||||||
|
port: variables.port,
|
||||||
|
secure: variables.secure,
|
||||||
|
password: variables.password,
|
||||||
|
username: variables.handle,
|
||||||
|
},
|
||||||
|
...(variables.id ? { id: variables.id } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
enqueueSnackBar(
|
||||||
|
connectedAccountId
|
||||||
|
? t`IMAP connection successfully updated`
|
||||||
|
: t`IMAP connection successfully created`,
|
||||||
|
{
|
||||||
|
variant: SnackBarVariant.Success,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
navigate(SettingsPath.Accounts);
|
||||||
|
} catch (error) {
|
||||||
|
enqueueSnackBar((error as Error).message, {
|
||||||
|
variant: SnackBarVariant.Error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
formMethods,
|
||||||
|
handleSave,
|
||||||
|
handleSubmit,
|
||||||
|
canSave,
|
||||||
|
isSubmitting,
|
||||||
|
loading,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import { ConnectedAccountProvider } from 'twenty-shared/types';
|
||||||
|
|
||||||
|
import { useTriggerApisOAuth } from '@/settings/accounts/hooks/useTriggerApiOAuth';
|
||||||
|
import { SettingsPath } from '@/types/SettingsPath';
|
||||||
|
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
|
||||||
|
|
||||||
|
export const useTriggerProviderReconnect = () => {
|
||||||
|
const { triggerApisOAuth } = useTriggerApisOAuth();
|
||||||
|
const navigate = useNavigateSettings();
|
||||||
|
|
||||||
|
const triggerProviderReconnect = useCallback(
|
||||||
|
async (
|
||||||
|
provider: ConnectedAccountProvider,
|
||||||
|
accountId?: string,
|
||||||
|
options?: Parameters<typeof triggerApisOAuth>[1],
|
||||||
|
) => {
|
||||||
|
if (provider === ConnectedAccountProvider.IMAP_SMTP_CALDAV) {
|
||||||
|
if (!accountId) {
|
||||||
|
navigate(SettingsPath.NewImapConnection);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
navigate(SettingsPath.EditImapConnection, {
|
||||||
|
connectedAccountId: accountId,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await triggerApisOAuth(provider, options);
|
||||||
|
},
|
||||||
|
[triggerApisOAuth, navigate],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { triggerProviderReconnect };
|
||||||
|
};
|
||||||
@ -5,6 +5,8 @@ export enum SettingsPath {
|
|||||||
NewAccount = 'accounts/new',
|
NewAccount = 'accounts/new',
|
||||||
AccountsCalendars = 'accounts/calendars',
|
AccountsCalendars = 'accounts/calendars',
|
||||||
AccountsEmails = 'accounts/emails',
|
AccountsEmails = 'accounts/emails',
|
||||||
|
NewImapConnection = 'accounts/new-imap-connection',
|
||||||
|
EditImapConnection = 'accounts/edit-imap-connection/:connectedAccountId',
|
||||||
Billing = 'billing',
|
Billing = 'billing',
|
||||||
Objects = 'objects',
|
Objects = 'objects',
|
||||||
ObjectOverview = 'objects/overview',
|
ObjectOverview = 'objects/overview',
|
||||||
|
|||||||
@ -25,6 +25,7 @@ const PROVIDORS_ICON_MAPPING = {
|
|||||||
EMAIL: {
|
EMAIL: {
|
||||||
[ConnectedAccountProvider.MICROSOFT]: IconMicrosoftOutlook,
|
[ConnectedAccountProvider.MICROSOFT]: IconMicrosoftOutlook,
|
||||||
[ConnectedAccountProvider.GOOGLE]: IconGmail,
|
[ConnectedAccountProvider.GOOGLE]: IconGmail,
|
||||||
|
[ConnectedAccountProvider.IMAP_SMTP_CALDAV]: IconMail,
|
||||||
default: IconMail,
|
default: IconMail,
|
||||||
},
|
},
|
||||||
CALENDAR: {
|
CALENDAR: {
|
||||||
@ -50,7 +51,11 @@ export const ActorDisplay = ({
|
|||||||
case 'EMAIL':
|
case 'EMAIL':
|
||||||
return PROVIDORS_ICON_MAPPING.EMAIL[context?.provider ?? 'default'];
|
return PROVIDORS_ICON_MAPPING.EMAIL[context?.provider ?? 'default'];
|
||||||
case 'CALENDAR':
|
case 'CALENDAR':
|
||||||
return PROVIDORS_ICON_MAPPING.CALENDAR[context?.provider ?? 'default'];
|
return (
|
||||||
|
PROVIDORS_ICON_MAPPING.CALENDAR[
|
||||||
|
context?.provider as keyof typeof PROVIDORS_ICON_MAPPING.CALENDAR
|
||||||
|
] ?? PROVIDORS_ICON_MAPPING.CALENDAR.default
|
||||||
|
);
|
||||||
case 'SYSTEM':
|
case 'SYSTEM':
|
||||||
return IconRobot;
|
return IconRobot;
|
||||||
case 'WORKFLOW':
|
case 'WORKFLOW':
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { FormTextFieldInput } from '@/object-record/record-field/form-types/comp
|
|||||||
import { useTriggerApisOAuth } from '@/settings/accounts/hooks/useTriggerApiOAuth';
|
import { useTriggerApisOAuth } from '@/settings/accounts/hooks/useTriggerApiOAuth';
|
||||||
import { SettingsPath } from '@/types/SettingsPath';
|
import { SettingsPath } from '@/types/SettingsPath';
|
||||||
import { Select } from '@/ui/input/components/Select';
|
import { Select } from '@/ui/input/components/Select';
|
||||||
|
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
|
||||||
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
|
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
|
||||||
import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState';
|
import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState';
|
||||||
import { WorkflowSendEmailAction } from '@/workflow/types/Workflow';
|
import { WorkflowSendEmailAction } from '@/workflow/types/Workflow';
|
||||||
@ -15,6 +16,7 @@ import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowS
|
|||||||
import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader';
|
import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader';
|
||||||
import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader';
|
import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader';
|
||||||
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
|
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
|
||||||
|
import { useTheme } from '@emotion/react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useRecoilValue } from 'recoil';
|
import { useRecoilValue } from 'recoil';
|
||||||
import { ConnectedAccountProvider } from 'twenty-shared/types';
|
import { ConnectedAccountProvider } from 'twenty-shared/types';
|
||||||
@ -24,8 +26,6 @@ import { SelectOption } from 'twenty-ui/input';
|
|||||||
import { JsonValue } from 'type-fest';
|
import { JsonValue } from 'type-fest';
|
||||||
import { useDebouncedCallback } from 'use-debounce';
|
import { useDebouncedCallback } from 'use-debounce';
|
||||||
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
|
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
|
||||||
import { useTheme } from '@emotion/react';
|
|
||||||
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
|
|
||||||
|
|
||||||
type WorkflowEditActionSendEmailProps = {
|
type WorkflowEditActionSendEmailProps = {
|
||||||
action: WorkflowSendEmailAction;
|
action: WorkflowSendEmailAction;
|
||||||
@ -88,6 +88,8 @@ export const WorkflowEditActionSendEmail = ({
|
|||||||
return scopes.some((scope) => scope === GMAIL_SEND_SCOPE);
|
return scopes.some((scope) => scope === GMAIL_SEND_SCOPE);
|
||||||
case ConnectedAccountProvider.MICROSOFT:
|
case ConnectedAccountProvider.MICROSOFT:
|
||||||
return scopes.some((scope) => scope === MICROSOFT_SEND_SCOPE);
|
return scopes.some((scope) => scope === MICROSOFT_SEND_SCOPE);
|
||||||
|
case ConnectedAccountProvider.IMAP_SMTP_CALDAV:
|
||||||
|
return isDefined(connectedAccount.connectionParameters?.SMTP);
|
||||||
default:
|
default:
|
||||||
assertUnreachable(
|
assertUnreachable(
|
||||||
connectedAccount.provider,
|
connectedAccount.provider,
|
||||||
@ -185,6 +187,13 @@ export const WorkflowEditActionSendEmail = ({
|
|||||||
const connectedAccountOptions: SelectOption<string | null>[] = [];
|
const connectedAccountOptions: SelectOption<string | null>[] = [];
|
||||||
|
|
||||||
accounts.forEach((account) => {
|
accounts.forEach((account) => {
|
||||||
|
if (
|
||||||
|
account.provider === ConnectedAccountProvider.IMAP_SMTP_CALDAV &&
|
||||||
|
!isDefined(account.connectionParameters?.SMTP)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const selectOption = {
|
const selectOption = {
|
||||||
label: account.handle,
|
label: account.handle,
|
||||||
value: account.id,
|
value: account.id,
|
||||||
|
|||||||
@ -54,4 +54,5 @@ export const mockedClientConfig: ClientConfig = {
|
|||||||
isGoogleCalendarEnabled: true,
|
isGoogleCalendarEnabled: true,
|
||||||
isAttachmentPreviewEnabled: true,
|
isAttachmentPreviewEnabled: true,
|
||||||
isConfigVariablesInDbEnabled: false,
|
isConfigVariablesInDbEnabled: false,
|
||||||
|
isIMAPMessagingEnabled: false,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -14,6 +14,7 @@ FRONTEND_URL=http://localhost:3001
|
|||||||
# REFRESH_TOKEN_EXPIRES_IN=90d
|
# REFRESH_TOKEN_EXPIRES_IN=90d
|
||||||
# FILE_TOKEN_EXPIRES_IN=1d
|
# FILE_TOKEN_EXPIRES_IN=1d
|
||||||
# MESSAGING_PROVIDER_GMAIL_ENABLED=false
|
# MESSAGING_PROVIDER_GMAIL_ENABLED=false
|
||||||
|
# MESSAGING_PROVIDER_IMAP_ENABLED=false
|
||||||
# CALENDAR_PROVIDER_GOOGLE_ENABLED=false
|
# CALENDAR_PROVIDER_GOOGLE_ENABLED=false
|
||||||
# MESSAGING_PROVIDER_MICROSOFT_ENABLED=false
|
# MESSAGING_PROVIDER_MICROSOFT_ENABLED=false
|
||||||
# CALENDAR_PROVIDER_MICROSOFT_ENABLED=false
|
# CALENDAR_PROVIDER_MICROSOFT_ENABLED=false
|
||||||
|
|||||||
@ -11,6 +11,7 @@ FRONTEND_URL=http://localhost:3001
|
|||||||
|
|
||||||
AUTH_GOOGLE_ENABLED=false
|
AUTH_GOOGLE_ENABLED=false
|
||||||
MESSAGING_PROVIDER_GMAIL_ENABLED=false
|
MESSAGING_PROVIDER_GMAIL_ENABLED=false
|
||||||
|
MESSAGING_PROVIDER_IMAP_ENABLED=false
|
||||||
CALENDAR_PROVIDER_GOOGLE_ENABLED=false
|
CALENDAR_PROVIDER_GOOGLE_ENABLED=false
|
||||||
MESSAGING_PROVIDER_MICROSOFT_ENABLED=false
|
MESSAGING_PROVIDER_MICROSOFT_ENABLED=false
|
||||||
CALENDAR_PROVIDER_MICROSOFT_ENABLED=false
|
CALENDAR_PROVIDER_MICROSOFT_ENABLED=false
|
||||||
|
|||||||
@ -96,6 +96,7 @@ describe('ClientConfigController', () => {
|
|||||||
isGoogleMessagingEnabled: false,
|
isGoogleMessagingEnabled: false,
|
||||||
isGoogleCalendarEnabled: false,
|
isGoogleCalendarEnabled: false,
|
||||||
isConfigVariablesInDbEnabled: false,
|
isConfigVariablesInDbEnabled: false,
|
||||||
|
isIMAPMessagingEnabled: false,
|
||||||
calendarBookingPageId: undefined,
|
calendarBookingPageId: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -177,6 +177,9 @@ export class ClientConfig {
|
|||||||
@Field(() => Boolean)
|
@Field(() => Boolean)
|
||||||
isConfigVariablesInDbEnabled: boolean;
|
isConfigVariablesInDbEnabled: boolean;
|
||||||
|
|
||||||
|
@Field(() => Boolean)
|
||||||
|
isIMAPMessagingEnabled: boolean;
|
||||||
|
|
||||||
@Field(() => String, { nullable: true })
|
@Field(() => String, { nullable: true })
|
||||||
calendarBookingPageId?: string;
|
calendarBookingPageId?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -137,6 +137,9 @@ export class ClientConfigService {
|
|||||||
isConfigVariablesInDbEnabled: this.twentyConfigService.get(
|
isConfigVariablesInDbEnabled: this.twentyConfigService.get(
|
||||||
'IS_CONFIG_VARIABLES_IN_DB_ENABLED',
|
'IS_CONFIG_VARIABLES_IN_DB_ENABLED',
|
||||||
),
|
),
|
||||||
|
isIMAPMessagingEnabled: this.twentyConfigService.get(
|
||||||
|
'MESSAGING_PROVIDER_IMAP_ENABLED',
|
||||||
|
),
|
||||||
calendarBookingPageId: this.twentyConfigService.get(
|
calendarBookingPageId: this.twentyConfigService.get(
|
||||||
'CALENDAR_BOOKING_PAGE_ID',
|
'CALENDAR_BOOKING_PAGE_ID',
|
||||||
),
|
),
|
||||||
|
|||||||
@ -23,6 +23,7 @@ import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-
|
|||||||
import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module';
|
import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module';
|
||||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||||
import { HealthModule } from 'src/engine/core-modules/health/health.module';
|
import { HealthModule } from 'src/engine/core-modules/health/health.module';
|
||||||
|
import { ImapSmtpCaldavModule } from 'src/engine/core-modules/imap-smtp-caldav-connection/imap-smtp-caldav-connection.module';
|
||||||
import { LabModule } from 'src/engine/core-modules/lab/lab.module';
|
import { LabModule } from 'src/engine/core-modules/lab/lab.module';
|
||||||
import { LoggerModule } from 'src/engine/core-modules/logger/logger.module';
|
import { LoggerModule } from 'src/engine/core-modules/logger/logger.module';
|
||||||
import { loggerModuleFactory } from 'src/engine/core-modules/logger/logger.module-factory';
|
import { loggerModuleFactory } from 'src/engine/core-modules/logger/logger.module-factory';
|
||||||
@ -83,6 +84,7 @@ import { FileModule } from './file/file.module';
|
|||||||
RedisClientModule,
|
RedisClientModule,
|
||||||
WorkspaceQueryRunnerModule,
|
WorkspaceQueryRunnerModule,
|
||||||
SubscriptionsModule,
|
SubscriptionsModule,
|
||||||
|
ImapSmtpCaldavModule,
|
||||||
FileStorageModule.forRoot(),
|
FileStorageModule.forRoot(),
|
||||||
LoggerModule.forRootAsync({
|
LoggerModule.forRootAsync({
|
||||||
useFactory: loggerModuleFactory,
|
useFactory: loggerModuleFactory,
|
||||||
@ -125,6 +127,7 @@ import { FileModule } from './file/file.module';
|
|||||||
WorkspaceModule,
|
WorkspaceModule,
|
||||||
WorkspaceInvitationModule,
|
WorkspaceInvitationModule,
|
||||||
WorkspaceSSOModule,
|
WorkspaceSSOModule,
|
||||||
|
ImapSmtpCaldavModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class CoreEngineModule {}
|
export class CoreEngineModule {}
|
||||||
|
|||||||
@ -12,6 +12,15 @@ export type PublicFeatureFlag = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const PUBLIC_FEATURE_FLAGS: PublicFeatureFlag[] = [
|
export const PUBLIC_FEATURE_FLAGS: PublicFeatureFlag[] = [
|
||||||
|
{
|
||||||
|
key: FeatureFlagKey.IS_IMAP_ENABLED,
|
||||||
|
metadata: {
|
||||||
|
label: 'IMAP',
|
||||||
|
description:
|
||||||
|
'Easily add email accounts from any provider that supports IMAP (and soon, send emails with SMTP)',
|
||||||
|
imagePath: 'https://twenty.com/images/lab/is-imap-enabled.png',
|
||||||
|
},
|
||||||
|
},
|
||||||
...(process.env.CLOUDFLARE_API_KEY
|
...(process.env.CLOUDFLARE_API_KEY
|
||||||
? [
|
? [
|
||||||
// {
|
// {
|
||||||
|
|||||||
@ -5,4 +5,5 @@ export enum FeatureFlagKey {
|
|||||||
IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED',
|
IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED',
|
||||||
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
|
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
|
||||||
IS_AI_ENABLED = 'IS_AI_ENABLED',
|
IS_AI_ENABLED = 'IS_AI_ENABLED',
|
||||||
|
IS_IMAP_ENABLED = 'IS_IMAP_ENABLED',
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,23 @@
|
|||||||
|
import { Field, ObjectType } from '@nestjs/graphql';
|
||||||
|
|
||||||
|
import { ConnectedAccountProvider } from 'twenty-shared/types';
|
||||||
|
|
||||||
|
import { ImapSmtpCaldavConnectionParameters } from 'src/engine/core-modules/imap-smtp-caldav-connection/dtos/imap-smtp-caldav-connection.dto';
|
||||||
|
|
||||||
|
@ObjectType()
|
||||||
|
export class ConnectedImapSmtpCaldavAccount {
|
||||||
|
@Field(() => String)
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Field(() => String)
|
||||||
|
handle: string;
|
||||||
|
|
||||||
|
@Field(() => String)
|
||||||
|
provider: ConnectedAccountProvider;
|
||||||
|
|
||||||
|
@Field(() => String)
|
||||||
|
accountOwnerId: string;
|
||||||
|
|
||||||
|
@Field(() => ImapSmtpCaldavConnectionParameters, { nullable: true })
|
||||||
|
connectionParameters: ImapSmtpCaldavConnectionParameters | null;
|
||||||
|
}
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
import { Field, ObjectType } from '@nestjs/graphql';
|
||||||
|
|
||||||
|
@ObjectType()
|
||||||
|
export class ImapSmtpCaldavConnectionSuccess {
|
||||||
|
@Field(() => Boolean)
|
||||||
|
success: boolean;
|
||||||
|
}
|
||||||
@ -0,0 +1,60 @@
|
|||||||
|
import { Field, InputType, ObjectType } from '@nestjs/graphql';
|
||||||
|
|
||||||
|
@InputType()
|
||||||
|
export class AccountType {
|
||||||
|
@Field(() => String)
|
||||||
|
type: 'IMAP' | 'SMTP' | 'CALDAV';
|
||||||
|
}
|
||||||
|
|
||||||
|
@InputType()
|
||||||
|
export class ConnectionParameters {
|
||||||
|
@Field(() => String)
|
||||||
|
host: string;
|
||||||
|
|
||||||
|
@Field(() => Number)
|
||||||
|
port: number;
|
||||||
|
|
||||||
|
@Field(() => String)
|
||||||
|
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,
|
||||||
|
* as database access implies a broader compromise. For context, see discussion in PR #12576.
|
||||||
|
*/
|
||||||
|
@Field(() => String)
|
||||||
|
password: string;
|
||||||
|
|
||||||
|
@Field(() => Boolean, { nullable: true })
|
||||||
|
secure?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType()
|
||||||
|
export class ConnectionParametersOutput {
|
||||||
|
@Field(() => String)
|
||||||
|
host: string;
|
||||||
|
|
||||||
|
@Field(() => Number)
|
||||||
|
port: number;
|
||||||
|
|
||||||
|
@Field(() => String)
|
||||||
|
username: string;
|
||||||
|
|
||||||
|
@Field(() => String)
|
||||||
|
password: string;
|
||||||
|
|
||||||
|
@Field(() => Boolean, { nullable: true })
|
||||||
|
secure?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType()
|
||||||
|
export class ImapSmtpCaldavConnectionParameters {
|
||||||
|
@Field(() => ConnectionParametersOutput, { nullable: true })
|
||||||
|
IMAP?: ConnectionParametersOutput;
|
||||||
|
|
||||||
|
@Field(() => ConnectionParametersOutput, { nullable: true })
|
||||||
|
SMTP?: ConnectionParametersOutput;
|
||||||
|
|
||||||
|
@Field(() => ConnectionParametersOutput, { nullable: true })
|
||||||
|
CALDAV?: ConnectionParametersOutput;
|
||||||
|
}
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||||
|
import { ImapSmtpCaldavValidatorModule } from 'src/engine/core-modules/imap-smtp-caldav-connection/services/imap-smtp-caldav-connection-validator.module';
|
||||||
|
import { MessageQueueModule } from 'src/engine/core-modules/message-queue/message-queue.module';
|
||||||
|
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||||
|
import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module';
|
||||||
|
import { ConnectedAccountModule } from 'src/modules/connected-account/connected-account.module';
|
||||||
|
import { IMAPAPIsModule } from 'src/modules/connected-account/imap-api/imap-apis.module';
|
||||||
|
import { MessagingIMAPDriverModule } from 'src/modules/messaging/message-import-manager/drivers/imap/messaging-imap-driver.module';
|
||||||
|
import { MessagingImportManagerModule } from 'src/modules/messaging/message-import-manager/messaging-import-manager.module';
|
||||||
|
|
||||||
|
import { ImapSmtpCaldavResolver } from './imap-smtp-caldav-connection.resolver';
|
||||||
|
|
||||||
|
import { ImapSmtpCaldavService } from './services/imap-smtp-caldav-connection.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ConnectedAccountModule,
|
||||||
|
MessagingIMAPDriverModule,
|
||||||
|
IMAPAPIsModule,
|
||||||
|
MessagingImportManagerModule,
|
||||||
|
MessageQueueModule,
|
||||||
|
TwentyORMModule,
|
||||||
|
FeatureFlagModule,
|
||||||
|
ImapSmtpCaldavValidatorModule,
|
||||||
|
PermissionsModule,
|
||||||
|
],
|
||||||
|
providers: [ImapSmtpCaldavResolver, ImapSmtpCaldavService],
|
||||||
|
exports: [ImapSmtpCaldavService],
|
||||||
|
})
|
||||||
|
export class ImapSmtpCaldavModule {}
|
||||||
@ -0,0 +1,139 @@
|
|||||||
|
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
|
||||||
|
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
|
||||||
|
|
||||||
|
import { ConnectedAccountProvider } from 'twenty-shared/types';
|
||||||
|
|
||||||
|
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
|
||||||
|
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||||
|
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||||
|
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
|
||||||
|
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 { 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';
|
||||||
|
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||||
|
import { SettingsPermissionsGuard } from 'src/engine/guards/settings-permissions.guard';
|
||||||
|
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||||
|
import { SettingPermissionType } from 'src/engine/metadata-modules/permissions/constants/setting-permission-type.constants';
|
||||||
|
import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
|
||||||
|
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
|
||||||
|
import { ImapSmtpCalDavAPIService } from 'src/modules/connected-account/services/imap-smtp-caldav-apis.service';
|
||||||
|
import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
|
||||||
|
|
||||||
|
@Resolver()
|
||||||
|
@UsePipes(ResolverValidationPipe)
|
||||||
|
@UseFilters(AuthGraphqlApiExceptionFilter, PermissionsGraphqlApiExceptionFilter)
|
||||||
|
@UseGuards(SettingsPermissionsGuard(SettingPermissionType.WORKSPACE))
|
||||||
|
export class ImapSmtpCaldavResolver {
|
||||||
|
constructor(
|
||||||
|
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
|
||||||
|
private readonly ImapSmtpCaldavConnectionService: ImapSmtpCaldavService,
|
||||||
|
private readonly imapSmtpCaldavApisService: ImapSmtpCalDavAPIService,
|
||||||
|
private readonly featureFlagService: FeatureFlagService,
|
||||||
|
private readonly mailConnectionValidatorService: ImapSmtpCaldavValidatorService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private async checkIfFeatureEnabled(
|
||||||
|
workspaceId: string,
|
||||||
|
accountType: AccountType,
|
||||||
|
): Promise<void> {
|
||||||
|
if (accountType.type === 'IMAP') {
|
||||||
|
const isImapEnabled = await this.featureFlagService.isFeatureEnabled(
|
||||||
|
FeatureFlagKey.IS_IMAP_ENABLED,
|
||||||
|
workspaceId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isImapEnabled) {
|
||||||
|
throw new UserInputError(
|
||||||
|
'IMAP feature is not enabled for this workspace',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accountType.type === 'SMTP') {
|
||||||
|
throw new UserInputError(
|
||||||
|
'SMTP feature is not enabled for this workspace',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accountType.type === 'CALDAV') {
|
||||||
|
throw new UserInputError(
|
||||||
|
'CALDAV feature is not enabled for this workspace',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Query(() => ConnectedImapSmtpCaldavAccount)
|
||||||
|
@UseGuards(WorkspaceAuthGuard)
|
||||||
|
async getConnectedImapSmtpCaldavAccount(
|
||||||
|
@Args('id') id: string,
|
||||||
|
@AuthWorkspace() workspace: Workspace,
|
||||||
|
): Promise<ConnectedImapSmtpCaldavAccount> {
|
||||||
|
const connectedAccountRepository =
|
||||||
|
await this.twentyORMGlobalManager.getRepositoryForWorkspace<ConnectedAccountWorkspaceEntity>(
|
||||||
|
workspace.id,
|
||||||
|
'connectedAccount',
|
||||||
|
);
|
||||||
|
|
||||||
|
const connectedAccount = await connectedAccountRepository.findOne({
|
||||||
|
where: { id, provider: ConnectedAccountProvider.IMAP_SMTP_CALDAV },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!connectedAccount) {
|
||||||
|
throw new UserInputError(
|
||||||
|
`Connected mail account with ID ${id} not found`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: connectedAccount.id,
|
||||||
|
handle: connectedAccount.handle,
|
||||||
|
provider: connectedAccount.provider,
|
||||||
|
connectionParameters: connectedAccount.connectionParameters,
|
||||||
|
accountOwnerId: connectedAccount.accountOwnerId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Mutation(() => ImapSmtpCaldavConnectionSuccess)
|
||||||
|
@UseGuards(WorkspaceAuthGuard)
|
||||||
|
async saveImapSmtpCaldav(
|
||||||
|
@Args('accountOwnerId') accountOwnerId: string,
|
||||||
|
@Args('handle') handle: string,
|
||||||
|
@Args('accountType') accountType: AccountType,
|
||||||
|
@Args('connectionParameters')
|
||||||
|
connectionParameters: ConnectionParameters,
|
||||||
|
@AuthWorkspace() workspace: Workspace,
|
||||||
|
@Args('id', { nullable: true }) id?: string,
|
||||||
|
): Promise<ImapSmtpCaldavConnectionSuccess> {
|
||||||
|
await this.checkIfFeatureEnabled(workspace.id, accountType);
|
||||||
|
|
||||||
|
const validatedParams =
|
||||||
|
this.mailConnectionValidatorService.validateProtocolConnectionParams(
|
||||||
|
connectionParameters,
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.ImapSmtpCaldavConnectionService.testImapSmtpCaldav(
|
||||||
|
validatedParams,
|
||||||
|
accountType.type,
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.imapSmtpCaldavApisService.setupConnectedAccount({
|
||||||
|
handle,
|
||||||
|
workspaceMemberId: accountOwnerId,
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
connectionParams: validatedParams,
|
||||||
|
accountType: accountType.type,
|
||||||
|
connectedAccountId: id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { ImapSmtpCaldavValidatorService } from './imap-smtp-caldav-connection-validator.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [ImapSmtpCaldavValidatorService],
|
||||||
|
exports: [ImapSmtpCaldavValidatorService],
|
||||||
|
})
|
||||||
|
export class ImapSmtpCaldavValidatorModule {}
|
||||||
@ -0,0 +1,41 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
|
||||||
|
import { ConnectionParameters } from 'src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
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().min(1, 'Username is required'),
|
||||||
|
password: z.string().min(1, 'Password is required'),
|
||||||
|
secure: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
validateProtocolConnectionParams(
|
||||||
|
params: ConnectionParameters,
|
||||||
|
): ConnectionParameters {
|
||||||
|
if (!params) {
|
||||||
|
throw new UserInputError('Protocol connection parameters are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return this.protocolConnectionSchema.parse(params);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
const errorMessages = error.errors
|
||||||
|
.map((err) => `${err.path.join('.')}: ${err.message}`)
|
||||||
|
.join(', ');
|
||||||
|
|
||||||
|
throw new UserInputError(
|
||||||
|
`Protocol connection validation failed: ${errorMessages}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new UserInputError('Protocol connection validation failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,129 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { ImapFlow } from 'imapflow';
|
||||||
|
import { ConnectedAccountProvider } from 'twenty-shared/types';
|
||||||
|
|
||||||
|
import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
|
||||||
|
import {
|
||||||
|
AccountType,
|
||||||
|
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 { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ImapSmtpCaldavService {
|
||||||
|
private readonly logger = new Logger(ImapSmtpCaldavService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async testImapConnection(params: ConnectionParameters): Promise<boolean> {
|
||||||
|
if (!params.host || !params.username || !params.password) {
|
||||||
|
throw new UserInputError('Missing required IMAP connection parameters');
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new ImapFlow({
|
||||||
|
host: params.host,
|
||||||
|
port: params.port,
|
||||||
|
secure: params.secure ?? true,
|
||||||
|
auth: {
|
||||||
|
user: params.username,
|
||||||
|
pass: params.password,
|
||||||
|
},
|
||||||
|
logger: false,
|
||||||
|
tls: {
|
||||||
|
rejectUnauthorized: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
const mailboxes = await client.list();
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`IMAP connection successful. Found ${mailboxes.length} mailboxes.`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`IMAP connection failed: ${error.message}`,
|
||||||
|
error.stack,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (error.authenticationFailed) {
|
||||||
|
throw new UserInputError(
|
||||||
|
'IMAP authentication failed. Please check your credentials.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.code === 'ECONNREFUSED') {
|
||||||
|
throw new UserInputError(
|
||||||
|
`IMAP connection refused. Please verify server and port.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new UserInputError(`IMAP connection failed: ${error.message}`);
|
||||||
|
} finally {
|
||||||
|
if (client.authenticated) {
|
||||||
|
await client.logout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async testSmtpConnection(params: ConnectionParameters): Promise<boolean> {
|
||||||
|
this.logger.log('SMTP connection testing not yet implemented', params);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async testCaldavConnection(params: ConnectionParameters): Promise<boolean> {
|
||||||
|
this.logger.log('CALDAV connection testing not yet implemented', params);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async testImapSmtpCaldav(
|
||||||
|
params: ConnectionParameters,
|
||||||
|
accountType: AccountType,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (accountType === 'IMAP') {
|
||||||
|
return this.testImapConnection(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accountType === 'SMTP') {
|
||||||
|
return this.testSmtpConnection(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accountType === 'CALDAV') {
|
||||||
|
return this.testCaldavConnection(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new UserInputError(
|
||||||
|
'Invalid account type. Must be one of: IMAP, SMTP, CALDAV',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getImapSmtpCaldav(
|
||||||
|
workspaceId: string,
|
||||||
|
connectionId: string,
|
||||||
|
): Promise<ConnectedAccountWorkspaceEntity | null> {
|
||||||
|
const connectedAccountRepository =
|
||||||
|
await this.twentyORMGlobalManager.getRepositoryForWorkspace<ConnectedAccountWorkspaceEntity>(
|
||||||
|
workspaceId,
|
||||||
|
'connectedAccount',
|
||||||
|
);
|
||||||
|
|
||||||
|
const connectedAccount = await connectedAccountRepository.findOne({
|
||||||
|
where: {
|
||||||
|
id: connectionId,
|
||||||
|
provider: ConnectedAccountProvider.IMAP_SMTP_CALDAV,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return connectedAccount;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
export type ConnectionParameters = {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
secure?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AccountType = 'IMAP' | 'SMTP' | 'CALDAV';
|
||||||
|
|
||||||
|
export type ImapSmtpCaldavParams = {
|
||||||
|
handle: string;
|
||||||
|
IMAP?: ConnectionParameters;
|
||||||
|
SMTP?: ConnectionParameters;
|
||||||
|
CALDAV?: ConnectionParameters;
|
||||||
|
};
|
||||||
@ -143,6 +143,13 @@ export class ConfigVariables {
|
|||||||
})
|
})
|
||||||
MESSAGING_PROVIDER_GMAIL_ENABLED = false;
|
MESSAGING_PROVIDER_GMAIL_ENABLED = false;
|
||||||
|
|
||||||
|
@ConfigVariablesMetadata({
|
||||||
|
group: ConfigVariablesGroup.Other,
|
||||||
|
description: 'Enable or disable the IMAP messaging integration',
|
||||||
|
type: ConfigVariableType.BOOLEAN,
|
||||||
|
})
|
||||||
|
MESSAGING_PROVIDER_IMAP_ENABLED = false;
|
||||||
|
|
||||||
@ConfigVariablesMetadata({
|
@ConfigVariablesMetadata({
|
||||||
group: ConfigVariablesGroup.MicrosoftAuth,
|
group: ConfigVariablesGroup.MicrosoftAuth,
|
||||||
description: 'Enable or disable Microsoft authentication',
|
description: 'Enable or disable Microsoft authentication',
|
||||||
|
|||||||
@ -40,6 +40,11 @@ export const seedFeatureFlags = async (
|
|||||||
workspaceId: workspaceId,
|
workspaceId: workspaceId,
|
||||||
value: true,
|
value: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: FeatureFlagKey.IS_IMAP_ENABLED,
|
||||||
|
workspaceId: workspaceId,
|
||||||
|
value: true,
|
||||||
|
},
|
||||||
])
|
])
|
||||||
.execute();
|
.execute();
|
||||||
};
|
};
|
||||||
|
|||||||
@ -155,6 +155,7 @@ export const CONNECTED_ACCOUNT_STANDARD_FIELD_IDS = {
|
|||||||
calendarChannels: '20202020-af4a-47bb-99ec-51911c1d3977',
|
calendarChannels: '20202020-af4a-47bb-99ec-51911c1d3977',
|
||||||
handleAliases: '20202020-8a3d-46be-814f-6228af16c47b',
|
handleAliases: '20202020-8a3d-46be-814f-6228af16c47b',
|
||||||
scopes: '20202020-8a3d-46be-814f-6228af16c47c',
|
scopes: '20202020-8a3d-46be-814f-6228af16c47c',
|
||||||
|
connectionParameters: '20202020-a1b2-46be-814f-6228af16c481',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EVENT_STANDARD_FIELD_IDS = {
|
export const EVENT_STANDARD_FIELD_IDS = {
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
import { assertUnreachable } from 'twenty-shared/utils';
|
|
||||||
import { ConnectedAccountProvider } from 'twenty-shared/types';
|
import { ConnectedAccountProvider } from 'twenty-shared/types';
|
||||||
|
import { assertUnreachable } from 'twenty-shared/utils';
|
||||||
|
|
||||||
import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
|
import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
|
||||||
import { GoogleEmailAliasManagerService } from 'src/modules/connected-account/email-alias-manager/drivers/google/google-email-alias-manager.service';
|
import { GoogleEmailAliasManagerService } from 'src/modules/connected-account/email-alias-manager/drivers/google/google-email-alias-manager.service';
|
||||||
@ -34,6 +34,10 @@ export class EmailAliasManagerService {
|
|||||||
connectedAccount,
|
connectedAccount,
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
case ConnectedAccountProvider.IMAP_SMTP_CALDAV:
|
||||||
|
// IMAP Protocol does not support email aliases
|
||||||
|
handleAliases = [];
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
assertUnreachable(
|
assertUnreachable(
|
||||||
connectedAccount.provider,
|
connectedAccount.provider,
|
||||||
|
|||||||
@ -0,0 +1,24 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
|
||||||
|
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||||
|
import { MessageQueueModule } from 'src/engine/core-modules/message-queue/message-queue.module';
|
||||||
|
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
|
||||||
|
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||||
|
import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module';
|
||||||
|
import { WorkspaceEventEmitterModule } from 'src/engine/workspace-event-emitter/workspace-event-emitter.module';
|
||||||
|
import { ImapSmtpCalDavAPIService } from 'src/modules/connected-account/services/imap-smtp-caldav-apis.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([ObjectMetadataEntity], 'core'),
|
||||||
|
MessageQueueModule,
|
||||||
|
WorkspaceEventEmitterModule,
|
||||||
|
TwentyConfigModule,
|
||||||
|
TwentyORMModule,
|
||||||
|
FeatureFlagModule,
|
||||||
|
],
|
||||||
|
providers: [ImapSmtpCalDavAPIService],
|
||||||
|
exports: [ImapSmtpCalDavAPIService],
|
||||||
|
})
|
||||||
|
export class IMAPAPIsModule {}
|
||||||
@ -85,6 +85,11 @@ export class ConnectedAccountRefreshTokensService {
|
|||||||
return await this.microsoftAPIRefreshAccessTokenService.refreshTokens(
|
return await this.microsoftAPIRefreshAccessTokenService.refreshTokens(
|
||||||
refreshToken,
|
refreshToken,
|
||||||
);
|
);
|
||||||
|
case ConnectedAccountProvider.IMAP_SMTP_CALDAV:
|
||||||
|
throw new ConnectedAccountRefreshAccessTokenException(
|
||||||
|
`Token refresh is not supported for IMAP provider for connected account ${connectedAccount.id} in workspace ${workspaceId}`,
|
||||||
|
ConnectedAccountRefreshAccessTokenExceptionCode.REFRESH_ACCESS_TOKEN_FAILED,
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
return assertUnreachable(
|
return assertUnreachable(
|
||||||
connectedAccount.provider,
|
connectedAccount.provider,
|
||||||
|
|||||||
@ -0,0 +1,248 @@
|
|||||||
|
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 { 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 { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
|
||||||
|
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
|
||||||
|
import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
|
||||||
|
import {
|
||||||
|
MessageChannelSyncStage,
|
||||||
|
MessageChannelSyncStatus,
|
||||||
|
MessageChannelType,
|
||||||
|
MessageChannelWorkspaceEntity,
|
||||||
|
} from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
|
||||||
|
import {
|
||||||
|
MessagingMessageListFetchJob,
|
||||||
|
MessagingMessageListFetchJobData,
|
||||||
|
} from 'src/modules/messaging/message-import-manager/jobs/messaging-message-list-fetch.job';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ImapSmtpCalDavAPIService {
|
||||||
|
constructor(
|
||||||
|
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
|
||||||
|
@InjectMessageQueue(MessageQueue.messagingQueue)
|
||||||
|
private readonly messageQueueService: MessageQueueService,
|
||||||
|
private readonly twentyConfigService: TwentyConfigService,
|
||||||
|
private readonly workspaceEventEmitter: WorkspaceEventEmitter,
|
||||||
|
@InjectRepository(ObjectMetadataEntity, 'core')
|
||||||
|
private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>,
|
||||||
|
private readonly featureFlagService: FeatureFlagService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async setupConnectedAccount(input: {
|
||||||
|
handle: string;
|
||||||
|
workspaceMemberId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
accountType: AccountType;
|
||||||
|
connectionParams: ConnectionParameters;
|
||||||
|
connectedAccountId?: string;
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
handle,
|
||||||
|
workspaceId,
|
||||||
|
workspaceMemberId,
|
||||||
|
connectionParams,
|
||||||
|
connectedAccountId,
|
||||||
|
} = input;
|
||||||
|
|
||||||
|
const connectedAccountRepository =
|
||||||
|
await this.twentyORMGlobalManager.getRepositoryForWorkspace<ConnectedAccountWorkspaceEntity>(
|
||||||
|
workspaceId,
|
||||||
|
'connectedAccount',
|
||||||
|
);
|
||||||
|
|
||||||
|
const connectedAccount = connectedAccountId
|
||||||
|
? await connectedAccountRepository.findOne({
|
||||||
|
where: { id: connectedAccountId },
|
||||||
|
})
|
||||||
|
: await connectedAccountRepository.findOne({
|
||||||
|
where: { handle, accountOwnerId: workspaceMemberId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const existingAccountId = connectedAccount?.id;
|
||||||
|
const newOrExistingConnectedAccountId =
|
||||||
|
existingAccountId ?? connectedAccountId ?? v4();
|
||||||
|
|
||||||
|
const messageChannelRepository =
|
||||||
|
await this.twentyORMGlobalManager.getRepositoryForWorkspace<MessageChannelWorkspaceEntity>(
|
||||||
|
workspaceId,
|
||||||
|
'messageChannel',
|
||||||
|
);
|
||||||
|
|
||||||
|
const workspaceDataSource =
|
||||||
|
await this.twentyORMGlobalManager.getDataSourceForWorkspace({
|
||||||
|
workspaceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
const connectedAccountMetadata =
|
||||||
|
await this.objectMetadataRepository.findOneOrFail({
|
||||||
|
where: { nameSingular: 'connectedAccount', workspaceId },
|
||||||
|
});
|
||||||
|
|
||||||
|
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,
|
||||||
|
syncStatus: MessageChannelSyncStatus.ONGOING,
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
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: null,
|
||||||
|
syncCursor: '',
|
||||||
|
syncStageStartedAt: null,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.twentyConfigService.get('MESSAGING_PROVIDER_IMAP_ENABLED')) {
|
||||||
|
const messageChannels = await messageChannelRepository.find({
|
||||||
|
where: {
|
||||||
|
connectedAccountId: newOrExistingConnectedAccountId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const messageChannel of messageChannels) {
|
||||||
|
await this.messageQueueService.add<MessagingMessageListFetchJobData>(
|
||||||
|
MessagingMessageListFetchJob.name,
|
||||||
|
{
|
||||||
|
workspaceId,
|
||||||
|
messageChannelId: messageChannel.id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8,6 +8,7 @@ import { RelationOnDeleteAction } from 'src/engine/metadata-modules/field-metada
|
|||||||
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
|
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
|
||||||
import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface';
|
import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface';
|
||||||
|
|
||||||
|
import { ImapSmtpCaldavParams } from 'src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type';
|
||||||
import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
|
import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
|
||||||
import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator';
|
import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator';
|
||||||
import { WorkspaceField } from 'src/engine/twenty-orm/decorators/workspace-field.decorator';
|
import { WorkspaceField } from 'src/engine/twenty-orm/decorators/workspace-field.decorator';
|
||||||
@ -107,6 +108,16 @@ export class ConnectedAccountWorkspaceEntity extends BaseWorkspaceEntity {
|
|||||||
@WorkspaceIsNullable()
|
@WorkspaceIsNullable()
|
||||||
scopes: string[] | null;
|
scopes: string[] | null;
|
||||||
|
|
||||||
|
@WorkspaceField({
|
||||||
|
standardId: CONNECTED_ACCOUNT_STANDARD_FIELD_IDS.connectionParameters,
|
||||||
|
type: FieldMetadataType.RAW_JSON,
|
||||||
|
label: msg`Custom Connection Parameters`,
|
||||||
|
description: msg`JSON object containing custom connection parameters`,
|
||||||
|
icon: 'IconSettings',
|
||||||
|
})
|
||||||
|
@WorkspaceIsNullable()
|
||||||
|
connectionParameters: ImapSmtpCaldavParams | null;
|
||||||
|
|
||||||
@WorkspaceRelation({
|
@WorkspaceRelation({
|
||||||
standardId: CONNECTED_ACCOUNT_STANDARD_FIELD_IDS.accountOwner,
|
standardId: CONNECTED_ACCOUNT_STANDARD_FIELD_IDS.accountOwner,
|
||||||
type: RelationType.MANY_TO_ONE,
|
type: RelationType.MANY_TO_ONE,
|
||||||
|
|||||||
@ -5,9 +5,9 @@ import planer from 'planer';
|
|||||||
import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
|
import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
|
||||||
import { computeMessageDirection } from 'src/modules/messaging/message-import-manager/drivers/gmail/utils/compute-message-direction.util';
|
import { computeMessageDirection } from 'src/modules/messaging/message-import-manager/drivers/gmail/utils/compute-message-direction.util';
|
||||||
import { parseGmailMessage } from 'src/modules/messaging/message-import-manager/drivers/gmail/utils/parse-gmail-message.util';
|
import { parseGmailMessage } from 'src/modules/messaging/message-import-manager/drivers/gmail/utils/parse-gmail-message.util';
|
||||||
import { sanitizeString } from 'src/modules/messaging/message-import-manager/drivers/gmail/utils/sanitize-string.util';
|
|
||||||
import { MessageWithParticipants } from 'src/modules/messaging/message-import-manager/types/message';
|
import { MessageWithParticipants } from 'src/modules/messaging/message-import-manager/types/message';
|
||||||
import { formatAddressObjectAsParticipants } from 'src/modules/messaging/message-import-manager/utils/format-address-object-as-participants.util';
|
import { formatAddressObjectAsParticipants } from 'src/modules/messaging/message-import-manager/utils/format-address-object-as-participants.util';
|
||||||
|
import { sanitizeString } from 'src/modules/messaging/message-import-manager/utils/sanitize-string.util';
|
||||||
|
|
||||||
export const parseAndFormatGmailMessage = (
|
export const parseAndFormatGmailMessage = (
|
||||||
message: gmailV1.Schema$Message,
|
message: gmailV1.Schema$Message,
|
||||||
|
|||||||
@ -0,0 +1,47 @@
|
|||||||
|
import { HttpModule } from '@nestjs/axios';
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
|
||||||
|
import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
|
||||||
|
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||||
|
import { ObjectMetadataRepositoryModule } from 'src/engine/object-metadata-repository/object-metadata-repository.module';
|
||||||
|
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
|
||||||
|
import { BlocklistWorkspaceEntity } from 'src/modules/blocklist/standard-objects/blocklist.workspace-entity';
|
||||||
|
import { EmailAliasManagerModule } from 'src/modules/connected-account/email-alias-manager/email-alias-manager.module';
|
||||||
|
import { MessagingCommonModule } from 'src/modules/messaging/common/messaging-common.module';
|
||||||
|
import { ImapClientProvider } from 'src/modules/messaging/message-import-manager/drivers/imap/providers/imap-client.provider';
|
||||||
|
import { ImapFetchByBatchService } from 'src/modules/messaging/message-import-manager/drivers/imap/services/imap-fetch-by-batch.service';
|
||||||
|
import { ImapGetMessageListService } from 'src/modules/messaging/message-import-manager/drivers/imap/services/imap-get-message-list.service';
|
||||||
|
import { ImapGetMessagesService } from 'src/modules/messaging/message-import-manager/drivers/imap/services/imap-get-messages.service';
|
||||||
|
import { ImapHandleErrorService } from 'src/modules/messaging/message-import-manager/drivers/imap/services/imap-handle-error.service';
|
||||||
|
import { ImapMessageLocatorService } from 'src/modules/messaging/message-import-manager/drivers/imap/services/imap-message-locator.service';
|
||||||
|
import { ImapMessageProcessorService } from 'src/modules/messaging/message-import-manager/drivers/imap/services/imap-message-processor.service';
|
||||||
|
import { MessageParticipantManagerModule } from 'src/modules/messaging/message-participant-manager/message-participant-manager.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
HttpModule,
|
||||||
|
ObjectMetadataRepositoryModule.forFeature([BlocklistWorkspaceEntity]),
|
||||||
|
MessagingCommonModule,
|
||||||
|
TypeOrmModule.forFeature([FeatureFlag], 'core'),
|
||||||
|
EmailAliasManagerModule,
|
||||||
|
FeatureFlagModule,
|
||||||
|
WorkspaceDataSourceModule,
|
||||||
|
MessageParticipantManagerModule,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
ImapClientProvider,
|
||||||
|
ImapFetchByBatchService,
|
||||||
|
ImapGetMessagesService,
|
||||||
|
ImapGetMessageListService,
|
||||||
|
ImapHandleErrorService,
|
||||||
|
ImapMessageLocatorService,
|
||||||
|
ImapMessageProcessorService,
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
ImapGetMessagesService,
|
||||||
|
ImapGetMessageListService,
|
||||||
|
ImapClientProvider,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class MessagingIMAPDriverModule {}
|
||||||
@ -0,0 +1,110 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { ImapFlow } from 'imapflow';
|
||||||
|
import { ConnectedAccountProvider } from 'twenty-shared/types';
|
||||||
|
import { isDefined } from 'twenty-shared/utils';
|
||||||
|
|
||||||
|
import { ImapSmtpCaldavParams } from 'src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type';
|
||||||
|
import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
|
||||||
|
|
||||||
|
interface ImapClientInstance {
|
||||||
|
client: ImapFlow;
|
||||||
|
isReady: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ImapClientProvider {
|
||||||
|
private readonly logger = new Logger(ImapClientProvider.name);
|
||||||
|
private readonly clientInstances = new Map<string, ImapClientInstance>();
|
||||||
|
|
||||||
|
constructor() {}
|
||||||
|
|
||||||
|
async getClient(
|
||||||
|
connectedAccount: Pick<
|
||||||
|
ConnectedAccountWorkspaceEntity,
|
||||||
|
'id' | 'provider' | 'connectionParameters' | 'handle'
|
||||||
|
>,
|
||||||
|
): Promise<ImapFlow> {
|
||||||
|
const cacheKey = `${connectedAccount.id}`;
|
||||||
|
|
||||||
|
if (this.clientInstances.has(cacheKey)) {
|
||||||
|
const instance = this.clientInstances.get(cacheKey);
|
||||||
|
|
||||||
|
if (instance?.isReady) {
|
||||||
|
return instance.client;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
connectedAccount.provider !== ConnectedAccountProvider.IMAP_SMTP_CALDAV ||
|
||||||
|
!isDefined(connectedAccount.connectionParameters?.IMAP)
|
||||||
|
) {
|
||||||
|
throw new Error('Connected account is not an IMAP provider');
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectionParameters: ImapSmtpCaldavParams =
|
||||||
|
(connectedAccount.connectionParameters as unknown as ImapSmtpCaldavParams) ||
|
||||||
|
{};
|
||||||
|
|
||||||
|
const client = new ImapFlow({
|
||||||
|
host: connectionParameters.IMAP?.host || '',
|
||||||
|
port: connectionParameters.IMAP?.port || 993,
|
||||||
|
secure: connectionParameters.IMAP?.secure,
|
||||||
|
auth: {
|
||||||
|
user: connectedAccount.handle,
|
||||||
|
pass: connectionParameters.IMAP?.password || '',
|
||||||
|
},
|
||||||
|
logger: false,
|
||||||
|
tls: {
|
||||||
|
rejectUnauthorized: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Connected to IMAP server for ${connectionParameters.handle}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mailboxes = await client.list();
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Available mailboxes for ${connectionParameters.handle}: ${mailboxes.map((m) => m.path).join(', ')}`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`Failed to list mailboxes: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.clientInstances.set(cacheKey, {
|
||||||
|
client,
|
||||||
|
isReady: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return client;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to connect to IMAP server: ${error.message}`,
|
||||||
|
error.stack,
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async closeClient(connectedAccountId: string): Promise<void> {
|
||||||
|
const cacheKey = `${connectedAccountId}`;
|
||||||
|
const instance = this.clientInstances.get(cacheKey);
|
||||||
|
|
||||||
|
if (instance?.isReady) {
|
||||||
|
try {
|
||||||
|
await instance.client.logout();
|
||||||
|
this.logger.log('Closed IMAP client');
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Error closing IMAP client: ${error.message}`);
|
||||||
|
} finally {
|
||||||
|
this.clientInstances.delete(cacheKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,147 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { ImapFlow } from 'imapflow';
|
||||||
|
|
||||||
|
import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
|
||||||
|
import { ImapClientProvider } from 'src/modules/messaging/message-import-manager/drivers/imap/providers/imap-client.provider';
|
||||||
|
import {
|
||||||
|
ImapMessageLocatorService,
|
||||||
|
MessageLocation,
|
||||||
|
} from 'src/modules/messaging/message-import-manager/drivers/imap/services/imap-message-locator.service';
|
||||||
|
import {
|
||||||
|
ImapMessageProcessorService,
|
||||||
|
MessageFetchResult,
|
||||||
|
} from 'src/modules/messaging/message-import-manager/drivers/imap/services/imap-message-processor.service';
|
||||||
|
|
||||||
|
type ConnectedAccount = Pick<
|
||||||
|
ConnectedAccountWorkspaceEntity,
|
||||||
|
'id' | 'provider' | 'handle' | 'handleAliases' | 'connectionParameters'
|
||||||
|
>;
|
||||||
|
|
||||||
|
type FetchAllResult = {
|
||||||
|
messageIdsByBatch: string[][];
|
||||||
|
batchResults: MessageFetchResult[][];
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ImapFetchByBatchService {
|
||||||
|
private readonly logger = new Logger(ImapFetchByBatchService.name);
|
||||||
|
|
||||||
|
private static readonly RETRY_ATTEMPTS = 2;
|
||||||
|
private static readonly RETRY_DELAY_MS = 1000;
|
||||||
|
private static readonly BATCH_LIMIT = 20;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly imapClientProvider: ImapClientProvider,
|
||||||
|
private readonly imapMessageLocatorService: ImapMessageLocatorService,
|
||||||
|
private readonly imapMessageProcessorService: ImapMessageProcessorService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async fetchAllByBatches(
|
||||||
|
messageIds: string[],
|
||||||
|
connectedAccount: ConnectedAccount,
|
||||||
|
): Promise<FetchAllResult> {
|
||||||
|
const batchResults: MessageFetchResult[][] = [];
|
||||||
|
const messageIdsByBatch: string[][] = [];
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Starting optimized batch fetch for ${messageIds.length} messages`,
|
||||||
|
);
|
||||||
|
|
||||||
|
let client: ImapFlow | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
client = await this.imapClientProvider.getClient(connectedAccount);
|
||||||
|
|
||||||
|
const messageLocations =
|
||||||
|
await this.imapMessageLocatorService.locateAllMessages(
|
||||||
|
messageIds,
|
||||||
|
client,
|
||||||
|
);
|
||||||
|
|
||||||
|
const batches = this.chunkArray(
|
||||||
|
messageIds,
|
||||||
|
ImapFetchByBatchService.BATCH_LIMIT,
|
||||||
|
);
|
||||||
|
|
||||||
|
let processedCount = 0;
|
||||||
|
|
||||||
|
for (const batch of batches) {
|
||||||
|
const batchResult = await this.fetchBatchWithRetry(
|
||||||
|
batch,
|
||||||
|
messageLocations,
|
||||||
|
client,
|
||||||
|
);
|
||||||
|
|
||||||
|
batchResults.push(batchResult);
|
||||||
|
messageIdsByBatch.push(batch);
|
||||||
|
|
||||||
|
processedCount += batch.length;
|
||||||
|
this.logger.log(
|
||||||
|
`Fetched ${processedCount}/${messageIds.length} messages`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { messageIdsByBatch, batchResults };
|
||||||
|
} finally {
|
||||||
|
if (client) {
|
||||||
|
await this.imapClientProvider.closeClient(connectedAccount.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchBatchWithRetry(
|
||||||
|
messageIds: string[],
|
||||||
|
messageLocations: Map<string, MessageLocation>,
|
||||||
|
client: ImapFlow,
|
||||||
|
attempt = 1,
|
||||||
|
): Promise<MessageFetchResult[]> {
|
||||||
|
try {
|
||||||
|
return await this.imapMessageProcessorService.processMessagesByIds(
|
||||||
|
messageIds,
|
||||||
|
messageLocations,
|
||||||
|
client,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (attempt < ImapFetchByBatchService.RETRY_ATTEMPTS) {
|
||||||
|
const delay = ImapFetchByBatchService.RETRY_DELAY_MS * attempt;
|
||||||
|
|
||||||
|
this.logger.warn(
|
||||||
|
`Batch fetch attempt ${attempt} failed, retrying in ${delay}ms: ${error.message}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.delay(delay);
|
||||||
|
|
||||||
|
return this.fetchBatchWithRetry(
|
||||||
|
messageIds,
|
||||||
|
messageLocations,
|
||||||
|
client,
|
||||||
|
attempt + 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.error(
|
||||||
|
`Batch fetch failed after ${ImapFetchByBatchService.RETRY_ATTEMPTS} attempts: ${error.message}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.imapMessageProcessorService.createErrorResults(
|
||||||
|
messageIds,
|
||||||
|
error as Error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private chunkArray<T>(array: T[], chunkSize: number): T[][] {
|
||||||
|
const chunks: T[][] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < array.length; i += chunkSize) {
|
||||||
|
chunks.push(array.slice(i, i + chunkSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
private delay(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,201 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { ImapFlow } from 'imapflow';
|
||||||
|
|
||||||
|
import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
|
||||||
|
import { ImapClientProvider } from 'src/modules/messaging/message-import-manager/drivers/imap/providers/imap-client.provider';
|
||||||
|
import { ImapHandleErrorService } from 'src/modules/messaging/message-import-manager/drivers/imap/services/imap-handle-error.service';
|
||||||
|
import { findSentMailbox } from 'src/modules/messaging/message-import-manager/drivers/imap/utils/find-sent-mailbox.util';
|
||||||
|
import { GetFullMessageListResponse } from 'src/modules/messaging/message-import-manager/services/messaging-get-message-list.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ImapGetMessageListService {
|
||||||
|
private readonly logger = new Logger(ImapGetMessageListService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly imapClientProvider: ImapClientProvider,
|
||||||
|
private readonly imapHandleErrorService: ImapHandleErrorService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getFullMessageList(
|
||||||
|
connectedAccount: Pick<
|
||||||
|
ConnectedAccountWorkspaceEntity,
|
||||||
|
'id' | 'provider' | 'connectionParameters' | 'handle'
|
||||||
|
>,
|
||||||
|
): Promise<GetFullMessageListResponse> {
|
||||||
|
try {
|
||||||
|
const client = await this.imapClientProvider.getClient(connectedAccount);
|
||||||
|
|
||||||
|
const mailboxes = ['INBOX'];
|
||||||
|
|
||||||
|
const sentFolder = await findSentMailbox(client, this.logger);
|
||||||
|
|
||||||
|
if (sentFolder) {
|
||||||
|
mailboxes.push(sentFolder);
|
||||||
|
}
|
||||||
|
|
||||||
|
let allMessages: { id: string; date: string }[] = [];
|
||||||
|
|
||||||
|
for (const mailbox of mailboxes) {
|
||||||
|
try {
|
||||||
|
const messages = await this.getMessagesFromMailbox(client, mailbox);
|
||||||
|
|
||||||
|
allMessages = [...allMessages, ...messages];
|
||||||
|
this.logger.log(
|
||||||
|
`Fetched ${messages.length} messages from ${mailbox}`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Error fetching from mailbox ${mailbox}: ${error.message}. Continuing with other mailboxes.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
allMessages.sort(
|
||||||
|
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const messageExternalIds = allMessages.map((message) => message.id);
|
||||||
|
|
||||||
|
const nextSyncCursor =
|
||||||
|
allMessages.length > 0 ? allMessages[allMessages.length - 1].date : '';
|
||||||
|
|
||||||
|
return {
|
||||||
|
messageExternalIds,
|
||||||
|
nextSyncCursor,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Error getting message list: ${error.message}`,
|
||||||
|
error.stack,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.imapHandleErrorService.handleImapMessageListFetchError(error);
|
||||||
|
|
||||||
|
return { messageExternalIds: [], nextSyncCursor: '' };
|
||||||
|
} finally {
|
||||||
|
await this.imapClientProvider.closeClient(connectedAccount.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPartialMessageList(
|
||||||
|
connectedAccount: Pick<
|
||||||
|
ConnectedAccountWorkspaceEntity,
|
||||||
|
'id' | 'provider' | 'connectionParameters' | 'handle'
|
||||||
|
>,
|
||||||
|
syncCursor?: string,
|
||||||
|
): Promise<{ messageExternalIds: string[]; nextSyncCursor: string }> {
|
||||||
|
try {
|
||||||
|
const client = await this.imapClientProvider.getClient(connectedAccount);
|
||||||
|
|
||||||
|
const mailboxes = ['INBOX'];
|
||||||
|
|
||||||
|
const sentFolder = await findSentMailbox(client, this.logger);
|
||||||
|
|
||||||
|
if (sentFolder) {
|
||||||
|
mailboxes.push(sentFolder);
|
||||||
|
}
|
||||||
|
|
||||||
|
let allMessages: { id: string; date: string }[] = [];
|
||||||
|
|
||||||
|
for (const mailbox of mailboxes) {
|
||||||
|
try {
|
||||||
|
const messages = await this.getMessagesFromMailbox(
|
||||||
|
client,
|
||||||
|
mailbox,
|
||||||
|
syncCursor,
|
||||||
|
);
|
||||||
|
|
||||||
|
allMessages = [...allMessages, ...messages];
|
||||||
|
this.logger.log(
|
||||||
|
`Fetched ${messages.length} messages from ${mailbox}`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Error fetching from mailbox ${mailbox}: ${error.message}. Continuing with other mailboxes.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
allMessages.sort(
|
||||||
|
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const messageExternalIds = allMessages.map((message) => message.id);
|
||||||
|
|
||||||
|
const nextSyncCursor =
|
||||||
|
allMessages.length > 0
|
||||||
|
? allMessages[allMessages.length - 1].date
|
||||||
|
: syncCursor || '';
|
||||||
|
|
||||||
|
return {
|
||||||
|
messageExternalIds,
|
||||||
|
nextSyncCursor,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Error getting message list: ${error.message}`,
|
||||||
|
error.stack,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.imapHandleErrorService.handleImapMessageListFetchError(error);
|
||||||
|
|
||||||
|
return { messageExternalIds: [], nextSyncCursor: syncCursor || '' };
|
||||||
|
} finally {
|
||||||
|
await this.imapClientProvider.closeClient(connectedAccount.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getMessagesFromMailbox(
|
||||||
|
client: ImapFlow,
|
||||||
|
mailbox: string,
|
||||||
|
cursor?: string,
|
||||||
|
): Promise<{ id: string; date: string }[]> {
|
||||||
|
let lock;
|
||||||
|
|
||||||
|
try {
|
||||||
|
lock = await client.getMailboxLock(mailbox);
|
||||||
|
|
||||||
|
let searchOptions = {};
|
||||||
|
|
||||||
|
if (cursor) {
|
||||||
|
searchOptions = {
|
||||||
|
since: new Date(cursor),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages: { id: string; date: string }[] = [];
|
||||||
|
|
||||||
|
for await (const message of client.fetch(searchOptions, {
|
||||||
|
envelope: true,
|
||||||
|
})) {
|
||||||
|
if (message.envelope?.messageId) {
|
||||||
|
const messageDate = message.envelope.date
|
||||||
|
? new Date(message.envelope.date)
|
||||||
|
: new Date();
|
||||||
|
const validDate = isNaN(messageDate.getTime())
|
||||||
|
? new Date()
|
||||||
|
: messageDate;
|
||||||
|
|
||||||
|
messages.push({
|
||||||
|
id: message.envelope.messageId,
|
||||||
|
date: validDate.toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Error fetching from mailbox ${mailbox}: ${error.message}`,
|
||||||
|
error.stack,
|
||||||
|
);
|
||||||
|
|
||||||
|
return [];
|
||||||
|
} finally {
|
||||||
|
if (lock) {
|
||||||
|
lock.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,218 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { AddressObject, ParsedMail } from 'mailparser';
|
||||||
|
// @ts-expect-error legacy noImplicitAny
|
||||||
|
import planer from 'planer';
|
||||||
|
import { isDefined } from 'twenty-shared/utils';
|
||||||
|
|
||||||
|
import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
|
||||||
|
import { computeMessageDirection } from 'src/modules/messaging/message-import-manager/drivers/gmail/utils/compute-message-direction.util';
|
||||||
|
import { ImapFetchByBatchService } from 'src/modules/messaging/message-import-manager/drivers/imap/services/imap-fetch-by-batch.service';
|
||||||
|
import { MessageFetchResult } from 'src/modules/messaging/message-import-manager/drivers/imap/services/imap-message-processor.service';
|
||||||
|
import { EmailAddress } from 'src/modules/messaging/message-import-manager/types/email-address';
|
||||||
|
import { MessageWithParticipants } from 'src/modules/messaging/message-import-manager/types/message';
|
||||||
|
import { formatAddressObjectAsParticipants } from 'src/modules/messaging/message-import-manager/utils/format-address-object-as-participants.util';
|
||||||
|
import { sanitizeString } from 'src/modules/messaging/message-import-manager/utils/sanitize-string.util';
|
||||||
|
|
||||||
|
type AddressType = 'from' | 'to' | 'cc' | 'bcc';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ImapGetMessagesService {
|
||||||
|
private readonly logger = new Logger(ImapGetMessagesService.name);
|
||||||
|
|
||||||
|
constructor(private readonly fetchByBatchService: ImapFetchByBatchService) {}
|
||||||
|
|
||||||
|
async getMessages(
|
||||||
|
messageIds: string[],
|
||||||
|
connectedAccount: Pick<
|
||||||
|
ConnectedAccountWorkspaceEntity,
|
||||||
|
'id' | 'provider' | 'handle' | 'handleAliases' | 'connectionParameters'
|
||||||
|
>,
|
||||||
|
): Promise<MessageWithParticipants[]> {
|
||||||
|
if (!messageIds.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { messageIdsByBatch, batchResults } =
|
||||||
|
await this.fetchByBatchService.fetchAllByBatches(
|
||||||
|
messageIds,
|
||||||
|
connectedAccount,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(`IMAP fetch completed`);
|
||||||
|
|
||||||
|
const messages = batchResults.flatMap((batchResult, index) => {
|
||||||
|
return this.formatBatchResultAsMessages(
|
||||||
|
messageIdsByBatch[index],
|
||||||
|
batchResult,
|
||||||
|
connectedAccount,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatBatchResultAsMessages(
|
||||||
|
messageIds: string[],
|
||||||
|
batchResults: MessageFetchResult[],
|
||||||
|
connectedAccount: Pick<
|
||||||
|
ConnectedAccountWorkspaceEntity,
|
||||||
|
'handle' | 'handleAliases'
|
||||||
|
>,
|
||||||
|
): MessageWithParticipants[] {
|
||||||
|
const messages = batchResults.map((result) => {
|
||||||
|
if (!result.parsed) {
|
||||||
|
this.logger.debug(
|
||||||
|
`Message ${result.messageId} could not be parsed - likely not found in current mailboxes`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.createMessageFromParsedMail(
|
||||||
|
result.parsed,
|
||||||
|
result.messageId,
|
||||||
|
connectedAccount,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return messages.filter(isDefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
private createMessageFromParsedMail(
|
||||||
|
parsed: ParsedMail,
|
||||||
|
messageId: string,
|
||||||
|
connectedAccount: Pick<
|
||||||
|
ConnectedAccountWorkspaceEntity,
|
||||||
|
'handle' | 'handleAliases'
|
||||||
|
>,
|
||||||
|
): MessageWithParticipants {
|
||||||
|
const participants = this.extractAllParticipants(parsed);
|
||||||
|
const attachments = this.extractAttachments(parsed);
|
||||||
|
const threadId = this.extractThreadId(parsed);
|
||||||
|
|
||||||
|
const fromAddresses = this.extractAddresses(
|
||||||
|
parsed.from as AddressObject | undefined,
|
||||||
|
'from',
|
||||||
|
);
|
||||||
|
|
||||||
|
const fromHandle = fromAddresses.length > 0 ? fromAddresses[0].address : '';
|
||||||
|
|
||||||
|
const textWithoutReplyQuotations = parsed.text
|
||||||
|
? planer.extractFrom(parsed.text, 'text/plain')
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const direction = computeMessageDirection(fromHandle, connectedAccount);
|
||||||
|
const text = sanitizeString(textWithoutReplyQuotations);
|
||||||
|
|
||||||
|
return {
|
||||||
|
externalId: messageId,
|
||||||
|
messageThreadExternalId: threadId || messageId,
|
||||||
|
headerMessageId: parsed.messageId || messageId,
|
||||||
|
subject: parsed.subject || '',
|
||||||
|
text: text,
|
||||||
|
receivedAt: parsed.date || new Date(),
|
||||||
|
direction: direction,
|
||||||
|
attachments,
|
||||||
|
participants,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractThreadId(parsed: ParsedMail): string | null {
|
||||||
|
const { messageId, references, inReplyTo } = parsed;
|
||||||
|
|
||||||
|
if (references && Array.isArray(references) && references.length > 0) {
|
||||||
|
const threadRoot = references[0].trim();
|
||||||
|
|
||||||
|
if (threadRoot && threadRoot.length > 0) {
|
||||||
|
return this.normalizeMessageId(threadRoot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inReplyTo) {
|
||||||
|
const cleanInReplyTo =
|
||||||
|
typeof inReplyTo === 'string'
|
||||||
|
? inReplyTo.trim()
|
||||||
|
: String(inReplyTo).trim();
|
||||||
|
|
||||||
|
if (cleanInReplyTo && cleanInReplyTo.length > 0) {
|
||||||
|
return this.normalizeMessageId(cleanInReplyTo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (messageId) {
|
||||||
|
return this.normalizeMessageId(messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const randomSuffix = Math.random().toString(36).substring(2, 11);
|
||||||
|
|
||||||
|
return `thread-${timestamp}-${randomSuffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeMessageId(messageId: string): string {
|
||||||
|
const trimmedMessageId = messageId.trim();
|
||||||
|
|
||||||
|
if (
|
||||||
|
trimmedMessageId.includes('@') &&
|
||||||
|
!trimmedMessageId.startsWith('<') &&
|
||||||
|
!trimmedMessageId.endsWith('>')
|
||||||
|
) {
|
||||||
|
return `<${trimmedMessageId}>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmedMessageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractAllParticipants(parsed: ParsedMail) {
|
||||||
|
const fromAddresses = this.extractAddresses(
|
||||||
|
parsed.from as AddressObject | undefined,
|
||||||
|
'from',
|
||||||
|
);
|
||||||
|
const toAddresses = this.extractAddresses(
|
||||||
|
parsed.to as AddressObject | undefined,
|
||||||
|
'to',
|
||||||
|
);
|
||||||
|
const ccAddresses = this.extractAddresses(
|
||||||
|
parsed.cc as AddressObject | undefined,
|
||||||
|
'cc',
|
||||||
|
);
|
||||||
|
const bccAddresses = this.extractAddresses(
|
||||||
|
parsed.bcc as AddressObject | undefined,
|
||||||
|
'bcc',
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
...formatAddressObjectAsParticipants(fromAddresses, 'from'),
|
||||||
|
...formatAddressObjectAsParticipants(toAddresses, 'to'),
|
||||||
|
...formatAddressObjectAsParticipants(ccAddresses, 'cc'),
|
||||||
|
...formatAddressObjectAsParticipants(bccAddresses, 'bcc'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractAddresses(
|
||||||
|
addressObject: AddressObject | undefined,
|
||||||
|
_type: AddressType,
|
||||||
|
): EmailAddress[] {
|
||||||
|
const addresses: EmailAddress[] = [];
|
||||||
|
|
||||||
|
if (addressObject && 'value' in addressObject) {
|
||||||
|
for (const addr of addressObject.value) {
|
||||||
|
if (addr.address) {
|
||||||
|
addresses.push({
|
||||||
|
address: addr.address,
|
||||||
|
name: addr.name || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return addresses;
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractAttachments(parsed: ParsedMail) {
|
||||||
|
return (parsed.attachments || []).map((attachment) => ({
|
||||||
|
filename: attachment.filename || 'unnamed-attachment',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,107 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
|
||||||
|
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||||
|
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
|
||||||
|
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
|
||||||
|
import {
|
||||||
|
MessageChannelSyncStatus,
|
||||||
|
MessageChannelWorkspaceEntity,
|
||||||
|
} from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
|
||||||
|
import { parseImapError } from 'src/modules/messaging/message-import-manager/drivers/imap/utils/parse-imap-error.util';
|
||||||
|
import { parseImapMessageListFetchError } from 'src/modules/messaging/message-import-manager/drivers/imap/utils/parse-imap-message-list-fetch-error.util';
|
||||||
|
import { parseImapMessagesImportError } from 'src/modules/messaging/message-import-manager/drivers/imap/utils/parse-imap-messages-import-error.util';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ImapHandleErrorService {
|
||||||
|
private readonly logger = new Logger(ImapHandleErrorService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
|
||||||
|
private readonly workspaceEventEmitter: WorkspaceEventEmitter,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async handleError(
|
||||||
|
error: Error,
|
||||||
|
workspaceId: string,
|
||||||
|
messageChannelId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
this.logger.error(
|
||||||
|
`IMAP error for message channel ${messageChannelId}: ${error.message}`,
|
||||||
|
error.stack,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const messageChannelRepository =
|
||||||
|
await this.twentyORMGlobalManager.getRepositoryForWorkspace<MessageChannelWorkspaceEntity>(
|
||||||
|
workspaceId,
|
||||||
|
'messageChannel',
|
||||||
|
);
|
||||||
|
|
||||||
|
const messageChannel = await messageChannelRepository.findOneOrFail({
|
||||||
|
where: { id: messageChannelId },
|
||||||
|
});
|
||||||
|
|
||||||
|
await messageChannelRepository.update(
|
||||||
|
{ id: messageChannelId },
|
||||||
|
{
|
||||||
|
syncStatus: MessageChannelSyncStatus.FAILED_UNKNOWN,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const dataSource =
|
||||||
|
await this.twentyORMGlobalManager.getDataSourceForWorkspace({
|
||||||
|
workspaceId,
|
||||||
|
});
|
||||||
|
const messageChannelMetadata = await dataSource
|
||||||
|
.getRepository(ObjectMetadataEntity)
|
||||||
|
.findOneOrFail({
|
||||||
|
where: { nameSingular: 'messageChannel', workspaceId },
|
||||||
|
});
|
||||||
|
|
||||||
|
this.workspaceEventEmitter.emitDatabaseBatchEvent({
|
||||||
|
objectMetadataNameSingular: 'messageChannel',
|
||||||
|
action: DatabaseEventAction.UPDATED,
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
recordId: messageChannelId,
|
||||||
|
objectMetadata: messageChannelMetadata,
|
||||||
|
properties: {
|
||||||
|
before: { syncStatus: messageChannel.syncStatus },
|
||||||
|
after: { syncStatus: MessageChannelSyncStatus.FAILED_UNKNOWN },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
workspaceId,
|
||||||
|
});
|
||||||
|
} catch (handleErrorError) {
|
||||||
|
this.logger.error(
|
||||||
|
`Error handling IMAP error: ${handleErrorError.message}`,
|
||||||
|
handleErrorError.stack,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public handleImapMessageListFetchError(error: Error): void {
|
||||||
|
const imapError = parseImapError(error);
|
||||||
|
|
||||||
|
if (imapError) {
|
||||||
|
throw imapError;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw parseImapMessageListFetchError(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
public handleImapMessagesImportError(
|
||||||
|
error: Error,
|
||||||
|
messageExternalId: string,
|
||||||
|
): void {
|
||||||
|
const imapError = parseImapError(error);
|
||||||
|
|
||||||
|
if (imapError) {
|
||||||
|
throw imapError;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw parseImapMessagesImportError(error, messageExternalId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,112 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { ImapFlow } from 'imapflow';
|
||||||
|
|
||||||
|
import { findSentMailbox } from 'src/modules/messaging/message-import-manager/drivers/imap/utils/find-sent-mailbox.util';
|
||||||
|
|
||||||
|
export type MessageLocation = {
|
||||||
|
messageId: string;
|
||||||
|
sequence: number;
|
||||||
|
mailbox: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ImapMessageLocatorService {
|
||||||
|
private readonly logger = new Logger(ImapMessageLocatorService.name);
|
||||||
|
|
||||||
|
private static readonly IMAP_SEARCH_BATCH_SIZE = 50;
|
||||||
|
|
||||||
|
async locateAllMessages(
|
||||||
|
messageIds: string[],
|
||||||
|
client: ImapFlow,
|
||||||
|
): Promise<Map<string, MessageLocation>> {
|
||||||
|
const locations = new Map<string, MessageLocation>();
|
||||||
|
const mailboxes = await this.getMailboxesToSearch(client);
|
||||||
|
|
||||||
|
for (const mailbox of mailboxes) {
|
||||||
|
try {
|
||||||
|
const lock = await client.getMailboxLock(mailbox);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const searchBatches = this.chunkArray(
|
||||||
|
messageIds.filter((id) => !locations.has(id)),
|
||||||
|
ImapMessageLocatorService.IMAP_SEARCH_BATCH_SIZE,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const batch of searchBatches) {
|
||||||
|
await this.locateMessagesInMailbox(
|
||||||
|
batch,
|
||||||
|
mailbox,
|
||||||
|
client,
|
||||||
|
locations,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
lock.release();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Error searching mailbox ${mailbox}: ${error.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return locations;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async locateMessagesInMailbox(
|
||||||
|
messageIds: string[],
|
||||||
|
mailbox: string,
|
||||||
|
client: ImapFlow,
|
||||||
|
locations: Map<string, MessageLocation>,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const orConditions = messageIds.map((id) => ({
|
||||||
|
header: { 'message-id': id },
|
||||||
|
}));
|
||||||
|
const searchResults = await client.search({ or: orConditions });
|
||||||
|
|
||||||
|
if (searchResults.length === 0) return;
|
||||||
|
|
||||||
|
const fetchResults = client.fetch(
|
||||||
|
searchResults.map((r) => r.toString()).join(','),
|
||||||
|
{ envelope: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
for await (const message of fetchResults) {
|
||||||
|
const messageId = message.envelope?.messageId;
|
||||||
|
|
||||||
|
if (messageId && messageIds.includes(messageId)) {
|
||||||
|
locations.set(messageId, {
|
||||||
|
messageId,
|
||||||
|
sequence: message.seq,
|
||||||
|
mailbox,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.debug(`Batch search failed in ${mailbox}: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getMailboxesToSearch(client: ImapFlow): Promise<string[]> {
|
||||||
|
const mailboxes = ['INBOX'];
|
||||||
|
const sentFolder = await findSentMailbox(client, this.logger);
|
||||||
|
|
||||||
|
if (sentFolder) {
|
||||||
|
mailboxes.push(sentFolder);
|
||||||
|
}
|
||||||
|
|
||||||
|
return mailboxes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private chunkArray<T>(array: T[], chunkSize: number): T[][] {
|
||||||
|
const chunks: T[][] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < array.length; i += chunkSize) {
|
||||||
|
chunks.push(array.slice(i, i + chunkSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,238 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { FetchMessageObject, ImapFlow } from 'imapflow';
|
||||||
|
import { ParsedMail, simpleParser } from 'mailparser';
|
||||||
|
|
||||||
|
import { ImapHandleErrorService } from 'src/modules/messaging/message-import-manager/drivers/imap/services/imap-handle-error.service';
|
||||||
|
import { MessageLocation } from 'src/modules/messaging/message-import-manager/drivers/imap/services/imap-message-locator.service';
|
||||||
|
|
||||||
|
export type MessageFetchResult = {
|
||||||
|
messageId: string;
|
||||||
|
parsed: ParsedMail | null;
|
||||||
|
processingTimeMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ImapMessageProcessorService {
|
||||||
|
private readonly logger = new Logger(ImapMessageProcessorService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly imapHandleErrorService: ImapHandleErrorService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async processMessagesByIds(
|
||||||
|
messageIds: string[],
|
||||||
|
messageLocations: Map<string, MessageLocation>,
|
||||||
|
client: ImapFlow,
|
||||||
|
): Promise<MessageFetchResult[]> {
|
||||||
|
if (!messageIds.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: MessageFetchResult[] = [];
|
||||||
|
|
||||||
|
const messagesByMailbox = new Map<string, MessageLocation[]>();
|
||||||
|
const notFoundIds: string[] = [];
|
||||||
|
|
||||||
|
for (const messageId of messageIds) {
|
||||||
|
const location = messageLocations.get(messageId);
|
||||||
|
|
||||||
|
if (location) {
|
||||||
|
const locations = messagesByMailbox.get(location.mailbox) || [];
|
||||||
|
|
||||||
|
locations.push(location);
|
||||||
|
messagesByMailbox.set(location.mailbox, locations);
|
||||||
|
} else {
|
||||||
|
notFoundIds.push(messageId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchPromises = Array.from(messagesByMailbox.entries()).map(
|
||||||
|
([mailbox, locations]) =>
|
||||||
|
this.fetchMessagesFromMailbox(locations, client, mailbox),
|
||||||
|
);
|
||||||
|
|
||||||
|
const mailboxResults = await Promise.allSettled(fetchPromises);
|
||||||
|
|
||||||
|
for (const result of mailboxResults) {
|
||||||
|
if (result.status === 'fulfilled') {
|
||||||
|
results.push(...result.value);
|
||||||
|
} else {
|
||||||
|
this.logger.error(`Mailbox batch fetch failed: ${result.reason}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const messageId of notFoundIds) {
|
||||||
|
results.push({
|
||||||
|
messageId,
|
||||||
|
parsed: null,
|
||||||
|
processingTimeMs: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchMessagesFromMailbox(
|
||||||
|
messageLocations: MessageLocation[],
|
||||||
|
client: ImapFlow,
|
||||||
|
mailbox: string,
|
||||||
|
): Promise<MessageFetchResult[]> {
|
||||||
|
if (!messageLocations.length) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const lock = await client.getMailboxLock(mailbox);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await this.fetchMessagesWithSequences(messageLocations, client);
|
||||||
|
} finally {
|
||||||
|
lock.release();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to fetch messages from mailbox ${mailbox}: ${error.message}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return messageLocations.map((location) =>
|
||||||
|
this.createErrorResult(location.messageId, error as Error, Date.now()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchMessagesWithSequences(
|
||||||
|
messageLocations: MessageLocation[],
|
||||||
|
client: ImapFlow,
|
||||||
|
): Promise<MessageFetchResult[]> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
const results: MessageFetchResult[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sequences = messageLocations.map((loc) => loc.sequence.toString());
|
||||||
|
const sequenceSet = sequences.join(',');
|
||||||
|
|
||||||
|
const fetchResults = client.fetch(sequenceSet, {
|
||||||
|
source: true,
|
||||||
|
envelope: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const messagesData = new Map<number, FetchMessageObject>();
|
||||||
|
|
||||||
|
for await (const message of fetchResults) {
|
||||||
|
messagesData.set(message.seq, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const location of messageLocations) {
|
||||||
|
const messageData = messagesData.get(location.sequence);
|
||||||
|
|
||||||
|
if (messageData) {
|
||||||
|
const result = await this.processMessageData(
|
||||||
|
location.messageId,
|
||||||
|
messageData,
|
||||||
|
startTime,
|
||||||
|
);
|
||||||
|
|
||||||
|
results.push(result);
|
||||||
|
} else {
|
||||||
|
results.push({
|
||||||
|
messageId: location.messageId,
|
||||||
|
parsed: null,
|
||||||
|
processingTimeMs: Date.now() - startTime,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Batch fetch failed: ${error.message}`);
|
||||||
|
|
||||||
|
return messageLocations.map((location) =>
|
||||||
|
this.createErrorResult(location.messageId, error as Error, startTime),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async processMessageData(
|
||||||
|
messageId: string,
|
||||||
|
messageData: FetchMessageObject,
|
||||||
|
startTime: number,
|
||||||
|
): Promise<MessageFetchResult> {
|
||||||
|
try {
|
||||||
|
const rawContent = messageData.source?.toString() || '';
|
||||||
|
|
||||||
|
if (!rawContent) {
|
||||||
|
this.logger.debug(`No source content for message ${messageId}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
messageId,
|
||||||
|
parsed: null,
|
||||||
|
processingTimeMs: Date.now() - startTime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = await this.parseMessage(rawContent, messageId);
|
||||||
|
const processingTime = Date.now() - startTime;
|
||||||
|
|
||||||
|
this.logger.debug(
|
||||||
|
`Processed message ${messageId} in ${processingTime}ms`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
messageId,
|
||||||
|
parsed,
|
||||||
|
processingTimeMs: processingTime,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return this.createErrorResult(messageId, error as Error, startTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async parseMessage(
|
||||||
|
rawContent: string,
|
||||||
|
messageId: string,
|
||||||
|
): Promise<ParsedMail> {
|
||||||
|
try {
|
||||||
|
return await simpleParser(rawContent);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to parse message ${messageId}: ${error.message}`,
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createErrorResult(
|
||||||
|
messageId: string,
|
||||||
|
error: Error,
|
||||||
|
startTime: number,
|
||||||
|
): MessageFetchResult {
|
||||||
|
const processingTime = Date.now() - startTime;
|
||||||
|
|
||||||
|
this.logger.error(`Failed to fetch message ${messageId}: ${error.message}`);
|
||||||
|
|
||||||
|
this.imapHandleErrorService.handleImapMessagesImportError(error, messageId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
messageId,
|
||||||
|
parsed: null,
|
||||||
|
processingTimeMs: processingTime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
createErrorResults(messageIds: string[], error: Error): MessageFetchResult[] {
|
||||||
|
return messageIds.map((messageId) => {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to fetch message ${messageId}: ${error.message}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.imapHandleErrorService.handleImapMessagesImportError(
|
||||||
|
error,
|
||||||
|
messageId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
messageId,
|
||||||
|
parsed: null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
export interface ImapFlowError extends Error {
|
||||||
|
code?: string;
|
||||||
|
serverResponseCode?: string;
|
||||||
|
responseText?: string;
|
||||||
|
responseStatus?: string;
|
||||||
|
executedCommand?: string;
|
||||||
|
authenticationFailed?: boolean;
|
||||||
|
response?: string;
|
||||||
|
syscall?: string;
|
||||||
|
errno?: number;
|
||||||
|
}
|
||||||
@ -0,0 +1,59 @@
|
|||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { ImapFlow } from 'imapflow';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find sent folder using IMAP special-use flags
|
||||||
|
*
|
||||||
|
* This function uses IMAP special-use extension (RFC 6154) to identify
|
||||||
|
* the sent folder by looking for the \Sent flag rather than relying on
|
||||||
|
* folder names which can vary across providers and locales.
|
||||||
|
*
|
||||||
|
* Falls back to regex-based detection if special-use flags are not available.
|
||||||
|
* The regex pattern is inspired by imapsync's comprehensive folder mapping.
|
||||||
|
*/
|
||||||
|
export async function findSentMailbox(
|
||||||
|
client: ImapFlow,
|
||||||
|
logger: Logger,
|
||||||
|
): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const list = await client.list();
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`Available folders: ${list.map((item) => item.path).join(', ')}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const folder of list) {
|
||||||
|
if (folder.specialUse && folder.specialUse.includes('\\Sent')) {
|
||||||
|
logger.log(`Found sent folder via special-use flag: ${folder.path}`);
|
||||||
|
|
||||||
|
return folder.path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: comprehensive regex pattern for legacy IMAP servers
|
||||||
|
// Source: https://imapsync.lamiral.info/FAQ.d/FAQ.Folders_Mapping.txt
|
||||||
|
// Based on imapsync's regextrans2 examples (originally "Sent|Sent Messages|Gesendet")
|
||||||
|
// Extended with additional common localizations for broader provider/language support
|
||||||
|
const sentFolderPattern =
|
||||||
|
/^(.*\/)?(sent|sent[\s_-]?(items|mail|messages|elements)?|envoy[éê]s?|[ée]l[ée]ments[\s_-]?envoy[éê]s|gesendet|gesendete[\s_-]?elemente|enviados?|elementos[\s_-]?enviados|itens[\s_-]?enviados|posta[\s_-]?inviata|inviati|보낸편지함|\[gmail\]\/sent[\s_-]?mail)$/i;
|
||||||
|
|
||||||
|
const availableFolders = list.map((item) => item.path);
|
||||||
|
|
||||||
|
for (const folder of availableFolders) {
|
||||||
|
if (sentFolderPattern.test(folder)) {
|
||||||
|
logger.log(`Found sent folder via pattern match: ${folder}`);
|
||||||
|
|
||||||
|
return folder;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn('No sent folder found. Only inbox messages will be imported.');
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`Error listing mailboxes: ${error.message}`);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
import { ImapFlowError } from 'src/modules/messaging/message-import-manager/drivers/imap/types/imap-error.type';
|
||||||
|
|
||||||
|
export const isImapFlowError = (error: Error): error is ImapFlowError => {
|
||||||
|
return error !== undefined && error !== null;
|
||||||
|
};
|
||||||
@ -0,0 +1,63 @@
|
|||||||
|
import {
|
||||||
|
MessageImportDriverException,
|
||||||
|
MessageImportDriverExceptionCode,
|
||||||
|
} from 'src/modules/messaging/message-import-manager/drivers/exceptions/message-import-driver.exception';
|
||||||
|
import { isImapFlowError } from 'src/modules/messaging/message-import-manager/drivers/imap/utils/is-imap-flow-error.util';
|
||||||
|
|
||||||
|
export const parseImapError = (
|
||||||
|
error: Error,
|
||||||
|
): MessageImportDriverException | null => {
|
||||||
|
if (!error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isImapFlowError(error)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.code === 'ECONNREFUSED' || error.message === 'Failed to connect') {
|
||||||
|
return new MessageImportDriverException(
|
||||||
|
`IMAP connection error: ${error.message}`,
|
||||||
|
MessageImportDriverExceptionCode.UNKNOWN_NETWORK_ERROR,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.serverResponseCode) {
|
||||||
|
if (error.serverResponseCode === 'AUTHENTICATIONFAILED') {
|
||||||
|
return new MessageImportDriverException(
|
||||||
|
`IMAP authentication error: ${error.responseText || error.message}`,
|
||||||
|
MessageImportDriverExceptionCode.INSUFFICIENT_PERMISSIONS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.serverResponseCode === 'NONEXISTENT') {
|
||||||
|
return new MessageImportDriverException(
|
||||||
|
`IMAP mailbox not found: ${error.responseText || error.message}`,
|
||||||
|
MessageImportDriverExceptionCode.NOT_FOUND,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.authenticationFailed === true) {
|
||||||
|
return new MessageImportDriverException(
|
||||||
|
`IMAP authentication error: ${error.responseText || error.message}`,
|
||||||
|
MessageImportDriverExceptionCode.INSUFFICIENT_PERMISSIONS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.message === 'Command failed' && error.responseText) {
|
||||||
|
if (error.responseText.includes('Resource temporarily unavailable')) {
|
||||||
|
return new MessageImportDriverException(
|
||||||
|
`IMAP temporary error: ${error.responseText}`,
|
||||||
|
MessageImportDriverExceptionCode.TEMPORARY_ERROR,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new MessageImportDriverException(
|
||||||
|
`IMAP command failed: ${error.responseText}`,
|
||||||
|
MessageImportDriverExceptionCode.UNKNOWN,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
@ -0,0 +1,63 @@
|
|||||||
|
import {
|
||||||
|
MessageImportDriverException,
|
||||||
|
MessageImportDriverExceptionCode,
|
||||||
|
} from 'src/modules/messaging/message-import-manager/drivers/exceptions/message-import-driver.exception';
|
||||||
|
import { isImapFlowError } from 'src/modules/messaging/message-import-manager/drivers/imap/utils/is-imap-flow-error.util';
|
||||||
|
|
||||||
|
export const parseImapMessageListFetchError = (
|
||||||
|
error: Error,
|
||||||
|
): MessageImportDriverException => {
|
||||||
|
if (!error) {
|
||||||
|
return new MessageImportDriverException(
|
||||||
|
'Unknown IMAP message list fetch error: No error provided',
|
||||||
|
MessageImportDriverExceptionCode.UNKNOWN,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorMessage = error.message || '';
|
||||||
|
|
||||||
|
if (!isImapFlowError(error)) {
|
||||||
|
return new MessageImportDriverException(
|
||||||
|
`Unknown IMAP message list fetch error: ${errorMessage}`,
|
||||||
|
MessageImportDriverExceptionCode.UNKNOWN,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.responseText) {
|
||||||
|
if (
|
||||||
|
error.responseText.includes('Invalid search') ||
|
||||||
|
error.responseText.includes('invalid sequence set')
|
||||||
|
) {
|
||||||
|
return new MessageImportDriverException(
|
||||||
|
`IMAP sync cursor error: ${error.responseText}`,
|
||||||
|
MessageImportDriverExceptionCode.SYNC_CURSOR_ERROR,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.responseText.includes('No matching messages')) {
|
||||||
|
return new MessageImportDriverException(
|
||||||
|
'No messages found for next sync cursor',
|
||||||
|
MessageImportDriverExceptionCode.NO_NEXT_SYNC_CURSOR,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorMessage.includes('Invalid sequence set')) {
|
||||||
|
return new MessageImportDriverException(
|
||||||
|
`IMAP sync cursor error: ${errorMessage}`,
|
||||||
|
MessageImportDriverExceptionCode.SYNC_CURSOR_ERROR,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorMessage.includes('No messages found')) {
|
||||||
|
return new MessageImportDriverException(
|
||||||
|
'No messages found for next sync cursor',
|
||||||
|
MessageImportDriverExceptionCode.NO_NEXT_SYNC_CURSOR,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new MessageImportDriverException(
|
||||||
|
`Unknown IMAP message list fetch error: ${errorMessage}`,
|
||||||
|
MessageImportDriverExceptionCode.UNKNOWN,
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,71 @@
|
|||||||
|
import {
|
||||||
|
MessageImportDriverException,
|
||||||
|
MessageImportDriverExceptionCode,
|
||||||
|
} from 'src/modules/messaging/message-import-manager/drivers/exceptions/message-import-driver.exception';
|
||||||
|
import { isImapFlowError } from 'src/modules/messaging/message-import-manager/drivers/imap/utils/is-imap-flow-error.util';
|
||||||
|
|
||||||
|
export const parseImapMessagesImportError = (
|
||||||
|
error: Error,
|
||||||
|
messageExternalId: string,
|
||||||
|
): MessageImportDriverException => {
|
||||||
|
if (!error) {
|
||||||
|
return new MessageImportDriverException(
|
||||||
|
`Unknown IMAP message import error for message ${messageExternalId}: No error provided`,
|
||||||
|
MessageImportDriverExceptionCode.UNKNOWN,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorMessage = error.message || '';
|
||||||
|
|
||||||
|
if (!isImapFlowError(error)) {
|
||||||
|
return new MessageImportDriverException(
|
||||||
|
`Unknown IMAP message import error for message ${messageExternalId}: ${errorMessage}`,
|
||||||
|
MessageImportDriverExceptionCode.UNKNOWN,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.responseText) {
|
||||||
|
if (error.responseText.includes('No such message')) {
|
||||||
|
return new MessageImportDriverException(
|
||||||
|
`IMAP message not found: ${messageExternalId}`,
|
||||||
|
MessageImportDriverExceptionCode.NOT_FOUND,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.responseText.includes('expunged')) {
|
||||||
|
return new MessageImportDriverException(
|
||||||
|
`IMAP message no longer exists (expunged): ${messageExternalId}`,
|
||||||
|
MessageImportDriverExceptionCode.NOT_FOUND,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.responseText.includes('message size exceeds')) {
|
||||||
|
return new MessageImportDriverException(
|
||||||
|
`IMAP message fetch error for message ${messageExternalId}: ${error.responseText}`,
|
||||||
|
MessageImportDriverExceptionCode.TEMPORARY_ERROR,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
errorMessage.includes('Message not found') ||
|
||||||
|
errorMessage.includes('Invalid sequence set')
|
||||||
|
) {
|
||||||
|
return new MessageImportDriverException(
|
||||||
|
`IMAP message not found: ${messageExternalId}`,
|
||||||
|
MessageImportDriverExceptionCode.NOT_FOUND,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorMessage.includes('Failed to fetch message')) {
|
||||||
|
return new MessageImportDriverException(
|
||||||
|
`IMAP message fetch error for message ${messageExternalId}: ${errorMessage}`,
|
||||||
|
MessageImportDriverExceptionCode.TEMPORARY_ERROR,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new MessageImportDriverException(
|
||||||
|
`Unknown IMAP message import error for message ${messageExternalId}: ${errorMessage}`,
|
||||||
|
MessageImportDriverExceptionCode.UNKNOWN,
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,20 +1,17 @@
|
|||||||
|
//
|
||||||
import { Scope } from '@nestjs/common';
|
import { Scope } from '@nestjs/common';
|
||||||
|
|
||||||
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
|
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
|
||||||
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
|
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
|
||||||
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||||
import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
|
import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
|
||||||
import { ConnectedAccountRefreshAccessTokenExceptionCode } from 'src/modules/connected-account/refresh-tokens-manager/exceptions/connected-account-refresh-tokens.exception';
|
|
||||||
import { ConnectedAccountRefreshTokensService } from 'src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service';
|
import { ConnectedAccountRefreshTokensService } from 'src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service';
|
||||||
import { isThrottled } from 'src/modules/connected-account/utils/is-throttled';
|
import { isThrottled } from 'src/modules/connected-account/utils/is-throttled';
|
||||||
import {
|
import {
|
||||||
MessageChannelSyncStage,
|
MessageChannelSyncStage,
|
||||||
MessageChannelWorkspaceEntity,
|
MessageChannelWorkspaceEntity,
|
||||||
} from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
|
} from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
|
||||||
import {
|
import { MessagingAccountAuthenticationService } from 'src/modules/messaging/message-import-manager/services/messaging-account-authentication.service';
|
||||||
MessageImportDriverException,
|
|
||||||
MessageImportDriverExceptionCode,
|
|
||||||
} from 'src/modules/messaging/message-import-manager/drivers/exceptions/message-import-driver.exception';
|
|
||||||
import { MessagingFullMessageListFetchService } from 'src/modules/messaging/message-import-manager/services/messaging-full-message-list-fetch.service';
|
import { MessagingFullMessageListFetchService } from 'src/modules/messaging/message-import-manager/services/messaging-full-message-list-fetch.service';
|
||||||
import {
|
import {
|
||||||
MessageImportExceptionHandlerService,
|
MessageImportExceptionHandlerService,
|
||||||
@ -40,6 +37,7 @@ export class MessagingMessageListFetchJob {
|
|||||||
private readonly twentyORMManager: TwentyORMManager,
|
private readonly twentyORMManager: TwentyORMManager,
|
||||||
private readonly connectedAccountRefreshTokensService: ConnectedAccountRefreshTokensService,
|
private readonly connectedAccountRefreshTokensService: ConnectedAccountRefreshTokensService,
|
||||||
private readonly messageImportErrorHandlerService: MessageImportExceptionHandlerService,
|
private readonly messageImportErrorHandlerService: MessageImportExceptionHandlerService,
|
||||||
|
private readonly messagingAccountAuthenticationService: MessagingAccountAuthenticationService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Process(MessagingMessageListFetchJob.name)
|
@Process(MessagingMessageListFetchJob.name)
|
||||||
@ -84,41 +82,10 @@ export class MessagingMessageListFetchJob {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
await this.messagingAccountAuthenticationService.validateAndPrepareAuthentication(
|
||||||
messageChannel.connectedAccount.accessToken =
|
messageChannel,
|
||||||
await this.connectedAccountRefreshTokensService.refreshAndSaveTokens(
|
workspaceId,
|
||||||
messageChannel.connectedAccount,
|
);
|
||||||
workspaceId,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
switch (error.code) {
|
|
||||||
case ConnectedAccountRefreshAccessTokenExceptionCode.TEMPORARY_NETWORK_ERROR:
|
|
||||||
throw new MessageImportDriverException(
|
|
||||||
error.message,
|
|
||||||
MessageImportDriverExceptionCode.TEMPORARY_ERROR,
|
|
||||||
);
|
|
||||||
case ConnectedAccountRefreshAccessTokenExceptionCode.REFRESH_ACCESS_TOKEN_FAILED:
|
|
||||||
case ConnectedAccountRefreshAccessTokenExceptionCode.REFRESH_TOKEN_NOT_FOUND:
|
|
||||||
await this.messagingMonitoringService.track({
|
|
||||||
eventName: `refresh_token.error.insufficient_permissions`,
|
|
||||||
workspaceId,
|
|
||||||
connectedAccountId: messageChannel.connectedAccountId,
|
|
||||||
messageChannelId: messageChannel.id,
|
|
||||||
message: `${error.code}: ${error.reason ?? ''}`,
|
|
||||||
});
|
|
||||||
throw new MessageImportDriverException(
|
|
||||||
error.message,
|
|
||||||
MessageImportDriverExceptionCode.INSUFFICIENT_PERMISSIONS,
|
|
||||||
);
|
|
||||||
case ConnectedAccountRefreshAccessTokenExceptionCode.PROVIDER_NOT_SUPPORTED:
|
|
||||||
throw new MessageImportDriverException(
|
|
||||||
error.message,
|
|
||||||
MessageImportDriverExceptionCode.PROVIDER_NOT_SUPPORTED,
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (messageChannel.syncStage) {
|
switch (messageChannel.syncStage) {
|
||||||
case MessageChannelSyncStage.PARTIAL_MESSAGE_LIST_FETCH_PENDING:
|
case MessageChannelSyncStage.PARTIAL_MESSAGE_LIST_FETCH_PENDING:
|
||||||
|
|||||||
@ -20,6 +20,7 @@ import { MessagingMessageListFetchCronJob } from 'src/modules/messaging/message-
|
|||||||
import { MessagingMessagesImportCronJob } from 'src/modules/messaging/message-import-manager/crons/jobs/messaging-messages-import.cron.job';
|
import { MessagingMessagesImportCronJob } from 'src/modules/messaging/message-import-manager/crons/jobs/messaging-messages-import.cron.job';
|
||||||
import { MessagingOngoingStaleCronJob } from 'src/modules/messaging/message-import-manager/crons/jobs/messaging-ongoing-stale.cron.job';
|
import { MessagingOngoingStaleCronJob } from 'src/modules/messaging/message-import-manager/crons/jobs/messaging-ongoing-stale.cron.job';
|
||||||
import { MessagingGmailDriverModule } from 'src/modules/messaging/message-import-manager/drivers/gmail/messaging-gmail-driver.module';
|
import { MessagingGmailDriverModule } from 'src/modules/messaging/message-import-manager/drivers/gmail/messaging-gmail-driver.module';
|
||||||
|
import { MessagingIMAPDriverModule } from 'src/modules/messaging/message-import-manager/drivers/imap/messaging-imap-driver.module';
|
||||||
import { MessagingMicrosoftDriverModule } from 'src/modules/messaging/message-import-manager/drivers/microsoft/messaging-microsoft-driver.module';
|
import { MessagingMicrosoftDriverModule } from 'src/modules/messaging/message-import-manager/drivers/microsoft/messaging-microsoft-driver.module';
|
||||||
import { MessagingAddSingleMessageToCacheForImportJob } from 'src/modules/messaging/message-import-manager/jobs/messaging-add-single-message-to-cache-for-import.job';
|
import { MessagingAddSingleMessageToCacheForImportJob } from 'src/modules/messaging/message-import-manager/jobs/messaging-add-single-message-to-cache-for-import.job';
|
||||||
import { MessagingCleanCacheJob } from 'src/modules/messaging/message-import-manager/jobs/messaging-clean-cache';
|
import { MessagingCleanCacheJob } from 'src/modules/messaging/message-import-manager/jobs/messaging-clean-cache';
|
||||||
@ -27,6 +28,7 @@ import { MessagingMessageListFetchJob } from 'src/modules/messaging/message-impo
|
|||||||
import { MessagingMessagesImportJob } from 'src/modules/messaging/message-import-manager/jobs/messaging-messages-import.job';
|
import { MessagingMessagesImportJob } from 'src/modules/messaging/message-import-manager/jobs/messaging-messages-import.job';
|
||||||
import { MessagingOngoingStaleJob } from 'src/modules/messaging/message-import-manager/jobs/messaging-ongoing-stale.job';
|
import { MessagingOngoingStaleJob } from 'src/modules/messaging/message-import-manager/jobs/messaging-ongoing-stale.job';
|
||||||
import { MessagingMessageImportManagerMessageChannelListener } from 'src/modules/messaging/message-import-manager/listeners/messaging-import-manager-message-channel.listener';
|
import { MessagingMessageImportManagerMessageChannelListener } from 'src/modules/messaging/message-import-manager/listeners/messaging-import-manager-message-channel.listener';
|
||||||
|
import { MessagingAccountAuthenticationService } from 'src/modules/messaging/message-import-manager/services/messaging-account-authentication.service';
|
||||||
import { MessagingCursorService } from 'src/modules/messaging/message-import-manager/services/messaging-cursor.service';
|
import { MessagingCursorService } from 'src/modules/messaging/message-import-manager/services/messaging-cursor.service';
|
||||||
import { MessagingFullMessageListFetchService } from 'src/modules/messaging/message-import-manager/services/messaging-full-message-list-fetch.service';
|
import { MessagingFullMessageListFetchService } from 'src/modules/messaging/message-import-manager/services/messaging-full-message-list-fetch.service';
|
||||||
import { MessagingGetMessageListService } from 'src/modules/messaging/message-import-manager/services/messaging-get-message-list.service';
|
import { MessagingGetMessageListService } from 'src/modules/messaging/message-import-manager/services/messaging-get-message-list.service';
|
||||||
@ -45,6 +47,7 @@ import { MessagingMonitoringModule } from 'src/modules/messaging/monitoring/mess
|
|||||||
WorkspaceDataSourceModule,
|
WorkspaceDataSourceModule,
|
||||||
MessagingGmailDriverModule,
|
MessagingGmailDriverModule,
|
||||||
MessagingMicrosoftDriverModule,
|
MessagingMicrosoftDriverModule,
|
||||||
|
MessagingIMAPDriverModule,
|
||||||
MessagingCommonModule,
|
MessagingCommonModule,
|
||||||
TypeOrmModule.forFeature(
|
TypeOrmModule.forFeature(
|
||||||
[Workspace, DataSourceEntity, ObjectMetadataEntity],
|
[Workspace, DataSourceEntity, ObjectMetadataEntity],
|
||||||
@ -82,6 +85,7 @@ import { MessagingMonitoringModule } from 'src/modules/messaging/monitoring/mess
|
|||||||
MessageImportExceptionHandlerService,
|
MessageImportExceptionHandlerService,
|
||||||
MessagingCursorService,
|
MessagingCursorService,
|
||||||
MessagingSendMessageService,
|
MessagingSendMessageService,
|
||||||
|
MessagingAccountAuthenticationService,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
MessagingSendMessageService,
|
MessagingSendMessageService,
|
||||||
|
|||||||
@ -0,0 +1,155 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { isDefined } from 'class-validator';
|
||||||
|
import { ConnectedAccountProvider } from 'twenty-shared/types';
|
||||||
|
|
||||||
|
import { ConnectedAccountRefreshAccessTokenExceptionCode } from 'src/modules/connected-account/refresh-tokens-manager/exceptions/connected-account-refresh-tokens.exception';
|
||||||
|
import { ConnectedAccountRefreshTokensService } from 'src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service';
|
||||||
|
import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
|
||||||
|
import { MessageChannelWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
|
||||||
|
import {
|
||||||
|
MessageImportDriverException,
|
||||||
|
MessageImportDriverExceptionCode,
|
||||||
|
} from 'src/modules/messaging/message-import-manager/drivers/exceptions/message-import-driver.exception';
|
||||||
|
import { MessagingMonitoringService } from 'src/modules/messaging/monitoring/services/messaging-monitoring.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MessagingAccountAuthenticationService {
|
||||||
|
constructor(
|
||||||
|
private readonly connectedAccountRefreshTokensService: ConnectedAccountRefreshTokensService,
|
||||||
|
private readonly messagingMonitoringService: MessagingMonitoringService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async validateAndPrepareAuthentication(
|
||||||
|
messageChannel: MessageChannelWorkspaceEntity,
|
||||||
|
workspaceId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (
|
||||||
|
messageChannel.connectedAccount.provider ===
|
||||||
|
ConnectedAccountProvider.IMAP_SMTP_CALDAV
|
||||||
|
) {
|
||||||
|
await this.validateImapCredentials(messageChannel, workspaceId);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.refreshAccessTokenForNonImapProvider(
|
||||||
|
messageChannel.connectedAccount,
|
||||||
|
workspaceId,
|
||||||
|
messageChannel.id,
|
||||||
|
messageChannel.connectedAccountId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async validateConnectedAccountAuthentication(
|
||||||
|
connectedAccount: ConnectedAccountWorkspaceEntity,
|
||||||
|
workspaceId: string,
|
||||||
|
messageChannelId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (
|
||||||
|
connectedAccount.provider === ConnectedAccountProvider.IMAP_SMTP_CALDAV &&
|
||||||
|
isDefined(connectedAccount.connectionParameters?.IMAP)
|
||||||
|
) {
|
||||||
|
await this.validateImapCredentialsForConnectedAccount(
|
||||||
|
connectedAccount,
|
||||||
|
workspaceId,
|
||||||
|
messageChannelId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.refreshAccessTokenForNonImapProvider(
|
||||||
|
connectedAccount,
|
||||||
|
workspaceId,
|
||||||
|
messageChannelId,
|
||||||
|
connectedAccount.id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async validateImapCredentialsForConnectedAccount(
|
||||||
|
connectedAccount: ConnectedAccountWorkspaceEntity,
|
||||||
|
workspaceId: string,
|
||||||
|
messageChannelId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!connectedAccount.connectionParameters) {
|
||||||
|
await this.messagingMonitoringService.track({
|
||||||
|
eventName: 'messages_import.error.missing_imap_credentials',
|
||||||
|
workspaceId,
|
||||||
|
connectedAccountId: connectedAccount.id,
|
||||||
|
messageChannelId,
|
||||||
|
});
|
||||||
|
|
||||||
|
throw {
|
||||||
|
code: MessageImportDriverExceptionCode.INSUFFICIENT_PERMISSIONS,
|
||||||
|
message: 'Missing IMAP credentials in connectionParameters',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async validateImapCredentials(
|
||||||
|
messageChannel: MessageChannelWorkspaceEntity,
|
||||||
|
workspaceId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (
|
||||||
|
!isDefined(messageChannel.connectedAccount.connectionParameters?.IMAP)
|
||||||
|
) {
|
||||||
|
await this.messagingMonitoringService.track({
|
||||||
|
eventName: 'message_list_fetch_job.error.missing_imap_credentials',
|
||||||
|
workspaceId,
|
||||||
|
connectedAccountId: messageChannel.connectedAccount.id,
|
||||||
|
messageChannelId: messageChannel.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
throw {
|
||||||
|
code: MessageImportDriverExceptionCode.INSUFFICIENT_PERMISSIONS,
|
||||||
|
message: 'Missing IMAP credentials in connectionParameters',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refreshAccessTokenForNonImapProvider(
|
||||||
|
connectedAccount: ConnectedAccountWorkspaceEntity,
|
||||||
|
workspaceId: string,
|
||||||
|
messageChannelId: string,
|
||||||
|
connectedAccountId: string,
|
||||||
|
): Promise<string> {
|
||||||
|
try {
|
||||||
|
const accessToken =
|
||||||
|
await this.connectedAccountRefreshTokensService.refreshAndSaveTokens(
|
||||||
|
connectedAccount,
|
||||||
|
workspaceId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return accessToken;
|
||||||
|
} catch (error) {
|
||||||
|
switch (error.code) {
|
||||||
|
case ConnectedAccountRefreshAccessTokenExceptionCode.TEMPORARY_NETWORK_ERROR:
|
||||||
|
throw new MessageImportDriverException(
|
||||||
|
error.message,
|
||||||
|
MessageImportDriverExceptionCode.TEMPORARY_ERROR,
|
||||||
|
);
|
||||||
|
case ConnectedAccountRefreshAccessTokenExceptionCode.REFRESH_ACCESS_TOKEN_FAILED:
|
||||||
|
case ConnectedAccountRefreshAccessTokenExceptionCode.REFRESH_TOKEN_NOT_FOUND:
|
||||||
|
await this.messagingMonitoringService.track({
|
||||||
|
eventName: `refresh_token.error.insufficient_permissions`,
|
||||||
|
workspaceId,
|
||||||
|
connectedAccountId,
|
||||||
|
messageChannelId,
|
||||||
|
message: `${error.code}: ${error.reason ?? ''}`,
|
||||||
|
});
|
||||||
|
throw new MessageImportDriverException(
|
||||||
|
error.message,
|
||||||
|
MessageImportDriverExceptionCode.INSUFFICIENT_PERMISSIONS,
|
||||||
|
);
|
||||||
|
case ConnectedAccountRefreshAccessTokenExceptionCode.PROVIDER_NOT_SUPPORTED:
|
||||||
|
throw new MessageImportDriverException(
|
||||||
|
error.message,
|
||||||
|
MessageImportDriverExceptionCode.PROVIDER_NOT_SUPPORTED,
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,6 +6,7 @@ import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
|
|||||||
import { MessageChannelWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
|
import { MessageChannelWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
|
||||||
import { MessageFolderWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-folder.workspace-entity';
|
import { MessageFolderWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-folder.workspace-entity';
|
||||||
import { GmailGetMessageListService } from 'src/modules/messaging/message-import-manager/drivers/gmail/services/gmail-get-message-list.service';
|
import { GmailGetMessageListService } from 'src/modules/messaging/message-import-manager/drivers/gmail/services/gmail-get-message-list.service';
|
||||||
|
import { ImapGetMessageListService } from 'src/modules/messaging/message-import-manager/drivers/imap/services/imap-get-message-list.service';
|
||||||
import { MicrosoftGetMessageListService } from 'src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-get-message-list.service';
|
import { MicrosoftGetMessageListService } from 'src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-get-message-list.service';
|
||||||
import {
|
import {
|
||||||
MessageImportException,
|
MessageImportException,
|
||||||
@ -40,6 +41,7 @@ export class MessagingGetMessageListService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly gmailGetMessageListService: GmailGetMessageListService,
|
private readonly gmailGetMessageListService: GmailGetMessageListService,
|
||||||
private readonly microsoftGetMessageListService: MicrosoftGetMessageListService,
|
private readonly microsoftGetMessageListService: MicrosoftGetMessageListService,
|
||||||
|
private readonly imapGetMessageListService: ImapGetMessageListService,
|
||||||
private readonly messagingCursorService: MessagingCursorService,
|
private readonly messagingCursorService: MessagingCursorService,
|
||||||
private readonly twentyORMManager: TwentyORMManager,
|
private readonly twentyORMManager: TwentyORMManager,
|
||||||
) {}
|
) {}
|
||||||
@ -78,6 +80,19 @@ export class MessagingGetMessageListService {
|
|||||||
folders,
|
folders,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
case ConnectedAccountProvider.IMAP_SMTP_CALDAV: {
|
||||||
|
const fullMessageList =
|
||||||
|
await this.imapGetMessageListService.getFullMessageList(
|
||||||
|
messageChannel.connectedAccount,
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
...fullMessageList,
|
||||||
|
folderId: undefined,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
throw new MessageImportException(
|
throw new MessageImportException(
|
||||||
`Provider ${messageChannel.connectedAccount.provider} is not supported`,
|
`Provider ${messageChannel.connectedAccount.provider} is not supported`,
|
||||||
@ -105,6 +120,23 @@ export class MessagingGetMessageListService {
|
|||||||
messageChannel.connectedAccount,
|
messageChannel.connectedAccount,
|
||||||
messageChannel,
|
messageChannel,
|
||||||
);
|
);
|
||||||
|
case ConnectedAccountProvider.IMAP_SMTP_CALDAV: {
|
||||||
|
const messageList =
|
||||||
|
await this.imapGetMessageListService.getPartialMessageList(
|
||||||
|
messageChannel.connectedAccount,
|
||||||
|
messageChannel.syncCursor,
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
messageExternalIds: messageList.messageExternalIds,
|
||||||
|
messageExternalIdsToDelete: [],
|
||||||
|
previousSyncCursor: messageChannel.syncCursor || '',
|
||||||
|
nextSyncCursor: messageList.nextSyncCursor || '',
|
||||||
|
folderId: undefined,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
throw new MessageImportException(
|
throw new MessageImportException(
|
||||||
`Provider ${messageChannel.connectedAccount.provider} is not supported`,
|
`Provider ${messageChannel.connectedAccount.provider} is not supported`,
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { ConnectedAccountProvider } from 'twenty-shared/types';
|
|||||||
|
|
||||||
import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
|
import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
|
||||||
import { GmailGetMessagesService } from 'src/modules/messaging/message-import-manager/drivers/gmail/services/gmail-get-messages.service';
|
import { GmailGetMessagesService } from 'src/modules/messaging/message-import-manager/drivers/gmail/services/gmail-get-messages.service';
|
||||||
|
import { ImapGetMessagesService } from 'src/modules/messaging/message-import-manager/drivers/imap/services/imap-get-messages.service';
|
||||||
import { MicrosoftGetMessagesService } from 'src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-get-messages.service';
|
import { MicrosoftGetMessagesService } from 'src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-get-messages.service';
|
||||||
import {
|
import {
|
||||||
MessageImportException,
|
MessageImportException,
|
||||||
@ -18,6 +19,7 @@ export class MessagingGetMessagesService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly gmailGetMessagesService: GmailGetMessagesService,
|
private readonly gmailGetMessagesService: GmailGetMessagesService,
|
||||||
private readonly microsoftGetMessagesService: MicrosoftGetMessagesService,
|
private readonly microsoftGetMessagesService: MicrosoftGetMessagesService,
|
||||||
|
private readonly imapGetMessagesService: ImapGetMessagesService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async getMessages(
|
public async getMessages(
|
||||||
@ -30,6 +32,8 @@ export class MessagingGetMessagesService {
|
|||||||
| 'id'
|
| 'id'
|
||||||
| 'handle'
|
| 'handle'
|
||||||
| 'handleAliases'
|
| 'handleAliases'
|
||||||
|
| 'accountOwnerId'
|
||||||
|
| 'connectionParameters'
|
||||||
>,
|
>,
|
||||||
): Promise<GetMessagesResponse> {
|
): Promise<GetMessagesResponse> {
|
||||||
switch (connectedAccount.provider) {
|
switch (connectedAccount.provider) {
|
||||||
@ -43,6 +47,11 @@ export class MessagingGetMessagesService {
|
|||||||
messageIds,
|
messageIds,
|
||||||
connectedAccount,
|
connectedAccount,
|
||||||
);
|
);
|
||||||
|
case ConnectedAccountProvider.IMAP_SMTP_CALDAV:
|
||||||
|
return this.imapGetMessagesService.getMessages(
|
||||||
|
messageIds,
|
||||||
|
connectedAccount,
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
throw new MessageImportException(
|
throw new MessageImportException(
|
||||||
`Provider ${connectedAccount.provider} is not supported`,
|
`Provider ${connectedAccount.provider} is not supported`,
|
||||||
|
|||||||
@ -16,11 +16,13 @@ import {
|
|||||||
MessageChannelWorkspaceEntity,
|
MessageChannelWorkspaceEntity,
|
||||||
} from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
|
} from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
|
||||||
import { MESSAGING_GMAIL_USERS_MESSAGES_GET_BATCH_SIZE } from 'src/modules/messaging/message-import-manager/drivers/gmail/constants/messaging-gmail-users-messages-get-batch-size.constant';
|
import { MESSAGING_GMAIL_USERS_MESSAGES_GET_BATCH_SIZE } from 'src/modules/messaging/message-import-manager/drivers/gmail/constants/messaging-gmail-users-messages-get-batch-size.constant';
|
||||||
|
import { MessagingAccountAuthenticationService } from 'src/modules/messaging/message-import-manager/services/messaging-account-authentication.service';
|
||||||
import { MessagingGetMessagesService } from 'src/modules/messaging/message-import-manager/services/messaging-get-messages.service';
|
import { MessagingGetMessagesService } from 'src/modules/messaging/message-import-manager/services/messaging-get-messages.service';
|
||||||
import { MessageImportExceptionHandlerService } from 'src/modules/messaging/message-import-manager/services/messaging-import-exception-handler.service';
|
import { MessageImportExceptionHandlerService } from 'src/modules/messaging/message-import-manager/services/messaging-import-exception-handler.service';
|
||||||
import { MessagingMessagesImportService } from 'src/modules/messaging/message-import-manager/services/messaging-messages-import.service';
|
import { MessagingMessagesImportService } from 'src/modules/messaging/message-import-manager/services/messaging-messages-import.service';
|
||||||
import { MessagingSaveMessagesAndEnqueueContactCreationService } from 'src/modules/messaging/message-import-manager/services/messaging-save-messages-and-enqueue-contact-creation.service';
|
import { MessagingSaveMessagesAndEnqueueContactCreationService } from 'src/modules/messaging/message-import-manager/services/messaging-save-messages-and-enqueue-contact-creation.service';
|
||||||
import { MessagingMonitoringService } from 'src/modules/messaging/monitoring/services/messaging-monitoring.service';
|
import { MessagingMonitoringService } from 'src/modules/messaging/monitoring/services/messaging-monitoring.service';
|
||||||
|
|
||||||
describe('MessagingMessagesImportService', () => {
|
describe('MessagingMessagesImportService', () => {
|
||||||
let service: MessagingMessagesImportService;
|
let service: MessagingMessagesImportService;
|
||||||
let messageChannelSyncStatusService: MessageChannelSyncStatusService;
|
let messageChannelSyncStatusService: MessageChannelSyncStatusService;
|
||||||
@ -139,6 +141,10 @@ describe('MessagingMessagesImportService', () => {
|
|||||||
handleDriverException: jest.fn().mockResolvedValue(undefined),
|
handleDriverException: jest.fn().mockResolvedValue(undefined),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: MessagingAccountAuthenticationService,
|
||||||
|
useClass: MessagingAccountAuthenticationService,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
//
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
|
||||||
import { InjectCacheStorage } from 'src/engine/core-modules/cache-storage/decorators/cache-storage.decorator';
|
import { InjectCacheStorage } from 'src/engine/core-modules/cache-storage/decorators/cache-storage.decorator';
|
||||||
@ -8,7 +9,6 @@ import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
|
|||||||
import { BlocklistRepository } from 'src/modules/blocklist/repositories/blocklist.repository';
|
import { BlocklistRepository } from 'src/modules/blocklist/repositories/blocklist.repository';
|
||||||
import { BlocklistWorkspaceEntity } from 'src/modules/blocklist/standard-objects/blocklist.workspace-entity';
|
import { BlocklistWorkspaceEntity } from 'src/modules/blocklist/standard-objects/blocklist.workspace-entity';
|
||||||
import { EmailAliasManagerService } from 'src/modules/connected-account/email-alias-manager/services/email-alias-manager.service';
|
import { EmailAliasManagerService } from 'src/modules/connected-account/email-alias-manager/services/email-alias-manager.service';
|
||||||
import { ConnectedAccountRefreshAccessTokenExceptionCode } from 'src/modules/connected-account/refresh-tokens-manager/exceptions/connected-account-refresh-tokens.exception';
|
|
||||||
import { ConnectedAccountRefreshTokensService } from 'src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service';
|
import { ConnectedAccountRefreshTokensService } from 'src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service';
|
||||||
import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
|
import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
|
||||||
import { MessageChannelSyncStatusService } from 'src/modules/messaging/common/services/message-channel-sync-status.service';
|
import { MessageChannelSyncStatusService } from 'src/modules/messaging/common/services/message-channel-sync-status.service';
|
||||||
@ -16,11 +16,8 @@ import {
|
|||||||
MessageChannelSyncStage,
|
MessageChannelSyncStage,
|
||||||
MessageChannelWorkspaceEntity,
|
MessageChannelWorkspaceEntity,
|
||||||
} from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
|
} from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
|
||||||
import {
|
|
||||||
MessageImportDriverException,
|
|
||||||
MessageImportDriverExceptionCode,
|
|
||||||
} from 'src/modules/messaging/message-import-manager/drivers/exceptions/message-import-driver.exception';
|
|
||||||
import { MESSAGING_GMAIL_USERS_MESSAGES_GET_BATCH_SIZE } from 'src/modules/messaging/message-import-manager/drivers/gmail/constants/messaging-gmail-users-messages-get-batch-size.constant';
|
import { MESSAGING_GMAIL_USERS_MESSAGES_GET_BATCH_SIZE } from 'src/modules/messaging/message-import-manager/drivers/gmail/constants/messaging-gmail-users-messages-get-batch-size.constant';
|
||||||
|
import { MessagingAccountAuthenticationService } from 'src/modules/messaging/message-import-manager/services/messaging-account-authentication.service';
|
||||||
import { MessagingGetMessagesService } from 'src/modules/messaging/message-import-manager/services/messaging-get-messages.service';
|
import { MessagingGetMessagesService } from 'src/modules/messaging/message-import-manager/services/messaging-get-messages.service';
|
||||||
import {
|
import {
|
||||||
MessageImportExceptionHandlerService,
|
MessageImportExceptionHandlerService,
|
||||||
@ -46,6 +43,7 @@ export class MessagingMessagesImportService {
|
|||||||
private readonly twentyORMManager: TwentyORMManager,
|
private readonly twentyORMManager: TwentyORMManager,
|
||||||
private readonly messagingGetMessagesService: MessagingGetMessagesService,
|
private readonly messagingGetMessagesService: MessagingGetMessagesService,
|
||||||
private readonly messageImportErrorHandlerService: MessageImportExceptionHandlerService,
|
private readonly messageImportErrorHandlerService: MessageImportExceptionHandlerService,
|
||||||
|
private readonly messagingAccountAuthenticationService: MessagingAccountAuthenticationService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async processMessageBatchImport(
|
async processMessageBatchImport(
|
||||||
@ -74,45 +72,11 @@ export class MessagingMessagesImportService {
|
|||||||
messageChannel.id,
|
messageChannel.id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
try {
|
await this.messagingAccountAuthenticationService.validateConnectedAccountAuthentication(
|
||||||
connectedAccount.accessToken =
|
connectedAccount,
|
||||||
await this.connectedAccountRefreshTokensService.refreshAndSaveTokens(
|
workspaceId,
|
||||||
connectedAccount,
|
messageChannel.id,
|
||||||
workspaceId,
|
);
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
switch (error.code) {
|
|
||||||
case ConnectedAccountRefreshAccessTokenExceptionCode.TEMPORARY_NETWORK_ERROR:
|
|
||||||
throw new MessageImportDriverException(
|
|
||||||
error.message,
|
|
||||||
MessageImportDriverExceptionCode.TEMPORARY_ERROR,
|
|
||||||
);
|
|
||||||
case ConnectedAccountRefreshAccessTokenExceptionCode.REFRESH_ACCESS_TOKEN_FAILED:
|
|
||||||
case ConnectedAccountRefreshAccessTokenExceptionCode.REFRESH_TOKEN_NOT_FOUND:
|
|
||||||
await this.messagingMonitoringService.track({
|
|
||||||
eventName: `refresh_token.error.insufficient_permissions`,
|
|
||||||
workspaceId,
|
|
||||||
connectedAccountId: messageChannel.connectedAccountId,
|
|
||||||
messageChannelId: messageChannel.id,
|
|
||||||
message: `${error.code}: ${error.reason}`,
|
|
||||||
});
|
|
||||||
throw new MessageImportDriverException(
|
|
||||||
error.message,
|
|
||||||
MessageImportDriverExceptionCode.INSUFFICIENT_PERMISSIONS,
|
|
||||||
);
|
|
||||||
case ConnectedAccountRefreshAccessTokenExceptionCode.PROVIDER_NOT_SUPPORTED:
|
|
||||||
throw new MessageImportDriverException(
|
|
||||||
error.message,
|
|
||||||
MessageImportDriverExceptionCode.PROVIDER_NOT_SUPPORTED,
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
this.logger.error(
|
|
||||||
`Error (${error.code}) refreshing access token for account ${connectedAccount.id}`,
|
|
||||||
);
|
|
||||||
this.logger.log(error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.emailAliasManagerService.refreshHandleAliases(
|
await this.emailAliasManagerService.refreshHandleAliases(
|
||||||
connectedAccount,
|
connectedAccount,
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
import { z } from 'zod';
|
|
||||||
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
|
|
||||||
import { ConnectedAccountProvider } from 'twenty-shared/types';
|
import { ConnectedAccountProvider } from 'twenty-shared/types';
|
||||||
|
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
|
import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
|
||||||
import { GmailClientProvider } from 'src/modules/messaging/message-import-manager/drivers/gmail/providers/gmail-client.provider';
|
import { GmailClientProvider } from 'src/modules/messaging/message-import-manager/drivers/gmail/providers/gmail-client.provider';
|
||||||
import { MicrosoftClientProvider } from 'src/modules/messaging/message-import-manager/drivers/microsoft/providers/microsoft-client.provider';
|
|
||||||
import { OAuth2ClientProvider } from 'src/modules/messaging/message-import-manager/drivers/gmail/providers/oauth2-client.provider';
|
import { OAuth2ClientProvider } from 'src/modules/messaging/message-import-manager/drivers/gmail/providers/oauth2-client.provider';
|
||||||
|
import { MicrosoftClientProvider } from 'src/modules/messaging/message-import-manager/drivers/microsoft/providers/microsoft-client.provider';
|
||||||
import { mimeEncode } from 'src/modules/messaging/message-import-manager/utils/mime-encode.util';
|
import { mimeEncode } from 'src/modules/messaging/message-import-manager/utils/mime-encode.util';
|
||||||
|
|
||||||
interface SendMessageInput {
|
interface SendMessageInput {
|
||||||
@ -93,6 +93,9 @@ export class MessagingSendMessageService {
|
|||||||
await microsoftClient.api(`/me/messages/${response.id}/send`).post({});
|
await microsoftClient.api(`/me/messages/${response.id}/send`).post({});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case ConnectedAccountProvider.IMAP_SMTP_CALDAV: {
|
||||||
|
throw new Error('IMAP provider does not support sending messages');
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
assertUnreachable(
|
assertUnreachable(
|
||||||
connectedAccount.provider,
|
connectedAccount.provider,
|
||||||
|
|||||||
@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* Removes null characters (\0) from a string to prevent unexpected errors
|
||||||
|
*/
|
||||||
export const sanitizeString = (str: string) => {
|
export const sanitizeString = (str: string) => {
|
||||||
return str.replace(/\0/g, '');
|
return str.replace(/\0/g, '');
|
||||||
};
|
};
|
||||||
@ -1,4 +1,5 @@
|
|||||||
export enum ConnectedAccountProvider {
|
export enum ConnectedAccountProvider {
|
||||||
GOOGLE = 'google',
|
GOOGLE = 'google',
|
||||||
MICROSOFT = 'microsoft',
|
MICROSOFT = 'microsoft',
|
||||||
|
IMAP_SMTP_CALDAV = 'imap_smtp_caldav',
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
packages/twenty-website/public/images/lab/is-imap-enabled.png
Normal file
BIN
packages/twenty-website/public/images/lab/is-imap-enabled.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 104 KiB |
240
yarn.lock
240
yarn.lock
@ -23579,6 +23579,15 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"@types/imapflow@npm:^1.0.21":
|
||||||
|
version: 1.0.21
|
||||||
|
resolution: "@types/imapflow@npm:1.0.21"
|
||||||
|
dependencies:
|
||||||
|
"@types/node": "npm:*"
|
||||||
|
checksum: 10c0/82b4d370ab9649b358246a85c2fac2daba19202c39defc05e580a5a96f9d4192c5302c438f147ad6ee0b7183e3460adeebfd713bbd6e1aa8e9a29bc675ccf449
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"@types/is-hotkey@npm:^0.1.1":
|
"@types/is-hotkey@npm:^0.1.1":
|
||||||
version: 0.1.10
|
version: 0.1.10
|
||||||
resolution: "@types/is-hotkey@npm:0.1.10"
|
resolution: "@types/is-hotkey@npm:0.1.10"
|
||||||
@ -23994,6 +24003,16 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"@types/mailparser@npm:^3.4.6":
|
||||||
|
version: 3.4.6
|
||||||
|
resolution: "@types/mailparser@npm:3.4.6"
|
||||||
|
dependencies:
|
||||||
|
"@types/node": "npm:*"
|
||||||
|
iconv-lite: "npm:^0.6.3"
|
||||||
|
checksum: 10c0/5b08f226d6911daf7b8102f88eece0cd6d33a56ea339b85334bd4fdafe6b15e556c22f06a8ede5d54c8990368a93a71f5c599d7f83718f1ae9eea9420ffcd27c
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"@types/markdown-it@npm:12.2.3":
|
"@types/markdown-it@npm:12.2.3":
|
||||||
version: 12.2.3
|
version: 12.2.3
|
||||||
resolution: "@types/markdown-it@npm:12.2.3"
|
resolution: "@types/markdown-it@npm:12.2.3"
|
||||||
@ -27534,6 +27553,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"atomic-sleep@npm:^1.0.0":
|
||||||
|
version: 1.0.0
|
||||||
|
resolution: "atomic-sleep@npm:1.0.0"
|
||||||
|
checksum: 10c0/e329a6665512736a9bbb073e1761b4ec102f7926cce35037753146a9db9c8104f5044c1662e4a863576ce544fb8be27cd2be6bc8c1a40147d03f31eb1cfb6e8a
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"attr-accept@npm:^2.2.2":
|
"attr-accept@npm:^2.2.2":
|
||||||
version: 2.2.2
|
version: 2.2.2
|
||||||
resolution: "attr-accept@npm:2.2.2"
|
resolution: "attr-accept@npm:2.2.2"
|
||||||
@ -33666,6 +33692,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"encoding-japanese@npm:2.2.0":
|
||||||
|
version: 2.2.0
|
||||||
|
resolution: "encoding-japanese@npm:2.2.0"
|
||||||
|
checksum: 10c0/9d1f10dde16f59da8a8a1a04499dffa3e9926b0dbd7dfab8054570527b7e6de30c47e828851f42d2727af31586ec8049a84eeae593ad8b22eea10921fd269798
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"encoding@npm:^0.1.12, encoding@npm:^0.1.13":
|
"encoding@npm:^0.1.12, encoding@npm:^0.1.13":
|
||||||
version: 0.1.13
|
version: 0.1.13
|
||||||
resolution: "encoding@npm:0.1.13"
|
resolution: "encoding@npm:0.1.13"
|
||||||
@ -35890,6 +35923,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"fast-redact@npm:^3.1.1":
|
||||||
|
version: 3.5.0
|
||||||
|
resolution: "fast-redact@npm:3.5.0"
|
||||||
|
checksum: 10c0/7e2ce4aad6e7535e0775bf12bd3e4f2e53d8051d8b630e0fa9e67f68cb0b0e6070d2f7a94b1d0522ef07e32f7c7cda5755e2b677a6538f1e9070ca053c42343a
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"fast-safe-stringify@npm:2.1.1, fast-safe-stringify@npm:^2.0.7, fast-safe-stringify@npm:^2.1.1":
|
"fast-safe-stringify@npm:2.1.1, fast-safe-stringify@npm:^2.0.7, fast-safe-stringify@npm:^2.1.1":
|
||||||
version: 2.1.1
|
version: 2.1.1
|
||||||
resolution: "fast-safe-stringify@npm:2.1.1"
|
resolution: "fast-safe-stringify@npm:2.1.1"
|
||||||
@ -38636,7 +38676,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"he@npm:^1.2.0":
|
"he@npm:1.2.0, he@npm:^1.2.0":
|
||||||
version: 1.2.0
|
version: 1.2.0
|
||||||
resolution: "he@npm:1.2.0"
|
resolution: "he@npm:1.2.0"
|
||||||
bin:
|
bin:
|
||||||
@ -39315,6 +39355,23 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"imapflow@npm:^1.0.186":
|
||||||
|
version: 1.0.187
|
||||||
|
resolution: "imapflow@npm:1.0.187"
|
||||||
|
dependencies:
|
||||||
|
encoding-japanese: "npm:2.2.0"
|
||||||
|
iconv-lite: "npm:0.6.3"
|
||||||
|
libbase64: "npm:1.3.0"
|
||||||
|
libmime: "npm:5.3.6"
|
||||||
|
libqp: "npm:2.1.1"
|
||||||
|
mailsplit: "npm:5.4.3"
|
||||||
|
nodemailer: "npm:7.0.3"
|
||||||
|
pino: "npm:9.7.0"
|
||||||
|
socks: "npm:2.8.4"
|
||||||
|
checksum: 10c0/e29aa43eb5bd9623892ecd23cfdf86b8e762a904488ff6af3cff5afaa3ac125c9db5538f8f052865c32c732bfe21901f38a27242f7500eb42567a29fd1ea4542
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"imask@npm:^7.6.1":
|
"imask@npm:^7.6.1":
|
||||||
version: 7.6.1
|
version: 7.6.1
|
||||||
resolution: "imask@npm:7.6.1"
|
resolution: "imask@npm:7.6.1"
|
||||||
@ -42707,6 +42764,25 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"libbase64@npm:1.3.0":
|
||||||
|
version: 1.3.0
|
||||||
|
resolution: "libbase64@npm:1.3.0"
|
||||||
|
checksum: 10c0/4ece76ce09fa389d0c578c83a121c16452916521b177f50c3e8637dd9919170c96c12e1c7de63b1c88da8e5aa7e7ab574c26d18f1f64666f46b358b4b5873c8b
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"libmime@npm:5.3.6":
|
||||||
|
version: 5.3.6
|
||||||
|
resolution: "libmime@npm:5.3.6"
|
||||||
|
dependencies:
|
||||||
|
encoding-japanese: "npm:2.2.0"
|
||||||
|
iconv-lite: "npm:0.6.3"
|
||||||
|
libbase64: "npm:1.3.0"
|
||||||
|
libqp: "npm:2.1.1"
|
||||||
|
checksum: 10c0/54afa19f3500fe14b7562fd055518f1d82bf0f1076690a9b097d1a126322de7425d6d29aa9db9b51bc41b2ed16eacff65d7f30cd3622f7010f60993dfc79041e
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"libphonenumber-js@npm:^1.10.14, libphonenumber-js@npm:^1.10.26, libphonenumber-js@npm:^1.11.5":
|
"libphonenumber-js@npm:^1.10.14, libphonenumber-js@npm:^1.10.26, libphonenumber-js@npm:^1.11.5":
|
||||||
version: 1.11.5
|
version: 1.11.5
|
||||||
resolution: "libphonenumber-js@npm:1.11.5"
|
resolution: "libphonenumber-js@npm:1.11.5"
|
||||||
@ -42714,6 +42790,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"libqp@npm:2.1.1":
|
||||||
|
version: 2.1.1
|
||||||
|
resolution: "libqp@npm:2.1.1"
|
||||||
|
checksum: 10c0/6e78f0676cd2424b3ddbf3273ab8539871299310dba433b7e2ec10a41830acecb4d074ea8b78b706dea349996f011ce519d92f81ede712c4824a2dd402aa376c
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"lie@npm:~3.3.0":
|
"lie@npm:~3.3.0":
|
||||||
version: 3.3.0
|
version: 3.3.0
|
||||||
resolution: "lie@npm:3.3.0"
|
resolution: "lie@npm:3.3.0"
|
||||||
@ -42754,7 +42837,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"linkify-it@npm:^5.0.0":
|
"linkify-it@npm:5.0.0, linkify-it@npm:^5.0.0":
|
||||||
version: 5.0.0
|
version: 5.0.0
|
||||||
resolution: "linkify-it@npm:5.0.0"
|
resolution: "linkify-it@npm:5.0.0"
|
||||||
dependencies:
|
dependencies:
|
||||||
@ -43510,6 +43593,35 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"mailparser@npm:^3.7.3":
|
||||||
|
version: 3.7.3
|
||||||
|
resolution: "mailparser@npm:3.7.3"
|
||||||
|
dependencies:
|
||||||
|
encoding-japanese: "npm:2.2.0"
|
||||||
|
he: "npm:1.2.0"
|
||||||
|
html-to-text: "npm:9.0.5"
|
||||||
|
iconv-lite: "npm:0.6.3"
|
||||||
|
libmime: "npm:5.3.6"
|
||||||
|
linkify-it: "npm:5.0.0"
|
||||||
|
mailsplit: "npm:5.4.3"
|
||||||
|
nodemailer: "npm:7.0.3"
|
||||||
|
punycode.js: "npm:2.3.1"
|
||||||
|
tlds: "npm:1.259.0"
|
||||||
|
checksum: 10c0/91724c70b87487ca7a2ae9b0a9d095fea07b67215bb1d14dc899cc67442518547b0d57de6b1fe665cee84d2692da991268a4ccf9b603a092544a55545ce9d5aa
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"mailsplit@npm:5.4.3":
|
||||||
|
version: 5.4.3
|
||||||
|
resolution: "mailsplit@npm:5.4.3"
|
||||||
|
dependencies:
|
||||||
|
libbase64: "npm:1.3.0"
|
||||||
|
libmime: "npm:5.3.6"
|
||||||
|
libqp: "npm:2.1.1"
|
||||||
|
checksum: 10c0/8b95df9f7fa6d810936f8f90163b215eb829a8a3073b9badd134f21cd73365b60c3fe9fda806ec73fd05dd580af3630922a17e2d9b029124f13df6f2a6511e01
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"make-cancellable-promise@npm:^1.3.1":
|
"make-cancellable-promise@npm:^1.3.1":
|
||||||
version: 1.3.2
|
version: 1.3.2
|
||||||
resolution: "make-cancellable-promise@npm:1.3.2"
|
resolution: "make-cancellable-promise@npm:1.3.2"
|
||||||
@ -47007,6 +47119,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"nodemailer@npm:7.0.3":
|
||||||
|
version: 7.0.3
|
||||||
|
resolution: "nodemailer@npm:7.0.3"
|
||||||
|
checksum: 10c0/835492262328471b94a080cea43ea20f4232e19a915400cd71c7f4f4ab93a7d361775154eebe30a8fc40379eecf11a0bbc73e6cf4bbee9dccb6dd1cf7a1dc792
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"nodemailer@npm:^6.9.8":
|
"nodemailer@npm:^6.9.8":
|
||||||
version: 6.9.14
|
version: 6.9.14
|
||||||
resolution: "nodemailer@npm:6.9.14"
|
resolution: "nodemailer@npm:6.9.14"
|
||||||
@ -47714,6 +47833,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"on-exit-leak-free@npm:^2.1.0":
|
||||||
|
version: 2.1.2
|
||||||
|
resolution: "on-exit-leak-free@npm:2.1.2"
|
||||||
|
checksum: 10c0/faea2e1c9d696ecee919026c32be8d6a633a7ac1240b3b87e944a380e8a11dc9c95c4a1f8fb0568de7ab8db3823e790f12bda45296b1d111e341aad3922a0570
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"on-finished@npm:2.4.1":
|
"on-finished@npm:2.4.1":
|
||||||
version: 2.4.1
|
version: 2.4.1
|
||||||
resolution: "on-finished@npm:2.4.1"
|
resolution: "on-finished@npm:2.4.1"
|
||||||
@ -49082,6 +49208,43 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"pino-abstract-transport@npm:^2.0.0":
|
||||||
|
version: 2.0.0
|
||||||
|
resolution: "pino-abstract-transport@npm:2.0.0"
|
||||||
|
dependencies:
|
||||||
|
split2: "npm:^4.0.0"
|
||||||
|
checksum: 10c0/02c05b8f2ffce0d7c774c8e588f61e8b77de8ccb5f8125afd4a7325c9ea0e6af7fb78168999657712ae843e4462bb70ac550dfd6284f930ee57f17f486f25a9f
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"pino-std-serializers@npm:^7.0.0":
|
||||||
|
version: 7.0.0
|
||||||
|
resolution: "pino-std-serializers@npm:7.0.0"
|
||||||
|
checksum: 10c0/73e694d542e8de94445a03a98396cf383306de41fd75ecc07085d57ed7a57896198508a0dec6eefad8d701044af21eb27253ccc352586a03cf0d4a0bd25b4133
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"pino@npm:9.7.0":
|
||||||
|
version: 9.7.0
|
||||||
|
resolution: "pino@npm:9.7.0"
|
||||||
|
dependencies:
|
||||||
|
atomic-sleep: "npm:^1.0.0"
|
||||||
|
fast-redact: "npm:^3.1.1"
|
||||||
|
on-exit-leak-free: "npm:^2.1.0"
|
||||||
|
pino-abstract-transport: "npm:^2.0.0"
|
||||||
|
pino-std-serializers: "npm:^7.0.0"
|
||||||
|
process-warning: "npm:^5.0.0"
|
||||||
|
quick-format-unescaped: "npm:^4.0.3"
|
||||||
|
real-require: "npm:^0.2.0"
|
||||||
|
safe-stable-stringify: "npm:^2.3.1"
|
||||||
|
sonic-boom: "npm:^4.0.1"
|
||||||
|
thread-stream: "npm:^3.0.0"
|
||||||
|
bin:
|
||||||
|
pino: bin.js
|
||||||
|
checksum: 10c0/c7f8a83a9a9d728b4eff6d0f4b9367f031c91bcaa5806fbf0eedcc8e77faba593d59baf11a8fba0dd1c778bb17ca7ed01418ac1df4ec129faeedd4f3ecaff66f
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"pinpoint@npm:^1.1.0":
|
"pinpoint@npm:^1.1.0":
|
||||||
version: 1.1.0
|
version: 1.1.0
|
||||||
resolution: "pinpoint@npm:1.1.0"
|
resolution: "pinpoint@npm:1.1.0"
|
||||||
@ -49780,6 +49943,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"process-warning@npm:^5.0.0":
|
||||||
|
version: 5.0.0
|
||||||
|
resolution: "process-warning@npm:5.0.0"
|
||||||
|
checksum: 10c0/941f48863d368ec161e0b5890ba0c6af94170078f3d6b5e915c19b36fb59edb0dc2f8e834d25e0d375a8bf368a49d490f080508842168832b93489d17843ec29
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"process@npm:^0.11.10, process@npm:~0.11.0":
|
"process@npm:^0.11.10, process@npm:~0.11.0":
|
||||||
version: 0.11.10
|
version: 0.11.10
|
||||||
resolution: "process@npm:0.11.10"
|
resolution: "process@npm:0.11.10"
|
||||||
@ -50292,7 +50462,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"punycode.js@npm:^2.3.1":
|
"punycode.js@npm:2.3.1, punycode.js@npm:^2.3.1":
|
||||||
version: 2.3.1
|
version: 2.3.1
|
||||||
resolution: "punycode.js@npm:2.3.1"
|
resolution: "punycode.js@npm:2.3.1"
|
||||||
checksum: 10c0/1d12c1c0e06127fa5db56bd7fdf698daf9a78104456a6b67326877afc21feaa821257b171539caedd2f0524027fa38e67b13dd094159c8d70b6d26d2bea4dfdb
|
checksum: 10c0/1d12c1c0e06127fa5db56bd7fdf698daf9a78104456a6b67326877afc21feaa821257b171539caedd2f0524027fa38e67b13dd094159c8d70b6d26d2bea4dfdb
|
||||||
@ -50444,6 +50614,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"quick-format-unescaped@npm:^4.0.3":
|
||||||
|
version: 4.0.4
|
||||||
|
resolution: "quick-format-unescaped@npm:4.0.4"
|
||||||
|
checksum: 10c0/fe5acc6f775b172ca5b4373df26f7e4fd347975578199e7d74b2ae4077f0af05baa27d231de1e80e8f72d88275ccc6028568a7a8c9ee5e7368ace0e18eff93a4
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"quick-lru@npm:^5.1.1":
|
"quick-lru@npm:^5.1.1":
|
||||||
version: 5.1.1
|
version: 5.1.1
|
||||||
resolution: "quick-lru@npm:5.1.1"
|
resolution: "quick-lru@npm:5.1.1"
|
||||||
@ -51460,6 +51637,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"real-require@npm:^0.2.0":
|
||||||
|
version: 0.2.0
|
||||||
|
resolution: "real-require@npm:0.2.0"
|
||||||
|
checksum: 10c0/23eea5623642f0477412ef8b91acd3969015a1501ed34992ada0e3af521d3c865bb2fe4cdbfec5fe4b505f6d1ef6a03e5c3652520837a8c3b53decff7e74b6a0
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"recast@npm:^0.23.1, recast@npm:^0.23.3":
|
"recast@npm:^0.23.1, recast@npm:^0.23.3":
|
||||||
version: 0.23.9
|
version: 0.23.9
|
||||||
resolution: "recast@npm:0.23.9"
|
resolution: "recast@npm:0.23.9"
|
||||||
@ -53001,6 +53185,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"safe-stable-stringify@npm:^2.3.1":
|
||||||
|
version: 2.5.0
|
||||||
|
resolution: "safe-stable-stringify@npm:2.5.0"
|
||||||
|
checksum: 10c0/baea14971858cadd65df23894a40588ed791769db21bafb7fd7608397dbdce9c5aac60748abae9995e0fc37e15f2061980501e012cd48859740796bea2987f49
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"safer-buffer@npm:>= 2.1.2 < 3, safer-buffer@npm:>= 2.1.2 < 3.0.0, safer-buffer@npm:^2.0.2, safer-buffer@npm:^2.1.0, safer-buffer@npm:~2.1.0":
|
"safer-buffer@npm:>= 2.1.2 < 3, safer-buffer@npm:>= 2.1.2 < 3.0.0, safer-buffer@npm:^2.0.2, safer-buffer@npm:^2.1.0, safer-buffer@npm:~2.1.0":
|
||||||
version: 2.1.2
|
version: 2.1.2
|
||||||
resolution: "safer-buffer@npm:2.1.2"
|
resolution: "safer-buffer@npm:2.1.2"
|
||||||
@ -53870,6 +54061,16 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"socks@npm:2.8.4":
|
||||||
|
version: 2.8.4
|
||||||
|
resolution: "socks@npm:2.8.4"
|
||||||
|
dependencies:
|
||||||
|
ip-address: "npm:^9.0.5"
|
||||||
|
smart-buffer: "npm:^4.2.0"
|
||||||
|
checksum: 10c0/00c3271e233ccf1fb83a3dd2060b94cc37817e0f797a93c560b9a7a86c4a0ec2961fb31263bdd24a3c28945e24868b5f063cd98744171d9e942c513454b50ae5
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"socks@npm:^2.6.2, socks@npm:^2.8.3":
|
"socks@npm:^2.6.2, socks@npm:^2.8.3":
|
||||||
version: 2.8.3
|
version: 2.8.3
|
||||||
resolution: "socks@npm:2.8.3"
|
resolution: "socks@npm:2.8.3"
|
||||||
@ -53880,6 +54081,15 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"sonic-boom@npm:^4.0.1":
|
||||||
|
version: 4.2.0
|
||||||
|
resolution: "sonic-boom@npm:4.2.0"
|
||||||
|
dependencies:
|
||||||
|
atomic-sleep: "npm:^1.0.0"
|
||||||
|
checksum: 10c0/ae897e6c2cd6d3cb7cdcf608bc182393b19c61c9413a85ce33ffd25891485589f39bece0db1de24381d0a38fc03d08c9862ded0c60f184f1b852f51f97af9684
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"sort-keys-length@npm:^1.0.0":
|
"sort-keys-length@npm:^1.0.0":
|
||||||
version: 1.0.1
|
version: 1.0.1
|
||||||
resolution: "sort-keys-length@npm:1.0.1"
|
resolution: "sort-keys-length@npm:1.0.1"
|
||||||
@ -54121,7 +54331,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"split2@npm:^4.1.0":
|
"split2@npm:^4.0.0, split2@npm:^4.1.0":
|
||||||
version: 4.2.0
|
version: 4.2.0
|
||||||
resolution: "split2@npm:4.2.0"
|
resolution: "split2@npm:4.2.0"
|
||||||
checksum: 10c0/b292beb8ce9215f8c642bb68be6249c5a4c7f332fc8ecadae7be5cbdf1ea95addc95f0459ef2e7ad9d45fd1064698a097e4eb211c83e772b49bc0ee423e91534
|
checksum: 10c0/b292beb8ce9215f8c642bb68be6249c5a4c7f332fc8ecadae7be5cbdf1ea95addc95f0459ef2e7ad9d45fd1064698a097e4eb211c83e772b49bc0ee423e91534
|
||||||
@ -55515,6 +55725,15 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"thread-stream@npm:^3.0.0":
|
||||||
|
version: 3.1.0
|
||||||
|
resolution: "thread-stream@npm:3.1.0"
|
||||||
|
dependencies:
|
||||||
|
real-require: "npm:^0.2.0"
|
||||||
|
checksum: 10c0/c36118379940b77a6ef3e6f4d5dd31e97b8210c3f7b9a54eb8fe6358ab173f6d0acfaf69b9c3db024b948c0c5fd2a7df93e2e49151af02076b35ada3205ec9a6
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"throttle-debounce@npm:^3.0.1":
|
"throttle-debounce@npm:^3.0.1":
|
||||||
version: 3.0.1
|
version: 3.0.1
|
||||||
resolution: "throttle-debounce@npm:3.0.1"
|
resolution: "throttle-debounce@npm:3.0.1"
|
||||||
@ -55661,6 +55880,15 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"tlds@npm:1.259.0":
|
||||||
|
version: 1.259.0
|
||||||
|
resolution: "tlds@npm:1.259.0"
|
||||||
|
bin:
|
||||||
|
tlds: bin.js
|
||||||
|
checksum: 10c0/1a22109aee9faf826f9da4cc79a7ca2c327955033ab3ad87258665e2bde4011171f536357c9f83d9450b60c87a2261ef9037c0cd8977a39c639909904d051780
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"tldts-core@npm:^6.1.86":
|
"tldts-core@npm:^6.1.86":
|
||||||
version: 6.1.86
|
version: 6.1.86
|
||||||
resolution: "tldts-core@npm:6.1.86"
|
resolution: "tldts-core@npm:6.1.86"
|
||||||
@ -56800,6 +57028,7 @@ __metadata:
|
|||||||
"@types/file-saver": "npm:^2.0.7"
|
"@types/file-saver": "npm:^2.0.7"
|
||||||
"@types/graphql-fields": "npm:^1.3.6"
|
"@types/graphql-fields": "npm:^1.3.6"
|
||||||
"@types/graphql-upload": "npm:^8.0.12"
|
"@types/graphql-upload": "npm:^8.0.12"
|
||||||
|
"@types/imapflow": "npm:^1.0.21"
|
||||||
"@types/js-cookie": "npm:^3.0.3"
|
"@types/js-cookie": "npm:^3.0.3"
|
||||||
"@types/js-levenshtein": "npm:^1.1.3"
|
"@types/js-levenshtein": "npm:^1.1.3"
|
||||||
"@types/lodash.camelcase": "npm:^4.3.7"
|
"@types/lodash.camelcase": "npm:^4.3.7"
|
||||||
@ -56821,6 +57050,7 @@ __metadata:
|
|||||||
"@types/lodash.snakecase": "npm:^4.1.7"
|
"@types/lodash.snakecase": "npm:^4.1.7"
|
||||||
"@types/lodash.upperfirst": "npm:^4.3.7"
|
"@types/lodash.upperfirst": "npm:^4.3.7"
|
||||||
"@types/luxon": "npm:^3.3.0"
|
"@types/luxon": "npm:^3.3.0"
|
||||||
|
"@types/mailparser": "npm:^3.4.6"
|
||||||
"@types/ms": "npm:^0.7.31"
|
"@types/ms": "npm:^0.7.31"
|
||||||
"@types/node": "npm:^22.0.0"
|
"@types/node": "npm:^22.0.0"
|
||||||
"@types/nodemailer": "npm:^6.4.14"
|
"@types/nodemailer": "npm:^6.4.14"
|
||||||
@ -56913,6 +57143,7 @@ __metadata:
|
|||||||
hex-rgb: "npm:^5.0.0"
|
hex-rgb: "npm:^5.0.0"
|
||||||
http-server: "npm:^14.1.1"
|
http-server: "npm:^14.1.1"
|
||||||
iframe-resizer-react: "npm:^1.1.0"
|
iframe-resizer-react: "npm:^1.1.0"
|
||||||
|
imapflow: "npm:^1.0.186"
|
||||||
immer: "npm:^10.0.2"
|
immer: "npm:^10.0.2"
|
||||||
jest: "npm:29.7.0"
|
jest: "npm:29.7.0"
|
||||||
jest-environment-jsdom: "npm:30.0.0-beta.3"
|
jest-environment-jsdom: "npm:30.0.0-beta.3"
|
||||||
@ -56944,6 +57175,7 @@ __metadata:
|
|||||||
lodash.snakecase: "npm:^4.1.1"
|
lodash.snakecase: "npm:^4.1.1"
|
||||||
lodash.upperfirst: "npm:^4.3.1"
|
lodash.upperfirst: "npm:^4.3.1"
|
||||||
luxon: "npm:^3.3.0"
|
luxon: "npm:^3.3.0"
|
||||||
|
mailparser: "npm:^3.7.3"
|
||||||
microdiff: "npm:^1.3.2"
|
microdiff: "npm:^1.3.2"
|
||||||
moize: "npm:^6.1.6"
|
moize: "npm:^6.1.6"
|
||||||
msw: "npm:^2.0.11"
|
msw: "npm:^2.0.11"
|
||||||
|
|||||||
Reference in New Issue
Block a user