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 && (
-
- )}
- {callToAction && (
-
- )}
-
- );
-};
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: Value;
@@ -18,6 +19,12 @@ export type SelectOption = {
Icon?: IconComponent;
};
+type CallToActionButton = {
+ text: string;
+ onClick: (event: MouseEvent) => void;
+ Icon?: IconComponent;
+};
+
export type SelectProps = {
className?: string;
disabled?: boolean;
@@ -32,6 +39,7 @@ export type SelectProps = {
options: SelectOption[];
value?: Value;
withSearchInput?: boolean;
+ callToActionButton?: CallToActionButton;
};
const StyledContainer = styled.div<{ fullWidth?: boolean }>`
@@ -89,6 +97,7 @@ export const Select = ({
options,
value,
withSearchInput,
+ callToActionButton,
}: SelectProps) => {
const selectContainerRef = useRef(null);
@@ -97,8 +106,8 @@ export const Select = ({
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 = ({
[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 = ({
))}
)}
+ {!!callToActionButton && !!filteredOptions.length && (
+
+ )}
+ {!!callToActionButton && (
+
+
+
+ )}
>
}
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;
@@ -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({
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({
+ objectNameSingular: 'connectedAccount',
+ filter,
+ });
+
+ let emptyOption: SelectOption = { label: 'None', value: null };
+ const connectedAccountOptions: SelectOption[] = [];
+
+ 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 (
- }
- actionTitle="Send Email"
- actionType="Email"
- >
-
- (
- {
- field.onChange(email);
-
- handleSave();
- }}
- />
- )}
- />
-
- (
-
-
+ !loading && (
+ }
+ actionTitle="Send Email"
+ actionType="Email"
+ >
+
+ (
+
+
+ )
);
};
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(
+ 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 {
+ 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,