diff --git a/packages/twenty-emails/src/emails/workflow-action.email.tsx b/packages/twenty-emails/src/emails/workflow-action.email.tsx deleted file mode 100644 index 2eaa3a451ebb..000000000000 --- a/packages/twenty-emails/src/emails/workflow-action.email.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { BaseEmail } from 'src/components/BaseEmail'; -import { Title } from 'src/components/Title'; -import { CallToAction } from 'src/components/CallToAction'; - -type WorkflowActionEmailProps = { - dangerousHTML?: string; - title?: string; - callToAction?: { - value: string; - href: string; - }; -}; -export const WorkflowActionEmail = ({ - dangerousHTML, - title, - callToAction, -}: WorkflowActionEmailProps) => { - return ( - - {title && } - {dangerousHTML && ( - <div dangerouslySetInnerHTML={{ __html: dangerousHTML }} /> - )} - {callToAction && ( - <CallToAction value={callToAction.value} href={callToAction.href} /> - )} - </BaseEmail> - ); -}; diff --git a/packages/twenty-emails/src/index.ts b/packages/twenty-emails/src/index.ts index 9fca13d73b55..ddecb05c8655 100644 --- a/packages/twenty-emails/src/index.ts +++ b/packages/twenty-emails/src/index.ts @@ -3,4 +3,3 @@ export * from './emails/delete-inactive-workspaces.email'; export * from './emails/password-reset-link.email'; export * from './emails/password-update-notify.email'; export * from './emails/send-invite-link.email'; -export * from './emails/workflow-action.email'; diff --git a/packages/twenty-front/src/modules/accounts/constants/GmailSendScope.ts b/packages/twenty-front/src/modules/accounts/constants/GmailSendScope.ts new file mode 100644 index 000000000000..6918d126f2b3 --- /dev/null +++ b/packages/twenty-front/src/modules/accounts/constants/GmailSendScope.ts @@ -0,0 +1 @@ +export const GMAIL_SEND_SCOPE = 'https://www.googleapis.com/auth/gmail.send'; diff --git a/packages/twenty-front/src/modules/accounts/types/ConnectedAccount.ts b/packages/twenty-front/src/modules/accounts/types/ConnectedAccount.ts index d8f42da6d53c..f0ba4f489296 100644 --- a/packages/twenty-front/src/modules/accounts/types/ConnectedAccount.ts +++ b/packages/twenty-front/src/modules/accounts/types/ConnectedAccount.ts @@ -13,5 +13,6 @@ export type ConnectedAccount = { authFailedAt: Date | null; messageChannels: MessageChannel[]; calendarChannels: CalendarChannel[]; + scopes: string[] | null; __typename: 'ConnectedAccount'; }; diff --git a/packages/twenty-front/src/modules/settings/accounts/hooks/useTriggerGoogleApisOAuth.ts b/packages/twenty-front/src/modules/settings/accounts/hooks/useTriggerGoogleApisOAuth.ts index 0c6ec18d28bc..921ebdea12ee 100644 --- a/packages/twenty-front/src/modules/settings/accounts/hooks/useTriggerGoogleApisOAuth.ts +++ b/packages/twenty-front/src/modules/settings/accounts/hooks/useTriggerGoogleApisOAuth.ts @@ -12,11 +12,17 @@ export const useTriggerGoogleApisOAuth = () => { const [generateTransientToken] = useGenerateTransientTokenMutation(); const triggerGoogleApisOAuth = useCallback( - async ( - redirectLocation?: AppPath, - messageVisibility?: MessageChannelVisibility, - calendarVisibility?: CalendarChannelVisibility, - ) => { + async ({ + redirectLocation, + messageVisibility, + calendarVisibility, + loginHint, + }: { + redirectLocation?: AppPath | string; + messageVisibility?: MessageChannelVisibility; + calendarVisibility?: CalendarChannelVisibility; + loginHint?: string; + } = {}) => { const authServerUrl = REACT_APP_SERVER_BASE_URL; const transientToken = await generateTransientToken(); @@ -38,6 +44,8 @@ export const useTriggerGoogleApisOAuth = () => { ? `&messageVisibility=${messageVisibility}` : ''; + params += loginHint ? `&loginHint=${loginHint}` : ''; + window.location.href = `${authServerUrl}/auth/google-apis?${params}`; }, [generateTransientToken], diff --git a/packages/twenty-front/src/modules/ui/input/components/Select.tsx b/packages/twenty-front/src/modules/ui/input/components/Select.tsx index c31a48a198ea..ba29909b509d 100644 --- a/packages/twenty-front/src/modules/ui/input/components/Select.tsx +++ b/packages/twenty-front/src/modules/ui/input/components/Select.tsx @@ -1,6 +1,6 @@ import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; -import { useMemo, useRef, useState } from 'react'; +import React, { MouseEvent, useMemo, useRef, useState } from 'react'; import { IconChevronDown, IconComponent } from 'twenty-ui'; import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; @@ -11,6 +11,7 @@ import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem'; import { SelectHotkeyScope } from '../types/SelectHotkeyScope'; +import { isDefined } from '~/utils/isDefined'; export type SelectOption<Value extends string | number | null> = { value: Value; @@ -18,6 +19,12 @@ export type SelectOption<Value extends string | number | null> = { Icon?: IconComponent; }; +type CallToActionButton = { + text: string; + onClick: (event: MouseEvent<HTMLDivElement>) => void; + Icon?: IconComponent; +}; + export type SelectProps<Value extends string | number | null> = { className?: string; disabled?: boolean; @@ -32,6 +39,7 @@ export type SelectProps<Value extends string | number | null> = { options: SelectOption<Value>[]; value?: Value; withSearchInput?: boolean; + callToActionButton?: CallToActionButton; }; const StyledContainer = styled.div<{ fullWidth?: boolean }>` @@ -89,6 +97,7 @@ export const Select = <Value extends string | number | null>({ options, value, withSearchInput, + callToActionButton, }: SelectProps<Value>) => { const selectContainerRef = useRef<HTMLDivElement>(null); @@ -97,8 +106,8 @@ export const Select = <Value extends string | number | null>({ const selectedOption = options.find(({ value: key }) => key === value) || - options[0] || - emptyOption; + emptyOption || + options[0]; const filteredOptions = useMemo( () => searchInputValue @@ -109,7 +118,9 @@ export const Select = <Value extends string | number | null>({ [options, searchInputValue], ); - const isDisabled = disabledFromProps || options.length <= 1; + const isDisabled = + disabledFromProps || + (options.length <= 1 && !isDefined(callToActionButton)); const { closeDropdown } = useDropdown(dropdownId); @@ -177,6 +188,18 @@ export const Select = <Value extends string | number | null>({ ))} </DropdownMenuItemsContainer> )} + {!!callToActionButton && !!filteredOptions.length && ( + <DropdownMenuSeparator /> + )} + {!!callToActionButton && ( + <DropdownMenuItemsContainer hasMaxHeight> + <MenuItem + onClick={callToActionButton.onClick} + LeftIcon={callToActionButton.Icon} + text={callToActionButton.text} + /> + </DropdownMenuItemsContainer> + )} </> } dropdownHotkeyScope={{ scope: SelectHotkeyScope.Select }} diff --git a/packages/twenty-front/src/modules/ui/input/components/__stories__/Select.stories.tsx b/packages/twenty-front/src/modules/ui/input/components/__stories__/Select.stories.tsx index d5ef7dc21c36..f24c7a1c9414 100644 --- a/packages/twenty-front/src/modules/ui/input/components/__stories__/Select.stories.tsx +++ b/packages/twenty-front/src/modules/ui/input/components/__stories__/Select.stories.tsx @@ -4,6 +4,7 @@ import { userEvent, within } from '@storybook/test'; import { ComponentDecorator } from 'twenty-ui'; import { Select, SelectProps } from '../Select'; +import { IconPlus } from 'packages/twenty-ui'; type RenderProps = SelectProps<string | number | null>; @@ -56,3 +57,13 @@ export const Disabled: Story = { export const WithSearch: Story = { args: { withSearchInput: true }, }; + +export const CallToActionButton: Story = { + args: { + callToActionButton: { + onClick: () => {}, + Icon: IconPlus, + text: 'Add action', + }, + }, +}; diff --git a/packages/twenty-front/src/modules/workflow/components/WorkflowEditActionFormSendEmail.tsx b/packages/twenty-front/src/modules/workflow/components/WorkflowEditActionFormSendEmail.tsx index cfa168c55c22..26664d93e0d0 100644 --- a/packages/twenty-front/src/modules/workflow/components/WorkflowEditActionFormSendEmail.tsx +++ b/packages/twenty-front/src/modules/workflow/components/WorkflowEditActionFormSendEmail.tsx @@ -4,10 +4,18 @@ import { WorkflowEditActionFormBase } from '@/workflow/components/WorkflowEditAc import { WorkflowSendEmailStep } from '@/workflow/types/Workflow'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; -import { useEffect } from 'react'; +import React, { useEffect } from 'react'; import { Controller, useForm } from 'react-hook-form'; -import { IconMail } from 'twenty-ui'; +import { IconMail, IconPlus, isDefined } from 'twenty-ui'; import { useDebouncedCallback } from 'use-debounce'; +import { Select, SelectOption } from '@/ui/input/components/Select'; +import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; +import { ConnectedAccount } from '@/accounts/types/ConnectedAccount'; +import { useRecoilValue } from 'recoil'; +import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; +import { useTriggerGoogleApisOAuth } from '@/settings/accounts/hooks/useTriggerGoogleApisOAuth'; +import { workflowIdState } from '@/workflow/states/workflowIdState'; +import { GMAIL_SEND_SCOPE } from '@/accounts/constants/GmailSendScope'; const StyledTriggerSettings = styled.div` padding: ${({ theme }) => theme.spacing(6)}; @@ -28,6 +36,7 @@ type WorkflowEditActionFormSendEmailProps = }; type SendEmailFormData = { + connectedAccountId: string; subject: string; body: string; }; @@ -36,35 +45,70 @@ export const WorkflowEditActionFormSendEmail = ( props: WorkflowEditActionFormSendEmailProps, ) => { const theme = useTheme(); + const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState); + const { triggerGoogleApisOAuth } = useTriggerGoogleApisOAuth(); + const workflowId = useRecoilValue(workflowIdState); + const redirectUrl = `/object/workflow/${workflowId}`; const form = useForm<SendEmailFormData>({ defaultValues: { + connectedAccountId: '', subject: '', body: '', }, disabled: props.readonly, }); - useEffect(() => { - form.setValue('subject', props.action.settings.subject ?? ''); - form.setValue('body', props.action.settings.template ?? ''); - }, [props.action.settings.subject, props.action.settings.template, form]); - - const saveAction = useDebouncedCallback((formData: SendEmailFormData) => { - if (props.readonly === true) { + const checkConnectedAccountScopes = async ( + connectedAccountId: string | null, + ) => { + const connectedAccount = accounts.find( + (account) => account.id === connectedAccountId, + ); + if (!isDefined(connectedAccount)) { return; } + const scopes = connectedAccount.scopes; + if ( + !isDefined(scopes) || + !isDefined(scopes.find((scope) => scope === GMAIL_SEND_SCOPE)) + ) { + await triggerGoogleApisOAuth({ + redirectLocation: redirectUrl, + loginHint: connectedAccount.handle, + }); + } + }; - props.onActionUpdate({ - ...props.action, - settings: { - ...props.action.settings, - title: formData.subject, - subject: formData.subject, - template: formData.body, - }, - }); - }, 1_000); + useEffect(() => { + form.setValue( + 'connectedAccountId', + props.action.settings.connectedAccountId ?? '', + ); + form.setValue('subject', props.action.settings.subject ?? ''); + form.setValue('body', props.action.settings.body ?? ''); + }, [props.action.settings, form]); + + const saveAction = useDebouncedCallback( + async (formData: SendEmailFormData, checkScopes = false) => { + if (props.readonly === true) { + return; + } + props.onActionUpdate({ + ...props.action, + settings: { + ...props.action.settings, + connectedAccountId: formData.connectedAccountId, + subject: formData.subject, + body: formData.body, + }, + }); + if (checkScopes === true) { + await checkConnectedAccountScopes(formData.connectedAccountId); + } + }, + 1_000, + ); useEffect(() => { return () => { @@ -72,52 +116,120 @@ export const WorkflowEditActionFormSendEmail = ( }; }, [saveAction]); - const handleSave = form.handleSubmit(saveAction); + const handleSave = (checkScopes = false) => + form.handleSubmit((formData: SendEmailFormData) => + saveAction(formData, checkScopes), + )(); + + const filter: { or: object[] } = { + or: [ + { + accountOwnerId: { + eq: currentWorkspaceMember?.id, + }, + }, + ], + }; + + if ( + isDefined(props.action.settings.connectedAccountId) && + props.action.settings.connectedAccountId !== '' + ) { + filter.or.push({ + id: { + eq: props.action.settings.connectedAccountId, + }, + }); + } + + const { records: accounts, loading } = useFindManyRecords<ConnectedAccount>({ + objectNameSingular: 'connectedAccount', + filter, + }); + + let emptyOption: SelectOption<string | null> = { label: 'None', value: null }; + const connectedAccountOptions: SelectOption<string | null>[] = []; + + accounts.forEach((account) => { + const selectOption = { + label: account.handle, + value: account.id, + }; + if (account.accountOwnerId === currentWorkspaceMember?.id) { + connectedAccountOptions.push(selectOption); + } else { + // This handle the case when the current connected account does not belong to the currentWorkspaceMember + // In that case, current connected account email is displayed, but cannot be selected + emptyOption = selectOption; + } + }); return ( - <WorkflowEditActionFormBase - ActionIcon={<IconMail color={theme.color.blue} />} - actionTitle="Send Email" - actionType="Email" - > - <StyledTriggerSettings> - <Controller - name="subject" - control={form.control} - render={({ field }) => ( - <TextInput - label="Subject" - placeholder="Thank you for building such an awesome CRM!" - value={field.value} - disabled={field.disabled} - onChange={(email) => { - field.onChange(email); - - handleSave(); - }} - /> - )} - /> - - <Controller - name="body" - control={form.control} - render={({ field }) => ( - <TextArea - label="Body" - placeholder="Thank you so much!" - value={field.value} - minRows={4} - disabled={field.disabled} - onChange={(email) => { - field.onChange(email); - - handleSave(); - }} - /> - )} - /> - </StyledTriggerSettings> - </WorkflowEditActionFormBase> + !loading && ( + <WorkflowEditActionFormBase + ActionIcon={<IconMail color={theme.color.blue} />} + actionTitle="Send Email" + actionType="Email" + > + <StyledTriggerSettings> + <Controller + name="connectedAccountId" + control={form.control} + render={({ field }) => ( + <Select + dropdownId="select-connected-account-id" + label="Account" + fullWidth + emptyOption={emptyOption} + value={field.value} + options={connectedAccountOptions} + callToActionButton={{ + onClick: () => + triggerGoogleApisOAuth({ redirectLocation: redirectUrl }), + Icon: IconPlus, + text: 'Add account', + }} + onChange={(connectedAccountId) => { + field.onChange(connectedAccountId); + handleSave(true); + }} + /> + )} + /> + <Controller + name="subject" + control={form.control} + render={({ field }) => ( + <TextInput + label="Subject" + placeholder="Enter email subject (use {{variable}} for dynamic content)" + value={field.value} + onChange={(email) => { + field.onChange(email); + handleSave(); + }} + /> + )} + /> + + <Controller + name="body" + control={form.control} + render={({ field }) => ( + <TextArea + label="Body" + placeholder="Enter email body (use {{variable}} for dynamic content)" + value={field.value} + minRows={4} + onChange={(email) => { + field.onChange(email); + handleSave(); + }} + /> + )} + /> + </StyledTriggerSettings> + </WorkflowEditActionFormBase> + ) ); }; diff --git a/packages/twenty-front/src/modules/workflow/types/Workflow.ts b/packages/twenty-front/src/modules/workflow/types/Workflow.ts index 7eb1a483993a..0ed8422846b9 100644 --- a/packages/twenty-front/src/modules/workflow/types/Workflow.ts +++ b/packages/twenty-front/src/modules/workflow/types/Workflow.ts @@ -14,13 +14,9 @@ export type WorkflowCodeStepSettings = BaseWorkflowStepSettings & { }; export type WorkflowSendEmailStepSettings = BaseWorkflowStepSettings & { + connectedAccountId: string; subject?: string; - template?: string; - title?: string; - callToAction?: { - value: string; - href: string; - }; + body?: string; }; type BaseWorkflowStep = { diff --git a/packages/twenty-front/src/modules/workflow/utils/getStepDefaultDefinition.ts b/packages/twenty-front/src/modules/workflow/utils/getStepDefaultDefinition.ts index 0047ff1395b5..48e8f9bd448f 100644 --- a/packages/twenty-front/src/modules/workflow/utils/getStepDefaultDefinition.ts +++ b/packages/twenty-front/src/modules/workflow/utils/getStepDefaultDefinition.ts @@ -33,9 +33,9 @@ export const getStepDefaultDefinition = ( type: 'SEND_EMAIL', valid: false, settings: { - subject: 'hello', - title: 'hello', - template: '{{title}}', + connectedAccountId: '', + subject: '', + body: '', errorHandlingOptions: { continueOnFailure: { value: false, diff --git a/packages/twenty-front/src/modules/workspace/types/FeatureFlagKey.ts b/packages/twenty-front/src/modules/workspace/types/FeatureFlagKey.ts index aca969f43df0..3abc8299b7f0 100644 --- a/packages/twenty-front/src/modules/workspace/types/FeatureFlagKey.ts +++ b/packages/twenty-front/src/modules/workspace/types/FeatureFlagKey.ts @@ -12,4 +12,5 @@ export type FeatureFlagKey = | 'IS_WORKSPACE_FAVORITE_ENABLED' | 'IS_SEARCH_ENABLED' | 'IS_QUERY_RUNNER_TWENTY_ORM_ENABLED' + | 'IS_GMAIL_SEND_EMAIL_SCOPE_ENABLED' | 'IS_WORKSPACE_MIGRATED_FOR_SEARCH'; diff --git a/packages/twenty-front/src/pages/onboarding/SyncEmails.tsx b/packages/twenty-front/src/pages/onboarding/SyncEmails.tsx index c6ddb99c82d6..c80eed892c75 100644 --- a/packages/twenty-front/src/pages/onboarding/SyncEmails.tsx +++ b/packages/twenty-front/src/pages/onboarding/SyncEmails.tsx @@ -54,11 +54,11 @@ export const SyncEmails = () => { ? CalendarChannelVisibility.ShareEverything : CalendarChannelVisibility.Metadata; - await triggerGoogleApisOAuth( - AppPath.Index, - visibility, - calendarChannelVisibility, - ); + await triggerGoogleApisOAuth({ + redirectLocation: AppPath.Index, + messageVisibility: visibility, + calendarVisibility: calendarChannelVisibility, + }); }; const continueWithoutSync = async () => { diff --git a/packages/twenty-server/src/database/typeorm-seeds/core/feature-flags.ts b/packages/twenty-server/src/database/typeorm-seeds/core/feature-flags.ts index b3068a265986..a8407151b284 100644 --- a/packages/twenty-server/src/database/typeorm-seeds/core/feature-flags.ts +++ b/packages/twenty-server/src/database/typeorm-seeds/core/feature-flags.ts @@ -70,6 +70,11 @@ export const seedFeatureFlags = async ( workspaceId: workspaceId, value: true, }, + { + key: FeatureFlagKey.IsGmailSendEmailScopeEnabled, + workspaceId: workspaceId, + value: true, + }, ]) .execute(); }; diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts index d88f37a1446b..708472826d10 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts @@ -27,6 +27,7 @@ import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-s import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module'; import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-manager.module'; import { ConnectedAccountModule } from 'src/modules/connected-account/connected-account.module'; +import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { AuthResolver } from './auth.resolver'; @@ -52,6 +53,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy'; OnboardingModule, WorkspaceDataSourceModule, ConnectedAccountModule, + FeatureFlagModule, ], controllers: [ GoogleAuthController, diff --git a/packages/twenty-server/src/engine/core-modules/auth/guards/google-apis-oauth-exchange-code-for-token.guard.ts b/packages/twenty-server/src/engine/core-modules/auth/guards/google-apis-oauth-exchange-code-for-token.guard.ts index 77c6b41ce1d0..08baa4ff5a51 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/guards/google-apis-oauth-exchange-code-for-token.guard.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/guards/google-apis-oauth-exchange-code-for-token.guard.ts @@ -8,18 +8,33 @@ import { import { GoogleAPIsOauthExchangeCodeForTokenStrategy } from 'src/engine/core-modules/auth/strategies/google-apis-oauth-exchange-code-for-token.auth.strategy'; import { setRequestExtraParams } from 'src/engine/core-modules/auth/utils/google-apis-set-request-extra-params.util'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; +import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; @Injectable() export class GoogleAPIsOauthExchangeCodeForTokenGuard extends AuthGuard( 'google-apis', ) { - constructor(private readonly environmentService: EnvironmentService) { + constructor( + private readonly environmentService: EnvironmentService, + private readonly featureFlagService: FeatureFlagService, + private readonly tokenService: TokenService, + ) { super(); } async canActivate(context: ExecutionContext) { const request = context.switchToHttp().getRequest(); const state = JSON.parse(request.query.state); + const { workspaceId } = await this.tokenService.verifyTransientToken( + state.transientToken, + ); + const isGmailSendEmailScopeEnabled = + await this.featureFlagService.isFeatureEnabled( + FeatureFlagKey.IsGmailSendEmailScopeEnabled, + workspaceId, + ); if ( !this.environmentService.get('MESSAGING_PROVIDER_GMAIL_ENABLED') && @@ -34,6 +49,7 @@ export class GoogleAPIsOauthExchangeCodeForTokenGuard extends AuthGuard( new GoogleAPIsOauthExchangeCodeForTokenStrategy( this.environmentService, {}, + isGmailSendEmailScopeEnabled, ); setRequestExtraParams(request, { diff --git a/packages/twenty-server/src/engine/core-modules/auth/guards/google-apis-oauth-request-code.guard.ts b/packages/twenty-server/src/engine/core-modules/auth/guards/google-apis-oauth-request-code.guard.ts index 04d860d5ebc5..9b0e8f26062a 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/guards/google-apis-oauth-request-code.guard.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/guards/google-apis-oauth-request-code.guard.ts @@ -8,10 +8,17 @@ import { import { GoogleAPIsOauthRequestCodeStrategy } from 'src/engine/core-modules/auth/strategies/google-apis-oauth-request-code.auth.strategy'; import { setRequestExtraParams } from 'src/engine/core-modules/auth/utils/google-apis-set-request-extra-params.util'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; +import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; @Injectable() export class GoogleAPIsOauthRequestCodeGuard extends AuthGuard('google-apis') { - constructor(private readonly environmentService: EnvironmentService) { + constructor( + private readonly environmentService: EnvironmentService, + private readonly featureFlagService: FeatureFlagService, + private readonly tokenService: TokenService, + ) { super({ prompt: 'select_account', }); @@ -20,6 +27,15 @@ export class GoogleAPIsOauthRequestCodeGuard extends AuthGuard('google-apis') { async canActivate(context: ExecutionContext) { const request = context.switchToHttp().getRequest(); + const { workspaceId } = await this.tokenService.verifyTransientToken( + request.query.transientToken, + ); + const isGmailSendEmailScopeEnabled = + await this.featureFlagService.isFeatureEnabled( + FeatureFlagKey.IsGmailSendEmailScopeEnabled, + workspaceId, + ); + if ( !this.environmentService.get('MESSAGING_PROVIDER_GMAIL_ENABLED') && !this.environmentService.get('CALENDAR_PROVIDER_GOOGLE_ENABLED') @@ -30,12 +46,17 @@ export class GoogleAPIsOauthRequestCodeGuard extends AuthGuard('google-apis') { ); } - new GoogleAPIsOauthRequestCodeStrategy(this.environmentService, {}); + new GoogleAPIsOauthRequestCodeStrategy( + this.environmentService, + {}, + isGmailSendEmailScopeEnabled, + ); setRequestExtraParams(request, { transientToken: request.query.transientToken, redirectLocation: request.query.redirectLocation, calendarVisibility: request.query.calendarVisibility, messageVisibility: request.query.messageVisibility, + loginHint: request.query.loginHint, }); const activate = (await super.canActivate(context)) as boolean; diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/google-apis.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/google-apis.service.ts index e140ecedd80d..04c77d2d9c19 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/google-apis.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/google-apis.service.ts @@ -33,6 +33,9 @@ import { MessagingMessageListFetchJobData, } from 'src/modules/messaging/message-import-manager/jobs/messaging-message-list-fetch.job'; import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; +import { getGoogleApisOauthScopes } from 'src/engine/core-modules/auth/utils/get-google-apis-oauth-scopes'; +import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; @Injectable() export class GoogleAPIsService { @@ -44,6 +47,7 @@ export class GoogleAPIsService { private readonly calendarQueueService: MessageQueueService, private readonly environmentService: EnvironmentService, private readonly accountsToReconnectService: AccountsToReconnectService, + private readonly featureFlagService: FeatureFlagService, ) {} async refreshGoogleRefreshToken(input: { @@ -95,6 +99,13 @@ export class GoogleAPIsService { const workspaceDataSource = await this.twentyORMGlobalManager.getDataSourceForWorkspace(workspaceId); + const isGmailSendEmailScopeEnabled = + await this.featureFlagService.isFeatureEnabled( + FeatureFlagKey.IsGmailSendEmailScopeEnabled, + workspaceId, + ); + const scopes = getGoogleApisOauthScopes(isGmailSendEmailScopeEnabled); + await workspaceDataSource.transaction(async (manager: EntityManager) => { if (!existingAccountId) { await connectedAccountRepository.save( @@ -105,6 +116,7 @@ export class GoogleAPIsService { accessToken: input.accessToken, refreshToken: input.refreshToken, accountOwnerId: workspaceMemberId, + scopes, }, {}, manager, @@ -146,6 +158,7 @@ export class GoogleAPIsService { { accessToken: input.accessToken, refreshToken: input.refreshToken, + scopes, }, manager, ); diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/google-apis-oauth-common.auth.strategy.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/google-apis-oauth-common.auth.strategy.ts index 8636924735f9..addf4b6e78cd 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/strategies/google-apis-oauth-common.auth.strategy.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/google-apis-oauth-common.auth.strategy.ts @@ -4,6 +4,7 @@ import { PassportStrategy } from '@nestjs/passport'; import { Strategy } from 'passport-google-oauth20'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { getGoogleApisOauthScopes } from 'src/engine/core-modules/auth/utils/get-google-apis-oauth-scopes'; export type GoogleAPIScopeConfig = { isCalendarEnabled?: boolean; @@ -18,14 +19,9 @@ export class GoogleAPIsOauthCommonStrategy extends PassportStrategy( constructor( environmentService: EnvironmentService, scopeConfig: GoogleAPIScopeConfig, + isGmailSendEmailScopeEnabled = false, ) { - const scopes = [ - 'email', - 'profile', - 'https://www.googleapis.com/auth/gmail.readonly', - 'https://www.googleapis.com/auth/calendar.events', - 'https://www.googleapis.com/auth/profile.emails.read', - ]; + const scopes = getGoogleApisOauthScopes(isGmailSendEmailScopeEnabled); super({ clientID: environmentService.get('AUTH_GOOGLE_CLIENT_ID'), diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/google-apis-oauth-exchange-code-for-token.auth.strategy.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/google-apis-oauth-exchange-code-for-token.auth.strategy.ts index 244b1066d846..c8559bd141f2 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/strategies/google-apis-oauth-exchange-code-for-token.auth.strategy.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/google-apis-oauth-exchange-code-for-token.auth.strategy.ts @@ -15,8 +15,9 @@ export class GoogleAPIsOauthExchangeCodeForTokenStrategy extends GoogleAPIsOauth constructor( environmentService: EnvironmentService, scopeConfig: GoogleAPIScopeConfig, + isGmailSendEmailScopeEnabled = false, ) { - super(environmentService, scopeConfig); + super(environmentService, scopeConfig, isGmailSendEmailScopeEnabled); } async validate( diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/google-apis-oauth-request-code.auth.strategy.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/google-apis-oauth-request-code.auth.strategy.ts index f93642bc7ece..ee0782b9cd8b 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/strategies/google-apis-oauth-request-code.auth.strategy.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/google-apis-oauth-request-code.auth.strategy.ts @@ -13,8 +13,9 @@ export class GoogleAPIsOauthRequestCodeStrategy extends GoogleAPIsOauthCommonStr constructor( environmentService: EnvironmentService, scopeConfig: GoogleAPIScopeConfig, + isGmailSendEmailScopeEnabled = false, ) { - super(environmentService, scopeConfig); + super(environmentService, scopeConfig, isGmailSendEmailScopeEnabled); } authenticate(req: any, options: any) { @@ -22,6 +23,7 @@ export class GoogleAPIsOauthRequestCodeStrategy extends GoogleAPIsOauthCommonStr ...options, accessType: 'offline', prompt: 'consent', + loginHint: req.params.loginHint, state: JSON.stringify({ transientToken: req.params.transientToken, redirectLocation: req.params.redirectLocation, diff --git a/packages/twenty-server/src/engine/core-modules/auth/utils/get-google-apis-oauth-scopes.ts b/packages/twenty-server/src/engine/core-modules/auth/utils/get-google-apis-oauth-scopes.ts new file mode 100644 index 000000000000..e532c3cdf405 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/utils/get-google-apis-oauth-scopes.ts @@ -0,0 +1,17 @@ +export const getGoogleApisOauthScopes = ( + isGmailSendEmailScopeEnabled = false, +) => { + const scopes = [ + 'email', + 'profile', + 'https://www.googleapis.com/auth/gmail.readonly', + 'https://www.googleapis.com/auth/calendar.events', + 'https://www.googleapis.com/auth/profile.emails.read', + ]; + + if (isGmailSendEmailScopeEnabled) { + scopes.push('https://www.googleapis.com/auth/gmail.send'); + } + + return scopes; +}; diff --git a/packages/twenty-server/src/engine/core-modules/auth/utils/google-apis-set-request-extra-params.util.ts b/packages/twenty-server/src/engine/core-modules/auth/utils/google-apis-set-request-extra-params.util.ts index b6549ceff071..76743c04d0b4 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/utils/google-apis-set-request-extra-params.util.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/utils/google-apis-set-request-extra-params.util.ts @@ -9,6 +9,7 @@ type GoogleAPIsRequestExtraParams = { redirectLocation?: string; calendarVisibility?: string; messageVisibility?: string; + loginHint?: string; }; export const setRequestExtraParams = ( @@ -20,6 +21,7 @@ export const setRequestExtraParams = ( redirectLocation, calendarVisibility, messageVisibility, + loginHint, } = params; if (!transientToken) { @@ -42,4 +44,7 @@ export const setRequestExtraParams = ( if (messageVisibility) { request.params.messageVisibility = messageVisibility; } + if (loginHint) { + request.params.loginHint = loginHint; + } }; 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 397e92d7b3a4..0ea23e1e2f6a 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 @@ -12,4 +12,5 @@ export enum FeatureFlagKey { IsWorkspaceFavoriteEnabled = 'IS_WORKSPACE_FAVORITE_ENABLED', IsSearchEnabled = 'IS_SEARCH_ENABLED', IsWorkspaceMigratedForSearch = 'IS_WORKSPACE_MIGRATED_FOR_SEARCH', + IsGmailSendEmailScopeEnabled = 'IS_GMAIL_SEND_EMAIL_SCOPE_ENABLED', } diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids.ts index b2f3e87ae6d7..624504052ce4 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids.ts @@ -150,6 +150,7 @@ export const CONNECTED_ACCOUNT_STANDARD_FIELD_IDS = { messageChannels: '20202020-24f7-4362-8468-042204d1e445', calendarChannels: '20202020-af4a-47bb-99ec-51911c1d3977', handleAliases: '20202020-8a3d-46be-814f-6228af16c47b', + scopes: '20202020-8a3d-46be-814f-6228af16c47c', }; export const EVENT_STANDARD_FIELD_IDS = { diff --git a/packages/twenty-server/src/modules/connected-account/standard-objects/connected-account.workspace-entity.ts b/packages/twenty-server/src/modules/connected-account/standard-objects/connected-account.workspace-entity.ts index f09bb74ea697..12363ed088b8 100644 --- a/packages/twenty-server/src/modules/connected-account/standard-objects/connected-account.workspace-entity.ts +++ b/packages/twenty-server/src/modules/connected-account/standard-objects/connected-account.workspace-entity.ts @@ -99,6 +99,16 @@ export class ConnectedAccountWorkspaceEntity extends BaseWorkspaceEntity { }) handleAliases: string; + @WorkspaceField({ + standardId: CONNECTED_ACCOUNT_STANDARD_FIELD_IDS.scopes, + type: FieldMetadataType.ARRAY, + label: 'Scopes', + description: 'Scopes', + icon: 'IconSettings', + }) + @WorkspaceIsNullable() + scopes: string[] | null; + @WorkspaceRelation({ standardId: CONNECTED_ACCOUNT_STANDARD_FIELD_IDS.accountOwner, type: RelationMetadataType.MANY_TO_ONE, diff --git a/packages/twenty-server/src/modules/mail-sender/exceptions/mail-sender.exception.ts b/packages/twenty-server/src/modules/mail-sender/exceptions/mail-sender.exception.ts new file mode 100644 index 000000000000..01e1b2ed932f --- /dev/null +++ b/packages/twenty-server/src/modules/mail-sender/exceptions/mail-sender.exception.ts @@ -0,0 +1,13 @@ +import { CustomException } from 'src/utils/custom-exception'; + +export class MailSenderException extends CustomException { + code: MailSenderExceptionCode; + constructor(message: string, code: MailSenderExceptionCode) { + super(message, code); + } +} + +export enum MailSenderExceptionCode { + PROVIDER_NOT_SUPPORTED = 'PROVIDER_NOT_SUPPORTED', + CONNECTED_ACCOUNT_NOT_FOUND = 'CONNECTED_ACCOUNT_NOT_FOUND', +} diff --git a/packages/twenty-server/src/modules/mail-sender/workflow-actions/send-email.workflow-action.ts b/packages/twenty-server/src/modules/mail-sender/workflow-actions/send-email.workflow-action.ts index 3a17c77cf92d..026b4537d898 100644 --- a/packages/twenty-server/src/modules/mail-sender/workflow-actions/send-email.workflow-action.ts +++ b/packages/twenty-server/src/modules/mail-sender/workflow-actions/send-email.workflow-action.ts @@ -4,13 +4,24 @@ import { z } from 'zod'; import Handlebars from 'handlebars'; import { JSDOM } from 'jsdom'; import DOMPurify from 'dompurify'; -import { WorkflowActionEmail } from 'twenty-emails'; -import { render } from '@react-email/components'; import { WorkflowActionResult } from 'src/modules/workflow/workflow-executor/types/workflow-action-result.type'; import { WorkflowSendEmailStep } from 'src/modules/workflow/workflow-executor/types/workflow-action.type'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { EmailService } from 'src/engine/core-modules/email/email.service'; +import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity'; +import { + WorkflowStepExecutorException, + WorkflowStepExecutorExceptionCode, +} from 'src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception'; +import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory'; +import { + MailSenderException, + MailSenderExceptionCode, +} from 'src/modules/mail-sender/exceptions/mail-sender.exception'; +import { GmailClientProvider } from 'src/modules/messaging/message-import-manager/drivers/gmail/providers/gmail-client.provider'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; +import { isDefined } from 'src/utils/is-defined'; @Injectable() export class SendEmailWorkflowAction { @@ -18,8 +29,48 @@ export class SendEmailWorkflowAction { constructor( private readonly environmentService: EnvironmentService, private readonly emailService: EmailService, + private readonly gmailClientProvider: GmailClientProvider, + private readonly scopedWorkspaceContextFactory: ScopedWorkspaceContextFactory, + private readonly twentyORMGlobalManager: TwentyORMGlobalManager, ) {} + private async getEmailClient(step: WorkflowSendEmailStep) { + const { workspaceId } = this.scopedWorkspaceContextFactory.create(); + + if (!workspaceId) { + throw new WorkflowStepExecutorException( + 'Scoped workspace not found', + WorkflowStepExecutorExceptionCode.SCOPED_WORKSPACE_NOT_FOUND, + ); + } + + const connectedAccountRepository = + await this.twentyORMGlobalManager.getRepositoryForWorkspace<ConnectedAccountWorkspaceEntity>( + workspaceId, + 'connectedAccount', + ); + const connectedAccount = await connectedAccountRepository.findOneBy({ + id: step.settings.connectedAccountId, + }); + + if (!isDefined(connectedAccount)) { + throw new MailSenderException( + `Connected Account '${step.settings.connectedAccountId}' not found`, + MailSenderExceptionCode.CONNECTED_ACCOUNT_NOT_FOUND, + ); + } + + switch (connectedAccount.provider) { + case 'google': + return await this.gmailClientProvider.getGmailClient(connectedAccount); + default: + throw new MailSenderException( + `Provider ${connectedAccount.provider} is not supported`, + MailSenderExceptionCode.PROVIDER_NOT_SUPPORTED, + ); + } + } + async execute({ step, payload, @@ -30,6 +81,8 @@ export class SendEmailWorkflowAction { [key: string]: string; }; }): Promise<WorkflowActionResult> { + const emailProvider = await this.getEmailClient(step); + try { const emailSchema = z.string().trim().email('Invalid email'); @@ -41,34 +94,34 @@ export class SendEmailWorkflowAction { return { result: { success: false } }; } - const mainText = Handlebars.compile(step.settings.template)(payload); + const body = Handlebars.compile(step.settings.body)(payload); + const subject = Handlebars.compile(step.settings.subject)(payload); const window = new JSDOM('').window; const purify = DOMPurify(window); - const safeHTML = purify.sanitize(mainText || ''); + const safeBody = purify.sanitize(body || ''); + const safeSubject = purify.sanitize(subject || ''); - const email = WorkflowActionEmail({ - dangerousHTML: safeHTML, - title: step.settings.title, - callToAction: step.settings.callToAction, - }); - const html = render(email, { - pretty: true, - }); - const text = render(email, { - plainText: true, - }); + const message = [ + `To: ${payload.email}`, + `Subject: ${safeSubject || ''}`, + 'MIME-Version: 1.0', + 'Content-Type: text/plain; charset="UTF-8"', + '', + safeBody, + ].join('\n'); + + const encodedMessage = Buffer.from(message).toString('base64'); - await this.emailService.send({ - from: `${this.environmentService.get( - 'EMAIL_FROM_NAME', - )} <${this.environmentService.get('EMAIL_FROM_ADDRESS')}>`, - to: payload.email, - subject: step.settings.subject || '', - text, - html, + await emailProvider.users.messages.send({ + userId: 'me', + requestBody: { + raw: encodedMessage, + }, }); + this.logger.log(`Email sent successfully`); + return { result: { success: true } }; } catch (error) { return { error }; diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/messaging-gmail-driver.module.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/messaging-gmail-driver.module.ts index fec4a7693ff4..c6ee8b72cff5 100644 --- a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/messaging-gmail-driver.module.ts +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/messaging-gmail-driver.module.ts @@ -42,6 +42,10 @@ import { MessageParticipantManagerModule } from 'src/modules/messaging/message-p GmailGetMessageListService, GmailHandleErrorService, ], - exports: [GmailGetMessagesService, GmailGetMessageListService], + exports: [ + GmailGetMessagesService, + GmailGetMessageListService, + GmailClientProvider, + ], }) export class MessagingGmailDriverModule {} diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/types/workflow-step-settings.type.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/types/workflow-step-settings.type.ts index 99b334d0f5f5..bb8f8351faab 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/types/workflow-step-settings.type.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/types/workflow-step-settings.type.ts @@ -14,11 +14,7 @@ export type WorkflowCodeStepSettings = BaseWorkflowStepSettings & { }; export type WorkflowSendEmailStepSettings = BaseWorkflowStepSettings & { + connectedAccountId: string; subject?: string; - template?: string; - title?: string; - callToAction?: { - value: string; - href: string; - }; + body?: string; }; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-executor.module.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-executor.module.ts index 0f68b2917c89..24ae66fd7f11 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-executor.module.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-executor.module.ts @@ -7,9 +7,14 @@ import { CodeWorkflowAction } from 'src/modules/serverless/workflow-actions/code import { SendEmailWorkflowAction } from 'src/modules/mail-sender/workflow-actions/send-email.workflow-action'; import { ServerlessFunctionModule } from 'src/engine/metadata-modules/serverless-function/serverless-function.module'; import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory'; +import { MessagingGmailDriverModule } from 'src/modules/messaging/message-import-manager/drivers/gmail/messaging-gmail-driver.module'; @Module({ - imports: [WorkflowCommonModule, ServerlessFunctionModule], + imports: [ + WorkflowCommonModule, + ServerlessFunctionModule, + MessagingGmailDriverModule, + ], providers: [ WorkflowExecutorWorkspaceService, ScopedWorkspaceContextFactory,