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:
@ -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';
|
||||
};
|
||||
|
||||
@ -0,0 +1,7 @@
|
||||
import { ConnectionParameters } from '~/generated/graphql';
|
||||
|
||||
export type ImapSmtpCaldavAccount = {
|
||||
IMAP?: ConnectionParameters;
|
||||
SMTP?: ConnectionParameters;
|
||||
CALDAV?: ConnectionParameters;
|
||||
};
|
||||
@ -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';
|
||||
};
|
||||
|
||||
@ -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
|
||||
|
||||
@ -30,6 +30,7 @@ export type ClientConfig = {
|
||||
isMicrosoftCalendarEnabled: boolean;
|
||||
isMicrosoftMessagingEnabled: boolean;
|
||||
isMultiWorkspaceEnabled: boolean;
|
||||
isIMAPMessagingEnabled: boolean;
|
||||
publicFeatureFlags: Array<PublicFeatureFlag>;
|
||||
sentry: Sentry;
|
||||
signInPrefilled: boolean;
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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 = ({
|
||||
|
||||
@ -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();
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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();
|
||||
}}
|
||||
/>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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 };
|
||||
};
|
||||
@ -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',
|
||||
|
||||
@ -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':
|
||||
|
||||
@ -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,
|
||||
|
||||
Reference in New Issue
Block a user