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:
neo773
2025-07-10 18:47:26 +05:30
committed by GitHub
parent fe9de195c3
commit aede38000e
50 changed files with 1358 additions and 484 deletions

141
nx.json
View File

@ -1,6 +1,12 @@
{ {
"workspaceLayout": {
"appsDir": "packages",
"libsDir": "packages"
},
"namedInputs": { "namedInputs": {
"default": ["{projectRoot}/**/*"], "default": [
"{projectRoot}/**/*"
],
"excludeStories": [ "excludeStories": [
"default", "default",
"!{projectRoot}/.storybook/*", "!{projectRoot}/.storybook/*",
@ -28,17 +34,26 @@
"targetDefaults": { "targetDefaults": {
"build": { "build": {
"cache": true, "cache": true,
"inputs": ["^production", "production"], "inputs": [
"dependsOn": ["^build"] "^production",
"production"
],
"dependsOn": [
"^build"
]
}, },
"start": { "start": {
"cache": true, "cache": true,
"dependsOn": ["^build"] "dependsOn": [
"^build"
]
}, },
"lint": { "lint": {
"executor": "@nx/eslint:lint", "executor": "@nx/eslint:lint",
"cache": true, "cache": true,
"outputs": ["{options.outputFile}"], "outputs": [
"{options.outputFile}"
],
"options": { "options": {
"eslintConfig": "{projectRoot}/.eslintrc.cjs", "eslintConfig": "{projectRoot}/.eslintrc.cjs",
"cache": true, "cache": true,
@ -46,10 +61,16 @@
"ignorePath": "{workspaceRoot}/.gitignore" "ignorePath": "{workspaceRoot}/.gitignore"
}, },
"configurations": { "configurations": {
"ci": { "cacheStrategy": "content" }, "ci": {
"fix": { "fix": true } "cacheStrategy": "content"
},
"fix": {
"fix": true
}
}, },
"dependsOn": ["^build"] "dependsOn": [
"^build"
]
}, },
"fmt": { "fmt": {
"executor": "nx:run-commands", "executor": "nx:run-commands",
@ -63,10 +84,16 @@
"write": false "write": false
}, },
"configurations": { "configurations": {
"ci": { "cacheStrategy": "content" }, "ci": {
"fix": { "write": true } "cacheStrategy": "content"
},
"fix": {
"write": true
}
}, },
"dependsOn": ["^build"] "dependsOn": [
"^build"
]
}, },
"typecheck": { "typecheck": {
"executor": "nx:run-commands", "executor": "nx:run-commands",
@ -76,24 +103,34 @@
"command": "tsc -b tsconfig.json --incremental" "command": "tsc -b tsconfig.json --incremental"
}, },
"configurations": { "configurations": {
"watch": { "watch": true } "watch": {
"watch": true
}
}, },
"dependsOn": ["^build"] "dependsOn": [
"^build"
]
}, },
"test": { "test": {
"executor": "@nx/jest:jest", "executor": "@nx/jest:jest",
"cache": true, "cache": true,
"dependsOn": ["^build"], "dependsOn": [
"^build"
],
"inputs": [ "inputs": [
"^default", "^default",
"excludeStories", "excludeStories",
"{workspaceRoot}/jest.preset.js" "{workspaceRoot}/jest.preset.js"
], ],
"outputs": ["{projectRoot}/coverage"], "outputs": [
"{projectRoot}/coverage"
],
"options": { "options": {
"jestConfig": "{projectRoot}/jest.config.ts", "jestConfig": "{projectRoot}/jest.config.ts",
"coverage": true, "coverage": true,
"coverageReporters": ["text-summary"], "coverageReporters": [
"text-summary"
],
"cacheDirectory": "../../.cache/jest/{projectRoot}" "cacheDirectory": "../../.cache/jest/{projectRoot}"
}, },
"configurations": { "configurations": {
@ -101,31 +138,49 @@
"ci": true, "ci": true,
"maxWorkers": 3 "maxWorkers": 3
}, },
"coverage": { "coverageReporters": ["lcov", "text"] }, "coverage": {
"watch": { "watch": true } "coverageReporters": [
"lcov",
"text"
]
},
"watch": {
"watch": true
}
} }
}, },
"test:e2e": { "test:e2e": {
"cache": true, "cache": true,
"dependsOn": ["^build"] "dependsOn": [
"^build"
]
}, },
"storybook:build": { "storybook:build": {
"executor": "nx:run-commands", "executor": "nx:run-commands",
"cache": true, "cache": true,
"inputs": ["^default", "excludeTests"], "inputs": [
"outputs": ["{projectRoot}/{options.output-dir}"], "^default",
"excludeTests"
],
"outputs": [
"{projectRoot}/{options.output-dir}"
],
"options": { "options": {
"cwd": "{projectRoot}", "cwd": "{projectRoot}",
"command": "VITE_DISABLE_TYPESCRIPT_CHECKER=true VITE_DISABLE_ESLINT_CHECKER=true storybook build --test", "command": "VITE_DISABLE_TYPESCRIPT_CHECKER=true VITE_DISABLE_ESLINT_CHECKER=true storybook build --test",
"output-dir": "storybook-static", "output-dir": "storybook-static",
"config-dir": ".storybook" "config-dir": ".storybook"
}, },
"dependsOn": ["^build"] "dependsOn": [
"^build"
]
}, },
"storybook:serve:dev": { "storybook:serve:dev": {
"executor": "nx:run-commands", "executor": "nx:run-commands",
"cache": true, "cache": true,
"dependsOn": ["^build"], "dependsOn": [
"^build"
],
"options": { "options": {
"cwd": "{projectRoot}", "cwd": "{projectRoot}",
"command": "storybook dev", "command": "storybook dev",
@ -134,7 +189,9 @@
}, },
"storybook:serve:static": { "storybook:serve:static": {
"executor": "nx:run-commands", "executor": "nx:run-commands",
"dependsOn": ["storybook:build"], "dependsOn": [
"storybook:build"
],
"options": { "options": {
"cwd": "{projectRoot}", "cwd": "{projectRoot}",
"command": "npx http-server {args.staticDir} -a={args.host} --port={args.port} --silent={args.silent}", "command": "npx http-server {args.staticDir} -a={args.host} --port={args.port} --silent={args.silent}",
@ -147,8 +204,13 @@
"storybook:test": { "storybook:test": {
"executor": "nx:run-commands", "executor": "nx:run-commands",
"cache": true, "cache": true,
"inputs": ["^default", "excludeTests"], "inputs": [
"outputs": ["{projectRoot}/coverage/storybook"], "^default",
"excludeTests"
],
"outputs": [
"{projectRoot}/coverage/storybook"
],
"options": { "options": {
"cwd": "{projectRoot}", "cwd": "{projectRoot}",
"commands": [ "commands": [
@ -164,7 +226,10 @@
}, },
"storybook:test:no-coverage": { "storybook:test:no-coverage": {
"executor": "nx:run-commands", "executor": "nx:run-commands",
"inputs": ["^default", "excludeTests"], "inputs": [
"^default",
"excludeTests"
],
"options": { "options": {
"cwd": "{projectRoot}", "cwd": "{projectRoot}",
"commands": [ "commands": [
@ -192,7 +257,9 @@
"checkCoverage": true "checkCoverage": true
}, },
"configurations": { "configurations": {
"text": { "reporter": "text" } "text": {
"reporter": "text"
}
} }
}, },
"storybook:serve-and-test:static": { "storybook:serve-and-test:static": {
@ -252,12 +319,20 @@
}, },
"@nx/vite:test": { "@nx/vite:test": {
"cache": true, "cache": true,
"inputs": ["default", "^default"] "inputs": [
"default",
"^default"
]
}, },
"@nx/vite:build": { "@nx/vite:build": {
"cache": true, "cache": true,
"dependsOn": ["^build"], "dependsOn": [
"inputs": ["default", "^default"] "^build"
],
"inputs": [
"default",
"^default"
]
} }
}, },
"installation": { "installation": {
@ -289,7 +364,9 @@
"tasksRunnerOptions": { "tasksRunnerOptions": {
"default": { "default": {
"options": { "options": {
"cacheableOperations": ["storybook:build"] "cacheableOperations": [
"storybook:build"
]
} }
} }
}, },

View File

@ -435,7 +435,6 @@ export type ConnectionParameters = {
password: Scalars['String']; password: Scalars['String'];
port: Scalars['Float']; port: Scalars['Float'];
secure?: InputMaybe<Scalars['Boolean']>; secure?: InputMaybe<Scalars['Boolean']>;
username: Scalars['String'];
}; };
export type ConnectionParametersOutput = { export type ConnectionParametersOutput = {
@ -444,7 +443,6 @@ export type ConnectionParametersOutput = {
password: Scalars['String']; password: Scalars['String'];
port: Scalars['Float']; port: Scalars['Float'];
secure?: Maybe<Scalars['Boolean']>; secure?: Maybe<Scalars['Boolean']>;
username: Scalars['String'];
}; };
export type CreateApiKeyDto = { export type CreateApiKeyDto = {
@ -708,7 +706,7 @@ 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_FIELDS_PERMISSIONS_ENABLED = 'IS_FIELDS_PERMISSIONS_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_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
IS_MORPH_RELATION_ENABLED = 'IS_MORPH_RELATION_ENABLED', IS_MORPH_RELATION_ENABLED = 'IS_MORPH_RELATION_ENABLED',
IS_POSTGRESQL_INTEGRATION_ENABLED = 'IS_POSTGRESQL_INTEGRATION_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<{ export type CreateDatabaseConfigVariableMutationVariables = Exact<{
key: Scalars['String']; key: Scalars['String'];
@ -6003,21 +6001,18 @@ export const GetConnectedImapSmtpCaldavAccountDocument = gql`
host host
port port
secure secure
username
password password
} }
SMTP { SMTP {
host host
port port
secure secure
username
password password
} }
CALDAV { CALDAV {
host host
port port
secure secure
username
password password
} }
} }

View File

@ -435,7 +435,6 @@ export type ConnectionParameters = {
password: Scalars['String']; password: Scalars['String'];
port: Scalars['Float']; port: Scalars['Float'];
secure?: InputMaybe<Scalars['Boolean']>; secure?: InputMaybe<Scalars['Boolean']>;
username: Scalars['String'];
}; };
export type ConnectionParametersOutput = { export type ConnectionParametersOutput = {
@ -444,7 +443,6 @@ export type ConnectionParametersOutput = {
password: Scalars['String']; password: Scalars['String'];
port: Scalars['Float']; port: Scalars['Float'];
secure?: Maybe<Scalars['Boolean']>; secure?: Maybe<Scalars['Boolean']>;
username: Scalars['String'];
}; };
export type CreateApiKeyDto = { export type CreateApiKeyDto = {
@ -672,7 +670,7 @@ 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_FIELDS_PERMISSIONS_ENABLED = 'IS_FIELDS_PERMISSIONS_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_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
IS_MORPH_RELATION_ENABLED = 'IS_MORPH_RELATION_ENABLED', IS_MORPH_RELATION_ENABLED = 'IS_MORPH_RELATION_ENABLED',
IS_POSTGRESQL_INTEGRATION_ENABLED = 'IS_POSTGRESQL_INTEGRATION_ENABLED', IS_POSTGRESQL_INTEGRATION_ENABLED = 'IS_POSTGRESQL_INTEGRATION_ENABLED',

View File

@ -64,19 +64,19 @@ const SettingsNewObject = lazy(() =>
})), })),
); );
const SettingsNewImapConnection = lazy(() => const SettingsNewImapSmtpCaldavConnection = lazy(() =>
import( import(
'@/settings/accounts/components/SettingsAccountsNewImapConnection' '@/settings/accounts/components/SettingsAccountsNewImapSmtpCaldavConnection'
).then((module) => ({ ).then((module) => ({
default: module.SettingsAccountsNewImapConnection, default: module.SettingsAccountsNewImapSmtpCaldavConnection,
})), })),
); );
const SettingsEditImapConnection = lazy(() => const SettingsEditImapSmtpCaldavConnection = lazy(() =>
import( import(
'@/settings/accounts/components/SettingsAccountsEditImapConnection' '@/settings/accounts/components/SettingsAccountsEditImapSmtpCaldavConnection'
).then((module) => ({ ).then((module) => ({
default: module.SettingsAccountsEditImapConnection, default: module.SettingsAccountsEditImapSmtpCaldavConnection,
})), })),
); );
@ -375,12 +375,12 @@ export const SettingsRoutes = ({
element={<SettingsAccountsEmails />} element={<SettingsAccountsEmails />}
/> />
<Route <Route
path={SettingsPath.NewImapConnection} path={SettingsPath.NewImapSmtpCaldavConnection}
element={<SettingsNewImapConnection />} element={<SettingsNewImapSmtpCaldavConnection />}
/> />
<Route <Route
path={SettingsPath.EditImapConnection} path={SettingsPath.EditImapSmtpCaldavConnection}
element={<SettingsEditImapConnection />} element={<SettingsEditImapSmtpCaldavConnection />}
/> />
<Route <Route
element={ element={

View File

@ -15,6 +15,7 @@ import { isDeveloperDefaultSignInPrefilledState } from '@/client-config/states/i
import { isEmailVerificationRequiredState } from '@/client-config/states/isEmailVerificationRequiredState'; import { isEmailVerificationRequiredState } from '@/client-config/states/isEmailVerificationRequiredState';
import { isGoogleCalendarEnabledState } from '@/client-config/states/isGoogleCalendarEnabledState'; import { isGoogleCalendarEnabledState } from '@/client-config/states/isGoogleCalendarEnabledState';
import { isGoogleMessagingEnabledState } from '@/client-config/states/isGoogleMessagingEnabledState'; import { isGoogleMessagingEnabledState } from '@/client-config/states/isGoogleMessagingEnabledState';
import { isImapSmtpCaldavEnabledState } from '@/client-config/states/isImapSmtpCaldavEnabledState';
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 { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState'; import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
@ -92,6 +93,10 @@ export const ClientConfigProviderEffect = () => {
calendarBookingPageIdState, calendarBookingPageIdState,
); );
const setIsImapSmtpCaldavEnabled = useSetRecoilState(
isImapSmtpCaldavEnabledState,
);
const { data, loading, error, fetchClientConfig } = useClientConfig(); const { data, loading, error, fetchClientConfig } = useClientConfig();
useEffect(() => { useEffect(() => {
@ -183,6 +188,7 @@ export const ClientConfigProviderEffect = () => {
})); }));
setCalendarBookingPageId(data?.clientConfig?.calendarBookingPageId ?? null); setCalendarBookingPageId(data?.clientConfig?.calendarBookingPageId ?? null);
setIsImapSmtpCaldavEnabled(data?.clientConfig?.isImapSmtpCaldavEnabled);
}, [ }, [
data, data,
loading, loading,
@ -210,6 +216,7 @@ export const ClientConfigProviderEffect = () => {
setIsAttachmentPreviewEnabled, setIsAttachmentPreviewEnabled,
setIsConfigVariablesInDbEnabled, setIsConfigVariablesInDbEnabled,
setCalendarBookingPageId, setCalendarBookingPageId,
setIsImapSmtpCaldavEnabled,
]); ]);
return <></>; return <></>;

View File

@ -0,0 +1,5 @@
import { createState } from 'twenty-ui/utilities';
export const isImapSmtpCaldavEnabledState = createState<boolean>({
key: 'isImapSmtpCaldavEnabled',
defaultValue: false,
});

View File

@ -30,7 +30,7 @@ export type ClientConfig = {
isMicrosoftCalendarEnabled: boolean; isMicrosoftCalendarEnabled: boolean;
isMicrosoftMessagingEnabled: boolean; isMicrosoftMessagingEnabled: boolean;
isMultiWorkspaceEnabled: boolean; isMultiWorkspaceEnabled: boolean;
isIMAPMessagingEnabled: boolean; isImapSmtpCaldavEnabled: boolean;
publicFeatureFlags: Array<PublicFeatureFlag>; publicFeatureFlags: Array<PublicFeatureFlag>;
sentry: Sentry; sentry: Sentry;
signInPrefilled: boolean; signInPrefilled: boolean;

View File

@ -4,21 +4,11 @@ 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 { useLingui } from '@lingui/react/macro';
import {
IconComponent, import { SettingsConnectedAccountIcon } from '@/settings/accounts/components/SettingsConnectedAccountIcon';
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';
const ProviderIcons: { [k: string]: IconComponent } = {
google: IconGoogle,
microsoft: IconMicrosoft,
imap: IconMail,
};
export const SettingsAccountsConnectedAccountsListCard = ({ export const SettingsAccountsConnectedAccountsListCard = ({
accounts, accounts,
loading, loading,
@ -38,7 +28,7 @@ export const SettingsAccountsConnectedAccountsListCard = ({
items={accounts} items={accounts}
getItemLabel={(account) => account.handle} getItemLabel={(account) => account.handle}
isLoading={loading} isLoading={loading}
RowIconFn={(row) => ProviderIcons[row.provider]} RowIconFn={(row) => SettingsConnectedAccountIcon({ account: row })}
RowRightComponent={({ item: account }) => ( RowRightComponent={({ item: account }) => (
<SettingsAccountsConnectedAccountsRowRightContainer account={account} /> <SettingsAccountsConnectedAccountsRowRightContainer account={account} />
)} )}

View File

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

View File

@ -1,93 +1,93 @@
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { FormProvider } from 'react-hook-form'; import { FormProvider } from 'react-hook-form';
import { useParams } from 'react-router-dom'; 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 { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsPath } from '@/types/SettingsPath'; import { SettingsPath } from '@/types/SettingsPath';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; 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 { Loader } from 'twenty-ui/feedback';
import { useNavigateSettings } from '~/hooks/useNavigateSettings'; import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath'; 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` const StyledLoadingContainer = styled.div`
align-items: center; align-items: center;
display: flex; display: flex;
height: 200px; height: 200px;
justify-content: center; justify-content: center;
width: 100%;
`; `;
export const SettingsAccountsEditImapConnection = () => { export const SettingsAccountsEditImapSmtpCaldavConnection = () => {
const { t } = useLingui(); const { t } = useLingui();
const navigate = useNavigateSettings(); const navigate = useNavigateSettings();
const { connectedAccountId } = useParams<{ connectedAccountId: string }>(); const { connectedAccountId } = useParams<{ connectedAccountId: string }>();
const { connectedAccount, loading: accountLoading } = const {
useConnectedImapSmtpCaldavAccount(connectedAccountId); formMethods,
handleSave,
const initialData = { handleSubmit,
handle: connectedAccount?.handle || '', canSave,
host: connectedAccount?.connectionParameters?.IMAP?.host || '', isSubmitting,
port: connectedAccount?.connectionParameters?.IMAP?.port || 993, loading,
secure: connectedAccount?.connectionParameters?.IMAP?.secure ?? true, connectedAccount,
password: connectedAccount?.connectionParameters?.IMAP?.password || '', } = useImapSmtpCaldavConnectionForm({
}; isEditing: true,
connectedAccountId,
const { formMethods, handleSave, handleSubmit, canSave, isSubmitting } = });
useImapConnectionForm({
initialData,
isEditing: true,
connectedAccountId,
});
const { control } = formMethods; const { control } = formMethods;
const renderLoadingState = () => ( if (loading && !connectedAccount) {
<StyledLoadingContainer> return (
<Loader /> <StyledLoadingContainer>
</StyledLoadingContainer> <Loader />
); </StyledLoadingContainer>
);
}
if (!connectedAccount && !loading) {
return <NotFound />;
}
const renderForm = () => ( const renderForm = () => (
// eslint-disable-next-line react/jsx-props-no-spreading // eslint-disable-next-line react/jsx-props-no-spreading
<FormProvider {...formMethods}> <FormProvider {...formMethods}>
<SubMenuTopBarContainer <SubMenuTopBarContainer
title={t`Edit IMAP Connection`} title={t`Edit Email Account`}
links={[ links={[
{ {
children: t`Settings`, children: t`Workspace`,
href: getSettingsPath(SettingsPath.Workspace), href: getSettingsPath(SettingsPath.Workspace),
}, },
{ {
children: t`Email Connections`, children: t`Accounts`,
href: getSettingsPath(SettingsPath.Accounts), href: getSettingsPath(SettingsPath.Accounts),
}, },
{ children: t`Edit IMAP Connection` }, { children: t`Edit Email Account` },
]} ]}
actionButton={ actionButton={
<SaveAndCancelButtons <SaveAndCancelButtons
isSaveDisabled={!canSave} isSaveDisabled={!canSave}
isCancelDisabled={isSubmitting} isCancelDisabled={isSubmitting}
isLoading={loading}
onCancel={() => navigate(SettingsPath.Accounts)} onCancel={() => navigate(SettingsPath.Accounts)}
onSave={handleSubmit(handleSave)} onSave={handleSubmit((data) => handleSave(data))}
/> />
} }
> >
<SettingsPageContainer> <SettingsPageContainer>
<SetttingsAccountsImapConnectionForm control={control} isEditing /> <SettingsAccountsConnectionForm control={control} isEditing />
</SettingsPageContainer> </SettingsPageContainer>
</SubMenuTopBarContainer> </SubMenuTopBarContainer>
</FormProvider> </FormProvider>
); );
if (accountLoading === true) {
return renderLoadingState();
}
return renderForm(); return renderForm();
}; };

View File

@ -9,7 +9,7 @@ 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 { IconAt, IconGoogle, 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 { FeatureFlagKey } from '~/generated-metadata/graphql'; import { FeatureFlagKey } from '~/generated-metadata/graphql';
@ -51,7 +51,9 @@ export const SettingsAccountsListEmptyStateCard = ({
isMicrosoftCalendarEnabledState, isMicrosoftCalendarEnabledState,
); );
const isImapEnabled = useIsFeatureEnabled(FeatureFlagKey.IS_IMAP_ENABLED); const isImapSmtpCaldavFeatureFlagEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_IMAP_SMTP_CALDAV_ENABLED,
);
return ( return (
<Card> <Card>
@ -75,12 +77,12 @@ export const SettingsAccountsListEmptyStateCard = ({
/> />
)} )}
{isImapEnabled && ( {isImapSmtpCaldavFeatureFlagEnabled && (
<Button <Button
Icon={IconMail} Icon={IconAt}
title={t`Connect with IMAP`} title={t`Connect Email Account`}
variant="secondary" variant="secondary"
to={getSettingsPath(SettingsPath.NewImapConnection)} to={getSettingsPath(SettingsPath.NewImapSmtpCaldavConnection)}
/> />
)} )}
</StyledBody> </StyledBody>

View File

@ -44,6 +44,9 @@ export const SettingsAccountsMessageChannelsContainer = () => {
connectedAccountId: { connectedAccountId: {
in: accounts.map((account) => account.id), in: accounts.map((account) => account.id),
}, },
isSyncEnabled: {
eq: true,
},
}, },
skip: !accounts.length, skip: !accounts.length,
}); });

View File

@ -1,21 +1,29 @@
import { useLingui } from '@lingui/react/macro';
import { FormProvider } from 'react-hook-form'; 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 { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsPath } from '@/types/SettingsPath'; import { SettingsPath } from '@/types/SettingsPath';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { useLingui } from '@lingui/react/macro';
import { useNavigateSettings } from '~/hooks/useNavigateSettings'; import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath'; 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 { t } = useLingui();
const navigate = useNavigateSettings(); const navigate = useNavigateSettings();
const { formMethods, handleSave, handleSubmit, canSave, isSubmitting } = const {
useImapConnectionForm(); formMethods,
handleSave,
handleSubmit,
canSave,
isSubmitting,
loading,
} = useImapSmtpCaldavConnectionForm({});
const { control } = formMethods; const { control } = formMethods;
@ -23,32 +31,30 @@ export const SettingsAccountsNewImapConnection = () => {
// eslint-disable-next-line react/jsx-props-no-spreading // eslint-disable-next-line react/jsx-props-no-spreading
<FormProvider {...formMethods}> <FormProvider {...formMethods}>
<SubMenuTopBarContainer <SubMenuTopBarContainer
title={t`New IMAP Connection`} title={t`New Email Account`}
links={[ links={[
{ {
children: t`Settings`, children: t`Workspace`,
href: getSettingsPath(SettingsPath.Workspace), href: getSettingsPath(SettingsPath.Workspace),
}, },
{ {
children: t`Email Connections`, children: t`Accounts`,
href: getSettingsPath(SettingsPath.Accounts), href: getSettingsPath(SettingsPath.Accounts),
}, },
{ children: t`New IMAP Connection` }, { children: t`New Email Account` },
]} ]}
actionButton={ actionButton={
<SaveAndCancelButtons <SaveAndCancelButtons
isSaveDisabled={!canSave} isSaveDisabled={!canSave}
isCancelDisabled={isSubmitting} isCancelDisabled={isSubmitting}
isLoading={loading}
onCancel={() => navigate(SettingsPath.Accounts)} onCancel={() => navigate(SettingsPath.Accounts)}
onSave={handleSubmit(handleSave)} onSave={handleSubmit((data) => handleSave(data))}
/> />
} }
> >
<SettingsPageContainer> <SettingsPageContainer>
<SetttingsAccountsImapConnectionForm <SettingsAccountsConnectionForm control={control} isEditing={false} />
control={control}
isEditing={false}
/>
</SettingsPageContainer> </SettingsPageContainer>
</SubMenuTopBarContainer> </SubMenuTopBarContainer>
</FormProvider> </FormProvider>

View File

@ -10,11 +10,13 @@ import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal'; import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useModal } from '@/ui/layout/modal/hooks/useModal'; import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { Trans, useLingui } from '@lingui/react/macro'; import { Trans, useLingui } from '@lingui/react/macro';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { import {
IconCalendarEvent, IconCalendarEvent,
IconDotsVertical, IconDotsVertical,
IconMail, IconMail,
IconRefresh, IconRefresh,
IconSettings,
IconTrash, IconTrash,
} from 'twenty-ui/display'; } from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input'; import { LightIconButton } from 'twenty-ui/input';
@ -57,6 +59,19 @@ export const SettingsAccountsRowDropdownMenu = ({
dropdownComponents={ dropdownComponents={
<DropdownContent> <DropdownContent>
<DropdownMenuItemsContainer> <DropdownMenuItemsContainer>
{account.provider ===
ConnectedAccountProvider.IMAP_SMTP_CALDAV && (
<MenuItem
text={t`Connection settings`}
LeftIcon={IconSettings}
onClick={() => {
navigate(SettingsPath.EditImapSmtpCaldavConnection, {
connectedAccountId: account.id,
});
closeDropdown(dropdownId);
}}
/>
)}
<MenuItem <MenuItem
LeftIcon={IconMail} LeftIcon={IconMail}
text={t`Emails settings`} text={t`Emails settings`}

View File

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

View File

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

View File

@ -0,0 +1 @@
export const ACCOUNT_PROTOCOLS = ['IMAP', 'SMTP', 'CALDAV'] as const;

View File

@ -12,21 +12,18 @@ export const GET_CONNECTED_IMAP_SMTP_CALDAV_ACCOUNT = gql`
host host
port port
secure secure
username
password password
} }
SMTP { SMTP {
host host
port port
secure secure
username
password password
} }
CALDAV { CALDAV {
host host
port port
secure secure
username
password password
} }
} }

View File

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

View File

@ -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 = ( export const useConnectedImapSmtpCaldavAccount = (
connectedAccountId: string | undefined, connectedAccountId: string | undefined,
onCompleted?: (data: ConnectedImapSmtpCaldavAccount) => void,
) => { ) => {
const { data, loading, error } = useGetConnectedImapSmtpCaldavAccountQuery({ const { data, loading, error } = useGetConnectedImapSmtpCaldavAccountQuery({
variables: { id: connectedAccountId ?? '' }, variables: { id: connectedAccountId ?? '' },
skip: !connectedAccountId, skip: !connectedAccountId,
onCompleted: (data) => {
onCompleted?.(data.getConnectedImapSmtpCaldavAccount);
},
}); });
return { return {

View File

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

View File

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

View File

@ -17,11 +17,11 @@ export const useTriggerProviderReconnect = () => {
) => { ) => {
if (provider === ConnectedAccountProvider.IMAP_SMTP_CALDAV) { if (provider === ConnectedAccountProvider.IMAP_SMTP_CALDAV) {
if (!accountId) { if (!accountId) {
navigate(SettingsPath.NewImapConnection); navigate(SettingsPath.NewImapSmtpCaldavConnection);
return; return;
} }
navigate(SettingsPath.EditImapConnection, { navigate(SettingsPath.EditImapSmtpCaldavConnection, {
connectedAccountId: accountId, connectedAccountId: accountId,
}); });
return; return;

View File

@ -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());
};

View File

@ -4,9 +4,9 @@ import { ComponentType } from 'react';
import { SettingsListSkeletonCard } from '@/settings/components/SettingsListSkeletonCard'; 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 { IconComponent, IconPlus } from 'twenty-ui/display';
import { Card, CardFooter } from 'twenty-ui/layout';
import { SettingsListItemCardContent } from './SettingsListItemCardContent';
const StyledFooter = styled(CardFooter)` const StyledFooter = styled(CardFooter)`
align-items: center; align-items: center;

View File

@ -5,8 +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', NewImapSmtpCaldavConnection = 'accounts/new-imap-smtp-caldav-connection',
EditImapConnection = 'accounts/edit-imap-connection/:connectedAccountId', EditImapSmtpCaldavConnection = 'accounts/edit-imap-smtp-caldav-connection/:connectedAccountId',
Billing = 'billing', Billing = 'billing',
Objects = 'objects', Objects = 'objects',
ObjectOverview = 'objects/overview', ObjectOverview = 'objects/overview',

View File

@ -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, { await triggerApisOAuth(connectedAccount.provider, {
redirectLocation: redirectUrl, redirectLocation: redirectUrl,
loginHint: connectedAccount.handle, loginHint: connectedAccount.handle,
@ -180,6 +183,7 @@ export const WorkflowEditActionSendEmail = ({
provider: true, provider: true,
scopes: true, scopes: true,
accountOwnerId: true, accountOwnerId: true,
connectionParameters: true,
}, },
}); });

View File

@ -54,5 +54,5 @@ export const mockedClientConfig: ClientConfig = {
isGoogleCalendarEnabled: true, isGoogleCalendarEnabled: true,
isAttachmentPreviewEnabled: true, isAttachmentPreviewEnabled: true,
isConfigVariablesInDbEnabled: false, isConfigVariablesInDbEnabled: false,
isIMAPMessagingEnabled: false, isImapSmtpCaldavEnabled: false,
}; };

View File

@ -14,7 +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 # IS_IMAP_SMTP_CALDAV_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

View File

@ -11,7 +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 IS_IMAP_SMTP_CALDAV_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

View File

@ -96,7 +96,7 @@ describe('ClientConfigController', () => {
isGoogleMessagingEnabled: false, isGoogleMessagingEnabled: false,
isGoogleCalendarEnabled: false, isGoogleCalendarEnabled: false,
isConfigVariablesInDbEnabled: false, isConfigVariablesInDbEnabled: false,
isIMAPMessagingEnabled: false, isImapSmtpCaldavEnabled: false,
calendarBookingPageId: undefined, calendarBookingPageId: undefined,
}; };

View File

@ -178,7 +178,7 @@ export class ClientConfig {
isConfigVariablesInDbEnabled: boolean; isConfigVariablesInDbEnabled: boolean;
@Field(() => Boolean) @Field(() => Boolean)
isIMAPMessagingEnabled: boolean; isImapSmtpCaldavEnabled: boolean;
@Field(() => String, { nullable: true }) @Field(() => String, { nullable: true })
calendarBookingPageId?: string; calendarBookingPageId?: string;

View File

@ -138,8 +138,8 @@ 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( isImapSmtpCaldavEnabled: this.twentyConfigService.get(
'MESSAGING_PROVIDER_IMAP_ENABLED', 'IS_IMAP_SMTP_CALDAV_ENABLED',
), ),
calendarBookingPageId: this.twentyConfigService.get( calendarBookingPageId: this.twentyConfigService.get(
'CALENDAR_BOOKING_PAGE_ID', 'CALENDAR_BOOKING_PAGE_ID',

View File

@ -13,12 +13,13 @@ export type PublicFeatureFlag = {
export const PUBLIC_FEATURE_FLAGS: PublicFeatureFlag[] = [ export const PUBLIC_FEATURE_FLAGS: PublicFeatureFlag[] = [
{ {
key: FeatureFlagKey.IS_IMAP_ENABLED, key: FeatureFlagKey.IS_IMAP_SMTP_CALDAV_ENABLED,
metadata: { metadata: {
label: 'IMAP', label: 'IMAP, SMTP, CalDAV',
description: description:
'Easily add email accounts from any provider that supports IMAP (and soon, send emails with SMTP)', '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-enabled.png', imagePath:
'https://twenty.com/images/lab/is-imap-smtp-caldav-enabled.png',
}, },
}, },
...(process.env.CLOUDFLARE_API_KEY ...(process.env.CLOUDFLARE_API_KEY

View File

@ -5,7 +5,7 @@ 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', IS_IMAP_SMTP_CALDAV_ENABLED = 'IS_IMAP_SMTP_CALDAV_ENABLED',
IS_MORPH_RELATION_ENABLED = 'IS_MORPH_RELATION_ENABLED', IS_MORPH_RELATION_ENABLED = 'IS_MORPH_RELATION_ENABLED',
IS_WORKFLOW_FILTERING_ENABLED = 'IS_WORKFLOW_FILTERING_ENABLED', IS_WORKFLOW_FILTERING_ENABLED = 'IS_WORKFLOW_FILTERING_ENABLED',
IS_RELATION_CONNECT_ENABLED = 'IS_RELATION_CONNECT_ENABLED', IS_RELATION_CONNECT_ENABLED = 'IS_RELATION_CONNECT_ENABLED',

View File

@ -14,9 +14,6 @@ export class ConnectionParameters {
@Field(() => Number) @Field(() => Number)
port: number; port: number;
@Field(() => String)
username: string;
/** /**
* Note: This field is stored in plain text in the database. * 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, * While encrypting it could provide an extra layer of defense, we have decided not to,
@ -37,9 +34,6 @@ export class ConnectionParametersOutput {
@Field(() => Number) @Field(() => Number)
port: number; port: number;
@Field(() => String)
username: string;
@Field(() => String) @Field(() => String)
password: string; password: string;

View File

@ -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 { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { ConnectedAccountProvider } from 'twenty-shared/types'; import { ConnectedAccountProvider } from 'twenty-shared/types';
@ -39,36 +45,6 @@ export class ImapSmtpCaldavResolver {
private readonly mailConnectionValidatorService: ImapSmtpCaldavValidatorService, 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) @Query(() => ConnectedImapSmtpCaldavAccount)
@UseGuards(WorkspaceAuthGuard) @UseGuards(WorkspaceAuthGuard)
async getConnectedImapSmtpCaldavAccount( async getConnectedImapSmtpCaldavAccount(
@ -111,7 +87,18 @@ export class ImapSmtpCaldavResolver {
@AuthWorkspace() workspace: Workspace, @AuthWorkspace() workspace: Workspace,
@Args('id', { nullable: true }) id?: string, @Args('id', { nullable: true }) id?: string,
): Promise<ImapSmtpCaldavConnectionSuccess> { ): 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 = const validatedParams =
this.mailConnectionValidatorService.validateProtocolConnectionParams( this.mailConnectionValidatorService.validateProtocolConnectionParams(
@ -119,6 +106,7 @@ export class ImapSmtpCaldavResolver {
); );
await this.ImapSmtpCaldavConnectionService.testImapSmtpCaldav( await this.ImapSmtpCaldavConnectionService.testImapSmtpCaldav(
handle,
validatedParams, validatedParams,
accountType.type, accountType.type,
); );

View File

@ -10,7 +10,6 @@ export class ImapSmtpCaldavValidatorService {
private readonly protocolConnectionSchema = z.object({ private readonly protocolConnectionSchema = z.object({
host: z.string().min(1, 'Host is required'), host: z.string().min(1, 'Host is required'),
port: z.number().int().positive('Port must be a positive number'), 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'), password: z.string().min(1, 'Password is required'),
secure: z.boolean().optional(), secure: z.boolean().optional(),
}); });
@ -19,7 +18,10 @@ export class ImapSmtpCaldavValidatorService {
params: ConnectionParameters, params: ConnectionParameters,
): ConnectionParameters { ): ConnectionParameters {
if (!params) { 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 { try {
@ -32,10 +34,17 @@ export class ImapSmtpCaldavValidatorService {
throw new UserInputError( throw new UserInputError(
`Protocol connection validation failed: ${errorMessages}`, `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.',
});
} }
} }
} }

View File

@ -1,6 +1,7 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { ImapFlow } from 'imapflow'; import { ImapFlow } from 'imapflow';
import { createTransport } from 'nodemailer';
import { ConnectedAccountProvider } from 'twenty-shared/types'; import { ConnectedAccountProvider } from 'twenty-shared/types';
import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
@ -19,17 +20,16 @@ export class ImapSmtpCaldavService {
private readonly twentyORMGlobalManager: TwentyORMGlobalManager, private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {} ) {}
async testImapConnection(params: ConnectionParameters): Promise<boolean> { async testImapConnection(
if (!params.host || !params.username || !params.password) { handle: string,
throw new UserInputError('Missing required IMAP connection parameters'); params: ConnectionParameters,
} ): Promise<boolean> {
const client = new ImapFlow({ const client = new ImapFlow({
host: params.host, host: params.host,
port: params.port, port: params.port,
secure: params.secure ?? true, secure: params.secure ?? true,
auth: { auth: {
user: params.username, user: handle,
pass: params.password, pass: params.password,
}, },
logger: false, logger: false,
@ -57,16 +57,27 @@ export class ImapSmtpCaldavService {
if (error.authenticationFailed) { if (error.authenticationFailed) {
throw new UserInputError( throw new UserInputError(
'IMAP authentication failed. Please check your credentials.', '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') { if (error.code === 'ECONNREFUSED') {
throw new UserInputError( throw new UserInputError(
`IMAP connection refused. Please verify server and port.`, `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 { } finally {
if (client.authenticated) { if (client.authenticated) {
await client.logout(); await client.logout();
@ -74,36 +85,70 @@ export class ImapSmtpCaldavService {
} }
} }
async testSmtpConnection(params: ConnectionParameters): Promise<boolean> { async testSmtpConnection(
this.logger.log('SMTP connection testing not yet implemented', params); 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; 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); this.logger.log('CALDAV connection testing not yet implemented', params);
return true; return true;
} }
async testImapSmtpCaldav( async testImapSmtpCaldav(
handle: string,
params: ConnectionParameters, params: ConnectionParameters,
accountType: AccountType, accountType: AccountType,
): Promise<boolean> { ): Promise<boolean> {
if (accountType === 'IMAP') { if (accountType === 'IMAP') {
return this.testImapConnection(params); return this.testImapConnection(handle, params);
} }
if (accountType === 'SMTP') { if (accountType === 'SMTP') {
return this.testSmtpConnection(params); return this.testSmtpConnection(handle, params);
} }
if (accountType === 'CALDAV') { if (accountType === 'CALDAV') {
return this.testCaldavConnection(params); return this.testCaldavConnection(handle, params);
} }
throw new UserInputError( throw new UserInputError(
'Invalid account type. Must be one of: IMAP, SMTP, CALDAV', 'Invalid account type. Must be one of: IMAP, SMTP, CALDAV',
{
userFriendlyMessage:
'Please select a valid connection type (IMAP, SMTP, or CalDAV) and try again.',
},
); );
} }

View File

@ -1,7 +1,6 @@
export type ConnectionParameters = { export type ConnectionParameters = {
host: string; host: string;
port: number; port: number;
username: string;
password: string; password: string;
secure?: boolean; secure?: boolean;
}; };
@ -9,7 +8,6 @@ export type ConnectionParameters = {
export type AccountType = 'IMAP' | 'SMTP' | 'CALDAV'; export type AccountType = 'IMAP' | 'SMTP' | 'CALDAV';
export type ImapSmtpCaldavParams = { export type ImapSmtpCaldavParams = {
handle: string;
IMAP?: ConnectionParameters; IMAP?: ConnectionParameters;
SMTP?: ConnectionParameters; SMTP?: ConnectionParameters;
CALDAV?: ConnectionParameters; CALDAV?: ConnectionParameters;

View File

@ -148,7 +148,7 @@ export class ConfigVariables {
description: 'Enable or disable the IMAP messaging integration', description: 'Enable or disable the IMAP messaging integration',
type: ConfigVariableType.BOOLEAN, type: ConfigVariableType.BOOLEAN,
}) })
MESSAGING_PROVIDER_IMAP_ENABLED = false; IS_IMAP_SMTP_CALDAV_ENABLED = false;
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.MicrosoftAuth, group: ConfigVariablesGroup.MicrosoftAuth,

View File

@ -46,7 +46,7 @@ export const seedFeatureFlags = async (
value: false, value: false,
}, },
{ {
key: FeatureFlagKey.IS_IMAP_ENABLED, key: FeatureFlagKey.IS_IMAP_SMTP_CALDAV_ENABLED,
workspaceId: workspaceId, workspaceId: workspaceId,
value: true, value: true,
}, },

View File

@ -88,6 +88,20 @@ export class ImapSmtpCalDavAPIService {
workspaceId, 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 () => { await workspaceDataSource.transaction(async () => {
if (!existingAccountId) { if (!existingAccountId) {
const newConnectedAccount = await connectedAccountRepository.save( const newConnectedAccount = await connectedAccountRepository.save(
@ -129,7 +143,10 @@ export class ImapSmtpCalDavAPIService {
connectedAccountId: newOrExistingConnectedAccountId, connectedAccountId: newOrExistingConnectedAccountId,
type: MessageChannelType.EMAIL, type: MessageChannelType.EMAIL,
handle, 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, syncStage: MessageChannelSyncStage.FULL_MESSAGE_LIST_FETCH_PENDING,
syncStatus: null, syncStatus: shouldEnableSync
? MessageChannelSyncStatus.ONGOING
: MessageChannelSyncStatus.NOT_SYNCED,
syncCursor: '', syncCursor: '',
syncStageStartedAt: null, syncStageStartedAt: null,
isSyncEnabled: shouldEnableSync,
}, },
); );
@ -227,22 +247,24 @@ export class ImapSmtpCalDavAPIService {
} }
}); });
if (this.twentyConfigService.get('MESSAGING_PROVIDER_IMAP_ENABLED')) { if (!shouldEnableSync) {
const messageChannels = await messageChannelRepository.find({ return;
where: { }
connectedAccountId: newOrExistingConnectedAccountId,
},
});
for (const messageChannel of messageChannels) { const messageChannels = await messageChannelRepository.find({
await this.messageQueueService.add<MessagingMessageListFetchJobData>( where: {
MessagingMessageListFetchJob.name, connectedAccountId: newOrExistingConnectedAccountId,
{ },
workspaceId, });
messageChannelId: messageChannel.id,
}, for (const messageChannel of messageChannels) {
); await this.messageQueueService.add<MessagingMessageListFetchJobData>(
} MessagingMessageListFetchJob.name,
{
workspaceId,
messageChannelId: messageChannel.id,
},
);
} }
} }
} }

View File

@ -64,14 +64,14 @@ export class ImapClientProvider {
await client.connect(); await client.connect();
this.logger.log( this.logger.log(
`Connected to IMAP server for ${connectionParameters.handle}`, `Connected to IMAP server for ${connectedAccount.handle}`,
); );
try { try {
const mailboxes = await client.list(); const mailboxes = await client.list();
this.logger.log( 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) { } catch (error) {
this.logger.warn(`Failed to list mailboxes: ${error.message}`); this.logger.warn(`Failed to list mailboxes: ${error.message}`);

View File

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { SmtpClientProvider } from './providers/smtp-client.provider';
@Module({
providers: [SmtpClientProvider],
exports: [SmtpClientProvider],
})
export class MessagingSmtpDriverModule {}

View File

@ -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;
}
}

View File

@ -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 { 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 { 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 { 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 { 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';
import { MessagingMessageListFetchJob } from 'src/modules/messaging/message-import-manager/jobs/messaging-message-list-fetch.job'; 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, MessagingGmailDriverModule,
MessagingMicrosoftDriverModule, MessagingMicrosoftDriverModule,
MessagingIMAPDriverModule, MessagingIMAPDriverModule,
MessagingSmtpDriverModule,
MessagingCommonModule, MessagingCommonModule,
TypeOrmModule.forFeature( TypeOrmModule.forFeature(
[Workspace, DataSourceEntity, ObjectMetadataEntity], [Workspace, DataSourceEntity, ObjectMetadataEntity],

View File

@ -12,6 +12,7 @@ import {
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 { 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 { 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 { 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'; 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 gmailClientProvider: GmailClientProvider,
private readonly oAuth2ClientProvider: OAuth2ClientProvider, private readonly oAuth2ClientProvider: OAuth2ClientProvider,
private readonly microsoftClientProvider: MicrosoftClientProvider, private readonly microsoftClientProvider: MicrosoftClientProvider,
private readonly smtpClientProvider: SmtpClientProvider,
) {} ) {}
public async sendMessage( public async sendMessage(
@ -120,7 +122,16 @@ export class MessagingSendMessageService {
break; break;
} }
case ConnectedAccountProvider.IMAP_SMTP_CALDAV: { 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: default:
assertUnreachable( assertUnreachable(

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB