mirror of
https://github.com/lingble/twenty.git
synced 2025-11-03 06:07:56 +00:00
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 
This commit is contained in:
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -3,4 +3,3 @@ export * from './emails/delete-inactive-workspaces.email';
|
|||||||
export * from './emails/password-reset-link.email';
|
export * from './emails/password-reset-link.email';
|
||||||
export * from './emails/password-update-notify.email';
|
export * from './emails/password-update-notify.email';
|
||||||
export * from './emails/send-invite-link.email';
|
export * from './emails/send-invite-link.email';
|
||||||
export * from './emails/workflow-action.email';
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export const GMAIL_SEND_SCOPE = 'https://www.googleapis.com/auth/gmail.send';
|
||||||
@@ -13,5 +13,6 @@ export type ConnectedAccount = {
|
|||||||
authFailedAt: Date | null;
|
authFailedAt: Date | null;
|
||||||
messageChannels: MessageChannel[];
|
messageChannels: MessageChannel[];
|
||||||
calendarChannels: CalendarChannel[];
|
calendarChannels: CalendarChannel[];
|
||||||
|
scopes: string[] | null;
|
||||||
__typename: 'ConnectedAccount';
|
__typename: 'ConnectedAccount';
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,11 +12,17 @@ export const useTriggerGoogleApisOAuth = () => {
|
|||||||
const [generateTransientToken] = useGenerateTransientTokenMutation();
|
const [generateTransientToken] = useGenerateTransientTokenMutation();
|
||||||
|
|
||||||
const triggerGoogleApisOAuth = useCallback(
|
const triggerGoogleApisOAuth = useCallback(
|
||||||
async (
|
async ({
|
||||||
redirectLocation?: AppPath,
|
redirectLocation,
|
||||||
messageVisibility?: MessageChannelVisibility,
|
messageVisibility,
|
||||||
calendarVisibility?: CalendarChannelVisibility,
|
calendarVisibility,
|
||||||
) => {
|
loginHint,
|
||||||
|
}: {
|
||||||
|
redirectLocation?: AppPath | string;
|
||||||
|
messageVisibility?: MessageChannelVisibility;
|
||||||
|
calendarVisibility?: CalendarChannelVisibility;
|
||||||
|
loginHint?: string;
|
||||||
|
} = {}) => {
|
||||||
const authServerUrl = REACT_APP_SERVER_BASE_URL;
|
const authServerUrl = REACT_APP_SERVER_BASE_URL;
|
||||||
|
|
||||||
const transientToken = await generateTransientToken();
|
const transientToken = await generateTransientToken();
|
||||||
@@ -38,6 +44,8 @@ export const useTriggerGoogleApisOAuth = () => {
|
|||||||
? `&messageVisibility=${messageVisibility}`
|
? `&messageVisibility=${messageVisibility}`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
|
params += loginHint ? `&loginHint=${loginHint}` : '';
|
||||||
|
|
||||||
window.location.href = `${authServerUrl}/auth/google-apis?${params}`;
|
window.location.href = `${authServerUrl}/auth/google-apis?${params}`;
|
||||||
},
|
},
|
||||||
[generateTransientToken],
|
[generateTransientToken],
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useTheme } from '@emotion/react';
|
import { useTheme } from '@emotion/react';
|
||||||
import styled from '@emotion/styled';
|
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 { IconChevronDown, IconComponent } from 'twenty-ui';
|
||||||
|
|
||||||
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
|
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 { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
|
||||||
|
|
||||||
import { SelectHotkeyScope } from '../types/SelectHotkeyScope';
|
import { SelectHotkeyScope } from '../types/SelectHotkeyScope';
|
||||||
|
import { isDefined } from '~/utils/isDefined';
|
||||||
|
|
||||||
export type SelectOption<Value extends string | number | null> = {
|
export type SelectOption<Value extends string | number | null> = {
|
||||||
value: Value;
|
value: Value;
|
||||||
@@ -18,6 +19,12 @@ export type SelectOption<Value extends string | number | null> = {
|
|||||||
Icon?: IconComponent;
|
Icon?: IconComponent;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type CallToActionButton = {
|
||||||
|
text: string;
|
||||||
|
onClick: (event: MouseEvent<HTMLDivElement>) => void;
|
||||||
|
Icon?: IconComponent;
|
||||||
|
};
|
||||||
|
|
||||||
export type SelectProps<Value extends string | number | null> = {
|
export type SelectProps<Value extends string | number | null> = {
|
||||||
className?: string;
|
className?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
@@ -32,6 +39,7 @@ export type SelectProps<Value extends string | number | null> = {
|
|||||||
options: SelectOption<Value>[];
|
options: SelectOption<Value>[];
|
||||||
value?: Value;
|
value?: Value;
|
||||||
withSearchInput?: boolean;
|
withSearchInput?: boolean;
|
||||||
|
callToActionButton?: CallToActionButton;
|
||||||
};
|
};
|
||||||
|
|
||||||
const StyledContainer = styled.div<{ fullWidth?: boolean }>`
|
const StyledContainer = styled.div<{ fullWidth?: boolean }>`
|
||||||
@@ -89,6 +97,7 @@ export const Select = <Value extends string | number | null>({
|
|||||||
options,
|
options,
|
||||||
value,
|
value,
|
||||||
withSearchInput,
|
withSearchInput,
|
||||||
|
callToActionButton,
|
||||||
}: SelectProps<Value>) => {
|
}: SelectProps<Value>) => {
|
||||||
const selectContainerRef = useRef<HTMLDivElement>(null);
|
const selectContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -97,8 +106,8 @@ export const Select = <Value extends string | number | null>({
|
|||||||
|
|
||||||
const selectedOption =
|
const selectedOption =
|
||||||
options.find(({ value: key }) => key === value) ||
|
options.find(({ value: key }) => key === value) ||
|
||||||
options[0] ||
|
emptyOption ||
|
||||||
emptyOption;
|
options[0];
|
||||||
const filteredOptions = useMemo(
|
const filteredOptions = useMemo(
|
||||||
() =>
|
() =>
|
||||||
searchInputValue
|
searchInputValue
|
||||||
@@ -109,7 +118,9 @@ export const Select = <Value extends string | number | null>({
|
|||||||
[options, searchInputValue],
|
[options, searchInputValue],
|
||||||
);
|
);
|
||||||
|
|
||||||
const isDisabled = disabledFromProps || options.length <= 1;
|
const isDisabled =
|
||||||
|
disabledFromProps ||
|
||||||
|
(options.length <= 1 && !isDefined(callToActionButton));
|
||||||
|
|
||||||
const { closeDropdown } = useDropdown(dropdownId);
|
const { closeDropdown } = useDropdown(dropdownId);
|
||||||
|
|
||||||
@@ -177,6 +188,18 @@ export const Select = <Value extends string | number | null>({
|
|||||||
))}
|
))}
|
||||||
</DropdownMenuItemsContainer>
|
</DropdownMenuItemsContainer>
|
||||||
)}
|
)}
|
||||||
|
{!!callToActionButton && !!filteredOptions.length && (
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
)}
|
||||||
|
{!!callToActionButton && (
|
||||||
|
<DropdownMenuItemsContainer hasMaxHeight>
|
||||||
|
<MenuItem
|
||||||
|
onClick={callToActionButton.onClick}
|
||||||
|
LeftIcon={callToActionButton.Icon}
|
||||||
|
text={callToActionButton.text}
|
||||||
|
/>
|
||||||
|
</DropdownMenuItemsContainer>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
dropdownHotkeyScope={{ scope: SelectHotkeyScope.Select }}
|
dropdownHotkeyScope={{ scope: SelectHotkeyScope.Select }}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { userEvent, within } from '@storybook/test';
|
|||||||
import { ComponentDecorator } from 'twenty-ui';
|
import { ComponentDecorator } from 'twenty-ui';
|
||||||
|
|
||||||
import { Select, SelectProps } from '../Select';
|
import { Select, SelectProps } from '../Select';
|
||||||
|
import { IconPlus } from 'packages/twenty-ui';
|
||||||
|
|
||||||
type RenderProps = SelectProps<string | number | null>;
|
type RenderProps = SelectProps<string | number | null>;
|
||||||
|
|
||||||
@@ -56,3 +57,13 @@ export const Disabled: Story = {
|
|||||||
export const WithSearch: Story = {
|
export const WithSearch: Story = {
|
||||||
args: { withSearchInput: true },
|
args: { withSearchInput: true },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const CallToActionButton: Story = {
|
||||||
|
args: {
|
||||||
|
callToActionButton: {
|
||||||
|
onClick: () => {},
|
||||||
|
Icon: IconPlus,
|
||||||
|
text: 'Add action',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -4,10 +4,18 @@ import { WorkflowEditActionFormBase } from '@/workflow/components/WorkflowEditAc
|
|||||||
import { WorkflowSendEmailStep } from '@/workflow/types/Workflow';
|
import { WorkflowSendEmailStep } from '@/workflow/types/Workflow';
|
||||||
import { useTheme } from '@emotion/react';
|
import { useTheme } from '@emotion/react';
|
||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
import { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { Controller, useForm } from 'react-hook-form';
|
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 { 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`
|
const StyledTriggerSettings = styled.div`
|
||||||
padding: ${({ theme }) => theme.spacing(6)};
|
padding: ${({ theme }) => theme.spacing(6)};
|
||||||
@@ -28,6 +36,7 @@ type WorkflowEditActionFormSendEmailProps =
|
|||||||
};
|
};
|
||||||
|
|
||||||
type SendEmailFormData = {
|
type SendEmailFormData = {
|
||||||
|
connectedAccountId: string;
|
||||||
subject: string;
|
subject: string;
|
||||||
body: string;
|
body: string;
|
||||||
};
|
};
|
||||||
@@ -36,35 +45,70 @@ export const WorkflowEditActionFormSendEmail = (
|
|||||||
props: WorkflowEditActionFormSendEmailProps,
|
props: WorkflowEditActionFormSendEmailProps,
|
||||||
) => {
|
) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
|
||||||
|
const { triggerGoogleApisOAuth } = useTriggerGoogleApisOAuth();
|
||||||
|
const workflowId = useRecoilValue(workflowIdState);
|
||||||
|
const redirectUrl = `/object/workflow/${workflowId}`;
|
||||||
|
|
||||||
const form = useForm<SendEmailFormData>({
|
const form = useForm<SendEmailFormData>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
|
connectedAccountId: '',
|
||||||
subject: '',
|
subject: '',
|
||||||
body: '',
|
body: '',
|
||||||
},
|
},
|
||||||
disabled: props.readonly,
|
disabled: props.readonly,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
const checkConnectedAccountScopes = async (
|
||||||
form.setValue('subject', props.action.settings.subject ?? '');
|
connectedAccountId: string | null,
|
||||||
form.setValue('body', props.action.settings.template ?? '');
|
) => {
|
||||||
}, [props.action.settings.subject, props.action.settings.template, form]);
|
const connectedAccount = accounts.find(
|
||||||
|
(account) => account.id === connectedAccountId,
|
||||||
const saveAction = useDebouncedCallback((formData: SendEmailFormData) => {
|
);
|
||||||
if (props.readonly === true) {
|
if (!isDefined(connectedAccount)) {
|
||||||
return;
|
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({
|
useEffect(() => {
|
||||||
...props.action,
|
form.setValue(
|
||||||
settings: {
|
'connectedAccountId',
|
||||||
...props.action.settings,
|
props.action.settings.connectedAccountId ?? '',
|
||||||
title: formData.subject,
|
);
|
||||||
subject: formData.subject,
|
form.setValue('subject', props.action.settings.subject ?? '');
|
||||||
template: formData.body,
|
form.setValue('body', props.action.settings.body ?? '');
|
||||||
},
|
}, [props.action.settings, form]);
|
||||||
});
|
|
||||||
}, 1_000);
|
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(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -72,52 +116,120 @@ export const WorkflowEditActionFormSendEmail = (
|
|||||||
};
|
};
|
||||||
}, [saveAction]);
|
}, [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 (
|
return (
|
||||||
<WorkflowEditActionFormBase
|
!loading && (
|
||||||
ActionIcon={<IconMail color={theme.color.blue} />}
|
<WorkflowEditActionFormBase
|
||||||
actionTitle="Send Email"
|
ActionIcon={<IconMail color={theme.color.blue} />}
|
||||||
actionType="Email"
|
actionTitle="Send Email"
|
||||||
>
|
actionType="Email"
|
||||||
<StyledTriggerSettings>
|
>
|
||||||
<Controller
|
<StyledTriggerSettings>
|
||||||
name="subject"
|
<Controller
|
||||||
control={form.control}
|
name="connectedAccountId"
|
||||||
render={({ field }) => (
|
control={form.control}
|
||||||
<TextInput
|
render={({ field }) => (
|
||||||
label="Subject"
|
<Select
|
||||||
placeholder="Thank you for building such an awesome CRM!"
|
dropdownId="select-connected-account-id"
|
||||||
value={field.value}
|
label="Account"
|
||||||
disabled={field.disabled}
|
fullWidth
|
||||||
onChange={(email) => {
|
emptyOption={emptyOption}
|
||||||
field.onChange(email);
|
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"
|
||||||
<Controller
|
placeholder="Enter email body (use {{variable}} for dynamic content)"
|
||||||
name="body"
|
value={field.value}
|
||||||
control={form.control}
|
minRows={4}
|
||||||
render={({ field }) => (
|
onChange={(email) => {
|
||||||
<TextArea
|
field.onChange(email);
|
||||||
label="Body"
|
handleSave();
|
||||||
placeholder="Thank you so much!"
|
}}
|
||||||
value={field.value}
|
/>
|
||||||
minRows={4}
|
)}
|
||||||
disabled={field.disabled}
|
/>
|
||||||
onChange={(email) => {
|
</StyledTriggerSettings>
|
||||||
field.onChange(email);
|
</WorkflowEditActionFormBase>
|
||||||
|
)
|
||||||
handleSave();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</StyledTriggerSettings>
|
|
||||||
</WorkflowEditActionFormBase>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,13 +14,9 @@ export type WorkflowCodeStepSettings = BaseWorkflowStepSettings & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type WorkflowSendEmailStepSettings = BaseWorkflowStepSettings & {
|
export type WorkflowSendEmailStepSettings = BaseWorkflowStepSettings & {
|
||||||
|
connectedAccountId: string;
|
||||||
subject?: string;
|
subject?: string;
|
||||||
template?: string;
|
body?: string;
|
||||||
title?: string;
|
|
||||||
callToAction?: {
|
|
||||||
value: string;
|
|
||||||
href: string;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type BaseWorkflowStep = {
|
type BaseWorkflowStep = {
|
||||||
|
|||||||
@@ -33,9 +33,9 @@ export const getStepDefaultDefinition = (
|
|||||||
type: 'SEND_EMAIL',
|
type: 'SEND_EMAIL',
|
||||||
valid: false,
|
valid: false,
|
||||||
settings: {
|
settings: {
|
||||||
subject: 'hello',
|
connectedAccountId: '',
|
||||||
title: 'hello',
|
subject: '',
|
||||||
template: '{{title}}',
|
body: '',
|
||||||
errorHandlingOptions: {
|
errorHandlingOptions: {
|
||||||
continueOnFailure: {
|
continueOnFailure: {
|
||||||
value: false,
|
value: false,
|
||||||
|
|||||||
@@ -12,4 +12,5 @@ export type FeatureFlagKey =
|
|||||||
| 'IS_WORKSPACE_FAVORITE_ENABLED'
|
| 'IS_WORKSPACE_FAVORITE_ENABLED'
|
||||||
| 'IS_SEARCH_ENABLED'
|
| 'IS_SEARCH_ENABLED'
|
||||||
| 'IS_QUERY_RUNNER_TWENTY_ORM_ENABLED'
|
| 'IS_QUERY_RUNNER_TWENTY_ORM_ENABLED'
|
||||||
|
| 'IS_GMAIL_SEND_EMAIL_SCOPE_ENABLED'
|
||||||
| 'IS_WORKSPACE_MIGRATED_FOR_SEARCH';
|
| 'IS_WORKSPACE_MIGRATED_FOR_SEARCH';
|
||||||
|
|||||||
@@ -54,11 +54,11 @@ export const SyncEmails = () => {
|
|||||||
? CalendarChannelVisibility.ShareEverything
|
? CalendarChannelVisibility.ShareEverything
|
||||||
: CalendarChannelVisibility.Metadata;
|
: CalendarChannelVisibility.Metadata;
|
||||||
|
|
||||||
await triggerGoogleApisOAuth(
|
await triggerGoogleApisOAuth({
|
||||||
AppPath.Index,
|
redirectLocation: AppPath.Index,
|
||||||
visibility,
|
messageVisibility: visibility,
|
||||||
calendarChannelVisibility,
|
calendarVisibility: calendarChannelVisibility,
|
||||||
);
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const continueWithoutSync = async () => {
|
const continueWithoutSync = async () => {
|
||||||
|
|||||||
@@ -70,6 +70,11 @@ export const seedFeatureFlags = async (
|
|||||||
workspaceId: workspaceId,
|
workspaceId: workspaceId,
|
||||||
value: true,
|
value: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: FeatureFlagKey.IsGmailSendEmailScopeEnabled,
|
||||||
|
workspaceId: workspaceId,
|
||||||
|
value: true,
|
||||||
|
},
|
||||||
])
|
])
|
||||||
.execute();
|
.execute();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
|
||||||
import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-manager.module';
|
import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-manager.module';
|
||||||
import { ConnectedAccountModule } from 'src/modules/connected-account/connected-account.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';
|
import { AuthResolver } from './auth.resolver';
|
||||||
|
|
||||||
@@ -52,6 +53,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
|
|||||||
OnboardingModule,
|
OnboardingModule,
|
||||||
WorkspaceDataSourceModule,
|
WorkspaceDataSourceModule,
|
||||||
ConnectedAccountModule,
|
ConnectedAccountModule,
|
||||||
|
FeatureFlagModule,
|
||||||
],
|
],
|
||||||
controllers: [
|
controllers: [
|
||||||
GoogleAuthController,
|
GoogleAuthController,
|
||||||
|
|||||||
@@ -8,18 +8,33 @@ import {
|
|||||||
import { GoogleAPIsOauthExchangeCodeForTokenStrategy } from 'src/engine/core-modules/auth/strategies/google-apis-oauth-exchange-code-for-token.auth.strategy';
|
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 { 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 { 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()
|
@Injectable()
|
||||||
export class GoogleAPIsOauthExchangeCodeForTokenGuard extends AuthGuard(
|
export class GoogleAPIsOauthExchangeCodeForTokenGuard extends AuthGuard(
|
||||||
'google-apis',
|
'google-apis',
|
||||||
) {
|
) {
|
||||||
constructor(private readonly environmentService: EnvironmentService) {
|
constructor(
|
||||||
|
private readonly environmentService: EnvironmentService,
|
||||||
|
private readonly featureFlagService: FeatureFlagService,
|
||||||
|
private readonly tokenService: TokenService,
|
||||||
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext) {
|
async canActivate(context: ExecutionContext) {
|
||||||
const request = context.switchToHttp().getRequest();
|
const request = context.switchToHttp().getRequest();
|
||||||
const state = JSON.parse(request.query.state);
|
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 (
|
if (
|
||||||
!this.environmentService.get('MESSAGING_PROVIDER_GMAIL_ENABLED') &&
|
!this.environmentService.get('MESSAGING_PROVIDER_GMAIL_ENABLED') &&
|
||||||
@@ -34,6 +49,7 @@ export class GoogleAPIsOauthExchangeCodeForTokenGuard extends AuthGuard(
|
|||||||
new GoogleAPIsOauthExchangeCodeForTokenStrategy(
|
new GoogleAPIsOauthExchangeCodeForTokenStrategy(
|
||||||
this.environmentService,
|
this.environmentService,
|
||||||
{},
|
{},
|
||||||
|
isGmailSendEmailScopeEnabled,
|
||||||
);
|
);
|
||||||
|
|
||||||
setRequestExtraParams(request, {
|
setRequestExtraParams(request, {
|
||||||
|
|||||||
@@ -8,10 +8,17 @@ import {
|
|||||||
import { GoogleAPIsOauthRequestCodeStrategy } from 'src/engine/core-modules/auth/strategies/google-apis-oauth-request-code.auth.strategy';
|
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 { 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 { 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()
|
@Injectable()
|
||||||
export class GoogleAPIsOauthRequestCodeGuard extends AuthGuard('google-apis') {
|
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({
|
super({
|
||||||
prompt: 'select_account',
|
prompt: 'select_account',
|
||||||
});
|
});
|
||||||
@@ -20,6 +27,15 @@ export class GoogleAPIsOauthRequestCodeGuard extends AuthGuard('google-apis') {
|
|||||||
async canActivate(context: ExecutionContext) {
|
async canActivate(context: ExecutionContext) {
|
||||||
const request = context.switchToHttp().getRequest();
|
const request = context.switchToHttp().getRequest();
|
||||||
|
|
||||||
|
const { workspaceId } = await this.tokenService.verifyTransientToken(
|
||||||
|
request.query.transientToken,
|
||||||
|
);
|
||||||
|
const isGmailSendEmailScopeEnabled =
|
||||||
|
await this.featureFlagService.isFeatureEnabled(
|
||||||
|
FeatureFlagKey.IsGmailSendEmailScopeEnabled,
|
||||||
|
workspaceId,
|
||||||
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!this.environmentService.get('MESSAGING_PROVIDER_GMAIL_ENABLED') &&
|
!this.environmentService.get('MESSAGING_PROVIDER_GMAIL_ENABLED') &&
|
||||||
!this.environmentService.get('CALENDAR_PROVIDER_GOOGLE_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, {
|
setRequestExtraParams(request, {
|
||||||
transientToken: request.query.transientToken,
|
transientToken: request.query.transientToken,
|
||||||
redirectLocation: request.query.redirectLocation,
|
redirectLocation: request.query.redirectLocation,
|
||||||
calendarVisibility: request.query.calendarVisibility,
|
calendarVisibility: request.query.calendarVisibility,
|
||||||
messageVisibility: request.query.messageVisibility,
|
messageVisibility: request.query.messageVisibility,
|
||||||
|
loginHint: request.query.loginHint,
|
||||||
});
|
});
|
||||||
|
|
||||||
const activate = (await super.canActivate(context)) as boolean;
|
const activate = (await super.canActivate(context)) as boolean;
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ import {
|
|||||||
MessagingMessageListFetchJobData,
|
MessagingMessageListFetchJobData,
|
||||||
} from 'src/modules/messaging/message-import-manager/jobs/messaging-message-list-fetch.job';
|
} 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 { 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()
|
@Injectable()
|
||||||
export class GoogleAPIsService {
|
export class GoogleAPIsService {
|
||||||
@@ -44,6 +47,7 @@ export class GoogleAPIsService {
|
|||||||
private readonly calendarQueueService: MessageQueueService,
|
private readonly calendarQueueService: MessageQueueService,
|
||||||
private readonly environmentService: EnvironmentService,
|
private readonly environmentService: EnvironmentService,
|
||||||
private readonly accountsToReconnectService: AccountsToReconnectService,
|
private readonly accountsToReconnectService: AccountsToReconnectService,
|
||||||
|
private readonly featureFlagService: FeatureFlagService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async refreshGoogleRefreshToken(input: {
|
async refreshGoogleRefreshToken(input: {
|
||||||
@@ -95,6 +99,13 @@ export class GoogleAPIsService {
|
|||||||
const workspaceDataSource =
|
const workspaceDataSource =
|
||||||
await this.twentyORMGlobalManager.getDataSourceForWorkspace(workspaceId);
|
await this.twentyORMGlobalManager.getDataSourceForWorkspace(workspaceId);
|
||||||
|
|
||||||
|
const isGmailSendEmailScopeEnabled =
|
||||||
|
await this.featureFlagService.isFeatureEnabled(
|
||||||
|
FeatureFlagKey.IsGmailSendEmailScopeEnabled,
|
||||||
|
workspaceId,
|
||||||
|
);
|
||||||
|
const scopes = getGoogleApisOauthScopes(isGmailSendEmailScopeEnabled);
|
||||||
|
|
||||||
await workspaceDataSource.transaction(async (manager: EntityManager) => {
|
await workspaceDataSource.transaction(async (manager: EntityManager) => {
|
||||||
if (!existingAccountId) {
|
if (!existingAccountId) {
|
||||||
await connectedAccountRepository.save(
|
await connectedAccountRepository.save(
|
||||||
@@ -105,6 +116,7 @@ export class GoogleAPIsService {
|
|||||||
accessToken: input.accessToken,
|
accessToken: input.accessToken,
|
||||||
refreshToken: input.refreshToken,
|
refreshToken: input.refreshToken,
|
||||||
accountOwnerId: workspaceMemberId,
|
accountOwnerId: workspaceMemberId,
|
||||||
|
scopes,
|
||||||
},
|
},
|
||||||
{},
|
{},
|
||||||
manager,
|
manager,
|
||||||
@@ -146,6 +158,7 @@ export class GoogleAPIsService {
|
|||||||
{
|
{
|
||||||
accessToken: input.accessToken,
|
accessToken: input.accessToken,
|
||||||
refreshToken: input.refreshToken,
|
refreshToken: input.refreshToken,
|
||||||
|
scopes,
|
||||||
},
|
},
|
||||||
manager,
|
manager,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { PassportStrategy } from '@nestjs/passport';
|
|||||||
import { Strategy } from 'passport-google-oauth20';
|
import { Strategy } from 'passport-google-oauth20';
|
||||||
|
|
||||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
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 = {
|
export type GoogleAPIScopeConfig = {
|
||||||
isCalendarEnabled?: boolean;
|
isCalendarEnabled?: boolean;
|
||||||
@@ -18,14 +19,9 @@ export class GoogleAPIsOauthCommonStrategy extends PassportStrategy(
|
|||||||
constructor(
|
constructor(
|
||||||
environmentService: EnvironmentService,
|
environmentService: EnvironmentService,
|
||||||
scopeConfig: GoogleAPIScopeConfig,
|
scopeConfig: GoogleAPIScopeConfig,
|
||||||
|
isGmailSendEmailScopeEnabled = false,
|
||||||
) {
|
) {
|
||||||
const scopes = [
|
const scopes = getGoogleApisOauthScopes(isGmailSendEmailScopeEnabled);
|
||||||
'email',
|
|
||||||
'profile',
|
|
||||||
'https://www.googleapis.com/auth/gmail.readonly',
|
|
||||||
'https://www.googleapis.com/auth/calendar.events',
|
|
||||||
'https://www.googleapis.com/auth/profile.emails.read',
|
|
||||||
];
|
|
||||||
|
|
||||||
super({
|
super({
|
||||||
clientID: environmentService.get('AUTH_GOOGLE_CLIENT_ID'),
|
clientID: environmentService.get('AUTH_GOOGLE_CLIENT_ID'),
|
||||||
|
|||||||
@@ -15,8 +15,9 @@ export class GoogleAPIsOauthExchangeCodeForTokenStrategy extends GoogleAPIsOauth
|
|||||||
constructor(
|
constructor(
|
||||||
environmentService: EnvironmentService,
|
environmentService: EnvironmentService,
|
||||||
scopeConfig: GoogleAPIScopeConfig,
|
scopeConfig: GoogleAPIScopeConfig,
|
||||||
|
isGmailSendEmailScopeEnabled = false,
|
||||||
) {
|
) {
|
||||||
super(environmentService, scopeConfig);
|
super(environmentService, scopeConfig, isGmailSendEmailScopeEnabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
async validate(
|
async validate(
|
||||||
|
|||||||
@@ -13,8 +13,9 @@ export class GoogleAPIsOauthRequestCodeStrategy extends GoogleAPIsOauthCommonStr
|
|||||||
constructor(
|
constructor(
|
||||||
environmentService: EnvironmentService,
|
environmentService: EnvironmentService,
|
||||||
scopeConfig: GoogleAPIScopeConfig,
|
scopeConfig: GoogleAPIScopeConfig,
|
||||||
|
isGmailSendEmailScopeEnabled = false,
|
||||||
) {
|
) {
|
||||||
super(environmentService, scopeConfig);
|
super(environmentService, scopeConfig, isGmailSendEmailScopeEnabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
authenticate(req: any, options: any) {
|
authenticate(req: any, options: any) {
|
||||||
@@ -22,6 +23,7 @@ export class GoogleAPIsOauthRequestCodeStrategy extends GoogleAPIsOauthCommonStr
|
|||||||
...options,
|
...options,
|
||||||
accessType: 'offline',
|
accessType: 'offline',
|
||||||
prompt: 'consent',
|
prompt: 'consent',
|
||||||
|
loginHint: req.params.loginHint,
|
||||||
state: JSON.stringify({
|
state: JSON.stringify({
|
||||||
transientToken: req.params.transientToken,
|
transientToken: req.params.transientToken,
|
||||||
redirectLocation: req.params.redirectLocation,
|
redirectLocation: req.params.redirectLocation,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -9,6 +9,7 @@ type GoogleAPIsRequestExtraParams = {
|
|||||||
redirectLocation?: string;
|
redirectLocation?: string;
|
||||||
calendarVisibility?: string;
|
calendarVisibility?: string;
|
||||||
messageVisibility?: string;
|
messageVisibility?: string;
|
||||||
|
loginHint?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const setRequestExtraParams = (
|
export const setRequestExtraParams = (
|
||||||
@@ -20,6 +21,7 @@ export const setRequestExtraParams = (
|
|||||||
redirectLocation,
|
redirectLocation,
|
||||||
calendarVisibility,
|
calendarVisibility,
|
||||||
messageVisibility,
|
messageVisibility,
|
||||||
|
loginHint,
|
||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
if (!transientToken) {
|
if (!transientToken) {
|
||||||
@@ -42,4 +44,7 @@ export const setRequestExtraParams = (
|
|||||||
if (messageVisibility) {
|
if (messageVisibility) {
|
||||||
request.params.messageVisibility = messageVisibility;
|
request.params.messageVisibility = messageVisibility;
|
||||||
}
|
}
|
||||||
|
if (loginHint) {
|
||||||
|
request.params.loginHint = loginHint;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,4 +12,5 @@ export enum FeatureFlagKey {
|
|||||||
IsWorkspaceFavoriteEnabled = 'IS_WORKSPACE_FAVORITE_ENABLED',
|
IsWorkspaceFavoriteEnabled = 'IS_WORKSPACE_FAVORITE_ENABLED',
|
||||||
IsSearchEnabled = 'IS_SEARCH_ENABLED',
|
IsSearchEnabled = 'IS_SEARCH_ENABLED',
|
||||||
IsWorkspaceMigratedForSearch = 'IS_WORKSPACE_MIGRATED_FOR_SEARCH',
|
IsWorkspaceMigratedForSearch = 'IS_WORKSPACE_MIGRATED_FOR_SEARCH',
|
||||||
|
IsGmailSendEmailScopeEnabled = 'IS_GMAIL_SEND_EMAIL_SCOPE_ENABLED',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ export const CONNECTED_ACCOUNT_STANDARD_FIELD_IDS = {
|
|||||||
messageChannels: '20202020-24f7-4362-8468-042204d1e445',
|
messageChannels: '20202020-24f7-4362-8468-042204d1e445',
|
||||||
calendarChannels: '20202020-af4a-47bb-99ec-51911c1d3977',
|
calendarChannels: '20202020-af4a-47bb-99ec-51911c1d3977',
|
||||||
handleAliases: '20202020-8a3d-46be-814f-6228af16c47b',
|
handleAliases: '20202020-8a3d-46be-814f-6228af16c47b',
|
||||||
|
scopes: '20202020-8a3d-46be-814f-6228af16c47c',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EVENT_STANDARD_FIELD_IDS = {
|
export const EVENT_STANDARD_FIELD_IDS = {
|
||||||
|
|||||||
@@ -99,6 +99,16 @@ export class ConnectedAccountWorkspaceEntity extends BaseWorkspaceEntity {
|
|||||||
})
|
})
|
||||||
handleAliases: string;
|
handleAliases: string;
|
||||||
|
|
||||||
|
@WorkspaceField({
|
||||||
|
standardId: CONNECTED_ACCOUNT_STANDARD_FIELD_IDS.scopes,
|
||||||
|
type: FieldMetadataType.ARRAY,
|
||||||
|
label: 'Scopes',
|
||||||
|
description: 'Scopes',
|
||||||
|
icon: 'IconSettings',
|
||||||
|
})
|
||||||
|
@WorkspaceIsNullable()
|
||||||
|
scopes: string[] | null;
|
||||||
|
|
||||||
@WorkspaceRelation({
|
@WorkspaceRelation({
|
||||||
standardId: CONNECTED_ACCOUNT_STANDARD_FIELD_IDS.accountOwner,
|
standardId: CONNECTED_ACCOUNT_STANDARD_FIELD_IDS.accountOwner,
|
||||||
type: RelationMetadataType.MANY_TO_ONE,
|
type: RelationMetadataType.MANY_TO_ONE,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
}
|
||||||
@@ -4,13 +4,24 @@ import { z } from 'zod';
|
|||||||
import Handlebars from 'handlebars';
|
import Handlebars from 'handlebars';
|
||||||
import { JSDOM } from 'jsdom';
|
import { JSDOM } from 'jsdom';
|
||||||
import DOMPurify from 'dompurify';
|
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 { 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 { WorkflowSendEmailStep } from 'src/modules/workflow/workflow-executor/types/workflow-action.type';
|
||||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||||
import { EmailService } from 'src/engine/core-modules/email/email.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()
|
@Injectable()
|
||||||
export class SendEmailWorkflowAction {
|
export class SendEmailWorkflowAction {
|
||||||
@@ -18,8 +29,48 @@ export class SendEmailWorkflowAction {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly environmentService: EnvironmentService,
|
private readonly environmentService: EnvironmentService,
|
||||||
private readonly emailService: EmailService,
|
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({
|
async execute({
|
||||||
step,
|
step,
|
||||||
payload,
|
payload,
|
||||||
@@ -30,6 +81,8 @@ export class SendEmailWorkflowAction {
|
|||||||
[key: string]: string;
|
[key: string]: string;
|
||||||
};
|
};
|
||||||
}): Promise<WorkflowActionResult> {
|
}): Promise<WorkflowActionResult> {
|
||||||
|
const emailProvider = await this.getEmailClient(step);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const emailSchema = z.string().trim().email('Invalid email');
|
const emailSchema = z.string().trim().email('Invalid email');
|
||||||
|
|
||||||
@@ -41,33 +94,33 @@ export class SendEmailWorkflowAction {
|
|||||||
return { result: { success: false } };
|
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 window = new JSDOM('').window;
|
||||||
const purify = DOMPurify(window);
|
const purify = DOMPurify(window);
|
||||||
const safeHTML = purify.sanitize(mainText || '');
|
const safeBody = purify.sanitize(body || '');
|
||||||
|
const safeSubject = purify.sanitize(subject || '');
|
||||||
|
|
||||||
const email = WorkflowActionEmail({
|
const message = [
|
||||||
dangerousHTML: safeHTML,
|
`To: ${payload.email}`,
|
||||||
title: step.settings.title,
|
`Subject: ${safeSubject || ''}`,
|
||||||
callToAction: step.settings.callToAction,
|
'MIME-Version: 1.0',
|
||||||
});
|
'Content-Type: text/plain; charset="UTF-8"',
|
||||||
const html = render(email, {
|
'',
|
||||||
pretty: true,
|
safeBody,
|
||||||
});
|
].join('\n');
|
||||||
const text = render(email, {
|
|
||||||
plainText: true,
|
const encodedMessage = Buffer.from(message).toString('base64');
|
||||||
|
|
||||||
|
await emailProvider.users.messages.send({
|
||||||
|
userId: 'me',
|
||||||
|
requestBody: {
|
||||||
|
raw: encodedMessage,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.emailService.send({
|
this.logger.log(`Email sent successfully`);
|
||||||
from: `${this.environmentService.get(
|
|
||||||
'EMAIL_FROM_NAME',
|
|
||||||
)} <${this.environmentService.get('EMAIL_FROM_ADDRESS')}>`,
|
|
||||||
to: payload.email,
|
|
||||||
subject: step.settings.subject || '',
|
|
||||||
text,
|
|
||||||
html,
|
|
||||||
});
|
|
||||||
|
|
||||||
return { result: { success: true } };
|
return { result: { success: true } };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ import { MessageParticipantManagerModule } from 'src/modules/messaging/message-p
|
|||||||
GmailGetMessageListService,
|
GmailGetMessageListService,
|
||||||
GmailHandleErrorService,
|
GmailHandleErrorService,
|
||||||
],
|
],
|
||||||
exports: [GmailGetMessagesService, GmailGetMessageListService],
|
exports: [
|
||||||
|
GmailGetMessagesService,
|
||||||
|
GmailGetMessageListService,
|
||||||
|
GmailClientProvider,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class MessagingGmailDriverModule {}
|
export class MessagingGmailDriverModule {}
|
||||||
|
|||||||
@@ -14,11 +14,7 @@ export type WorkflowCodeStepSettings = BaseWorkflowStepSettings & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type WorkflowSendEmailStepSettings = BaseWorkflowStepSettings & {
|
export type WorkflowSendEmailStepSettings = BaseWorkflowStepSettings & {
|
||||||
|
connectedAccountId: string;
|
||||||
subject?: string;
|
subject?: string;
|
||||||
template?: string;
|
body?: string;
|
||||||
title?: string;
|
|
||||||
callToAction?: {
|
|
||||||
value: string;
|
|
||||||
href: string;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 { 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 { ServerlessFunctionModule } from 'src/engine/metadata-modules/serverless-function/serverless-function.module';
|
||||||
import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory';
|
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({
|
@Module({
|
||||||
imports: [WorkflowCommonModule, ServerlessFunctionModule],
|
imports: [
|
||||||
|
WorkflowCommonModule,
|
||||||
|
ServerlessFunctionModule,
|
||||||
|
MessagingGmailDriverModule,
|
||||||
|
],
|
||||||
providers: [
|
providers: [
|
||||||
WorkflowExecutorWorkspaceService,
|
WorkflowExecutorWorkspaceService,
|
||||||
ScopedWorkspaceContextFactory,
|
ScopedWorkspaceContextFactory,
|
||||||
|
|||||||
Reference in New Issue
Block a user