feat: SMTP Driver Integration (#12993)
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:
141
nx.json
141
nx.json
@ -1,6 +1,12 @@
|
||||
{
|
||||
"workspaceLayout": {
|
||||
"appsDir": "packages",
|
||||
"libsDir": "packages"
|
||||
},
|
||||
"namedInputs": {
|
||||
"default": ["{projectRoot}/**/*"],
|
||||
"default": [
|
||||
"{projectRoot}/**/*"
|
||||
],
|
||||
"excludeStories": [
|
||||
"default",
|
||||
"!{projectRoot}/.storybook/*",
|
||||
@ -28,17 +34,26 @@
|
||||
"targetDefaults": {
|
||||
"build": {
|
||||
"cache": true,
|
||||
"inputs": ["^production", "production"],
|
||||
"dependsOn": ["^build"]
|
||||
"inputs": [
|
||||
"^production",
|
||||
"production"
|
||||
],
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
]
|
||||
},
|
||||
"start": {
|
||||
"cache": true,
|
||||
"dependsOn": ["^build"]
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
]
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint",
|
||||
"cache": true,
|
||||
"outputs": ["{options.outputFile}"],
|
||||
"outputs": [
|
||||
"{options.outputFile}"
|
||||
],
|
||||
"options": {
|
||||
"eslintConfig": "{projectRoot}/.eslintrc.cjs",
|
||||
"cache": true,
|
||||
@ -46,10 +61,16 @@
|
||||
"ignorePath": "{workspaceRoot}/.gitignore"
|
||||
},
|
||||
"configurations": {
|
||||
"ci": { "cacheStrategy": "content" },
|
||||
"fix": { "fix": true }
|
||||
"ci": {
|
||||
"cacheStrategy": "content"
|
||||
},
|
||||
"dependsOn": ["^build"]
|
||||
"fix": {
|
||||
"fix": true
|
||||
}
|
||||
},
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
]
|
||||
},
|
||||
"fmt": {
|
||||
"executor": "nx:run-commands",
|
||||
@ -63,10 +84,16 @@
|
||||
"write": false
|
||||
},
|
||||
"configurations": {
|
||||
"ci": { "cacheStrategy": "content" },
|
||||
"fix": { "write": true }
|
||||
"ci": {
|
||||
"cacheStrategy": "content"
|
||||
},
|
||||
"dependsOn": ["^build"]
|
||||
"fix": {
|
||||
"write": true
|
||||
}
|
||||
},
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
]
|
||||
},
|
||||
"typecheck": {
|
||||
"executor": "nx:run-commands",
|
||||
@ -76,24 +103,34 @@
|
||||
"command": "tsc -b tsconfig.json --incremental"
|
||||
},
|
||||
"configurations": {
|
||||
"watch": { "watch": true }
|
||||
"watch": {
|
||||
"watch": true
|
||||
}
|
||||
},
|
||||
"dependsOn": ["^build"]
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
]
|
||||
},
|
||||
"test": {
|
||||
"executor": "@nx/jest:jest",
|
||||
"cache": true,
|
||||
"dependsOn": ["^build"],
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
],
|
||||
"inputs": [
|
||||
"^default",
|
||||
"excludeStories",
|
||||
"{workspaceRoot}/jest.preset.js"
|
||||
],
|
||||
"outputs": ["{projectRoot}/coverage"],
|
||||
"outputs": [
|
||||
"{projectRoot}/coverage"
|
||||
],
|
||||
"options": {
|
||||
"jestConfig": "{projectRoot}/jest.config.ts",
|
||||
"coverage": true,
|
||||
"coverageReporters": ["text-summary"],
|
||||
"coverageReporters": [
|
||||
"text-summary"
|
||||
],
|
||||
"cacheDirectory": "../../.cache/jest/{projectRoot}"
|
||||
},
|
||||
"configurations": {
|
||||
@ -101,31 +138,49 @@
|
||||
"ci": true,
|
||||
"maxWorkers": 3
|
||||
},
|
||||
"coverage": { "coverageReporters": ["lcov", "text"] },
|
||||
"watch": { "watch": true }
|
||||
"coverage": {
|
||||
"coverageReporters": [
|
||||
"lcov",
|
||||
"text"
|
||||
]
|
||||
},
|
||||
"watch": {
|
||||
"watch": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"test:e2e": {
|
||||
"cache": true,
|
||||
"dependsOn": ["^build"]
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
]
|
||||
},
|
||||
"storybook:build": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"inputs": ["^default", "excludeTests"],
|
||||
"outputs": ["{projectRoot}/{options.output-dir}"],
|
||||
"inputs": [
|
||||
"^default",
|
||||
"excludeTests"
|
||||
],
|
||||
"outputs": [
|
||||
"{projectRoot}/{options.output-dir}"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "{projectRoot}",
|
||||
"command": "VITE_DISABLE_TYPESCRIPT_CHECKER=true VITE_DISABLE_ESLINT_CHECKER=true storybook build --test",
|
||||
"output-dir": "storybook-static",
|
||||
"config-dir": ".storybook"
|
||||
},
|
||||
"dependsOn": ["^build"]
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
]
|
||||
},
|
||||
"storybook:serve:dev": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"dependsOn": ["^build"],
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "{projectRoot}",
|
||||
"command": "storybook dev",
|
||||
@ -134,7 +189,9 @@
|
||||
},
|
||||
"storybook:serve:static": {
|
||||
"executor": "nx:run-commands",
|
||||
"dependsOn": ["storybook:build"],
|
||||
"dependsOn": [
|
||||
"storybook:build"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "{projectRoot}",
|
||||
"command": "npx http-server {args.staticDir} -a={args.host} --port={args.port} --silent={args.silent}",
|
||||
@ -147,8 +204,13 @@
|
||||
"storybook:test": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"inputs": ["^default", "excludeTests"],
|
||||
"outputs": ["{projectRoot}/coverage/storybook"],
|
||||
"inputs": [
|
||||
"^default",
|
||||
"excludeTests"
|
||||
],
|
||||
"outputs": [
|
||||
"{projectRoot}/coverage/storybook"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "{projectRoot}",
|
||||
"commands": [
|
||||
@ -164,7 +226,10 @@
|
||||
},
|
||||
"storybook:test:no-coverage": {
|
||||
"executor": "nx:run-commands",
|
||||
"inputs": ["^default", "excludeTests"],
|
||||
"inputs": [
|
||||
"^default",
|
||||
"excludeTests"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "{projectRoot}",
|
||||
"commands": [
|
||||
@ -192,7 +257,9 @@
|
||||
"checkCoverage": true
|
||||
},
|
||||
"configurations": {
|
||||
"text": { "reporter": "text" }
|
||||
"text": {
|
||||
"reporter": "text"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storybook:serve-and-test:static": {
|
||||
@ -252,12 +319,20 @@
|
||||
},
|
||||
"@nx/vite:test": {
|
||||
"cache": true,
|
||||
"inputs": ["default", "^default"]
|
||||
"inputs": [
|
||||
"default",
|
||||
"^default"
|
||||
]
|
||||
},
|
||||
"@nx/vite:build": {
|
||||
"cache": true,
|
||||
"dependsOn": ["^build"],
|
||||
"inputs": ["default", "^default"]
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
],
|
||||
"inputs": [
|
||||
"default",
|
||||
"^default"
|
||||
]
|
||||
}
|
||||
},
|
||||
"installation": {
|
||||
@ -289,7 +364,9 @@
|
||||
"tasksRunnerOptions": {
|
||||
"default": {
|
||||
"options": {
|
||||
"cacheableOperations": ["storybook:build"]
|
||||
"cacheableOperations": [
|
||||
"storybook:build"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@ -435,7 +435,6 @@ export type ConnectionParameters = {
|
||||
password: Scalars['String'];
|
||||
port: Scalars['Float'];
|
||||
secure?: InputMaybe<Scalars['Boolean']>;
|
||||
username: Scalars['String'];
|
||||
};
|
||||
|
||||
export type ConnectionParametersOutput = {
|
||||
@ -444,7 +443,6 @@ export type ConnectionParametersOutput = {
|
||||
password: Scalars['String'];
|
||||
port: Scalars['Float'];
|
||||
secure?: Maybe<Scalars['Boolean']>;
|
||||
username: Scalars['String'];
|
||||
};
|
||||
|
||||
export type CreateApiKeyDto = {
|
||||
@ -708,7 +706,7 @@ export enum FeatureFlagKey {
|
||||
IS_AIRTABLE_INTEGRATION_ENABLED = 'IS_AIRTABLE_INTEGRATION_ENABLED',
|
||||
IS_AI_ENABLED = 'IS_AI_ENABLED',
|
||||
IS_FIELDS_PERMISSIONS_ENABLED = 'IS_FIELDS_PERMISSIONS_ENABLED',
|
||||
IS_IMAP_ENABLED = 'IS_IMAP_ENABLED',
|
||||
IS_IMAP_SMTP_CALDAV_ENABLED = 'IS_IMAP_SMTP_CALDAV_ENABLED',
|
||||
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
|
||||
IS_MORPH_RELATION_ENABLED = 'IS_MORPH_RELATION_ENABLED',
|
||||
IS_POSTGRESQL_INTEGRATION_ENABLED = 'IS_POSTGRESQL_INTEGRATION_ENABLED',
|
||||
@ -3270,7 +3268,7 @@ export type GetConnectedImapSmtpCaldavAccountQueryVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
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 GetConnectedImapSmtpCaldavAccountQuery = { __typename?: 'Query', getConnectedImapSmtpCaldavAccount: { __typename?: 'ConnectedImapSmtpCaldavAccount', id: string, handle: string, provider: string, accountOwnerId: string, connectionParameters?: { __typename?: 'ImapSmtpCaldavConnectionParameters', IMAP?: { __typename?: 'ConnectionParametersOutput', host: string, port: number, secure?: boolean | null, password: string } | null, SMTP?: { __typename?: 'ConnectionParametersOutput', host: string, port: number, secure?: boolean | null, password: string } | null, CALDAV?: { __typename?: 'ConnectionParametersOutput', host: string, port: number, secure?: boolean | null, password: string } | null } | null } };
|
||||
|
||||
export type CreateDatabaseConfigVariableMutationVariables = Exact<{
|
||||
key: Scalars['String'];
|
||||
@ -6003,21 +6001,18 @@ export const GetConnectedImapSmtpCaldavAccountDocument = gql`
|
||||
host
|
||||
port
|
||||
secure
|
||||
username
|
||||
password
|
||||
}
|
||||
SMTP {
|
||||
host
|
||||
port
|
||||
secure
|
||||
username
|
||||
password
|
||||
}
|
||||
CALDAV {
|
||||
host
|
||||
port
|
||||
secure
|
||||
username
|
||||
password
|
||||
}
|
||||
}
|
||||
|
||||
@ -435,7 +435,6 @@ export type ConnectionParameters = {
|
||||
password: Scalars['String'];
|
||||
port: Scalars['Float'];
|
||||
secure?: InputMaybe<Scalars['Boolean']>;
|
||||
username: Scalars['String'];
|
||||
};
|
||||
|
||||
export type ConnectionParametersOutput = {
|
||||
@ -444,7 +443,6 @@ export type ConnectionParametersOutput = {
|
||||
password: Scalars['String'];
|
||||
port: Scalars['Float'];
|
||||
secure?: Maybe<Scalars['Boolean']>;
|
||||
username: Scalars['String'];
|
||||
};
|
||||
|
||||
export type CreateApiKeyDto = {
|
||||
@ -672,7 +670,7 @@ export enum FeatureFlagKey {
|
||||
IS_AIRTABLE_INTEGRATION_ENABLED = 'IS_AIRTABLE_INTEGRATION_ENABLED',
|
||||
IS_AI_ENABLED = 'IS_AI_ENABLED',
|
||||
IS_FIELDS_PERMISSIONS_ENABLED = 'IS_FIELDS_PERMISSIONS_ENABLED',
|
||||
IS_IMAP_ENABLED = 'IS_IMAP_ENABLED',
|
||||
IS_IMAP_SMTP_CALDAV_ENABLED = 'IS_IMAP_SMTP_CALDAV_ENABLED',
|
||||
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
|
||||
IS_MORPH_RELATION_ENABLED = 'IS_MORPH_RELATION_ENABLED',
|
||||
IS_POSTGRESQL_INTEGRATION_ENABLED = 'IS_POSTGRESQL_INTEGRATION_ENABLED',
|
||||
|
||||
@ -64,19 +64,19 @@ const SettingsNewObject = lazy(() =>
|
||||
})),
|
||||
);
|
||||
|
||||
const SettingsNewImapConnection = lazy(() =>
|
||||
const SettingsNewImapSmtpCaldavConnection = lazy(() =>
|
||||
import(
|
||||
'@/settings/accounts/components/SettingsAccountsNewImapConnection'
|
||||
'@/settings/accounts/components/SettingsAccountsNewImapSmtpCaldavConnection'
|
||||
).then((module) => ({
|
||||
default: module.SettingsAccountsNewImapConnection,
|
||||
default: module.SettingsAccountsNewImapSmtpCaldavConnection,
|
||||
})),
|
||||
);
|
||||
|
||||
const SettingsEditImapConnection = lazy(() =>
|
||||
const SettingsEditImapSmtpCaldavConnection = lazy(() =>
|
||||
import(
|
||||
'@/settings/accounts/components/SettingsAccountsEditImapConnection'
|
||||
'@/settings/accounts/components/SettingsAccountsEditImapSmtpCaldavConnection'
|
||||
).then((module) => ({
|
||||
default: module.SettingsAccountsEditImapConnection,
|
||||
default: module.SettingsAccountsEditImapSmtpCaldavConnection,
|
||||
})),
|
||||
);
|
||||
|
||||
@ -375,12 +375,12 @@ export const SettingsRoutes = ({
|
||||
element={<SettingsAccountsEmails />}
|
||||
/>
|
||||
<Route
|
||||
path={SettingsPath.NewImapConnection}
|
||||
element={<SettingsNewImapConnection />}
|
||||
path={SettingsPath.NewImapSmtpCaldavConnection}
|
||||
element={<SettingsNewImapSmtpCaldavConnection />}
|
||||
/>
|
||||
<Route
|
||||
path={SettingsPath.EditImapConnection}
|
||||
element={<SettingsEditImapConnection />}
|
||||
path={SettingsPath.EditImapSmtpCaldavConnection}
|
||||
element={<SettingsEditImapSmtpCaldavConnection />}
|
||||
/>
|
||||
<Route
|
||||
element={
|
||||
|
||||
@ -15,6 +15,7 @@ import { isDeveloperDefaultSignInPrefilledState } from '@/client-config/states/i
|
||||
import { isEmailVerificationRequiredState } from '@/client-config/states/isEmailVerificationRequiredState';
|
||||
import { isGoogleCalendarEnabledState } from '@/client-config/states/isGoogleCalendarEnabledState';
|
||||
import { isGoogleMessagingEnabledState } from '@/client-config/states/isGoogleMessagingEnabledState';
|
||||
import { isImapSmtpCaldavEnabledState } from '@/client-config/states/isImapSmtpCaldavEnabledState';
|
||||
import { isMicrosoftCalendarEnabledState } from '@/client-config/states/isMicrosoftCalendarEnabledState';
|
||||
import { isMicrosoftMessagingEnabledState } from '@/client-config/states/isMicrosoftMessagingEnabledState';
|
||||
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
|
||||
@ -92,6 +93,10 @@ export const ClientConfigProviderEffect = () => {
|
||||
calendarBookingPageIdState,
|
||||
);
|
||||
|
||||
const setIsImapSmtpCaldavEnabled = useSetRecoilState(
|
||||
isImapSmtpCaldavEnabledState,
|
||||
);
|
||||
|
||||
const { data, loading, error, fetchClientConfig } = useClientConfig();
|
||||
|
||||
useEffect(() => {
|
||||
@ -183,6 +188,7 @@ export const ClientConfigProviderEffect = () => {
|
||||
}));
|
||||
|
||||
setCalendarBookingPageId(data?.clientConfig?.calendarBookingPageId ?? null);
|
||||
setIsImapSmtpCaldavEnabled(data?.clientConfig?.isImapSmtpCaldavEnabled);
|
||||
}, [
|
||||
data,
|
||||
loading,
|
||||
@ -210,6 +216,7 @@ export const ClientConfigProviderEffect = () => {
|
||||
setIsAttachmentPreviewEnabled,
|
||||
setIsConfigVariablesInDbEnabled,
|
||||
setCalendarBookingPageId,
|
||||
setIsImapSmtpCaldavEnabled,
|
||||
]);
|
||||
|
||||
return <></>;
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
import { createState } from 'twenty-ui/utilities';
|
||||
export const isImapSmtpCaldavEnabledState = createState<boolean>({
|
||||
key: 'isImapSmtpCaldavEnabled',
|
||||
defaultValue: false,
|
||||
});
|
||||
@ -30,7 +30,7 @@ export type ClientConfig = {
|
||||
isMicrosoftCalendarEnabled: boolean;
|
||||
isMicrosoftMessagingEnabled: boolean;
|
||||
isMultiWorkspaceEnabled: boolean;
|
||||
isIMAPMessagingEnabled: boolean;
|
||||
isImapSmtpCaldavEnabled: boolean;
|
||||
publicFeatureFlags: Array<PublicFeatureFlag>;
|
||||
sentry: Sentry;
|
||||
signInPrefilled: boolean;
|
||||
|
||||
@ -4,21 +4,11 @@ import { SettingsPath } from '@/types/SettingsPath';
|
||||
|
||||
import { SettingsAccountsConnectedAccountsRowRightContainer } from '@/settings/accounts/components/SettingsAccountsConnectedAccountsRowRightContainer';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import {
|
||||
IconComponent,
|
||||
IconGoogle,
|
||||
IconMail,
|
||||
IconMicrosoft,
|
||||
} from 'twenty-ui/display';
|
||||
|
||||
import { SettingsConnectedAccountIcon } from '@/settings/accounts/components/SettingsConnectedAccountIcon';
|
||||
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
|
||||
import { SettingsListCard } from '../../components/SettingsListCard';
|
||||
|
||||
const ProviderIcons: { [k: string]: IconComponent } = {
|
||||
google: IconGoogle,
|
||||
microsoft: IconMicrosoft,
|
||||
imap: IconMail,
|
||||
};
|
||||
|
||||
export const SettingsAccountsConnectedAccountsListCard = ({
|
||||
accounts,
|
||||
loading,
|
||||
@ -38,7 +28,7 @@ export const SettingsAccountsConnectedAccountsListCard = ({
|
||||
items={accounts}
|
||||
getItemLabel={(account) => account.handle}
|
||||
isLoading={loading}
|
||||
RowIconFn={(row) => ProviderIcons[row.provider]}
|
||||
RowIconFn={(row) => SettingsConnectedAccountIcon({ account: row })}
|
||||
RowRightComponent={({ item: account }) => (
|
||||
<SettingsAccountsConnectedAccountsRowRightContainer account={account} />
|
||||
)}
|
||||
|
||||
@ -0,0 +1,354 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Control, Controller } from 'react-hook-form';
|
||||
|
||||
import { Select } from '@/ui/input/components/Select';
|
||||
import { TextInput } from '@/ui/input/components/TextInput';
|
||||
|
||||
import { ConnectionFormData } from '@/settings/accounts/hooks/useImapSmtpCaldavConnectionForm';
|
||||
import { H2Title } from 'twenty-ui/display';
|
||||
import { Section } from 'twenty-ui/layout';
|
||||
import { MOBILE_VIEWPORT } from 'twenty-ui/theme';
|
||||
|
||||
const StyledFormContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${({ theme }) => theme.spacing(6)};
|
||||
`;
|
||||
|
||||
const StyledConnectionSection = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const StyledSectionHeader = styled.div`
|
||||
margin-bottom: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const StyledSectionTitle = styled.h3`
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
font-size: ${({ theme }) => theme.font.size.md};
|
||||
font-weight: ${({ theme }) => theme.font.weight.medium};
|
||||
margin: 0;
|
||||
margin-bottom: ${({ theme }) => theme.spacing(1)};
|
||||
`;
|
||||
|
||||
const StyledSectionDescription = styled.p`
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
font-size: ${({ theme }) => theme.font.size.sm};
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const StyledFieldRow = styled.div`
|
||||
display: flex;
|
||||
gap: ${({ theme }) => theme.spacing(3)};
|
||||
|
||||
@media (max-width: ${MOBILE_VIEWPORT}px) {
|
||||
flex-direction: column;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledFieldGroup = styled.div`
|
||||
flex: 1;
|
||||
|
||||
& > * {
|
||||
width: 100%;
|
||||
}
|
||||
`;
|
||||
|
||||
type SettingsAccountsConnectionFormProps = {
|
||||
control: Control<ConnectionFormData>;
|
||||
isEditing: boolean;
|
||||
};
|
||||
|
||||
export const SettingsAccountsConnectionForm = ({
|
||||
control,
|
||||
isEditing,
|
||||
}: SettingsAccountsConnectionFormProps) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const getTitle = () => {
|
||||
return isEditing ? t`Edit Email Account` : t`New Email Account`;
|
||||
};
|
||||
|
||||
const getDescription = () => {
|
||||
if (isEditing) {
|
||||
return t`Update your email account configuration. Configure any combination of IMAP, SMTP, and CalDAV as needed.`;
|
||||
}
|
||||
return t`You can set up any combination of IMAP (receiving emails), SMTP (sending emails), and CalDAV (calendar sync).`;
|
||||
};
|
||||
|
||||
const handlePortChange = (value: string) => Number(value);
|
||||
|
||||
return (
|
||||
<Section>
|
||||
<H2Title title={getTitle()} description={getDescription()} />
|
||||
<StyledFormContainer>
|
||||
<Controller
|
||||
name="handle"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextInput
|
||||
instanceId="email-address-connection-form"
|
||||
label={t`Email Address`}
|
||||
placeholder={t`john.doe@example.com`}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
error={fieldState.error?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<StyledConnectionSection>
|
||||
<StyledSectionHeader>
|
||||
<StyledSectionTitle>{t`IMAP Configuration`}</StyledSectionTitle>
|
||||
<StyledSectionDescription>
|
||||
{t`Configure IMAP settings to receive and sync your emails.`}
|
||||
<br />
|
||||
{t`Leave blank if you don't need to import emails.`}
|
||||
</StyledSectionDescription>
|
||||
</StyledSectionHeader>
|
||||
|
||||
<Controller
|
||||
name="IMAP.host"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextInput
|
||||
instanceId="imap-host-connection-form"
|
||||
label={t`IMAP Server`}
|
||||
placeholder={t`imap.example.com`}
|
||||
value={field.value || ''}
|
||||
onChange={field.onChange}
|
||||
error={fieldState.error?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="IMAP.password"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextInput
|
||||
instanceId="imap-password-connection-form"
|
||||
label={t`IMAP Password`}
|
||||
placeholder={t`••••••••`}
|
||||
type="password"
|
||||
value={field.value || ''}
|
||||
onChange={field.onChange}
|
||||
error={fieldState.error?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<StyledFieldRow>
|
||||
<StyledFieldGroup>
|
||||
<Controller
|
||||
name="IMAP.port"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextInput
|
||||
instanceId="imap-port-connection-form"
|
||||
label={t`IMAP Port`}
|
||||
type="number"
|
||||
placeholder="993"
|
||||
value={field?.value ? field.value : 993}
|
||||
onChange={(value) =>
|
||||
field.onChange(handlePortChange(value))
|
||||
}
|
||||
error={fieldState.error?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</StyledFieldGroup>
|
||||
|
||||
<StyledFieldGroup>
|
||||
<Controller
|
||||
name="IMAP.secure"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
label={t`IMAP Encryption`}
|
||||
options={[
|
||||
{ label: 'SSL/TLS', value: true },
|
||||
{ label: 'None', value: false },
|
||||
]}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
dropdownId="imap-secure-dropdown"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</StyledFieldGroup>
|
||||
</StyledFieldRow>
|
||||
</StyledConnectionSection>
|
||||
|
||||
<StyledConnectionSection>
|
||||
<StyledSectionHeader>
|
||||
<StyledSectionTitle>{t`SMTP Configuration`}</StyledSectionTitle>
|
||||
<StyledSectionDescription>
|
||||
{t`Configure SMTP settings to send emails from your account.`}
|
||||
<br />
|
||||
{t`Leave blank if you don't need to send emails.`}
|
||||
</StyledSectionDescription>
|
||||
</StyledSectionHeader>
|
||||
|
||||
<Controller
|
||||
name="SMTP.host"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextInput
|
||||
instanceId="smtp-host-connection-form"
|
||||
label={t`SMTP Server`}
|
||||
placeholder={t`smtp.example.com`}
|
||||
value={field.value || ''}
|
||||
onChange={field.onChange}
|
||||
error={fieldState.error?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="SMTP.password"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextInput
|
||||
instanceId="smtp-password-connection-form"
|
||||
label={t`SMTP Password`}
|
||||
placeholder={t`••••••••`}
|
||||
type="password"
|
||||
value={field.value || ''}
|
||||
onChange={field.onChange}
|
||||
error={fieldState.error?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<StyledFieldRow>
|
||||
<StyledFieldGroup>
|
||||
<Controller
|
||||
name="SMTP.port"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextInput
|
||||
instanceId="smtp-port-connection-form"
|
||||
label={t`SMTP Port`}
|
||||
type="number"
|
||||
placeholder="587"
|
||||
value={field?.value ? field.value : 587}
|
||||
onChange={(value) =>
|
||||
field.onChange(handlePortChange(value))
|
||||
}
|
||||
error={fieldState.error?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</StyledFieldGroup>
|
||||
|
||||
<StyledFieldGroup>
|
||||
<Controller
|
||||
name="SMTP.secure"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
label={t`SMTP Encryption`}
|
||||
options={[
|
||||
{ label: 'SSL/TLS', value: true },
|
||||
{ label: 'STARTTLS', value: false },
|
||||
]}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
dropdownId="smtp-secure-dropdown"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</StyledFieldGroup>
|
||||
</StyledFieldRow>
|
||||
</StyledConnectionSection>
|
||||
|
||||
<StyledConnectionSection>
|
||||
<StyledSectionHeader>
|
||||
<StyledSectionTitle>{t`CalDAV Configuration`}</StyledSectionTitle>
|
||||
<StyledSectionDescription>
|
||||
{t`Configure CalDAV settings to sync your calendar events.`}
|
||||
<br />
|
||||
{t`Leave blank if you don't need calendar sync.`}
|
||||
</StyledSectionDescription>
|
||||
</StyledSectionHeader>
|
||||
|
||||
<Controller
|
||||
name="CALDAV.host"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextInput
|
||||
instanceId="caldav-host-connection-form"
|
||||
label={t`CalDAV Server`}
|
||||
placeholder={t`caldav.example.com`}
|
||||
value={field.value || ''}
|
||||
onChange={field.onChange}
|
||||
error={fieldState.error?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="CALDAV.password"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextInput
|
||||
instanceId="caldav-password-connection-form"
|
||||
label={t`CalDAV Password`}
|
||||
placeholder={t`••••••••`}
|
||||
type="password"
|
||||
value={field.value || ''}
|
||||
onChange={field.onChange}
|
||||
error={fieldState.error?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<StyledFieldRow>
|
||||
<StyledFieldGroup>
|
||||
<Controller
|
||||
name="CALDAV.port"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextInput
|
||||
instanceId="caldav-port-connection-form"
|
||||
label={t`CalDAV Port`}
|
||||
type="number"
|
||||
placeholder="443"
|
||||
value={field?.value ? field.value : 443}
|
||||
onChange={(value) =>
|
||||
field.onChange(handlePortChange(value))
|
||||
}
|
||||
error={fieldState.error?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</StyledFieldGroup>
|
||||
|
||||
<StyledFieldGroup>
|
||||
<Controller
|
||||
name="CALDAV.secure"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
label={t`CalDAV Encryption`}
|
||||
options={[
|
||||
{ label: 'SSL/TLS', value: true },
|
||||
{ label: 'None', value: false },
|
||||
]}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
dropdownId="caldav-secure-dropdown"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</StyledFieldGroup>
|
||||
</StyledFieldRow>
|
||||
</StyledConnectionSection>
|
||||
</StyledFormContainer>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
@ -1,93 +1,93 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
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';
|
||||
|
||||
import { NotFound } from '~/pages/not-found/NotFound';
|
||||
import { useImapSmtpCaldavConnectionForm } from '../hooks/useImapSmtpCaldavConnectionForm';
|
||||
import { SettingsAccountsConnectionForm } from './SettingsAccountsConnectionForm';
|
||||
|
||||
const StyledLoadingContainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
height: 200px;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const SettingsAccountsEditImapConnection = () => {
|
||||
export const SettingsAccountsEditImapSmtpCaldavConnection = () => {
|
||||
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,
|
||||
const {
|
||||
formMethods,
|
||||
handleSave,
|
||||
handleSubmit,
|
||||
canSave,
|
||||
isSubmitting,
|
||||
loading,
|
||||
connectedAccount,
|
||||
} = useImapSmtpCaldavConnectionForm({
|
||||
isEditing: true,
|
||||
connectedAccountId,
|
||||
});
|
||||
|
||||
const { control } = formMethods;
|
||||
|
||||
const renderLoadingState = () => (
|
||||
if (loading && !connectedAccount) {
|
||||
return (
|
||||
<StyledLoadingContainer>
|
||||
<Loader />
|
||||
</StyledLoadingContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (!connectedAccount && !loading) {
|
||||
return <NotFound />;
|
||||
}
|
||||
|
||||
const renderForm = () => (
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
<FormProvider {...formMethods}>
|
||||
<SubMenuTopBarContainer
|
||||
title={t`Edit IMAP Connection`}
|
||||
title={t`Edit Email Account`}
|
||||
links={[
|
||||
{
|
||||
children: t`Settings`,
|
||||
children: t`Workspace`,
|
||||
href: getSettingsPath(SettingsPath.Workspace),
|
||||
},
|
||||
{
|
||||
children: t`Email Connections`,
|
||||
children: t`Accounts`,
|
||||
href: getSettingsPath(SettingsPath.Accounts),
|
||||
},
|
||||
{ children: t`Edit IMAP Connection` },
|
||||
{ children: t`Edit Email Account` },
|
||||
]}
|
||||
actionButton={
|
||||
<SaveAndCancelButtons
|
||||
isSaveDisabled={!canSave}
|
||||
isCancelDisabled={isSubmitting}
|
||||
isLoading={loading}
|
||||
onCancel={() => navigate(SettingsPath.Accounts)}
|
||||
onSave={handleSubmit(handleSave)}
|
||||
onSave={handleSubmit((data) => handleSave(data))}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<SettingsPageContainer>
|
||||
<SetttingsAccountsImapConnectionForm control={control} isEditing />
|
||||
<SettingsAccountsConnectionForm control={control} isEditing />
|
||||
</SettingsPageContainer>
|
||||
</SubMenuTopBarContainer>
|
||||
</FormProvider>
|
||||
);
|
||||
|
||||
if (accountLoading === true) {
|
||||
return renderLoadingState();
|
||||
}
|
||||
|
||||
return renderForm();
|
||||
};
|
||||
@ -9,7 +9,7 @@ import styled from '@emotion/styled';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { ConnectedAccountProvider } from 'twenty-shared/types';
|
||||
import { IconGoogle, IconMail, IconMicrosoft } from 'twenty-ui/display';
|
||||
import { IconAt, IconGoogle, IconMicrosoft } from 'twenty-ui/display';
|
||||
import { Button } from 'twenty-ui/input';
|
||||
import { Card, CardContent, CardHeader } from 'twenty-ui/layout';
|
||||
import { FeatureFlagKey } from '~/generated-metadata/graphql';
|
||||
@ -51,7 +51,9 @@ export const SettingsAccountsListEmptyStateCard = ({
|
||||
isMicrosoftCalendarEnabledState,
|
||||
);
|
||||
|
||||
const isImapEnabled = useIsFeatureEnabled(FeatureFlagKey.IS_IMAP_ENABLED);
|
||||
const isImapSmtpCaldavFeatureFlagEnabled = useIsFeatureEnabled(
|
||||
FeatureFlagKey.IS_IMAP_SMTP_CALDAV_ENABLED,
|
||||
);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@ -75,12 +77,12 @@ export const SettingsAccountsListEmptyStateCard = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{isImapEnabled && (
|
||||
{isImapSmtpCaldavFeatureFlagEnabled && (
|
||||
<Button
|
||||
Icon={IconMail}
|
||||
title={t`Connect with IMAP`}
|
||||
Icon={IconAt}
|
||||
title={t`Connect Email Account`}
|
||||
variant="secondary"
|
||||
to={getSettingsPath(SettingsPath.NewImapConnection)}
|
||||
to={getSettingsPath(SettingsPath.NewImapSmtpCaldavConnection)}
|
||||
/>
|
||||
)}
|
||||
</StyledBody>
|
||||
|
||||
@ -44,6 +44,9 @@ export const SettingsAccountsMessageChannelsContainer = () => {
|
||||
connectedAccountId: {
|
||||
in: accounts.map((account) => account.id),
|
||||
},
|
||||
isSyncEnabled: {
|
||||
eq: true,
|
||||
},
|
||||
},
|
||||
skip: !accounts.length,
|
||||
});
|
||||
|
||||
@ -1,21 +1,29 @@
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
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 = () => {
|
||||
import { SettingsAccountsConnectionForm } from '@/settings/accounts/components/SettingsAccountsConnectionForm';
|
||||
import { useImapSmtpCaldavConnectionForm } from '../hooks/useImapSmtpCaldavConnectionForm';
|
||||
|
||||
export const SettingsAccountsNewImapSmtpCaldavConnection = () => {
|
||||
const { t } = useLingui();
|
||||
const navigate = useNavigateSettings();
|
||||
|
||||
const { formMethods, handleSave, handleSubmit, canSave, isSubmitting } =
|
||||
useImapConnectionForm();
|
||||
const {
|
||||
formMethods,
|
||||
handleSave,
|
||||
handleSubmit,
|
||||
canSave,
|
||||
isSubmitting,
|
||||
loading,
|
||||
} = useImapSmtpCaldavConnectionForm({});
|
||||
|
||||
const { control } = formMethods;
|
||||
|
||||
@ -23,32 +31,30 @@ export const SettingsAccountsNewImapConnection = () => {
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
<FormProvider {...formMethods}>
|
||||
<SubMenuTopBarContainer
|
||||
title={t`New IMAP Connection`}
|
||||
title={t`New Email Account`}
|
||||
links={[
|
||||
{
|
||||
children: t`Settings`,
|
||||
children: t`Workspace`,
|
||||
href: getSettingsPath(SettingsPath.Workspace),
|
||||
},
|
||||
{
|
||||
children: t`Email Connections`,
|
||||
children: t`Accounts`,
|
||||
href: getSettingsPath(SettingsPath.Accounts),
|
||||
},
|
||||
{ children: t`New IMAP Connection` },
|
||||
{ children: t`New Email Account` },
|
||||
]}
|
||||
actionButton={
|
||||
<SaveAndCancelButtons
|
||||
isSaveDisabled={!canSave}
|
||||
isCancelDisabled={isSubmitting}
|
||||
isLoading={loading}
|
||||
onCancel={() => navigate(SettingsPath.Accounts)}
|
||||
onSave={handleSubmit(handleSave)}
|
||||
onSave={handleSubmit((data) => handleSave(data))}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<SettingsPageContainer>
|
||||
<SetttingsAccountsImapConnectionForm
|
||||
control={control}
|
||||
isEditing={false}
|
||||
/>
|
||||
<SettingsAccountsConnectionForm control={control} isEditing={false} />
|
||||
</SettingsPageContainer>
|
||||
</SubMenuTopBarContainer>
|
||||
</FormProvider>
|
||||
@ -10,11 +10,13 @@ import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
|
||||
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
|
||||
import { useModal } from '@/ui/layout/modal/hooks/useModal';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { ConnectedAccountProvider } from 'twenty-shared/types';
|
||||
import {
|
||||
IconCalendarEvent,
|
||||
IconDotsVertical,
|
||||
IconMail,
|
||||
IconRefresh,
|
||||
IconSettings,
|
||||
IconTrash,
|
||||
} from 'twenty-ui/display';
|
||||
import { LightIconButton } from 'twenty-ui/input';
|
||||
@ -57,6 +59,19 @@ export const SettingsAccountsRowDropdownMenu = ({
|
||||
dropdownComponents={
|
||||
<DropdownContent>
|
||||
<DropdownMenuItemsContainer>
|
||||
{account.provider ===
|
||||
ConnectedAccountProvider.IMAP_SMTP_CALDAV && (
|
||||
<MenuItem
|
||||
text={t`Connection settings`}
|
||||
LeftIcon={IconSettings}
|
||||
onClick={() => {
|
||||
navigate(SettingsPath.EditImapSmtpCaldavConnection, {
|
||||
connectedAccountId: account.id,
|
||||
});
|
||||
closeDropdown(dropdownId);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<MenuItem
|
||||
LeftIcon={IconMail}
|
||||
text={t`Emails settings`}
|
||||
|
||||
@ -0,0 +1,83 @@
|
||||
import { ConnectedAccount } from '@/accounts/types/ConnectedAccount';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import { ConnectedAccountProvider } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
IconAt,
|
||||
IconCalendarEvent,
|
||||
IconComponent,
|
||||
IconComponentProps,
|
||||
IconGoogle,
|
||||
IconMail,
|
||||
IconMicrosoft,
|
||||
IconSend,
|
||||
} from 'twenty-ui/display';
|
||||
|
||||
const ImapSmtpCaldavIcon = (
|
||||
props: IconComponentProps & { account: ConnectedAccount },
|
||||
) => {
|
||||
const theme = useTheme();
|
||||
const { account } = props;
|
||||
|
||||
const hasImap = isDefined(account.connectionParameters?.IMAP);
|
||||
const hasSmtp = isDefined(account.connectionParameters?.SMTP);
|
||||
const hasCaldav = isDefined(account.connectionParameters?.CALDAV);
|
||||
|
||||
let IconToShow: IconComponent;
|
||||
|
||||
if (hasImap && hasSmtp && hasCaldav) {
|
||||
IconToShow = IconAt;
|
||||
} else if (hasImap && hasCaldav) {
|
||||
IconToShow = IconAt;
|
||||
} else if (hasImap && hasSmtp) {
|
||||
IconToShow = IconMail;
|
||||
} else if (hasImap) {
|
||||
IconToShow = IconMail;
|
||||
} else if (hasSmtp) {
|
||||
IconToShow = IconSend;
|
||||
} else if (hasCaldav) {
|
||||
IconToShow = IconCalendarEvent;
|
||||
} else {
|
||||
IconToShow = IconMail;
|
||||
}
|
||||
|
||||
return (
|
||||
<IconToShow
|
||||
className={props.className}
|
||||
style={props.style}
|
||||
size={props.size}
|
||||
stroke={props.stroke}
|
||||
color={props.color || theme.font.color.primary}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const getIconForProvider = (account: ConnectedAccount): IconComponent => {
|
||||
switch (account.provider) {
|
||||
case ConnectedAccountProvider.IMAP_SMTP_CALDAV:
|
||||
return (props) => (
|
||||
<ImapSmtpCaldavIcon
|
||||
account={account}
|
||||
className={props.className}
|
||||
style={props.style}
|
||||
size={props.size}
|
||||
stroke={props.stroke}
|
||||
color={props.color}
|
||||
/>
|
||||
);
|
||||
case ConnectedAccountProvider.GOOGLE:
|
||||
return IconGoogle;
|
||||
case ConnectedAccountProvider.MICROSOFT:
|
||||
return IconMicrosoft;
|
||||
default:
|
||||
return IconMail;
|
||||
}
|
||||
};
|
||||
|
||||
export const SettingsConnectedAccountIcon = ({
|
||||
account,
|
||||
}: {
|
||||
account: ConnectedAccount;
|
||||
}): IconComponent => {
|
||||
return getIconForProvider(account);
|
||||
};
|
||||
@ -1,123 +0,0 @@
|
||||
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
|
||||
instanceId="email-address-imap-connection-form"
|
||||
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
|
||||
instanceId="host-imap-connection-form"
|
||||
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
|
||||
instanceId="port-imap-connection-form"
|
||||
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
|
||||
instanceId="password-imap-connection-form"
|
||||
label={t`Password`}
|
||||
placeholder={t`••••••••`}
|
||||
type="password"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
error={fieldState.error?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</StyledFormContainer>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1 @@
|
||||
export const ACCOUNT_PROTOCOLS = ['IMAP', 'SMTP', 'CALDAV'] as const;
|
||||
@ -12,21 +12,18 @@ export const GET_CONNECTED_IMAP_SMTP_CALDAV_ACCOUNT = gql`
|
||||
host
|
||||
port
|
||||
secure
|
||||
username
|
||||
password
|
||||
}
|
||||
SMTP {
|
||||
host
|
||||
port
|
||||
secure
|
||||
username
|
||||
password
|
||||
}
|
||||
CALDAV {
|
||||
host
|
||||
port
|
||||
secure
|
||||
username
|
||||
password
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,238 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { ReactNode } from 'react';
|
||||
import { RecoilRoot } from 'recoil';
|
||||
|
||||
import { SettingsPath } from '@/types/SettingsPath';
|
||||
import { ConnectedAccountProvider } from 'twenty-shared/types';
|
||||
import {
|
||||
CalendarChannelVisibility,
|
||||
MessageChannelVisibility,
|
||||
} from '~/generated-metadata/graphql';
|
||||
import { useTriggerProviderReconnect } from '../useTriggerProviderReconnect';
|
||||
|
||||
const mockTriggerApisOAuth = jest.fn();
|
||||
const mockNavigate = jest.fn();
|
||||
|
||||
jest.mock('@/settings/accounts/hooks/useTriggerApiOAuth', () => ({
|
||||
useTriggerApisOAuth: jest.fn().mockImplementation(() => ({
|
||||
triggerApisOAuth: mockTriggerApisOAuth,
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('~/hooks/useNavigateSettings', () => ({
|
||||
useNavigateSettings: jest.fn().mockImplementation(() => mockNavigate),
|
||||
}));
|
||||
|
||||
const Wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<RecoilRoot>{children}</RecoilRoot>
|
||||
);
|
||||
|
||||
describe('useTriggerProviderReconnect', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('IMAP_SMTP_CALDAV provider', () => {
|
||||
it('should navigate to new connection when no accountId is provided', async () => {
|
||||
const { result } = renderHook(() => useTriggerProviderReconnect(), {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.triggerProviderReconnect(
|
||||
ConnectedAccountProvider.IMAP_SMTP_CALDAV,
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith(
|
||||
SettingsPath.NewImapSmtpCaldavConnection,
|
||||
);
|
||||
expect(mockTriggerApisOAuth).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should navigate to edit connection when accountId is provided', async () => {
|
||||
const { result } = renderHook(() => useTriggerProviderReconnect(), {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
const accountId = 'test-account-id-123';
|
||||
|
||||
await act(async () => {
|
||||
await result.current.triggerProviderReconnect(
|
||||
ConnectedAccountProvider.IMAP_SMTP_CALDAV,
|
||||
accountId,
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith(
|
||||
SettingsPath.EditImapSmtpCaldavConnection,
|
||||
{
|
||||
connectedAccountId: accountId,
|
||||
},
|
||||
);
|
||||
expect(mockTriggerApisOAuth).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should ignore options parameter for IMAP_SMTP_CALDAV provider', async () => {
|
||||
const { result } = renderHook(() => useTriggerProviderReconnect(), {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
const accountId = 'test-account-id-123';
|
||||
const options = {
|
||||
redirectLocation: '/some-path',
|
||||
calendarVisibility: CalendarChannelVisibility.SHARE_EVERYTHING,
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
await result.current.triggerProviderReconnect(
|
||||
ConnectedAccountProvider.IMAP_SMTP_CALDAV,
|
||||
accountId,
|
||||
options,
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith(
|
||||
SettingsPath.EditImapSmtpCaldavConnection,
|
||||
{
|
||||
connectedAccountId: accountId,
|
||||
},
|
||||
);
|
||||
expect(mockTriggerApisOAuth).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('OAuth providers', () => {
|
||||
it('should trigger OAuth for Google provider without options', async () => {
|
||||
const { result } = renderHook(() => useTriggerProviderReconnect(), {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.triggerProviderReconnect(
|
||||
ConnectedAccountProvider.GOOGLE,
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockTriggerApisOAuth).toHaveBeenCalledWith(
|
||||
ConnectedAccountProvider.GOOGLE,
|
||||
undefined,
|
||||
);
|
||||
expect(mockNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should trigger OAuth for Microsoft provider without options', async () => {
|
||||
const { result } = renderHook(() => useTriggerProviderReconnect(), {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.triggerProviderReconnect(
|
||||
ConnectedAccountProvider.MICROSOFT,
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockTriggerApisOAuth).toHaveBeenCalledWith(
|
||||
ConnectedAccountProvider.MICROSOFT,
|
||||
undefined,
|
||||
);
|
||||
expect(mockNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should trigger OAuth for Google provider with options', async () => {
|
||||
const { result } = renderHook(() => useTriggerProviderReconnect(), {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
const options = {
|
||||
redirectLocation: '/custom-redirect',
|
||||
calendarVisibility: CalendarChannelVisibility.METADATA,
|
||||
messageVisibility: MessageChannelVisibility.SUBJECT,
|
||||
loginHint: 'user@example.com',
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
await result.current.triggerProviderReconnect(
|
||||
ConnectedAccountProvider.GOOGLE,
|
||||
undefined,
|
||||
options,
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockTriggerApisOAuth).toHaveBeenCalledWith(
|
||||
ConnectedAccountProvider.GOOGLE,
|
||||
options,
|
||||
);
|
||||
expect(mockNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should trigger OAuth for Microsoft provider with options', async () => {
|
||||
const { result } = renderHook(() => useTriggerProviderReconnect(), {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
const options = {
|
||||
redirectLocation: '/another-redirect',
|
||||
calendarVisibility: CalendarChannelVisibility.SHARE_EVERYTHING,
|
||||
messageVisibility: MessageChannelVisibility.SHARE_EVERYTHING,
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
await result.current.triggerProviderReconnect(
|
||||
ConnectedAccountProvider.MICROSOFT,
|
||||
'some-account-id',
|
||||
options,
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockTriggerApisOAuth).toHaveBeenCalledWith(
|
||||
ConnectedAccountProvider.MICROSOFT,
|
||||
options,
|
||||
);
|
||||
expect(mockNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should ignore accountId parameter for OAuth providers', async () => {
|
||||
const { result } = renderHook(() => useTriggerProviderReconnect(), {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.triggerProviderReconnect(
|
||||
ConnectedAccountProvider.GOOGLE,
|
||||
'ignored-account-id',
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockTriggerApisOAuth).toHaveBeenCalledWith(
|
||||
ConnectedAccountProvider.GOOGLE,
|
||||
undefined,
|
||||
);
|
||||
expect(mockNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should handle triggerApisOAuth errors gracefully', async () => {
|
||||
const { result } = renderHook(() => useTriggerProviderReconnect(), {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
const error = new Error('OAuth failed');
|
||||
mockTriggerApisOAuth.mockRejectedValue(error);
|
||||
|
||||
await expect(async () => {
|
||||
await act(async () => {
|
||||
await result.current.triggerProviderReconnect(
|
||||
ConnectedAccountProvider.GOOGLE,
|
||||
);
|
||||
});
|
||||
}).rejects.toThrow('OAuth failed');
|
||||
|
||||
expect(mockTriggerApisOAuth).toHaveBeenCalledWith(
|
||||
ConnectedAccountProvider.GOOGLE,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,11 +1,21 @@
|
||||
import { useGetConnectedImapSmtpCaldavAccountQuery } from '~/generated-metadata/graphql';
|
||||
import {
|
||||
type GetConnectedImapSmtpCaldavAccountQuery,
|
||||
useGetConnectedImapSmtpCaldavAccountQuery,
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
export type ConnectedImapSmtpCaldavAccount =
|
||||
GetConnectedImapSmtpCaldavAccountQuery['getConnectedImapSmtpCaldavAccount'];
|
||||
|
||||
export const useConnectedImapSmtpCaldavAccount = (
|
||||
connectedAccountId: string | undefined,
|
||||
onCompleted?: (data: ConnectedImapSmtpCaldavAccount) => void,
|
||||
) => {
|
||||
const { data, loading, error } = useGetConnectedImapSmtpCaldavAccountQuery({
|
||||
variables: { id: connectedAccountId ?? '' },
|
||||
skip: !connectedAccountId,
|
||||
onCompleted: (data) => {
|
||||
onCompleted?.(data.getConnectedImapSmtpCaldavAccount);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@ -1,133 +0,0 @@
|
||||
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 { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { ApolloError } from '@apollo/client';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import {
|
||||
ConnectionParameters,
|
||||
useSaveImapSmtpCaldavMutation,
|
||||
} from '~/generated-metadata/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 { enqueueErrorSnackBar, enqueueSuccessSnackBar } = 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) {
|
||||
enqueueErrorSnackBar({});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentWorkspaceMember?.id) {
|
||||
enqueueErrorSnackBar({});
|
||||
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 } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
enqueueSuccessSnackBar({
|
||||
message: connectedAccountId
|
||||
? t`IMAP connection successfully updated`
|
||||
: t`IMAP connection successfully created`,
|
||||
});
|
||||
|
||||
navigate(SettingsPath.Accounts);
|
||||
} catch (error) {
|
||||
enqueueErrorSnackBar({
|
||||
apolloError: error instanceof ApolloError ? error : undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
formMethods,
|
||||
handleSave,
|
||||
handleSubmit,
|
||||
canSave,
|
||||
isSubmitting,
|
||||
loading,
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,184 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
|
||||
import { SettingsPath } from '@/types/SettingsPath';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import {
|
||||
ConnectionParameters,
|
||||
useSaveImapSmtpCaldavMutation,
|
||||
} from '~/generated-metadata/graphql';
|
||||
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
|
||||
|
||||
import { ImapSmtpCaldavAccount } from '@/accounts/types/ImapSmtpCaldavAccount';
|
||||
import { ACCOUNT_PROTOCOLS } from '@/settings/accounts/constants/AccountProtocols';
|
||||
import {
|
||||
connectionImapSmtpCalDav,
|
||||
isProtocolConfigured,
|
||||
} from '@/settings/accounts/validation-schemas/connectionImapSmtpCalDav';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
ConnectedImapSmtpCaldavAccount,
|
||||
useConnectedImapSmtpCaldavAccount,
|
||||
} from './useConnectedImapSmtpCaldavAccount';
|
||||
|
||||
type UseConnectionFormProps = {
|
||||
isEditing?: boolean;
|
||||
connectedAccountId?: string;
|
||||
};
|
||||
|
||||
export type ConnectionFormData = {
|
||||
handle: string;
|
||||
} & ImapSmtpCaldavAccount;
|
||||
|
||||
export const useImapSmtpCaldavConnectionForm = ({
|
||||
isEditing = false,
|
||||
connectedAccountId,
|
||||
}: UseConnectionFormProps = {}) => {
|
||||
const navigate = useNavigateSettings();
|
||||
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
|
||||
|
||||
const formMethods = useForm<ConnectionFormData>({
|
||||
mode: 'onSubmit',
|
||||
resolver: zodResolver(connectionImapSmtpCalDav),
|
||||
defaultValues: {
|
||||
handle: '',
|
||||
IMAP: { host: '', port: 993, password: '', secure: true },
|
||||
SMTP: { host: '', port: 587, password: '', secure: true },
|
||||
CALDAV: { host: '', port: 443, password: '', secure: true },
|
||||
},
|
||||
});
|
||||
|
||||
const { handleSubmit, formState, watch, reset } = formMethods;
|
||||
const { enqueueErrorSnackBar, enqueueSuccessSnackBar } = useSnackBar();
|
||||
const { isSubmitting } = formState;
|
||||
|
||||
const { connectedAccount, loading: accountLoading } =
|
||||
useConnectedImapSmtpCaldavAccount(
|
||||
isEditing ? connectedAccountId : undefined,
|
||||
useCallback(
|
||||
(account: ConnectedImapSmtpCaldavAccount | null) => {
|
||||
if (isDefined(account)) {
|
||||
reset({
|
||||
handle: account.handle || '',
|
||||
IMAP: account.connectionParameters?.IMAP || undefined,
|
||||
SMTP: account.connectionParameters?.SMTP || undefined,
|
||||
CALDAV: account.connectionParameters?.CALDAV || undefined,
|
||||
});
|
||||
}
|
||||
},
|
||||
[reset],
|
||||
),
|
||||
);
|
||||
|
||||
const [saveConnection, { loading: saveLoading }] =
|
||||
useSaveImapSmtpCaldavMutation();
|
||||
|
||||
const watchedValues = watch();
|
||||
|
||||
const getConfiguredProtocols = useCallback(
|
||||
(
|
||||
values: ConnectionFormData = watchedValues,
|
||||
): (keyof ImapSmtpCaldavAccount)[] => {
|
||||
return ACCOUNT_PROTOCOLS.filter((protocol) => {
|
||||
const protocolConfig = values[protocol];
|
||||
return (
|
||||
protocolConfig &&
|
||||
isProtocolConfigured(protocolConfig as ConnectionParameters)
|
||||
);
|
||||
});
|
||||
},
|
||||
[watchedValues],
|
||||
);
|
||||
|
||||
const isValid = useMemo(() => {
|
||||
return (
|
||||
Boolean(watchedValues.handle?.trim()) &&
|
||||
getConfiguredProtocols().length > 0
|
||||
);
|
||||
}, [getConfiguredProtocols, watchedValues.handle]);
|
||||
|
||||
const saveIndividualConnection = useCallback(
|
||||
async (
|
||||
protocol: keyof ImapSmtpCaldavAccount,
|
||||
formValues: ConnectionFormData,
|
||||
): Promise<void> => {
|
||||
if (!currentWorkspaceMember?.id) {
|
||||
throw new Error('Workspace member ID is missing');
|
||||
}
|
||||
|
||||
const protocolConfig = formValues[protocol];
|
||||
if (!protocolConfig) {
|
||||
throw new Error(`${protocol} configuration is missing`);
|
||||
}
|
||||
|
||||
await saveConnection({
|
||||
variables: {
|
||||
...(isEditing && connectedAccountId
|
||||
? { id: connectedAccountId }
|
||||
: {}),
|
||||
accountOwnerId: currentWorkspaceMember.id,
|
||||
handle: formValues.handle,
|
||||
accountType: {
|
||||
type: protocol,
|
||||
},
|
||||
connectionParameters: protocolConfig,
|
||||
},
|
||||
});
|
||||
},
|
||||
[saveConnection, isEditing, connectedAccountId, currentWorkspaceMember?.id],
|
||||
);
|
||||
|
||||
const handleSave = useCallback(
|
||||
async (formValues: ConnectionFormData): Promise<void> => {
|
||||
const configuredProtocols = getConfiguredProtocols(formValues);
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
configuredProtocols.map((protocol) =>
|
||||
saveIndividualConnection(protocol, formValues),
|
||||
),
|
||||
);
|
||||
|
||||
const successMessage = isEditing
|
||||
? t`Connection successfully updated`
|
||||
: t`Connection successfully created`;
|
||||
|
||||
enqueueSuccessSnackBar({ message: successMessage });
|
||||
navigate(SettingsPath.Accounts);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'An unexpected error occurred';
|
||||
|
||||
enqueueErrorSnackBar({ message: errorMessage });
|
||||
}
|
||||
},
|
||||
[
|
||||
getConfiguredProtocols,
|
||||
saveIndividualConnection,
|
||||
isEditing,
|
||||
enqueueSuccessSnackBar,
|
||||
enqueueErrorSnackBar,
|
||||
navigate,
|
||||
],
|
||||
);
|
||||
|
||||
const canSave = isValid && !isSubmitting;
|
||||
const loading = accountLoading || saveLoading;
|
||||
|
||||
return {
|
||||
formMethods,
|
||||
handleSave,
|
||||
handleSubmit,
|
||||
canSave,
|
||||
isSubmitting,
|
||||
loading,
|
||||
connectedAccount,
|
||||
};
|
||||
};
|
||||
@ -17,11 +17,11 @@ export const useTriggerProviderReconnect = () => {
|
||||
) => {
|
||||
if (provider === ConnectedAccountProvider.IMAP_SMTP_CALDAV) {
|
||||
if (!accountId) {
|
||||
navigate(SettingsPath.NewImapConnection);
|
||||
navigate(SettingsPath.NewImapSmtpCaldavConnection);
|
||||
return;
|
||||
}
|
||||
|
||||
navigate(SettingsPath.EditImapConnection, {
|
||||
navigate(SettingsPath.EditImapSmtpCaldavConnection, {
|
||||
connectedAccountId: accountId,
|
||||
});
|
||||
return;
|
||||
|
||||
@ -0,0 +1,47 @@
|
||||
import { ACCOUNT_PROTOCOLS } from '@/settings/accounts/constants/AccountProtocols';
|
||||
import { z } from 'zod';
|
||||
import { ConnectionParameters } from '~/generated/graphql';
|
||||
|
||||
const connectionParameters = z
|
||||
.object({
|
||||
host: z.string().default(''),
|
||||
port: z.number().int().nullable().default(null),
|
||||
password: z.string().default(''),
|
||||
secure: z.boolean().default(true),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (Boolean(data.host?.trim()) && Boolean(data.password?.trim())) {
|
||||
return data.port && data.port > 0;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: 'Port must be a positive number when configuring this protocol',
|
||||
path: ['port'],
|
||||
},
|
||||
);
|
||||
|
||||
export const connectionImapSmtpCalDav = z
|
||||
.object({
|
||||
handle: z.string().email('Invalid email address'),
|
||||
IMAP: connectionParameters.optional(),
|
||||
SMTP: connectionParameters.optional(),
|
||||
CALDAV: connectionParameters.optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
return ACCOUNT_PROTOCOLS.some((protocol) =>
|
||||
isProtocolConfigured(data[protocol] as ConnectionParameters),
|
||||
);
|
||||
},
|
||||
{
|
||||
message:
|
||||
'At least one account type (IMAP, SMTP, or CalDAV) must be completely configured',
|
||||
path: ['handle'],
|
||||
},
|
||||
);
|
||||
|
||||
export const isProtocolConfigured = (config: ConnectionParameters): boolean => {
|
||||
return Boolean(config?.host?.trim() && config?.password?.trim());
|
||||
};
|
||||
@ -4,9 +4,9 @@ import { ComponentType } from 'react';
|
||||
|
||||
import { SettingsListSkeletonCard } from '@/settings/components/SettingsListSkeletonCard';
|
||||
|
||||
import { SettingsListItemCardContent } from './SettingsListItemCardContent';
|
||||
import { Card, CardFooter } from 'twenty-ui/layout';
|
||||
import { IconComponent, IconPlus } from 'twenty-ui/display';
|
||||
import { Card, CardFooter } from 'twenty-ui/layout';
|
||||
import { SettingsListItemCardContent } from './SettingsListItemCardContent';
|
||||
|
||||
const StyledFooter = styled(CardFooter)`
|
||||
align-items: center;
|
||||
|
||||
@ -5,8 +5,8 @@ export enum SettingsPath {
|
||||
NewAccount = 'accounts/new',
|
||||
AccountsCalendars = 'accounts/calendars',
|
||||
AccountsEmails = 'accounts/emails',
|
||||
NewImapConnection = 'accounts/new-imap-connection',
|
||||
EditImapConnection = 'accounts/edit-imap-connection/:connectedAccountId',
|
||||
NewImapSmtpCaldavConnection = 'accounts/new-imap-smtp-caldav-connection',
|
||||
EditImapSmtpCaldavConnection = 'accounts/edit-imap-smtp-caldav-connection/:connectedAccountId',
|
||||
Billing = 'billing',
|
||||
Objects = 'objects',
|
||||
ObjectOverview = 'objects/overview',
|
||||
|
||||
@ -98,7 +98,10 @@ export const WorkflowEditActionSendEmail = ({
|
||||
}
|
||||
};
|
||||
|
||||
if (!isDefined(scopes) || !hasSendScope(connectedAccount, scopes)) {
|
||||
if (
|
||||
connectedAccount.provider !== ConnectedAccountProvider.IMAP_SMTP_CALDAV &&
|
||||
(!isDefined(scopes) || !hasSendScope(connectedAccount, scopes))
|
||||
) {
|
||||
await triggerApisOAuth(connectedAccount.provider, {
|
||||
redirectLocation: redirectUrl,
|
||||
loginHint: connectedAccount.handle,
|
||||
@ -180,6 +183,7 @@ export const WorkflowEditActionSendEmail = ({
|
||||
provider: true,
|
||||
scopes: true,
|
||||
accountOwnerId: true,
|
||||
connectionParameters: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -54,5 +54,5 @@ export const mockedClientConfig: ClientConfig = {
|
||||
isGoogleCalendarEnabled: true,
|
||||
isAttachmentPreviewEnabled: true,
|
||||
isConfigVariablesInDbEnabled: false,
|
||||
isIMAPMessagingEnabled: false,
|
||||
isImapSmtpCaldavEnabled: false,
|
||||
};
|
||||
|
||||
@ -14,7 +14,7 @@ FRONTEND_URL=http://localhost:3001
|
||||
# REFRESH_TOKEN_EXPIRES_IN=90d
|
||||
# FILE_TOKEN_EXPIRES_IN=1d
|
||||
# MESSAGING_PROVIDER_GMAIL_ENABLED=false
|
||||
# MESSAGING_PROVIDER_IMAP_ENABLED=false
|
||||
# IS_IMAP_SMTP_CALDAV_ENABLED=false
|
||||
# CALENDAR_PROVIDER_GOOGLE_ENABLED=false
|
||||
# MESSAGING_PROVIDER_MICROSOFT_ENABLED=false
|
||||
# CALENDAR_PROVIDER_MICROSOFT_ENABLED=false
|
||||
|
||||
@ -11,7 +11,7 @@ FRONTEND_URL=http://localhost:3001
|
||||
|
||||
AUTH_GOOGLE_ENABLED=false
|
||||
MESSAGING_PROVIDER_GMAIL_ENABLED=false
|
||||
MESSAGING_PROVIDER_IMAP_ENABLED=false
|
||||
IS_IMAP_SMTP_CALDAV_ENABLED=false
|
||||
CALENDAR_PROVIDER_GOOGLE_ENABLED=false
|
||||
MESSAGING_PROVIDER_MICROSOFT_ENABLED=false
|
||||
CALENDAR_PROVIDER_MICROSOFT_ENABLED=false
|
||||
|
||||
@ -96,7 +96,7 @@ describe('ClientConfigController', () => {
|
||||
isGoogleMessagingEnabled: false,
|
||||
isGoogleCalendarEnabled: false,
|
||||
isConfigVariablesInDbEnabled: false,
|
||||
isIMAPMessagingEnabled: false,
|
||||
isImapSmtpCaldavEnabled: false,
|
||||
calendarBookingPageId: undefined,
|
||||
};
|
||||
|
||||
|
||||
@ -178,7 +178,7 @@ export class ClientConfig {
|
||||
isConfigVariablesInDbEnabled: boolean;
|
||||
|
||||
@Field(() => Boolean)
|
||||
isIMAPMessagingEnabled: boolean;
|
||||
isImapSmtpCaldavEnabled: boolean;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
calendarBookingPageId?: string;
|
||||
|
||||
@ -138,8 +138,8 @@ export class ClientConfigService {
|
||||
isConfigVariablesInDbEnabled: this.twentyConfigService.get(
|
||||
'IS_CONFIG_VARIABLES_IN_DB_ENABLED',
|
||||
),
|
||||
isIMAPMessagingEnabled: this.twentyConfigService.get(
|
||||
'MESSAGING_PROVIDER_IMAP_ENABLED',
|
||||
isImapSmtpCaldavEnabled: this.twentyConfigService.get(
|
||||
'IS_IMAP_SMTP_CALDAV_ENABLED',
|
||||
),
|
||||
calendarBookingPageId: this.twentyConfigService.get(
|
||||
'CALENDAR_BOOKING_PAGE_ID',
|
||||
|
||||
@ -13,12 +13,13 @@ export type PublicFeatureFlag = {
|
||||
|
||||
export const PUBLIC_FEATURE_FLAGS: PublicFeatureFlag[] = [
|
||||
{
|
||||
key: FeatureFlagKey.IS_IMAP_ENABLED,
|
||||
key: FeatureFlagKey.IS_IMAP_SMTP_CALDAV_ENABLED,
|
||||
metadata: {
|
||||
label: 'IMAP',
|
||||
label: 'IMAP, SMTP, CalDAV',
|
||||
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',
|
||||
'Easily add email accounts from any provider that supports IMAP, send emails with SMTP (and soon, sync calendars with CalDAV)',
|
||||
imagePath:
|
||||
'https://twenty.com/images/lab/is-imap-smtp-caldav-enabled.png',
|
||||
},
|
||||
},
|
||||
...(process.env.CLOUDFLARE_API_KEY
|
||||
|
||||
@ -5,7 +5,7 @@ export enum FeatureFlagKey {
|
||||
IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED',
|
||||
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
|
||||
IS_AI_ENABLED = 'IS_AI_ENABLED',
|
||||
IS_IMAP_ENABLED = 'IS_IMAP_ENABLED',
|
||||
IS_IMAP_SMTP_CALDAV_ENABLED = 'IS_IMAP_SMTP_CALDAV_ENABLED',
|
||||
IS_MORPH_RELATION_ENABLED = 'IS_MORPH_RELATION_ENABLED',
|
||||
IS_WORKFLOW_FILTERING_ENABLED = 'IS_WORKFLOW_FILTERING_ENABLED',
|
||||
IS_RELATION_CONNECT_ENABLED = 'IS_RELATION_CONNECT_ENABLED',
|
||||
|
||||
@ -14,9 +14,6 @@ export class ConnectionParameters {
|
||||
@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,
|
||||
@ -37,9 +34,6 @@ export class ConnectionParametersOutput {
|
||||
@Field(() => Number)
|
||||
port: number;
|
||||
|
||||
@Field(() => String)
|
||||
username: string;
|
||||
|
||||
@Field(() => String)
|
||||
password: string;
|
||||
|
||||
|
||||
@ -1,4 +1,10 @@
|
||||
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
|
||||
import {
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
UseFilters,
|
||||
UseGuards,
|
||||
UsePipes,
|
||||
} from '@nestjs/common';
|
||||
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { ConnectedAccountProvider } from 'twenty-shared/types';
|
||||
@ -39,36 +45,6 @@ export class ImapSmtpCaldavResolver {
|
||||
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(
|
||||
@ -111,7 +87,18 @@ export class ImapSmtpCaldavResolver {
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
@Args('id', { nullable: true }) id?: string,
|
||||
): Promise<ImapSmtpCaldavConnectionSuccess> {
|
||||
await this.checkIfFeatureEnabled(workspace.id, accountType);
|
||||
const isImapSmtpCaldavFeatureFlagEnabled =
|
||||
await this.featureFlagService.isFeatureEnabled(
|
||||
FeatureFlagKey.IS_IMAP_SMTP_CALDAV_ENABLED,
|
||||
workspace.id,
|
||||
);
|
||||
|
||||
if (!isImapSmtpCaldavFeatureFlagEnabled) {
|
||||
throw new HttpException(
|
||||
'IMAP, SMTP, CalDAV feature is not enabled for this workspace',
|
||||
HttpStatus.FORBIDDEN,
|
||||
);
|
||||
}
|
||||
|
||||
const validatedParams =
|
||||
this.mailConnectionValidatorService.validateProtocolConnectionParams(
|
||||
@ -119,6 +106,7 @@ export class ImapSmtpCaldavResolver {
|
||||
);
|
||||
|
||||
await this.ImapSmtpCaldavConnectionService.testImapSmtpCaldav(
|
||||
handle,
|
||||
validatedParams,
|
||||
accountType.type,
|
||||
);
|
||||
|
||||
@ -10,7 +10,6 @@ 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(),
|
||||
});
|
||||
@ -19,7 +18,10 @@ export class ImapSmtpCaldavValidatorService {
|
||||
params: ConnectionParameters,
|
||||
): ConnectionParameters {
|
||||
if (!params) {
|
||||
throw new UserInputError('Protocol connection parameters are required');
|
||||
throw new UserInputError('Protocol connection parameters are required', {
|
||||
userFriendlyMessage:
|
||||
'Please provide connection details to configure your email account.',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
@ -32,10 +34,17 @@ export class ImapSmtpCaldavValidatorService {
|
||||
|
||||
throw new UserInputError(
|
||||
`Protocol connection validation failed: ${errorMessages}`,
|
||||
{
|
||||
userFriendlyMessage:
|
||||
'Please check your connection settings. Make sure the server host, port, and password are correct.',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
throw new UserInputError('Protocol connection validation failed');
|
||||
throw new UserInputError('Protocol connection validation failed', {
|
||||
userFriendlyMessage:
|
||||
'There was an issue with your connection settings. Please try again.',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { ImapFlow } from 'imapflow';
|
||||
import { createTransport } from 'nodemailer';
|
||||
import { ConnectedAccountProvider } from 'twenty-shared/types';
|
||||
|
||||
import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
|
||||
@ -19,17 +20,16 @@ export class ImapSmtpCaldavService {
|
||||
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');
|
||||
}
|
||||
|
||||
async testImapConnection(
|
||||
handle: string,
|
||||
params: ConnectionParameters,
|
||||
): Promise<boolean> {
|
||||
const client = new ImapFlow({
|
||||
host: params.host,
|
||||
port: params.port,
|
||||
secure: params.secure ?? true,
|
||||
auth: {
|
||||
user: params.username,
|
||||
user: handle,
|
||||
pass: params.password,
|
||||
},
|
||||
logger: false,
|
||||
@ -57,16 +57,27 @@ export class ImapSmtpCaldavService {
|
||||
if (error.authenticationFailed) {
|
||||
throw new UserInputError(
|
||||
'IMAP authentication failed. Please check your credentials.',
|
||||
{
|
||||
userFriendlyMessage:
|
||||
"We couldn't log in to your email account. Please check your email address and password, then try again.",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (error.code === 'ECONNREFUSED') {
|
||||
throw new UserInputError(
|
||||
`IMAP connection refused. Please verify server and port.`,
|
||||
{
|
||||
userFriendlyMessage:
|
||||
"We couldn't connect to your email server. Please check your server settings and try again.",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
throw new UserInputError(`IMAP connection failed: ${error.message}`);
|
||||
throw new UserInputError(`IMAP connection failed: ${error.message}`, {
|
||||
userFriendlyMessage:
|
||||
'We encountered an issue connecting to your email account. Please check your settings and try again.',
|
||||
});
|
||||
} finally {
|
||||
if (client.authenticated) {
|
||||
await client.logout();
|
||||
@ -74,36 +85,70 @@ export class ImapSmtpCaldavService {
|
||||
}
|
||||
}
|
||||
|
||||
async testSmtpConnection(params: ConnectionParameters): Promise<boolean> {
|
||||
this.logger.log('SMTP connection testing not yet implemented', params);
|
||||
async testSmtpConnection(
|
||||
handle: string,
|
||||
params: ConnectionParameters,
|
||||
): Promise<boolean> {
|
||||
const transport = createTransport({
|
||||
host: params.host,
|
||||
port: params.port,
|
||||
auth: {
|
||||
user: handle,
|
||||
pass: params.password,
|
||||
},
|
||||
tls: {
|
||||
rejectUnauthorized: false,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await transport.verify();
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`SMTP connection failed: ${error.message}`,
|
||||
error.stack,
|
||||
);
|
||||
throw new UserInputError(`SMTP connection failed: ${error.message}`, {
|
||||
userFriendlyMessage:
|
||||
"We couldn't connect to your outgoing email server. Please check your SMTP settings and try again.",
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async testCaldavConnection(params: ConnectionParameters): Promise<boolean> {
|
||||
async testCaldavConnection(
|
||||
handle: string,
|
||||
params: ConnectionParameters,
|
||||
): Promise<boolean> {
|
||||
this.logger.log('CALDAV connection testing not yet implemented', params);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async testImapSmtpCaldav(
|
||||
handle: string,
|
||||
params: ConnectionParameters,
|
||||
accountType: AccountType,
|
||||
): Promise<boolean> {
|
||||
if (accountType === 'IMAP') {
|
||||
return this.testImapConnection(params);
|
||||
return this.testImapConnection(handle, params);
|
||||
}
|
||||
|
||||
if (accountType === 'SMTP') {
|
||||
return this.testSmtpConnection(params);
|
||||
return this.testSmtpConnection(handle, params);
|
||||
}
|
||||
|
||||
if (accountType === 'CALDAV') {
|
||||
return this.testCaldavConnection(params);
|
||||
return this.testCaldavConnection(handle, params);
|
||||
}
|
||||
|
||||
throw new UserInputError(
|
||||
'Invalid account type. Must be one of: IMAP, SMTP, CALDAV',
|
||||
{
|
||||
userFriendlyMessage:
|
||||
'Please select a valid connection type (IMAP, SMTP, or CalDAV) and try again.',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
export type ConnectionParameters = {
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password: string;
|
||||
secure?: boolean;
|
||||
};
|
||||
@ -9,7 +8,6 @@ export type ConnectionParameters = {
|
||||
export type AccountType = 'IMAP' | 'SMTP' | 'CALDAV';
|
||||
|
||||
export type ImapSmtpCaldavParams = {
|
||||
handle: string;
|
||||
IMAP?: ConnectionParameters;
|
||||
SMTP?: ConnectionParameters;
|
||||
CALDAV?: ConnectionParameters;
|
||||
|
||||
@ -148,7 +148,7 @@ export class ConfigVariables {
|
||||
description: 'Enable or disable the IMAP messaging integration',
|
||||
type: ConfigVariableType.BOOLEAN,
|
||||
})
|
||||
MESSAGING_PROVIDER_IMAP_ENABLED = false;
|
||||
IS_IMAP_SMTP_CALDAV_ENABLED = false;
|
||||
|
||||
@ConfigVariablesMetadata({
|
||||
group: ConfigVariablesGroup.MicrosoftAuth,
|
||||
|
||||
@ -46,7 +46,7 @@ export const seedFeatureFlags = async (
|
||||
value: false,
|
||||
},
|
||||
{
|
||||
key: FeatureFlagKey.IS_IMAP_ENABLED,
|
||||
key: FeatureFlagKey.IS_IMAP_SMTP_CALDAV_ENABLED,
|
||||
workspaceId: workspaceId,
|
||||
value: true,
|
||||
},
|
||||
|
||||
@ -88,6 +88,20 @@ export class ImapSmtpCalDavAPIService {
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
let shouldEnableSync = false;
|
||||
|
||||
if (connectedAccount) {
|
||||
const hadOnlySmtp =
|
||||
connectedAccount.connectionParameters?.SMTP &&
|
||||
!connectedAccount.connectionParameters?.IMAP &&
|
||||
!connectedAccount.connectionParameters?.CALDAV;
|
||||
|
||||
const isAddingImapOrCaldav =
|
||||
input.accountType === 'IMAP' || input.accountType === 'CALDAV';
|
||||
|
||||
shouldEnableSync = Boolean(hadOnlySmtp && isAddingImapOrCaldav);
|
||||
}
|
||||
|
||||
await workspaceDataSource.transaction(async () => {
|
||||
if (!existingAccountId) {
|
||||
const newConnectedAccount = await connectedAccountRepository.save(
|
||||
@ -129,7 +143,10 @@ export class ImapSmtpCalDavAPIService {
|
||||
connectedAccountId: newOrExistingConnectedAccountId,
|
||||
type: MessageChannelType.EMAIL,
|
||||
handle,
|
||||
syncStatus: MessageChannelSyncStatus.ONGOING,
|
||||
isSyncEnabled: shouldEnableSync,
|
||||
syncStatus: shouldEnableSync
|
||||
? MessageChannelSyncStatus.ONGOING
|
||||
: MessageChannelSyncStatus.NOT_SYNCED,
|
||||
},
|
||||
{},
|
||||
);
|
||||
@ -200,9 +217,12 @@ export class ImapSmtpCalDavAPIService {
|
||||
},
|
||||
{
|
||||
syncStage: MessageChannelSyncStage.FULL_MESSAGE_LIST_FETCH_PENDING,
|
||||
syncStatus: null,
|
||||
syncStatus: shouldEnableSync
|
||||
? MessageChannelSyncStatus.ONGOING
|
||||
: MessageChannelSyncStatus.NOT_SYNCED,
|
||||
syncCursor: '',
|
||||
syncStageStartedAt: null,
|
||||
isSyncEnabled: shouldEnableSync,
|
||||
},
|
||||
);
|
||||
|
||||
@ -227,7 +247,10 @@ export class ImapSmtpCalDavAPIService {
|
||||
}
|
||||
});
|
||||
|
||||
if (this.twentyConfigService.get('MESSAGING_PROVIDER_IMAP_ENABLED')) {
|
||||
if (!shouldEnableSync) {
|
||||
return;
|
||||
}
|
||||
|
||||
const messageChannels = await messageChannelRepository.find({
|
||||
where: {
|
||||
connectedAccountId: newOrExistingConnectedAccountId,
|
||||
@ -244,5 +267,4 @@ export class ImapSmtpCalDavAPIService {
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -64,14 +64,14 @@ export class ImapClientProvider {
|
||||
await client.connect();
|
||||
|
||||
this.logger.log(
|
||||
`Connected to IMAP server for ${connectionParameters.handle}`,
|
||||
`Connected to IMAP server for ${connectedAccount.handle}`,
|
||||
);
|
||||
|
||||
try {
|
||||
const mailboxes = await client.list();
|
||||
|
||||
this.logger.log(
|
||||
`Available mailboxes for ${connectionParameters.handle}: ${mailboxes.map((m) => m.path).join(', ')}`,
|
||||
`Available mailboxes for ${connectedAccount.handle}: ${mailboxes.map((m) => m.path).join(', ')}`,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.warn(`Failed to list mailboxes: ${error.message}`);
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { SmtpClientProvider } from './providers/smtp-client.provider';
|
||||
|
||||
@Module({
|
||||
providers: [SmtpClientProvider],
|
||||
exports: [SmtpClientProvider],
|
||||
})
|
||||
export class MessagingSmtpDriverModule {}
|
||||
@ -0,0 +1,35 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { createTransport, Transporter } from 'nodemailer';
|
||||
|
||||
import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
|
||||
|
||||
@Injectable()
|
||||
export class SmtpClientProvider {
|
||||
public async getSmtpClient(
|
||||
connectedAccount: Pick<
|
||||
ConnectedAccountWorkspaceEntity,
|
||||
'connectionParameters' | 'handle'
|
||||
>,
|
||||
): Promise<Transporter> {
|
||||
const smtpParams = connectedAccount.connectionParameters?.SMTP;
|
||||
|
||||
if (!smtpParams) {
|
||||
throw new Error('SMTP settings not configured for this account');
|
||||
}
|
||||
|
||||
const transporter = createTransport({
|
||||
host: smtpParams.host,
|
||||
port: smtpParams.port,
|
||||
auth: {
|
||||
user: connectedAccount.handle,
|
||||
pass: smtpParams.password,
|
||||
},
|
||||
tls: {
|
||||
rejectUnauthorized: false,
|
||||
},
|
||||
});
|
||||
|
||||
return transporter;
|
||||
}
|
||||
}
|
||||
@ -22,6 +22,7 @@ import { MessagingOngoingStaleCronJob } from 'src/modules/messaging/message-impo
|
||||
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 { MessagingSmtpDriverModule } from 'src/modules/messaging/message-import-manager/drivers/smtp/messaging-smtp-driver.module';
|
||||
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 { MessagingMessageListFetchJob } from 'src/modules/messaging/message-import-manager/jobs/messaging-message-list-fetch.job';
|
||||
@ -48,6 +49,7 @@ import { MessagingMonitoringModule } from 'src/modules/messaging/monitoring/mess
|
||||
MessagingGmailDriverModule,
|
||||
MessagingMicrosoftDriverModule,
|
||||
MessagingIMAPDriverModule,
|
||||
MessagingSmtpDriverModule,
|
||||
MessagingCommonModule,
|
||||
TypeOrmModule.forFeature(
|
||||
[Workspace, DataSourceEntity, ObjectMetadataEntity],
|
||||
|
||||
@ -12,6 +12,7 @@ import {
|
||||
import { GmailClientProvider } from 'src/modules/messaging/message-import-manager/drivers/gmail/providers/gmail-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 { SmtpClientProvider } from 'src/modules/messaging/message-import-manager/drivers/smtp/providers/smtp-client.provider';
|
||||
import { isAccessTokenRefreshingError } from 'src/modules/messaging/message-import-manager/drivers/microsoft/utils/is-access-token-refreshing-error.utils';
|
||||
import { mimeEncode } from 'src/modules/messaging/message-import-manager/utils/mime-encode.util';
|
||||
|
||||
@ -27,6 +28,7 @@ export class MessagingSendMessageService {
|
||||
private readonly gmailClientProvider: GmailClientProvider,
|
||||
private readonly oAuth2ClientProvider: OAuth2ClientProvider,
|
||||
private readonly microsoftClientProvider: MicrosoftClientProvider,
|
||||
private readonly smtpClientProvider: SmtpClientProvider,
|
||||
) {}
|
||||
|
||||
public async sendMessage(
|
||||
@ -120,7 +122,16 @@ export class MessagingSendMessageService {
|
||||
break;
|
||||
}
|
||||
case ConnectedAccountProvider.IMAP_SMTP_CALDAV: {
|
||||
throw new Error('IMAP provider does not support sending messages');
|
||||
const smtpClient =
|
||||
await this.smtpClientProvider.getSmtpClient(connectedAccount);
|
||||
|
||||
await smtpClient.sendMail({
|
||||
from: connectedAccount.handle,
|
||||
to: sendMessageInput.to,
|
||||
subject: sendMessageInput.subject,
|
||||
text: sendMessageInput.body,
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
assertUnreachable(
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 104 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 130 KiB |
Reference in New Issue
Block a user