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": {
"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"
},
"fix": {
"fix": true
}
},
"dependsOn": ["^build"]
"dependsOn": [
"^build"
]
},
"fmt": {
"executor": "nx:run-commands",
@ -63,10 +84,16 @@
"write": false
},
"configurations": {
"ci": { "cacheStrategy": "content" },
"fix": { "write": true }
"ci": {
"cacheStrategy": "content"
},
"fix": {
"write": true
}
},
"dependsOn": ["^build"]
"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"
]
}
}
},

View File

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

View File

@ -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',

View File

@ -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={

View File

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

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;
isMicrosoftMessagingEnabled: boolean;
isMultiWorkspaceEnabled: boolean;
isIMAPMessagingEnabled: boolean;
isImapSmtpCaldavEnabled: boolean;
publicFeatureFlags: Array<PublicFeatureFlag>;
sentry: Sentry;
signInPrefilled: boolean;

View File

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

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 { 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,
isEditing: true,
connectedAccountId,
});
const {
formMethods,
handleSave,
handleSubmit,
canSave,
isSubmitting,
loading,
connectedAccount,
} = useImapSmtpCaldavConnectionForm({
isEditing: true,
connectedAccountId,
});
const { control } = formMethods;
const renderLoadingState = () => (
<StyledLoadingContainer>
<Loader />
</StyledLoadingContainer>
);
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();
};

View File

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

View File

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

View File

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

View File

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

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
port
secure
username
password
}
SMTP {
host
port
secure
username
password
}
CALDAV {
host
port
secure
username
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 = (
connectedAccountId: string | undefined,
onCompleted?: (data: ConnectedImapSmtpCaldavAccount) => void,
) => {
const { data, loading, error } = useGetConnectedImapSmtpCaldavAccountQuery({
variables: { id: connectedAccountId ?? '' },
skip: !connectedAccountId,
onCompleted: (data) => {
onCompleted?.(data.getConnectedImapSmtpCaldavAccount);
},
});
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 (!accountId) {
navigate(SettingsPath.NewImapConnection);
navigate(SettingsPath.NewImapSmtpCaldavConnection);
return;
}
navigate(SettingsPath.EditImapConnection, {
navigate(SettingsPath.EditImapSmtpCaldavConnection, {
connectedAccountId: accountId,
});
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 { 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;

View File

@ -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',

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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',

View File

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

View File

@ -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',

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,22 +247,24 @@ export class ImapSmtpCalDavAPIService {
}
});
if (this.twentyConfigService.get('MESSAGING_PROVIDER_IMAP_ENABLED')) {
const messageChannels = await messageChannelRepository.find({
where: {
connectedAccountId: newOrExistingConnectedAccountId,
},
});
if (!shouldEnableSync) {
return;
}
for (const messageChannel of messageChannels) {
await this.messageQueueService.add<MessagingMessageListFetchJobData>(
MessagingMessageListFetchJob.name,
{
workspaceId,
messageChannelId: messageChannel.id,
},
);
}
const messageChannels = await messageChannelRepository.find({
where: {
connectedAccountId: newOrExistingConnectedAccountId,
},
});
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();
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}`);

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 { 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],

View File

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