feat: IMAP Driver Integration (#12576)

### Added IMAP integration 

This PR adds support for connecting email accounts via IMAP protocol,
allowing users to sync their emails without OAuth.

#### DB Changes:
- Added customConnectionParams and connectionType fields to
ConnectedAccountWorkspaceEntity

#### UI:
- Added settings pages for creating and editing IMAP connections with
proper validation and connection testing.
- Implemented reconnection flows for handling permission issues.

#### Backend:
- Built ImapConnectionModule with corresponding resolver and service for
managing IMAP connections.
- Created MessagingIMAPDriverModule to handle IMAP client operations,
message fetching/parsing, and error handling.

#### Dependencies:
Integrated `imapflow` and `mailparser` libraries with their type
definitions to handle the IMAP protocol communication.

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
neo773
2025-06-30 01:02:15 +05:30
committed by GitHub
parent 3c5595e4ff
commit 7c8d362772
80 changed files with 3588 additions and 113 deletions

View File

@ -9,6 +9,7 @@ module.exports = {
'!./src/modules/object-metadata/**',
'!./src/modules/object-record/**',
'!./src/modules/settings/serverless-functions/**',
'!./src/modules/settings/accounts/hooks/**',
'./src/modules/**/*.tsx',
'./src/modules/**/*.ts',
'!./src/**/*.test.tsx',

View File

@ -30,6 +30,10 @@ export type Scalars = {
Upload: { input: any; output: any; }
};
export type AccountType = {
type: Scalars['String']['input'];
};
export type ActivateWorkspaceInput = {
displayName?: InputMaybe<Scalars['String']['input']>;
};
@ -412,6 +416,32 @@ export type ConfigVariablesOutput = {
groups: Array<ConfigVariablesGroupData>;
};
export type ConnectedImapSmtpCaldavAccount = {
__typename?: 'ConnectedImapSmtpCaldavAccount';
accountOwnerId: Scalars['String']['output'];
connectionParameters?: Maybe<ImapSmtpCaldavConnectionParameters>;
handle: Scalars['String']['output'];
id: Scalars['String']['output'];
provider: Scalars['String']['output'];
};
export type ConnectionParameters = {
host: Scalars['String']['input'];
password: Scalars['String']['input'];
port: Scalars['Float']['input'];
secure?: InputMaybe<Scalars['Boolean']['input']>;
username: Scalars['String']['input'];
};
export type ConnectionParametersOutput = {
__typename?: 'ConnectionParametersOutput';
host: Scalars['String']['output'];
password: Scalars['String']['output'];
port: Scalars['Float']['output'];
secure?: Maybe<Scalars['Boolean']['output']>;
username: Scalars['String']['output'];
};
export type CreateAgentInput = {
description?: InputMaybe<Scalars['String']['input']>;
modelId: Scalars['String']['input'];
@ -662,6 +692,7 @@ export type FeatureFlagDto = {
export enum FeatureFlagKey {
IS_AIRTABLE_INTEGRATION_ENABLED = 'IS_AIRTABLE_INTEGRATION_ENABLED',
IS_AI_ENABLED = 'IS_AI_ENABLED',
IS_IMAP_ENABLED = 'IS_IMAP_ENABLED',
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
IS_POSTGRESQL_INTEGRATION_ENABLED = 'IS_POSTGRESQL_INTEGRATION_ENABLED',
IS_STRIPE_INTEGRATION_ENABLED = 'IS_STRIPE_INTEGRATION_ENABLED',
@ -819,6 +850,18 @@ export enum IdentityProviderType {
SAML = 'SAML'
}
export type ImapSmtpCaldavConnectionParameters = {
__typename?: 'ImapSmtpCaldavConnectionParameters';
CALDAV?: Maybe<ConnectionParametersOutput>;
IMAP?: Maybe<ConnectionParametersOutput>;
SMTP?: Maybe<ConnectionParametersOutput>;
};
export type ImapSmtpCaldavConnectionSuccess = {
__typename?: 'ImapSmtpCaldavConnectionSuccess';
success: Scalars['Boolean']['output'];
};
export type ImpersonateOutput = {
__typename?: 'ImpersonateOutput';
loginToken: AuthToken;
@ -1010,6 +1053,7 @@ export type Mutation = {
resendEmailVerificationToken: ResendEmailVerificationTokenOutput;
resendWorkspaceInvitation: SendInvitationsOutput;
runWorkflowVersion: WorkflowRun;
saveImapSmtpCaldav: ImapSmtpCaldavConnectionSuccess;
sendInvitations: SendInvitationsOutput;
signIn: AvailableWorkspacesAndAccessTokensOutput;
signUp: AvailableWorkspacesAndAccessTokensOutput;
@ -1294,6 +1338,15 @@ export type MutationRunWorkflowVersionArgs = {
};
export type MutationSaveImapSmtpCaldavArgs = {
accountOwnerId: Scalars['String']['input'];
accountType: AccountType;
connectionParameters: ConnectionParameters;
handle: Scalars['String']['input'];
id?: InputMaybe<Scalars['String']['input']>;
};
export type MutationSendInvitationsArgs = {
emails: Array<Scalars['String']['input']>;
};
@ -1696,6 +1749,7 @@ export type Query = {
getApprovedAccessDomains: Array<ApprovedAccessDomain>;
getAvailablePackages: Scalars['JSON']['output'];
getConfigVariablesGrouped: ConfigVariablesOutput;
getConnectedImapSmtpCaldavAccount: ConnectedImapSmtpCaldavAccount;
getDatabaseConfigVariable: ConfigVariable;
getIndicatorHealthStatus: AdminPanelHealthServiceData;
getMeteredProductsUsage: Array<BillingMeteredProductUsageOutput>;
@ -1783,6 +1837,11 @@ export type QueryGetAvailablePackagesArgs = {
};
export type QueryGetConnectedImapSmtpCaldavAccountArgs = {
id: Scalars['String']['input'];
};
export type QueryGetDatabaseConfigVariableArgs = {
key: Scalars['String']['input'];
};

View File

@ -22,6 +22,10 @@ export type Scalars = {
Upload: any;
};
export type AccountType = {
type: Scalars['String'];
};
export type ActivateWorkspaceInput = {
displayName?: InputMaybe<Scalars['String']>;
};
@ -404,6 +408,32 @@ export type ConfigVariablesOutput = {
groups: Array<ConfigVariablesGroupData>;
};
export type ConnectedImapSmtpCaldavAccount = {
__typename?: 'ConnectedImapSmtpCaldavAccount';
accountOwnerId: Scalars['String'];
connectionParameters?: Maybe<ImapSmtpCaldavConnectionParameters>;
handle: Scalars['String'];
id: Scalars['String'];
provider: Scalars['String'];
};
export type ConnectionParameters = {
host: Scalars['String'];
password: Scalars['String'];
port: Scalars['Float'];
secure?: InputMaybe<Scalars['Boolean']>;
username: Scalars['String'];
};
export type ConnectionParametersOutput = {
__typename?: 'ConnectionParametersOutput';
host: Scalars['String'];
password: Scalars['String'];
port: Scalars['Float'];
secure?: Maybe<Scalars['Boolean']>;
username: Scalars['String'];
};
export type CreateAgentInput = {
description?: InputMaybe<Scalars['String']>;
modelId: Scalars['String'];
@ -618,6 +648,7 @@ export type FeatureFlagDto = {
export enum FeatureFlagKey {
IS_AIRTABLE_INTEGRATION_ENABLED = 'IS_AIRTABLE_INTEGRATION_ENABLED',
IS_AI_ENABLED = 'IS_AI_ENABLED',
IS_IMAP_ENABLED = 'IS_IMAP_ENABLED',
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
IS_POSTGRESQL_INTEGRATION_ENABLED = 'IS_POSTGRESQL_INTEGRATION_ENABLED',
IS_STRIPE_INTEGRATION_ENABLED = 'IS_STRIPE_INTEGRATION_ENABLED',
@ -768,6 +799,18 @@ export enum IdentityProviderType {
SAML = 'SAML'
}
export type ImapSmtpCaldavConnectionParameters = {
__typename?: 'ImapSmtpCaldavConnectionParameters';
CALDAV?: Maybe<ConnectionParametersOutput>;
IMAP?: Maybe<ConnectionParametersOutput>;
SMTP?: Maybe<ConnectionParametersOutput>;
};
export type ImapSmtpCaldavConnectionSuccess = {
__typename?: 'ImapSmtpCaldavConnectionSuccess';
success: Scalars['Boolean'];
};
export type ImpersonateOutput = {
__typename?: 'ImpersonateOutput';
loginToken: AuthToken;
@ -957,6 +1000,7 @@ export type Mutation = {
resendEmailVerificationToken: ResendEmailVerificationTokenOutput;
resendWorkspaceInvitation: SendInvitationsOutput;
runWorkflowVersion: WorkflowRun;
saveImapSmtpCaldav: ImapSmtpCaldavConnectionSuccess;
sendInvitations: SendInvitationsOutput;
signIn: AvailableWorkspacesAndAccessTokensOutput;
signUp: AvailableWorkspacesAndAccessTokensOutput;
@ -1217,6 +1261,15 @@ export type MutationRunWorkflowVersionArgs = {
};
export type MutationSaveImapSmtpCaldavArgs = {
accountOwnerId: Scalars['String'];
accountType: AccountType;
connectionParameters: ConnectionParameters;
handle: Scalars['String'];
id?: InputMaybe<Scalars['String']>;
};
export type MutationSendInvitationsArgs = {
emails: Array<Scalars['String']>;
};
@ -1596,6 +1649,7 @@ export type Query = {
getApprovedAccessDomains: Array<ApprovedAccessDomain>;
getAvailablePackages: Scalars['JSON'];
getConfigVariablesGrouped: ConfigVariablesOutput;
getConnectedImapSmtpCaldavAccount: ConnectedImapSmtpCaldavAccount;
getDatabaseConfigVariable: ConfigVariable;
getIndicatorHealthStatus: AdminPanelHealthServiceData;
getMeteredProductsUsage: Array<BillingMeteredProductUsageOutput>;
@ -1657,6 +1711,11 @@ export type QueryGetAvailablePackagesArgs = {
};
export type QueryGetConnectedImapSmtpCaldavAccountArgs = {
id: Scalars['String'];
};
export type QueryGetDatabaseConfigVariableArgs = {
key: Scalars['String'];
};
@ -2816,6 +2875,24 @@ export type SkipSyncEmailOnboardingStepMutationVariables = Exact<{ [key: string]
export type SkipSyncEmailOnboardingStepMutation = { __typename?: 'Mutation', skipSyncEmailOnboardingStep: { __typename?: 'OnboardingStepSuccess', success: boolean } };
export type SaveImapSmtpCaldavMutationVariables = Exact<{
accountOwnerId: Scalars['String'];
handle: Scalars['String'];
accountType: AccountType;
connectionParameters: ConnectionParameters;
id?: InputMaybe<Scalars['String']>;
}>;
export type SaveImapSmtpCaldavMutation = { __typename?: 'Mutation', saveImapSmtpCaldav: { __typename?: 'ImapSmtpCaldavConnectionSuccess', success: boolean } };
export type GetConnectedImapSmtpCaldavAccountQueryVariables = Exact<{
id: Scalars['String'];
}>;
export type GetConnectedImapSmtpCaldavAccountQuery = { __typename?: 'Query', getConnectedImapSmtpCaldavAccount: { __typename?: 'ConnectedImapSmtpCaldavAccount', id: string, handle: string, provider: string, accountOwnerId: string, connectionParameters?: { __typename?: 'ImapSmtpCaldavConnectionParameters', IMAP?: { __typename?: 'ConnectionParametersOutput', host: string, port: number, secure?: boolean | null, username: string, password: string } | null, SMTP?: { __typename?: 'ConnectionParametersOutput', host: string, port: number, secure?: boolean | null, username: string, password: string } | null, CALDAV?: { __typename?: 'ConnectionParametersOutput', host: string, port: number, secure?: boolean | null, username: string, password: string } | null } | null } };
export type CreateDatabaseConfigVariableMutationVariables = Exact<{
key: Scalars['String'];
value: Scalars['JSON'];
@ -4885,6 +4962,110 @@ export function useSkipSyncEmailOnboardingStepMutation(baseOptions?: Apollo.Muta
export type SkipSyncEmailOnboardingStepMutationHookResult = ReturnType<typeof useSkipSyncEmailOnboardingStepMutation>;
export type SkipSyncEmailOnboardingStepMutationResult = Apollo.MutationResult<SkipSyncEmailOnboardingStepMutation>;
export type SkipSyncEmailOnboardingStepMutationOptions = Apollo.BaseMutationOptions<SkipSyncEmailOnboardingStepMutation, SkipSyncEmailOnboardingStepMutationVariables>;
export const SaveImapSmtpCaldavDocument = gql`
mutation SaveImapSmtpCaldav($accountOwnerId: String!, $handle: String!, $accountType: AccountType!, $connectionParameters: ConnectionParameters!, $id: String) {
saveImapSmtpCaldav(
accountOwnerId: $accountOwnerId
handle: $handle
accountType: $accountType
connectionParameters: $connectionParameters
id: $id
) {
success
}
}
`;
export type SaveImapSmtpCaldavMutationFn = Apollo.MutationFunction<SaveImapSmtpCaldavMutation, SaveImapSmtpCaldavMutationVariables>;
/**
* __useSaveImapSmtpCaldavMutation__
*
* To run a mutation, you first call `useSaveImapSmtpCaldavMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useSaveImapSmtpCaldavMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [saveImapSmtpCaldavMutation, { data, loading, error }] = useSaveImapSmtpCaldavMutation({
* variables: {
* accountOwnerId: // value for 'accountOwnerId'
* handle: // value for 'handle'
* accountType: // value for 'accountType'
* connectionParameters: // value for 'connectionParameters'
* id: // value for 'id'
* },
* });
*/
export function useSaveImapSmtpCaldavMutation(baseOptions?: Apollo.MutationHookOptions<SaveImapSmtpCaldavMutation, SaveImapSmtpCaldavMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<SaveImapSmtpCaldavMutation, SaveImapSmtpCaldavMutationVariables>(SaveImapSmtpCaldavDocument, options);
}
export type SaveImapSmtpCaldavMutationHookResult = ReturnType<typeof useSaveImapSmtpCaldavMutation>;
export type SaveImapSmtpCaldavMutationResult = Apollo.MutationResult<SaveImapSmtpCaldavMutation>;
export type SaveImapSmtpCaldavMutationOptions = Apollo.BaseMutationOptions<SaveImapSmtpCaldavMutation, SaveImapSmtpCaldavMutationVariables>;
export const GetConnectedImapSmtpCaldavAccountDocument = gql`
query GetConnectedImapSmtpCaldavAccount($id: String!) {
getConnectedImapSmtpCaldavAccount(id: $id) {
id
handle
provider
accountOwnerId
connectionParameters {
IMAP {
host
port
secure
username
password
}
SMTP {
host
port
secure
username
password
}
CALDAV {
host
port
secure
username
password
}
}
}
}
`;
/**
* __useGetConnectedImapSmtpCaldavAccountQuery__
*
* To run a query within a React component, call `useGetConnectedImapSmtpCaldavAccountQuery` and pass it any options that fit your needs.
* When your component renders, `useGetConnectedImapSmtpCaldavAccountQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useGetConnectedImapSmtpCaldavAccountQuery({
* variables: {
* id: // value for 'id'
* },
* });
*/
export function useGetConnectedImapSmtpCaldavAccountQuery(baseOptions: Apollo.QueryHookOptions<GetConnectedImapSmtpCaldavAccountQuery, GetConnectedImapSmtpCaldavAccountQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<GetConnectedImapSmtpCaldavAccountQuery, GetConnectedImapSmtpCaldavAccountQueryVariables>(GetConnectedImapSmtpCaldavAccountDocument, options);
}
export function useGetConnectedImapSmtpCaldavAccountLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetConnectedImapSmtpCaldavAccountQuery, GetConnectedImapSmtpCaldavAccountQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<GetConnectedImapSmtpCaldavAccountQuery, GetConnectedImapSmtpCaldavAccountQueryVariables>(GetConnectedImapSmtpCaldavAccountDocument, options);
}
export type GetConnectedImapSmtpCaldavAccountQueryHookResult = ReturnType<typeof useGetConnectedImapSmtpCaldavAccountQuery>;
export type GetConnectedImapSmtpCaldavAccountLazyQueryHookResult = ReturnType<typeof useGetConnectedImapSmtpCaldavAccountLazyQuery>;
export type GetConnectedImapSmtpCaldavAccountQueryResult = Apollo.QueryResult<GetConnectedImapSmtpCaldavAccountQuery, GetConnectedImapSmtpCaldavAccountQueryVariables>;
export const CreateDatabaseConfigVariableDocument = gql`
mutation CreateDatabaseConfigVariable($key: String!, $value: JSON!) {
createDatabaseConfigVariable(key: $key, value: $value)

View File

@ -1,6 +1,7 @@
import { CalendarChannel } from '@/accounts/types/CalendarChannel';
import { MessageChannel } from './MessageChannel';
import { ImapSmtpCaldavAccount } from '@/accounts/types/ImapSmtpCaldavAccount';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { MessageChannel } from './MessageChannel';
export type ConnectedAccount = {
id: string;
@ -14,5 +15,6 @@ export type ConnectedAccount = {
messageChannels: MessageChannel[];
calendarChannels: CalendarChannel[];
scopes: string[] | null;
connectionParameters?: ImapSmtpCaldavAccount;
__typename: 'ConnectedAccount';
};

View File

@ -0,0 +1,7 @@
import { ConnectionParameters } from '~/generated/graphql';
export type ImapSmtpCaldavAccount = {
IMAP?: ConnectionParameters;
SMTP?: ConnectionParameters;
CALDAV?: ConnectionParameters;
};

View File

@ -1,5 +1,6 @@
import { MessageChannelVisibility } from '~/generated/graphql';
import { ImapSmtpCaldavAccount } from '@/accounts/types/ImapSmtpCaldavAccount';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { MessageChannelVisibility } from '~/generated/graphql';
export enum MessageChannelContactAutoCreationPolicy {
SENT_AND_RECEIVED = 'SENT_AND_RECEIVED',
@ -40,6 +41,7 @@ export type MessageChannel = {
connectedAccount?: {
id: string;
provider: ConnectedAccountProvider;
connectionParameters?: ImapSmtpCaldavAccount;
};
__typename: 'MessageChannel';
};

View File

@ -64,6 +64,22 @@ const SettingsNewObject = lazy(() =>
})),
);
const SettingsNewImapConnection = lazy(() =>
import(
'@/settings/accounts/components/SettingsAccountsNewImapConnection'
).then((module) => ({
default: module.SettingsAccountsNewImapConnection,
})),
);
const SettingsEditImapConnection = lazy(() =>
import(
'@/settings/accounts/components/SettingsAccountsEditImapConnection'
).then((module) => ({
default: module.SettingsAccountsEditImapConnection,
})),
);
const SettingsObjectDetailPage = lazy(() =>
import('~/pages/settings/data-model/SettingsObjectDetailPage').then(
(module) => ({
@ -358,6 +374,14 @@ export const SettingsRoutes = ({
path={SettingsPath.AccountsEmails}
element={<SettingsAccountsEmails />}
/>
<Route
path={SettingsPath.NewImapConnection}
element={<SettingsNewImapConnection />}
/>
<Route
path={SettingsPath.EditImapConnection}
element={<SettingsEditImapConnection />}
/>
<Route
element={
<SettingsProtectedRouteWrapper

View File

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

View File

@ -12,8 +12,9 @@ import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { assertUnreachable } from '@/workflow/utils/assertUnreachable';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { Button } from 'twenty-ui/input';
import { isDefined } from 'twenty-shared/utils';
import { IconArrowBackUp } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
const StyledWrapper = styled.div`
display: flex;
@ -41,6 +42,12 @@ const StyledButtonContainer = styled.div<{ isMobile: boolean }>`
box-sizing: border-box;
`;
const ALLOWED_REPLY_PROVIDERS = [
ConnectedAccountProvider.GOOGLE,
ConnectedAccountProvider.MICROSOFT,
ConnectedAccountProvider.IMAP_SMTP_CALDAV,
];
export const CommandMenuMessageThreadPage = () => {
const setMessageThread = useSetRecoilComponentStateV2(
messageThreadComponentState,
@ -58,6 +65,7 @@ export const CommandMenuMessageThreadPage = () => {
messageChannelLoading,
connectedAccountProvider,
lastMessageExternalId,
connectedAccountConnectionParameters,
} = useEmailThreadInCommandMenu();
useEffect(() => {
@ -83,10 +91,14 @@ export const CommandMenuMessageThreadPage = () => {
return (
connectedAccountHandle &&
connectedAccountProvider &&
ALLOWED_REPLY_PROVIDERS.includes(connectedAccountProvider) &&
(connectedAccountProvider !== ConnectedAccountProvider.IMAP_SMTP_CALDAV ||
isDefined(connectedAccountConnectionParameters?.SMTP)) &&
lastMessage &&
messageThreadExternalId != null
);
}, [
connectedAccountConnectionParameters,
connectedAccountHandle,
connectedAccountProvider,
lastMessage,
@ -108,6 +120,8 @@ export const CommandMenuMessageThreadPage = () => {
url = `https://mail.google.com/mail/?authuser=${connectedAccountHandle}#all/${messageThreadExternalId}`;
window.open(url, '_blank');
break;
case ConnectedAccountProvider.IMAP_SMTP_CALDAV:
throw new Error('Account provider not supported');
case null:
throw new Error('Account provider not provided');
default:

View File

@ -139,6 +139,7 @@ export const useEmailThreadInCommandMenu = () => {
connectedAccount: {
id: true,
provider: true,
connectionParameters: true,
},
},
skip: !lastMessageChannelId,
@ -175,12 +176,16 @@ export const useEmailThreadInCommandMenu = () => {
? messageChannelData[0]?.connectedAccount
: null;
const connectedAccountProvider = connectedAccount?.provider ?? null;
const connectedAccountConnectionParameters =
connectedAccount?.connectionParameters;
return {
thread,
messages: messagesWithSender,
messageThreadExternalId,
connectedAccountHandle,
connectedAccountProvider,
connectedAccountConnectionParameters,
threadLoading: messagesLoading,
messageChannelLoading,
lastMessageExternalId,

View File

@ -1,7 +1,7 @@
import { InformationBanner } from '@/information-banner/components/InformationBanner';
import { useAccountToReconnect } from '@/information-banner/hooks/useAccountToReconnect';
import { InformationBannerKeys } from '@/information-banner/types/InformationBannerKeys';
import { useTriggerApisOAuth } from '@/settings/accounts/hooks/useTriggerApiOAuth';
import { useTriggerProviderReconnect } from '@/settings/accounts/hooks/useTriggerProviderReconnect';
import { IconRefresh } from 'twenty-ui/display';
export const InformationBannerReconnectAccountEmailAliases = () => {
@ -9,7 +9,7 @@ export const InformationBannerReconnectAccountEmailAliases = () => {
InformationBannerKeys.ACCOUNTS_TO_RECONNECT_EMAIL_ALIASES,
);
const { triggerApisOAuth } = useTriggerApisOAuth();
const { triggerProviderReconnect } = useTriggerProviderReconnect();
if (!accountToReconnect) {
return null;
@ -17,10 +17,15 @@ export const InformationBannerReconnectAccountEmailAliases = () => {
return (
<InformationBanner
message={`Please reconnect your mailbox ${accountToReconnect?.handle} to update your email aliases:`}
message={`Please reconnect your mailbox ${accountToReconnect.handle} to update your email aliases:`}
buttonTitle="Reconnect"
buttonIcon={IconRefresh}
buttonOnClick={() => triggerApisOAuth(accountToReconnect.provider)}
buttonOnClick={() =>
triggerProviderReconnect(
accountToReconnect.provider,
accountToReconnect.id,
)
}
/>
);
};

View File

@ -1,7 +1,7 @@
import { InformationBanner } from '@/information-banner/components/InformationBanner';
import { useAccountToReconnect } from '@/information-banner/hooks/useAccountToReconnect';
import { InformationBannerKeys } from '@/information-banner/types/InformationBannerKeys';
import { useTriggerApisOAuth } from '@/settings/accounts/hooks/useTriggerApiOAuth';
import { useTriggerProviderReconnect } from '@/settings/accounts/hooks/useTriggerProviderReconnect';
import { IconRefresh } from 'twenty-ui/display';
export const InformationBannerReconnectAccountInsufficientPermissions = () => {
@ -9,7 +9,7 @@ export const InformationBannerReconnectAccountInsufficientPermissions = () => {
InformationBannerKeys.ACCOUNTS_TO_RECONNECT_INSUFFICIENT_PERMISSIONS,
);
const { triggerApisOAuth } = useTriggerApisOAuth();
const { triggerProviderReconnect } = useTriggerProviderReconnect();
if (!accountToReconnect) {
return null;
@ -17,11 +17,16 @@ export const InformationBannerReconnectAccountInsufficientPermissions = () => {
return (
<InformationBanner
message={`Sync lost with mailbox ${accountToReconnect?.handle}. Please
message={`Sync lost with mailbox ${accountToReconnect.handle}. Please
reconnect for updates:`}
buttonTitle="Reconnect"
buttonIcon={IconRefresh}
buttonOnClick={() => triggerApisOAuth(accountToReconnect.provider)}
buttonOnClick={() =>
triggerProviderReconnect(
accountToReconnect.provider,
accountToReconnect.id,
)
}
/>
);
};

View File

@ -3,14 +3,20 @@ import { SettingsAccountsListEmptyStateCard } from '@/settings/accounts/componen
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 { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { SettingsListCard } from '../../components/SettingsListCard';
import { useLingui } from '@lingui/react/macro';
import { IconComponent, IconGoogle, IconMicrosoft } from 'twenty-ui/display';
const ProviderIcons: { [k: string]: IconComponent } = {
google: IconGoogle,
microsoft: IconMicrosoft,
imap: IconMail,
};
export const SettingsAccountsConnectedAccountsListCard = ({

View File

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

View File

@ -3,13 +3,17 @@ import { isGoogleMessagingEnabledState } from '@/client-config/states/isGoogleMe
import { isMicrosoftCalendarEnabledState } from '@/client-config/states/isMicrosoftCalendarEnabledState';
import { isMicrosoftMessagingEnabledState } from '@/client-config/states/isMicrosoftMessagingEnabledState';
import { useTriggerApisOAuth } from '@/settings/accounts/hooks/useTriggerApiOAuth';
import { SettingsPath } from '@/types/SettingsPath';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
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 { Button } from 'twenty-ui/input';
import { Card, CardContent, CardHeader } from 'twenty-ui/layout';
import { IconGoogle, IconMicrosoft } from 'twenty-ui/display';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
const StyledHeader = styled(CardHeader)`
align-items: center;
@ -47,6 +51,8 @@ export const SettingsAccountsListEmptyStateCard = ({
isMicrosoftCalendarEnabledState,
);
const isImapEnabled = useIsFeatureEnabled(FeatureFlagKey.IS_IMAP_ENABLED);
return (
<Card>
<StyledHeader>{label || t`No connected account`}</StyledHeader>
@ -68,6 +74,15 @@ export const SettingsAccountsListEmptyStateCard = ({
onClick={() => triggerApisOAuth(ConnectedAccountProvider.MICROSOFT)}
/>
)}
{isImapEnabled && (
<Button
Icon={IconMail}
title={t`Connect with IMAP`}
variant="secondary"
to={getSettingsPath(SettingsPath.NewImapConnection)}
/>
)}
</StyledBody>
</Card>
);

View File

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

View File

@ -1,7 +1,7 @@
import { ConnectedAccount } from '@/accounts/types/ConnectedAccount';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useDestroyOneRecord } from '@/object-record/hooks/useDestroyOneRecord';
import { useTriggerApisOAuth } from '@/settings/accounts/hooks/useTriggerApiOAuth';
import { useTriggerProviderReconnect } from '@/settings/accounts/hooks/useTriggerProviderReconnect';
import { SettingsPath } from '@/types/SettingsPath';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
@ -40,7 +40,7 @@ export const SettingsAccountsRowDropdownMenu = ({
const { destroyOneRecord } = useDestroyOneRecord({
objectNameSingular: CoreObjectNameSingular.ConnectedAccount,
});
const { triggerApisOAuth } = useTriggerApisOAuth();
const { triggerProviderReconnect } = useTriggerProviderReconnect();
const deleteAccount = async () => {
await destroyOneRecord(account.id);
@ -78,7 +78,7 @@ export const SettingsAccountsRowDropdownMenu = ({
LeftIcon={IconRefresh}
text={t`Reconnect`}
onClick={() => {
triggerApisOAuth(account.provider);
triggerProviderReconnect(account.provider, account.id);
closeDropdown();
}}
/>

View File

@ -0,0 +1,119 @@
import { Control, Controller } from 'react-hook-form';
import { Select } from '@/ui/input/components/Select';
import { TextInput } from '@/ui/input/components/TextInput';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { H2Title } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import { ConnectionParameters } from '~/generated/graphql';
const StyledFormContainer = styled.form`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(4)};
`;
type SetttingsAccountsImapConnectionFormProps = {
control: Control<ConnectionParameters & { handle: string }>;
isEditing: boolean;
defaultValues?: Partial<ConnectionParameters & { handle: string }>;
};
export const SetttingsAccountsImapConnectionForm = ({
control,
isEditing,
defaultValues,
}: SetttingsAccountsImapConnectionFormProps) => {
const { t } = useLingui();
return (
<Section>
<H2Title
title={t`IMAP Connection Details`}
description={
isEditing
? t`Update your IMAP email account configuration`
: t`Configure your IMAP email account`
}
/>
<StyledFormContainer>
<Controller
name="handle"
control={control}
defaultValue={defaultValues?.handle}
render={({ field, fieldState }) => (
<TextInput
label={t`Email Address`}
placeholder={t`john.doe@example.com`}
value={field.value}
onChange={field.onChange}
error={fieldState.error?.message}
/>
)}
/>
<Controller
name="host"
control={control}
defaultValue={defaultValues?.host}
render={({ field, fieldState }) => (
<TextInput
label={t`IMAP Server`}
placeholder={t`imap.example.com`}
value={field.value}
onChange={field.onChange}
error={fieldState.error?.message}
/>
)}
/>
<Controller
name="port"
control={control}
defaultValue={defaultValues?.port ?? 993}
render={({ field, fieldState }) => (
<TextInput
label={t`IMAP Port`}
type="number"
placeholder={t`993`}
value={field.value.toString()}
onChange={(value) => field.onChange(Number(value))}
error={fieldState.error?.message}
/>
)}
/>
<Controller
name="secure"
control={control}
defaultValue={defaultValues?.secure}
render={({ field }) => (
<Select
label={t`Encryption`}
options={[
{ label: 'SSL/TLS', value: true },
{ label: 'None', value: false },
]}
value={field.value}
onChange={field.onChange}
dropdownId="secure-dropdown"
/>
)}
/>
<Controller
name="password"
control={control}
defaultValue={defaultValues?.password}
render={({ field, fieldState }) => (
<TextInput
label={t`Password`}
placeholder={t`••••••••`}
type="password"
value={field.value}
onChange={field.onChange}
error={fieldState.error?.message}
/>
)}
/>
</StyledFormContainer>
</Section>
);
};

View File

@ -0,0 +1,21 @@
import gql from 'graphql-tag';
export const SAVE_IMAP_SMTP_CALDAV_CONNECTION = gql`
mutation SaveImapSmtpCaldav(
$accountOwnerId: String!
$handle: String!
$accountType: AccountType!
$connectionParameters: ConnectionParameters!
$id: String
) {
saveImapSmtpCaldav(
accountOwnerId: $accountOwnerId
handle: $handle
accountType: $accountType
connectionParameters: $connectionParameters
id: $id
) {
success
}
}
`;

View File

@ -0,0 +1,35 @@
import gql from 'graphql-tag';
export const GET_CONNECTED_IMAP_SMTP_CALDAV_ACCOUNT = gql`
query GetConnectedImapSmtpCaldavAccount($id: String!) {
getConnectedImapSmtpCaldavAccount(id: $id) {
id
handle
provider
accountOwnerId
connectionParameters {
IMAP {
host
port
secure
username
password
}
SMTP {
host
port
secure
username
password
}
CALDAV {
host
port
secure
username
password
}
}
}
}
`;

View File

@ -0,0 +1,16 @@
import { useGetConnectedImapSmtpCaldavAccountQuery } from '~/generated/graphql';
export const useConnectedImapSmtpCaldavAccount = (
connectedAccountId: string | undefined,
) => {
const { data, loading, error } = useGetConnectedImapSmtpCaldavAccountQuery({
variables: { id: connectedAccountId ?? '' },
skip: !connectedAccountId,
});
return {
connectedAccount: data?.getConnectedImapSmtpCaldavAccount,
loading,
error,
};
};

View File

@ -0,0 +1,140 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { useRecoilValue } from 'recoil';
import { z } from 'zod';
import { SettingsPath } from '@/types/SettingsPath';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useLingui } from '@lingui/react/macro';
import {
ConnectionParameters,
useSaveImapSmtpCaldavMutation,
} from '~/generated/graphql';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { currentWorkspaceMemberState } from '~/modules/auth/states/currentWorkspaceMemberState';
import { currentWorkspaceState } from '~/modules/auth/states/currentWorkspaceState';
const imapConnectionFormSchema = z.object({
handle: z.string().email('Invalid email address'),
host: z.string().min(1, 'IMAP server is required'),
port: z.number().int().positive('Port must be a positive number'),
secure: z.boolean(),
password: z.string().min(1, 'Password is required'),
});
type ImapConnectionFormValues = z.infer<typeof imapConnectionFormSchema>;
type UseImapConnectionFormProps = {
initialData?: ImapConnectionFormValues;
isEditing?: boolean;
connectedAccountId?: string;
};
export const useImapConnectionForm = ({
initialData,
isEditing = false,
connectedAccountId,
}: UseImapConnectionFormProps = {}) => {
const { t } = useLingui();
const navigate = useNavigateSettings();
const { enqueueSnackBar } = useSnackBar();
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const [saveImapConnection, { loading: saveLoading }] =
useSaveImapSmtpCaldavMutation();
const resolver = zodResolver(imapConnectionFormSchema);
const defaultValues = {
handle: initialData?.handle || '',
host: initialData?.host || '',
port: initialData?.port || 993,
secure: initialData?.secure ?? true,
password: initialData?.password || '',
};
const formMethods = useForm<ConnectionParameters & { handle: string }>({
mode: 'onSubmit',
resolver,
defaultValues,
});
const { handleSubmit, formState } = formMethods;
const { isValid, isSubmitting } = formState;
const canSave = isValid && !isSubmitting;
const loading = saveLoading;
const handleSave = async (
formValues: ConnectionParameters & { handle: string },
) => {
if (!currentWorkspace?.id) {
enqueueSnackBar('Workspace ID is missing', {
variant: SnackBarVariant.Error,
});
return;
}
if (!currentWorkspaceMember?.id) {
enqueueSnackBar('Workspace member ID is missing', {
variant: SnackBarVariant.Error,
});
return;
}
try {
const variables = {
...(isEditing && connectedAccountId ? { id: connectedAccountId } : {}),
accountOwnerId: currentWorkspaceMember.id,
handle: formValues.handle,
host: formValues.host,
port: formValues.port,
secure: formValues.secure,
password: formValues.password,
};
await saveImapConnection({
variables: {
accountOwnerId: variables.accountOwnerId,
handle: variables.handle,
accountType: {
type: 'IMAP',
},
connectionParameters: {
host: variables.host,
port: variables.port,
secure: variables.secure,
password: variables.password,
username: variables.handle,
},
...(variables.id ? { id: variables.id } : {}),
},
});
enqueueSnackBar(
connectedAccountId
? t`IMAP connection successfully updated`
: t`IMAP connection successfully created`,
{
variant: SnackBarVariant.Success,
},
);
navigate(SettingsPath.Accounts);
} catch (error) {
enqueueSnackBar((error as Error).message, {
variant: SnackBarVariant.Error,
});
}
};
return {
formMethods,
handleSave,
handleSubmit,
canSave,
isSubmitting,
loading,
};
};

View File

@ -0,0 +1,36 @@
import { useCallback } from 'react';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { useTriggerApisOAuth } from '@/settings/accounts/hooks/useTriggerApiOAuth';
import { SettingsPath } from '@/types/SettingsPath';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
export const useTriggerProviderReconnect = () => {
const { triggerApisOAuth } = useTriggerApisOAuth();
const navigate = useNavigateSettings();
const triggerProviderReconnect = useCallback(
async (
provider: ConnectedAccountProvider,
accountId?: string,
options?: Parameters<typeof triggerApisOAuth>[1],
) => {
if (provider === ConnectedAccountProvider.IMAP_SMTP_CALDAV) {
if (!accountId) {
navigate(SettingsPath.NewImapConnection);
return;
}
navigate(SettingsPath.EditImapConnection, {
connectedAccountId: accountId,
});
return;
}
await triggerApisOAuth(provider, options);
},
[triggerApisOAuth, navigate],
);
return { triggerProviderReconnect };
};

View File

@ -5,6 +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',
Billing = 'billing',
Objects = 'objects',
ObjectOverview = 'objects/overview',

View File

@ -25,6 +25,7 @@ const PROVIDORS_ICON_MAPPING = {
EMAIL: {
[ConnectedAccountProvider.MICROSOFT]: IconMicrosoftOutlook,
[ConnectedAccountProvider.GOOGLE]: IconGmail,
[ConnectedAccountProvider.IMAP_SMTP_CALDAV]: IconMail,
default: IconMail,
},
CALENDAR: {
@ -50,7 +51,11 @@ export const ActorDisplay = ({
case 'EMAIL':
return PROVIDORS_ICON_MAPPING.EMAIL[context?.provider ?? 'default'];
case 'CALENDAR':
return PROVIDORS_ICON_MAPPING.CALENDAR[context?.provider ?? 'default'];
return (
PROVIDORS_ICON_MAPPING.CALENDAR[
context?.provider as keyof typeof PROVIDORS_ICON_MAPPING.CALENDAR
] ?? PROVIDORS_ICON_MAPPING.CALENDAR.default
);
case 'SYSTEM':
return IconRobot;
case 'WORKFLOW':

View File

@ -8,6 +8,7 @@ import { FormTextFieldInput } from '@/object-record/record-field/form-types/comp
import { useTriggerApisOAuth } from '@/settings/accounts/hooks/useTriggerApiOAuth';
import { SettingsPath } from '@/types/SettingsPath';
import { Select } from '@/ui/input/components/Select';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState';
import { WorkflowSendEmailAction } from '@/workflow/types/Workflow';
@ -15,6 +16,7 @@ import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowS
import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader';
import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader';
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
import { useTheme } from '@emotion/react';
import { useEffect, useState } from 'react';
import { useRecoilValue } from 'recoil';
import { ConnectedAccountProvider } from 'twenty-shared/types';
@ -24,8 +26,6 @@ import { SelectOption } from 'twenty-ui/input';
import { JsonValue } from 'type-fest';
import { useDebouncedCallback } from 'use-debounce';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { useTheme } from '@emotion/react';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
type WorkflowEditActionSendEmailProps = {
action: WorkflowSendEmailAction;
@ -88,6 +88,8 @@ export const WorkflowEditActionSendEmail = ({
return scopes.some((scope) => scope === GMAIL_SEND_SCOPE);
case ConnectedAccountProvider.MICROSOFT:
return scopes.some((scope) => scope === MICROSOFT_SEND_SCOPE);
case ConnectedAccountProvider.IMAP_SMTP_CALDAV:
return isDefined(connectedAccount.connectionParameters?.SMTP);
default:
assertUnreachable(
connectedAccount.provider,
@ -185,6 +187,13 @@ export const WorkflowEditActionSendEmail = ({
const connectedAccountOptions: SelectOption<string | null>[] = [];
accounts.forEach((account) => {
if (
account.provider === ConnectedAccountProvider.IMAP_SMTP_CALDAV &&
!isDefined(account.connectionParameters?.SMTP)
) {
return;
}
const selectOption = {
label: account.handle,
value: account.id,

View File

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