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

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