diff --git a/nx.json b/nx.json index f00167368..144c02a8c 100644 --- a/nx.json +++ b/nx.json @@ -1,6 +1,12 @@ { + "workspaceLayout": { + "appsDir": "packages", + "libsDir": "packages" + }, "namedInputs": { - "default": ["{projectRoot}/**/*"], + "default": [ + "{projectRoot}/**/*" + ], "excludeStories": [ "default", "!{projectRoot}/.storybook/*", @@ -28,17 +34,26 @@ "targetDefaults": { "build": { "cache": true, - "inputs": ["^production", "production"], - "dependsOn": ["^build"] + "inputs": [ + "^production", + "production" + ], + "dependsOn": [ + "^build" + ] }, "start": { "cache": true, - "dependsOn": ["^build"] + "dependsOn": [ + "^build" + ] }, "lint": { "executor": "@nx/eslint:lint", "cache": true, - "outputs": ["{options.outputFile}"], + "outputs": [ + "{options.outputFile}" + ], "options": { "eslintConfig": "{projectRoot}/.eslintrc.cjs", "cache": true, @@ -46,10 +61,16 @@ "ignorePath": "{workspaceRoot}/.gitignore" }, "configurations": { - "ci": { "cacheStrategy": "content" }, - "fix": { "fix": true } + "ci": { + "cacheStrategy": "content" + }, + "fix": { + "fix": true + } }, - "dependsOn": ["^build"] + "dependsOn": [ + "^build" + ] }, "fmt": { "executor": "nx:run-commands", @@ -63,10 +84,16 @@ "write": false }, "configurations": { - "ci": { "cacheStrategy": "content" }, - "fix": { "write": true } + "ci": { + "cacheStrategy": "content" + }, + "fix": { + "write": true + } }, - "dependsOn": ["^build"] + "dependsOn": [ + "^build" + ] }, "typecheck": { "executor": "nx:run-commands", @@ -76,24 +103,34 @@ "command": "tsc -b tsconfig.json --incremental" }, "configurations": { - "watch": { "watch": true } + "watch": { + "watch": true + } }, - "dependsOn": ["^build"] + "dependsOn": [ + "^build" + ] }, "test": { "executor": "@nx/jest:jest", "cache": true, - "dependsOn": ["^build"], + "dependsOn": [ + "^build" + ], "inputs": [ "^default", "excludeStories", "{workspaceRoot}/jest.preset.js" ], - "outputs": ["{projectRoot}/coverage"], + "outputs": [ + "{projectRoot}/coverage" + ], "options": { "jestConfig": "{projectRoot}/jest.config.ts", "coverage": true, - "coverageReporters": ["text-summary"], + "coverageReporters": [ + "text-summary" + ], "cacheDirectory": "../../.cache/jest/{projectRoot}" }, "configurations": { @@ -101,31 +138,49 @@ "ci": true, "maxWorkers": 3 }, - "coverage": { "coverageReporters": ["lcov", "text"] }, - "watch": { "watch": true } + "coverage": { + "coverageReporters": [ + "lcov", + "text" + ] + }, + "watch": { + "watch": true + } } }, "test:e2e": { "cache": true, - "dependsOn": ["^build"] + "dependsOn": [ + "^build" + ] }, "storybook:build": { "executor": "nx:run-commands", "cache": true, - "inputs": ["^default", "excludeTests"], - "outputs": ["{projectRoot}/{options.output-dir}"], + "inputs": [ + "^default", + "excludeTests" + ], + "outputs": [ + "{projectRoot}/{options.output-dir}" + ], "options": { "cwd": "{projectRoot}", "command": "VITE_DISABLE_TYPESCRIPT_CHECKER=true VITE_DISABLE_ESLINT_CHECKER=true storybook build --test", "output-dir": "storybook-static", "config-dir": ".storybook" }, - "dependsOn": ["^build"] + "dependsOn": [ + "^build" + ] }, "storybook:serve:dev": { "executor": "nx:run-commands", "cache": true, - "dependsOn": ["^build"], + "dependsOn": [ + "^build" + ], "options": { "cwd": "{projectRoot}", "command": "storybook dev", @@ -134,7 +189,9 @@ }, "storybook:serve:static": { "executor": "nx:run-commands", - "dependsOn": ["storybook:build"], + "dependsOn": [ + "storybook:build" + ], "options": { "cwd": "{projectRoot}", "command": "npx http-server {args.staticDir} -a={args.host} --port={args.port} --silent={args.silent}", @@ -147,8 +204,13 @@ "storybook:test": { "executor": "nx:run-commands", "cache": true, - "inputs": ["^default", "excludeTests"], - "outputs": ["{projectRoot}/coverage/storybook"], + "inputs": [ + "^default", + "excludeTests" + ], + "outputs": [ + "{projectRoot}/coverage/storybook" + ], "options": { "cwd": "{projectRoot}", "commands": [ @@ -164,7 +226,10 @@ }, "storybook:test:no-coverage": { "executor": "nx:run-commands", - "inputs": ["^default", "excludeTests"], + "inputs": [ + "^default", + "excludeTests" + ], "options": { "cwd": "{projectRoot}", "commands": [ @@ -192,7 +257,9 @@ "checkCoverage": true }, "configurations": { - "text": { "reporter": "text" } + "text": { + "reporter": "text" + } } }, "storybook:serve-and-test:static": { @@ -252,12 +319,20 @@ }, "@nx/vite:test": { "cache": true, - "inputs": ["default", "^default"] + "inputs": [ + "default", + "^default" + ] }, "@nx/vite:build": { "cache": true, - "dependsOn": ["^build"], - "inputs": ["default", "^default"] + "dependsOn": [ + "^build" + ], + "inputs": [ + "default", + "^default" + ] } }, "installation": { @@ -289,10 +364,12 @@ "tasksRunnerOptions": { "default": { "options": { - "cacheableOperations": ["storybook:build"] + "cacheableOperations": [ + "storybook:build" + ] } } }, "useInferencePlugins": false, "defaultBase": "main" -} +} \ No newline at end of file diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 909008775..299e3f8f5 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -435,7 +435,6 @@ export type ConnectionParameters = { password: Scalars['String']; port: Scalars['Float']; secure?: InputMaybe; - username: Scalars['String']; }; export type ConnectionParametersOutput = { @@ -444,7 +443,6 @@ export type ConnectionParametersOutput = { password: Scalars['String']; port: Scalars['Float']; secure?: Maybe; - 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 } } diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index df15bedef..8bcf6aa33 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -435,7 +435,6 @@ export type ConnectionParameters = { password: Scalars['String']; port: Scalars['Float']; secure?: InputMaybe; - username: Scalars['String']; }; export type ConnectionParametersOutput = { @@ -444,7 +443,6 @@ export type ConnectionParametersOutput = { password: Scalars['String']; port: Scalars['Float']; secure?: Maybe; - 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', diff --git a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx index 8a9f1332f..b4e996ffd 100644 --- a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx +++ b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx @@ -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={} /> } + path={SettingsPath.NewImapSmtpCaldavConnection} + element={} /> } + path={SettingsPath.EditImapSmtpCaldavConnection} + element={} /> { 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 <>; diff --git a/packages/twenty-front/src/modules/client-config/states/isImapSmtpCaldavEnabledState.ts b/packages/twenty-front/src/modules/client-config/states/isImapSmtpCaldavEnabledState.ts new file mode 100644 index 000000000..21d4efcd1 --- /dev/null +++ b/packages/twenty-front/src/modules/client-config/states/isImapSmtpCaldavEnabledState.ts @@ -0,0 +1,5 @@ +import { createState } from 'twenty-ui/utilities'; +export const isImapSmtpCaldavEnabledState = createState({ + key: 'isImapSmtpCaldavEnabled', + defaultValue: false, +}); diff --git a/packages/twenty-front/src/modules/client-config/types/ClientConfig.ts b/packages/twenty-front/src/modules/client-config/types/ClientConfig.ts index 6811e7d90..fcef4b3e3 100644 --- a/packages/twenty-front/src/modules/client-config/types/ClientConfig.ts +++ b/packages/twenty-front/src/modules/client-config/types/ClientConfig.ts @@ -30,7 +30,7 @@ export type ClientConfig = { isMicrosoftCalendarEnabled: boolean; isMicrosoftMessagingEnabled: boolean; isMultiWorkspaceEnabled: boolean; - isIMAPMessagingEnabled: boolean; + isImapSmtpCaldavEnabled: boolean; publicFeatureFlags: Array; sentry: Sentry; signInPrefilled: boolean; diff --git a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsConnectedAccountsListCard.tsx b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsConnectedAccountsListCard.tsx index a4603b1c2..4ce4a54fe 100644 --- a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsConnectedAccountsListCard.tsx +++ b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsConnectedAccountsListCard.tsx @@ -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 }) => ( )} diff --git a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsConnectionForm.tsx b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsConnectionForm.tsx new file mode 100644 index 000000000..61f9f17b8 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsConnectionForm.tsx @@ -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; + 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 ( +
+ + + ( + + )} + /> + + + + {t`IMAP Configuration`} + + {t`Configure IMAP settings to receive and sync your emails.`} +
+ {t`Leave blank if you don't need to import emails.`} +
+
+ + ( + + )} + /> + + ( + + )} + /> + + + + ( + + field.onChange(handlePortChange(value)) + } + error={fieldState.error?.message} + /> + )} + /> + + + + ( + + )} + /> + + +
+ + + + {t`CalDAV Configuration`} + + {t`Configure CalDAV settings to sync your calendar events.`} +
+ {t`Leave blank if you don't need calendar sync.`} +
+
+ + ( + + )} + /> + + ( + + )} + /> + + + + ( + + field.onChange(handlePortChange(value)) + } + error={fieldState.error?.message} + /> + )} + /> + + + + ( + - )} - /> - ( - - )} - /> -
-
- ); -}; diff --git a/packages/twenty-front/src/modules/settings/accounts/constants/AccountProtocols.ts b/packages/twenty-front/src/modules/settings/accounts/constants/AccountProtocols.ts new file mode 100644 index 000000000..d16da6b23 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/accounts/constants/AccountProtocols.ts @@ -0,0 +1 @@ +export const ACCOUNT_PROTOCOLS = ['IMAP', 'SMTP', 'CALDAV'] as const; diff --git a/packages/twenty-front/src/modules/settings/accounts/graphql/queries/getConnectedImapSmtpCaldavAccount.ts b/packages/twenty-front/src/modules/settings/accounts/graphql/queries/getConnectedImapSmtpCaldavAccount.ts index 133408e62..d02aac3c6 100644 --- a/packages/twenty-front/src/modules/settings/accounts/graphql/queries/getConnectedImapSmtpCaldavAccount.ts +++ b/packages/twenty-front/src/modules/settings/accounts/graphql/queries/getConnectedImapSmtpCaldavAccount.ts @@ -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 } } diff --git a/packages/twenty-front/src/modules/settings/accounts/hooks/__tests__/useTriggerProviderReconnect.test.tsx b/packages/twenty-front/src/modules/settings/accounts/hooks/__tests__/useTriggerProviderReconnect.test.tsx new file mode 100644 index 000000000..3556c2e58 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/accounts/hooks/__tests__/useTriggerProviderReconnect.test.tsx @@ -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 }) => ( + {children} +); + +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, + ); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/settings/accounts/hooks/useConnectedImapSmtpCaldavAccount.ts b/packages/twenty-front/src/modules/settings/accounts/hooks/useConnectedImapSmtpCaldavAccount.ts index f30bff7e1..6624a3cde 100644 --- a/packages/twenty-front/src/modules/settings/accounts/hooks/useConnectedImapSmtpCaldavAccount.ts +++ b/packages/twenty-front/src/modules/settings/accounts/hooks/useConnectedImapSmtpCaldavAccount.ts @@ -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 { diff --git a/packages/twenty-front/src/modules/settings/accounts/hooks/useImapConnectionForm.ts b/packages/twenty-front/src/modules/settings/accounts/hooks/useImapConnectionForm.ts deleted file mode 100644 index b3d544fdf..000000000 --- a/packages/twenty-front/src/modules/settings/accounts/hooks/useImapConnectionForm.ts +++ /dev/null @@ -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; - -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({ - 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, - }; -}; diff --git a/packages/twenty-front/src/modules/settings/accounts/hooks/useImapSmtpCaldavConnectionForm.ts b/packages/twenty-front/src/modules/settings/accounts/hooks/useImapSmtpCaldavConnectionForm.ts new file mode 100644 index 000000000..476bab325 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/accounts/hooks/useImapSmtpCaldavConnectionForm.ts @@ -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({ + 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 => { + 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 => { + 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, + }; +}; diff --git a/packages/twenty-front/src/modules/settings/accounts/hooks/useTriggerProviderReconnect.ts b/packages/twenty-front/src/modules/settings/accounts/hooks/useTriggerProviderReconnect.ts index 0c1305305..e4297ad2c 100644 --- a/packages/twenty-front/src/modules/settings/accounts/hooks/useTriggerProviderReconnect.ts +++ b/packages/twenty-front/src/modules/settings/accounts/hooks/useTriggerProviderReconnect.ts @@ -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; diff --git a/packages/twenty-front/src/modules/settings/accounts/validation-schemas/connectionImapSmtpCalDav.ts b/packages/twenty-front/src/modules/settings/accounts/validation-schemas/connectionImapSmtpCalDav.ts new file mode 100644 index 000000000..ee37c903b --- /dev/null +++ b/packages/twenty-front/src/modules/settings/accounts/validation-schemas/connectionImapSmtpCalDav.ts @@ -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()); +}; diff --git a/packages/twenty-front/src/modules/settings/components/SettingsListCard.tsx b/packages/twenty-front/src/modules/settings/components/SettingsListCard.tsx index 9b9be92be..706a76e2e 100644 --- a/packages/twenty-front/src/modules/settings/components/SettingsListCard.tsx +++ b/packages/twenty-front/src/modules/settings/components/SettingsListCard.tsx @@ -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; diff --git a/packages/twenty-front/src/modules/types/SettingsPath.ts b/packages/twenty-front/src/modules/types/SettingsPath.ts index f2e6e488c..78a0cbc2c 100644 --- a/packages/twenty-front/src/modules/types/SettingsPath.ts +++ b/packages/twenty-front/src/modules/types/SettingsPath.ts @@ -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', diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionSendEmail.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionSendEmail.tsx index 04b000a94..24242ce00 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionSendEmail.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionSendEmail.tsx @@ -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, }, }); diff --git a/packages/twenty-front/src/testing/mock-data/config.ts b/packages/twenty-front/src/testing/mock-data/config.ts index b7b1baabd..1e6a15f69 100644 --- a/packages/twenty-front/src/testing/mock-data/config.ts +++ b/packages/twenty-front/src/testing/mock-data/config.ts @@ -54,5 +54,5 @@ export const mockedClientConfig: ClientConfig = { isGoogleCalendarEnabled: true, isAttachmentPreviewEnabled: true, isConfigVariablesInDbEnabled: false, - isIMAPMessagingEnabled: false, + isImapSmtpCaldavEnabled: false, }; diff --git a/packages/twenty-server/.env.example b/packages/twenty-server/.env.example index 8fd19eb95..ff27505ef 100644 --- a/packages/twenty-server/.env.example +++ b/packages/twenty-server/.env.example @@ -14,7 +14,7 @@ FRONTEND_URL=http://localhost:3001 # REFRESH_TOKEN_EXPIRES_IN=90d # FILE_TOKEN_EXPIRES_IN=1d # MESSAGING_PROVIDER_GMAIL_ENABLED=false -# MESSAGING_PROVIDER_IMAP_ENABLED=false +# IS_IMAP_SMTP_CALDAV_ENABLED=false # CALENDAR_PROVIDER_GOOGLE_ENABLED=false # MESSAGING_PROVIDER_MICROSOFT_ENABLED=false # CALENDAR_PROVIDER_MICROSOFT_ENABLED=false diff --git a/packages/twenty-server/.env.test b/packages/twenty-server/.env.test index 5600d859c..a672bd9dc 100644 --- a/packages/twenty-server/.env.test +++ b/packages/twenty-server/.env.test @@ -11,7 +11,7 @@ FRONTEND_URL=http://localhost:3001 AUTH_GOOGLE_ENABLED=false MESSAGING_PROVIDER_GMAIL_ENABLED=false -MESSAGING_PROVIDER_IMAP_ENABLED=false +IS_IMAP_SMTP_CALDAV_ENABLED=false CALENDAR_PROVIDER_GOOGLE_ENABLED=false MESSAGING_PROVIDER_MICROSOFT_ENABLED=false CALENDAR_PROVIDER_MICROSOFT_ENABLED=false diff --git a/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.spec.ts b/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.spec.ts index 15ffc1e1b..aafe22aae 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.spec.ts @@ -96,7 +96,7 @@ describe('ClientConfigController', () => { isGoogleMessagingEnabled: false, isGoogleCalendarEnabled: false, isConfigVariablesInDbEnabled: false, - isIMAPMessagingEnabled: false, + isImapSmtpCaldavEnabled: false, calendarBookingPageId: undefined, }; diff --git a/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts b/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts index e48379c70..c13668547 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts @@ -178,7 +178,7 @@ export class ClientConfig { isConfigVariablesInDbEnabled: boolean; @Field(() => Boolean) - isIMAPMessagingEnabled: boolean; + isImapSmtpCaldavEnabled: boolean; @Field(() => String, { nullable: true }) calendarBookingPageId?: string; diff --git a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts index ae61139c9..3e7cb44b4 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts @@ -138,8 +138,8 @@ export class ClientConfigService { isConfigVariablesInDbEnabled: this.twentyConfigService.get( 'IS_CONFIG_VARIABLES_IN_DB_ENABLED', ), - isIMAPMessagingEnabled: this.twentyConfigService.get( - 'MESSAGING_PROVIDER_IMAP_ENABLED', + isImapSmtpCaldavEnabled: this.twentyConfigService.get( + 'IS_IMAP_SMTP_CALDAV_ENABLED', ), calendarBookingPageId: this.twentyConfigService.get( 'CALENDAR_BOOKING_PAGE_ID', diff --git a/packages/twenty-server/src/engine/core-modules/feature-flag/constants/public-feature-flag.const.ts b/packages/twenty-server/src/engine/core-modules/feature-flag/constants/public-feature-flag.const.ts index 23cb30105..b485167b5 100644 --- a/packages/twenty-server/src/engine/core-modules/feature-flag/constants/public-feature-flag.const.ts +++ b/packages/twenty-server/src/engine/core-modules/feature-flag/constants/public-feature-flag.const.ts @@ -13,12 +13,13 @@ export type PublicFeatureFlag = { export const PUBLIC_FEATURE_FLAGS: PublicFeatureFlag[] = [ { - key: FeatureFlagKey.IS_IMAP_ENABLED, + key: FeatureFlagKey.IS_IMAP_SMTP_CALDAV_ENABLED, metadata: { - label: 'IMAP', + label: 'IMAP, SMTP, CalDAV', description: - 'Easily add email accounts from any provider that supports IMAP (and soon, send emails with SMTP)', - imagePath: 'https://twenty.com/images/lab/is-imap-enabled.png', + 'Easily add email accounts from any provider that supports IMAP, send emails with SMTP (and soon, sync calendars with CalDAV)', + imagePath: + 'https://twenty.com/images/lab/is-imap-smtp-caldav-enabled.png', }, }, ...(process.env.CLOUDFLARE_API_KEY diff --git a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts index 099bd2ab7..edf2da73a 100644 --- a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts +++ b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts @@ -5,7 +5,7 @@ export enum FeatureFlagKey { IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED', IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED', IS_AI_ENABLED = 'IS_AI_ENABLED', - IS_IMAP_ENABLED = 'IS_IMAP_ENABLED', + IS_IMAP_SMTP_CALDAV_ENABLED = 'IS_IMAP_SMTP_CALDAV_ENABLED', IS_MORPH_RELATION_ENABLED = 'IS_MORPH_RELATION_ENABLED', IS_WORKFLOW_FILTERING_ENABLED = 'IS_WORKFLOW_FILTERING_ENABLED', IS_RELATION_CONNECT_ENABLED = 'IS_RELATION_CONNECT_ENABLED', diff --git a/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/dtos/imap-smtp-caldav-connection.dto.ts b/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/dtos/imap-smtp-caldav-connection.dto.ts index 9c63d8e78..c2de0808e 100644 --- a/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/dtos/imap-smtp-caldav-connection.dto.ts +++ b/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/dtos/imap-smtp-caldav-connection.dto.ts @@ -14,9 +14,6 @@ export class ConnectionParameters { @Field(() => Number) port: number; - @Field(() => String) - username: string; - /** * Note: This field is stored in plain text in the database. * While encrypting it could provide an extra layer of defense, we have decided not to, @@ -37,9 +34,6 @@ export class ConnectionParametersOutput { @Field(() => Number) port: number; - @Field(() => String) - username: string; - @Field(() => String) password: string; diff --git a/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/imap-smtp-caldav-connection.resolver.ts b/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/imap-smtp-caldav-connection.resolver.ts index 2fe6b7ec3..ef5060f35 100644 --- a/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/imap-smtp-caldav-connection.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/imap-smtp-caldav-connection.resolver.ts @@ -1,4 +1,10 @@ -import { UseFilters, UseGuards, UsePipes } from '@nestjs/common'; +import { + HttpException, + HttpStatus, + UseFilters, + UseGuards, + UsePipes, +} from '@nestjs/common'; import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; import { ConnectedAccountProvider } from 'twenty-shared/types'; @@ -39,36 +45,6 @@ export class ImapSmtpCaldavResolver { private readonly mailConnectionValidatorService: ImapSmtpCaldavValidatorService, ) {} - private async checkIfFeatureEnabled( - workspaceId: string, - accountType: AccountType, - ): Promise { - if (accountType.type === 'IMAP') { - const isImapEnabled = await this.featureFlagService.isFeatureEnabled( - FeatureFlagKey.IS_IMAP_ENABLED, - workspaceId, - ); - - if (!isImapEnabled) { - throw new UserInputError( - 'IMAP feature is not enabled for this workspace', - ); - } - } - - if (accountType.type === 'SMTP') { - throw new UserInputError( - 'SMTP feature is not enabled for this workspace', - ); - } - - if (accountType.type === 'CALDAV') { - throw new UserInputError( - 'CALDAV feature is not enabled for this workspace', - ); - } - } - @Query(() => ConnectedImapSmtpCaldavAccount) @UseGuards(WorkspaceAuthGuard) async getConnectedImapSmtpCaldavAccount( @@ -111,7 +87,18 @@ export class ImapSmtpCaldavResolver { @AuthWorkspace() workspace: Workspace, @Args('id', { nullable: true }) id?: string, ): Promise { - await this.checkIfFeatureEnabled(workspace.id, accountType); + const isImapSmtpCaldavFeatureFlagEnabled = + await this.featureFlagService.isFeatureEnabled( + FeatureFlagKey.IS_IMAP_SMTP_CALDAV_ENABLED, + workspace.id, + ); + + if (!isImapSmtpCaldavFeatureFlagEnabled) { + throw new HttpException( + 'IMAP, SMTP, CalDAV feature is not enabled for this workspace', + HttpStatus.FORBIDDEN, + ); + } const validatedParams = this.mailConnectionValidatorService.validateProtocolConnectionParams( @@ -119,6 +106,7 @@ export class ImapSmtpCaldavResolver { ); await this.ImapSmtpCaldavConnectionService.testImapSmtpCaldav( + handle, validatedParams, accountType.type, ); diff --git a/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/services/imap-smtp-caldav-connection-validator.service.ts b/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/services/imap-smtp-caldav-connection-validator.service.ts index 0c5c192fd..98dd0af38 100644 --- a/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/services/imap-smtp-caldav-connection-validator.service.ts +++ b/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/services/imap-smtp-caldav-connection-validator.service.ts @@ -10,7 +10,6 @@ export class ImapSmtpCaldavValidatorService { private readonly protocolConnectionSchema = z.object({ host: z.string().min(1, 'Host is required'), port: z.number().int().positive('Port must be a positive number'), - username: z.string().min(1, 'Username is required'), password: z.string().min(1, 'Password is required'), secure: z.boolean().optional(), }); @@ -19,7 +18,10 @@ export class ImapSmtpCaldavValidatorService { params: ConnectionParameters, ): ConnectionParameters { if (!params) { - throw new UserInputError('Protocol connection parameters are required'); + throw new UserInputError('Protocol connection parameters are required', { + userFriendlyMessage: + 'Please provide connection details to configure your email account.', + }); } try { @@ -32,10 +34,17 @@ export class ImapSmtpCaldavValidatorService { throw new UserInputError( `Protocol connection validation failed: ${errorMessages}`, + { + userFriendlyMessage: + 'Please check your connection settings. Make sure the server host, port, and password are correct.', + }, ); } - throw new UserInputError('Protocol connection validation failed'); + throw new UserInputError('Protocol connection validation failed', { + userFriendlyMessage: + 'There was an issue with your connection settings. Please try again.', + }); } } } diff --git a/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/services/imap-smtp-caldav-connection.service.ts b/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/services/imap-smtp-caldav-connection.service.ts index f0e4b0930..bf0b57705 100644 --- a/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/services/imap-smtp-caldav-connection.service.ts +++ b/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/services/imap-smtp-caldav-connection.service.ts @@ -1,6 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { ImapFlow } from 'imapflow'; +import { createTransport } from 'nodemailer'; import { ConnectedAccountProvider } from 'twenty-shared/types'; import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; @@ -19,17 +20,16 @@ export class ImapSmtpCaldavService { private readonly twentyORMGlobalManager: TwentyORMGlobalManager, ) {} - async testImapConnection(params: ConnectionParameters): Promise { - if (!params.host || !params.username || !params.password) { - throw new UserInputError('Missing required IMAP connection parameters'); - } - + async testImapConnection( + handle: string, + params: ConnectionParameters, + ): Promise { const client = new ImapFlow({ host: params.host, port: params.port, secure: params.secure ?? true, auth: { - user: params.username, + user: handle, pass: params.password, }, logger: false, @@ -57,16 +57,27 @@ export class ImapSmtpCaldavService { if (error.authenticationFailed) { throw new UserInputError( 'IMAP authentication failed. Please check your credentials.', + { + userFriendlyMessage: + "We couldn't log in to your email account. Please check your email address and password, then try again.", + }, ); } if (error.code === 'ECONNREFUSED') { throw new UserInputError( `IMAP connection refused. Please verify server and port.`, + { + userFriendlyMessage: + "We couldn't connect to your email server. Please check your server settings and try again.", + }, ); } - throw new UserInputError(`IMAP connection failed: ${error.message}`); + throw new UserInputError(`IMAP connection failed: ${error.message}`, { + userFriendlyMessage: + 'We encountered an issue connecting to your email account. Please check your settings and try again.', + }); } finally { if (client.authenticated) { await client.logout(); @@ -74,36 +85,70 @@ export class ImapSmtpCaldavService { } } - async testSmtpConnection(params: ConnectionParameters): Promise { - this.logger.log('SMTP connection testing not yet implemented', params); + async testSmtpConnection( + handle: string, + params: ConnectionParameters, + ): Promise { + const transport = createTransport({ + host: params.host, + port: params.port, + auth: { + user: handle, + pass: params.password, + }, + tls: { + rejectUnauthorized: false, + }, + }); + + try { + await transport.verify(); + } catch (error) { + this.logger.error( + `SMTP connection failed: ${error.message}`, + error.stack, + ); + throw new UserInputError(`SMTP connection failed: ${error.message}`, { + userFriendlyMessage: + "We couldn't connect to your outgoing email server. Please check your SMTP settings and try again.", + }); + } return true; } - async testCaldavConnection(params: ConnectionParameters): Promise { + async testCaldavConnection( + handle: string, + params: ConnectionParameters, + ): Promise { this.logger.log('CALDAV connection testing not yet implemented', params); return true; } async testImapSmtpCaldav( + handle: string, params: ConnectionParameters, accountType: AccountType, ): Promise { if (accountType === 'IMAP') { - return this.testImapConnection(params); + return this.testImapConnection(handle, params); } if (accountType === 'SMTP') { - return this.testSmtpConnection(params); + return this.testSmtpConnection(handle, params); } if (accountType === 'CALDAV') { - return this.testCaldavConnection(params); + return this.testCaldavConnection(handle, params); } throw new UserInputError( 'Invalid account type. Must be one of: IMAP, SMTP, CALDAV', + { + userFriendlyMessage: + 'Please select a valid connection type (IMAP, SMTP, or CalDAV) and try again.', + }, ); } diff --git a/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type.ts b/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type.ts index 40aace1f0..5109643a0 100644 --- a/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type.ts +++ b/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type.ts @@ -1,7 +1,6 @@ export type ConnectionParameters = { host: string; port: number; - username: string; password: string; secure?: boolean; }; @@ -9,7 +8,6 @@ export type ConnectionParameters = { export type AccountType = 'IMAP' | 'SMTP' | 'CALDAV'; export type ImapSmtpCaldavParams = { - handle: string; IMAP?: ConnectionParameters; SMTP?: ConnectionParameters; CALDAV?: ConnectionParameters; diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts index 81aa414bc..3027f2129 100644 --- a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts +++ b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts @@ -148,7 +148,7 @@ export class ConfigVariables { description: 'Enable or disable the IMAP messaging integration', type: ConfigVariableType.BOOLEAN, }) - MESSAGING_PROVIDER_IMAP_ENABLED = false; + IS_IMAP_SMTP_CALDAV_ENABLED = false; @ConfigVariablesMetadata({ group: ConfigVariablesGroup.MicrosoftAuth, diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts index f8f32439d..0af48112b 100644 --- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts @@ -46,7 +46,7 @@ export const seedFeatureFlags = async ( value: false, }, { - key: FeatureFlagKey.IS_IMAP_ENABLED, + key: FeatureFlagKey.IS_IMAP_SMTP_CALDAV_ENABLED, workspaceId: workspaceId, value: true, }, diff --git a/packages/twenty-server/src/modules/connected-account/services/imap-smtp-caldav-apis.service.ts b/packages/twenty-server/src/modules/connected-account/services/imap-smtp-caldav-apis.service.ts index 5e62d616b..dff013b17 100644 --- a/packages/twenty-server/src/modules/connected-account/services/imap-smtp-caldav-apis.service.ts +++ b/packages/twenty-server/src/modules/connected-account/services/imap-smtp-caldav-apis.service.ts @@ -88,6 +88,20 @@ export class ImapSmtpCalDavAPIService { workspaceId, }); + let shouldEnableSync = false; + + if (connectedAccount) { + const hadOnlySmtp = + connectedAccount.connectionParameters?.SMTP && + !connectedAccount.connectionParameters?.IMAP && + !connectedAccount.connectionParameters?.CALDAV; + + const isAddingImapOrCaldav = + input.accountType === 'IMAP' || input.accountType === 'CALDAV'; + + shouldEnableSync = Boolean(hadOnlySmtp && isAddingImapOrCaldav); + } + await workspaceDataSource.transaction(async () => { if (!existingAccountId) { const newConnectedAccount = await connectedAccountRepository.save( @@ -129,7 +143,10 @@ export class ImapSmtpCalDavAPIService { connectedAccountId: newOrExistingConnectedAccountId, type: MessageChannelType.EMAIL, handle, - syncStatus: MessageChannelSyncStatus.ONGOING, + isSyncEnabled: shouldEnableSync, + syncStatus: shouldEnableSync + ? MessageChannelSyncStatus.ONGOING + : MessageChannelSyncStatus.NOT_SYNCED, }, {}, ); @@ -200,9 +217,12 @@ export class ImapSmtpCalDavAPIService { }, { syncStage: MessageChannelSyncStage.FULL_MESSAGE_LIST_FETCH_PENDING, - syncStatus: null, + syncStatus: shouldEnableSync + ? MessageChannelSyncStatus.ONGOING + : MessageChannelSyncStatus.NOT_SYNCED, syncCursor: '', syncStageStartedAt: null, + isSyncEnabled: shouldEnableSync, }, ); @@ -227,22 +247,24 @@ export class ImapSmtpCalDavAPIService { } }); - if (this.twentyConfigService.get('MESSAGING_PROVIDER_IMAP_ENABLED')) { - const messageChannels = await messageChannelRepository.find({ - where: { - connectedAccountId: newOrExistingConnectedAccountId, - }, - }); + if (!shouldEnableSync) { + return; + } - for (const messageChannel of messageChannels) { - await this.messageQueueService.add( - MessagingMessageListFetchJob.name, - { - workspaceId, - messageChannelId: messageChannel.id, - }, - ); - } + const messageChannels = await messageChannelRepository.find({ + where: { + connectedAccountId: newOrExistingConnectedAccountId, + }, + }); + + for (const messageChannel of messageChannels) { + await this.messageQueueService.add( + MessagingMessageListFetchJob.name, + { + workspaceId, + messageChannelId: messageChannel.id, + }, + ); } } } diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/imap/providers/imap-client.provider.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/imap/providers/imap-client.provider.ts index 59f1ffe1e..c12360af3 100644 --- a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/imap/providers/imap-client.provider.ts +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/imap/providers/imap-client.provider.ts @@ -64,14 +64,14 @@ export class ImapClientProvider { await client.connect(); this.logger.log( - `Connected to IMAP server for ${connectionParameters.handle}`, + `Connected to IMAP server for ${connectedAccount.handle}`, ); try { const mailboxes = await client.list(); this.logger.log( - `Available mailboxes for ${connectionParameters.handle}: ${mailboxes.map((m) => m.path).join(', ')}`, + `Available mailboxes for ${connectedAccount.handle}: ${mailboxes.map((m) => m.path).join(', ')}`, ); } catch (error) { this.logger.warn(`Failed to list mailboxes: ${error.message}`); diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/smtp/messaging-smtp-driver.module.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/smtp/messaging-smtp-driver.module.ts new file mode 100644 index 000000000..e2d288fd0 --- /dev/null +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/smtp/messaging-smtp-driver.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; + +import { SmtpClientProvider } from './providers/smtp-client.provider'; + +@Module({ + providers: [SmtpClientProvider], + exports: [SmtpClientProvider], +}) +export class MessagingSmtpDriverModule {} diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/smtp/providers/smtp-client.provider.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/smtp/providers/smtp-client.provider.ts new file mode 100644 index 000000000..acb4a4706 --- /dev/null +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/smtp/providers/smtp-client.provider.ts @@ -0,0 +1,35 @@ +import { Injectable } from '@nestjs/common'; + +import { createTransport, Transporter } from 'nodemailer'; + +import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity'; + +@Injectable() +export class SmtpClientProvider { + public async getSmtpClient( + connectedAccount: Pick< + ConnectedAccountWorkspaceEntity, + 'connectionParameters' | 'handle' + >, + ): Promise { + const smtpParams = connectedAccount.connectionParameters?.SMTP; + + if (!smtpParams) { + throw new Error('SMTP settings not configured for this account'); + } + + const transporter = createTransport({ + host: smtpParams.host, + port: smtpParams.port, + auth: { + user: connectedAccount.handle, + pass: smtpParams.password, + }, + tls: { + rejectUnauthorized: false, + }, + }); + + return transporter; + } +} diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/messaging-import-manager.module.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/messaging-import-manager.module.ts index fcffe688c..aaf8617fd 100644 --- a/packages/twenty-server/src/modules/messaging/message-import-manager/messaging-import-manager.module.ts +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/messaging-import-manager.module.ts @@ -22,6 +22,7 @@ import { MessagingOngoingStaleCronJob } from 'src/modules/messaging/message-impo import { MessagingGmailDriverModule } from 'src/modules/messaging/message-import-manager/drivers/gmail/messaging-gmail-driver.module'; import { MessagingIMAPDriverModule } from 'src/modules/messaging/message-import-manager/drivers/imap/messaging-imap-driver.module'; import { MessagingMicrosoftDriverModule } from 'src/modules/messaging/message-import-manager/drivers/microsoft/messaging-microsoft-driver.module'; +import { MessagingSmtpDriverModule } from 'src/modules/messaging/message-import-manager/drivers/smtp/messaging-smtp-driver.module'; import { MessagingAddSingleMessageToCacheForImportJob } from 'src/modules/messaging/message-import-manager/jobs/messaging-add-single-message-to-cache-for-import.job'; import { MessagingCleanCacheJob } from 'src/modules/messaging/message-import-manager/jobs/messaging-clean-cache'; import { MessagingMessageListFetchJob } from 'src/modules/messaging/message-import-manager/jobs/messaging-message-list-fetch.job'; @@ -48,6 +49,7 @@ import { MessagingMonitoringModule } from 'src/modules/messaging/monitoring/mess MessagingGmailDriverModule, MessagingMicrosoftDriverModule, MessagingIMAPDriverModule, + MessagingSmtpDriverModule, MessagingCommonModule, TypeOrmModule.forFeature( [Workspace, DataSourceEntity, ObjectMetadataEntity], diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-send-message.service.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-send-message.service.ts index 7d6db35ca..be0018171 100644 --- a/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-send-message.service.ts +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-send-message.service.ts @@ -12,6 +12,7 @@ import { import { GmailClientProvider } from 'src/modules/messaging/message-import-manager/drivers/gmail/providers/gmail-client.provider'; import { OAuth2ClientProvider } from 'src/modules/messaging/message-import-manager/drivers/gmail/providers/oauth2-client.provider'; import { MicrosoftClientProvider } from 'src/modules/messaging/message-import-manager/drivers/microsoft/providers/microsoft-client.provider'; +import { SmtpClientProvider } from 'src/modules/messaging/message-import-manager/drivers/smtp/providers/smtp-client.provider'; import { isAccessTokenRefreshingError } from 'src/modules/messaging/message-import-manager/drivers/microsoft/utils/is-access-token-refreshing-error.utils'; import { mimeEncode } from 'src/modules/messaging/message-import-manager/utils/mime-encode.util'; @@ -27,6 +28,7 @@ export class MessagingSendMessageService { private readonly gmailClientProvider: GmailClientProvider, private readonly oAuth2ClientProvider: OAuth2ClientProvider, private readonly microsoftClientProvider: MicrosoftClientProvider, + private readonly smtpClientProvider: SmtpClientProvider, ) {} public async sendMessage( @@ -120,7 +122,16 @@ export class MessagingSendMessageService { break; } case ConnectedAccountProvider.IMAP_SMTP_CALDAV: { - throw new Error('IMAP provider does not support sending messages'); + const smtpClient = + await this.smtpClientProvider.getSmtpClient(connectedAccount); + + await smtpClient.sendMail({ + from: connectedAccount.handle, + to: sendMessageInput.to, + subject: sendMessageInput.subject, + text: sendMessageInput.body, + }); + break; } default: assertUnreachable( diff --git a/packages/twenty-website/public/images/lab/is-imap-enabled.png b/packages/twenty-website/public/images/lab/is-imap-enabled.png deleted file mode 100644 index d33c4fc83..000000000 Binary files a/packages/twenty-website/public/images/lab/is-imap-enabled.png and /dev/null differ diff --git a/packages/twenty-website/public/images/lab/is-imap-smtp-caldav-enabled.png b/packages/twenty-website/public/images/lab/is-imap-smtp-caldav-enabled.png new file mode 100644 index 000000000..c741d352c Binary files /dev/null and b/packages/twenty-website/public/images/lab/is-imap-smtp-caldav-enabled.png differ