7417 workflows i can send emails using the email account (#7431)

- update `send-email.workflow-action.ts` so it send email via the google
sdk
- remove useless `workflow-action.email.ts`
- add `send` authorization to google api scopes
- update the front workflow email step form to provide a
`connectedAccountId` from the available connected accounts
- update the permissions of connected accounts: ask users to reconnect
when selecting missing send permission


![image](https://github.com/user-attachments/assets/fe3c329d-fd67-4d0d-8450-099c35933645)
This commit is contained in:
martmull 2024-10-08 23:29:09 +02:00 committed by GitHub
parent 444cd3f03f
commit f138a1cf6e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 443 additions and 159 deletions

View File

@ -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 (
<BaseEmail>
{title && <Title value={title} />}
{dangerousHTML && (
<div dangerouslySetInnerHTML={{ __html: dangerousHTML }} />
)}
{callToAction && (
<CallToAction value={callToAction.value} href={callToAction.href} />
)}
</BaseEmail>
);
};

View File

@ -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';

View File

@ -0,0 +1 @@
export const GMAIL_SEND_SCOPE = 'https://www.googleapis.com/auth/gmail.send';

View File

@ -13,5 +13,6 @@ export type ConnectedAccount = {
authFailedAt: Date | null;
messageChannels: MessageChannel[];
calendarChannels: CalendarChannel[];
scopes: string[] | null;
__typename: 'ConnectedAccount';
};

View File

@ -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],

View File

@ -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 }}

View File

@ -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',
},
},
};

View File

@ -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);
!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();
}}
/>
)}
/>
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>
<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>
)
);
};

View File

@ -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 = {

View File

@ -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,

View File

@ -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';

View File

@ -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 () => {

View File

@ -70,6 +70,11 @@ export const seedFeatureFlags = async (
workspaceId: workspaceId,
value: true,
},
{
key: FeatureFlagKey.IsGmailSendEmailScopeEnabled,
workspaceId: workspaceId,
value: true,
},
])
.execute();
};

View File

@ -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,

View File

@ -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, {

View File

@ -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;

View File

@ -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,
);

View File

@ -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'),

View File

@ -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(

View File

@ -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,

View File

@ -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;
};

View File

@ -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;
}
};

View File

@ -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',
}

View File

@ -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 = {

View File

@ -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,

View File

@ -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',
}

View File

@ -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,33 +94,33 @@ 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 emailProvider.users.messages.send({
userId: 'me',
requestBody: {
raw: encodedMessage,
},
});
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,
});
this.logger.log(`Email sent successfully`);
return { result: { success: true } };
} catch (error) {

View File

@ -42,6 +42,10 @@ import { MessageParticipantManagerModule } from 'src/modules/messaging/message-p
GmailGetMessageListService,
GmailHandleErrorService,
],
exports: [GmailGetMessagesService, GmailGetMessageListService],
exports: [
GmailGetMessagesService,
GmailGetMessageListService,
GmailClientProvider,
],
})
export class MessagingGmailDriverModule {}

View File

@ -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;
};

View File

@ -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,