feat: add draft email workflow action (#17793)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
neo773
2026-02-13 22:50:20 +05:30
committed by GitHub
parent ebfaff0a53
commit 84afbb4d2c
64 changed files with 1451 additions and 614 deletions
@@ -1482,6 +1482,7 @@ export enum FeatureFlagKey {
IS_ATTACHMENT_MIGRATED = 'IS_ATTACHMENT_MIGRATED',
IS_COMMAND_MENU_ITEM_ENABLED = 'IS_COMMAND_MENU_ITEM_ENABLED',
IS_DASHBOARD_V2_ENABLED = 'IS_DASHBOARD_V2_ENABLED',
IS_DRAFT_EMAIL_ENABLED = 'IS_DRAFT_EMAIL_ENABLED',
IS_EMAILING_DOMAIN_ENABLED = 'IS_EMAILING_DOMAIN_ENABLED',
IS_FILES_FIELD_MIGRATED = 'IS_FILES_FIELD_MIGRATED',
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
@@ -500,6 +500,7 @@ export enum WorkflowActionType {
CREATE_RECORD = 'CREATE_RECORD',
DELAY = 'DELAY',
DELETE_RECORD = 'DELETE_RECORD',
DRAFT_EMAIL = 'DRAFT_EMAIL',
EMPTY = 'EMPTY',
FILTER = 'FILTER',
FIND_RECORDS = 'FIND_RECORDS',
@@ -0,0 +1,2 @@
export const GMAIL_COMPOSE_SCOPE =
'https://www.googleapis.com/auth/gmail.compose';
@@ -0,0 +1,84 @@
import { GMAIL_COMPOSE_SCOPE } from '@/accounts/constants/GmailComposeScope';
import { MICROSOFT_SEND_SCOPE } from '@/accounts/constants/MicrosoftSendScope';
import { type ConnectedAccount } from '@/accounts/types/ConnectedAccount';
import { getMissingDraftEmailScopes } from '@/accounts/utils/hasMissingDraftEmailScopes';
import { ConnectedAccountProvider } from 'twenty-shared/types';
const buildConnectedAccount = (
overrides: Partial<ConnectedAccount>,
): ConnectedAccount =>
({
id: 'test-id',
handle: 'test@example.com',
provider: ConnectedAccountProvider.GOOGLE,
scopes: [],
...overrides,
}) as ConnectedAccount;
describe('getMissingDraftEmailScopes', () => {
describe('Google provider', () => {
it('should return gmail.compose scope when it is missing', () => {
const account = buildConnectedAccount({
provider: ConnectedAccountProvider.GOOGLE,
scopes: [],
});
expect(getMissingDraftEmailScopes(account)).toEqual([
GMAIL_COMPOSE_SCOPE,
]);
});
it('should return empty array when gmail.compose scope exists', () => {
const account = buildConnectedAccount({
provider: ConnectedAccountProvider.GOOGLE,
scopes: [GMAIL_COMPOSE_SCOPE],
});
expect(getMissingDraftEmailScopes(account)).toEqual([]);
});
it('should return gmail.compose scope when scopes are null', () => {
const account = buildConnectedAccount({
provider: ConnectedAccountProvider.GOOGLE,
scopes: null,
});
expect(getMissingDraftEmailScopes(account)).toEqual([
GMAIL_COMPOSE_SCOPE,
]);
});
});
describe('Microsoft provider', () => {
it('should return Mail.Send scope when it is missing', () => {
const account = buildConnectedAccount({
provider: ConnectedAccountProvider.MICROSOFT,
scopes: [],
});
expect(getMissingDraftEmailScopes(account)).toEqual([
MICROSOFT_SEND_SCOPE,
]);
});
it('should return empty array when Mail.Send scope exists', () => {
const account = buildConnectedAccount({
provider: ConnectedAccountProvider.MICROSOFT,
scopes: [MICROSOFT_SEND_SCOPE],
});
expect(getMissingDraftEmailScopes(account)).toEqual([]);
});
});
describe('IMAP/SMTP provider', () => {
it('should always return empty array', () => {
const account = buildConnectedAccount({
provider: ConnectedAccountProvider.IMAP_SMTP_CALDAV,
connectionParameters: undefined,
});
expect(getMissingDraftEmailScopes(account)).toEqual([]);
});
});
});
@@ -0,0 +1,35 @@
import { GMAIL_COMPOSE_SCOPE } from '@/accounts/constants/GmailComposeScope';
import { MICROSOFT_SEND_SCOPE } from '@/accounts/constants/MicrosoftSendScope';
import { type ConnectedAccount } from '@/accounts/types/ConnectedAccount';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
export const getMissingDraftEmailScopes = (
connectedAccount: ConnectedAccount,
): string[] => {
const scopes = connectedAccount.scopes;
switch (connectedAccount.provider) {
case ConnectedAccountProvider.GOOGLE: {
const hasScope =
isDefined(scopes) &&
scopes.some((scope) => scope === GMAIL_COMPOSE_SCOPE);
return hasScope ? [] : [GMAIL_COMPOSE_SCOPE];
}
case ConnectedAccountProvider.MICROSOFT: {
const hasScope =
isDefined(scopes) &&
scopes.some((scope) => scope === MICROSOFT_SEND_SCOPE);
return hasScope ? [] : [MICROSOFT_SEND_SCOPE];
}
case ConnectedAccountProvider.IMAP_SMTP_CALDAV:
return [];
default:
assertUnreachable(
connectedAccount.provider,
'Provider not yet supported for draft email actions',
);
}
};
@@ -28,6 +28,9 @@ export const CommandMenuWorkflowSelectAction = ({
onActionSelected: (selection: WorkflowActionSelection) => void;
}) => {
const isAiEnabled = useIsFeatureEnabled(FeatureFlagKey.IS_AI_ENABLED);
const isDraftEmailEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_DRAFT_EMAIL_ENABLED,
);
const theme = useTheme();
const { t } = useLingui();
@@ -36,6 +39,10 @@ export const CommandMenuWorkflowSelectAction = ({
const toolFunctions = logicFunctions.filter((fn) => fn.isTool === true);
const coreActions = isDraftEmailEnabled
? CORE_ACTIONS
: CORE_ACTIONS.filter((action) => action.type !== 'DRAFT_EMAIL');
const handleActionClick = (actionType: WorkflowActionType) => {
onActionSelected({ type: actionType });
};
@@ -83,7 +90,7 @@ export const CommandMenuWorkflowSelectAction = ({
{t`Core`}
</RightDrawerWorkflowSelectStepTitle>
<WorkflowActionMenuItems
actions={CORE_ACTIONS}
actions={coreActions}
onClick={handleActionClick}
/>
@@ -9,6 +9,7 @@ import {
type workflowDatabaseEventTriggerSchema,
type workflowDelayActionSchema,
type workflowDeleteRecordActionSchema,
type workflowDraftEmailActionSchema,
type workflowEmptyActionSchema,
type workflowFilterActionSchema,
type workflowFindRecordsActionSchema,
@@ -37,6 +38,9 @@ export type WorkflowLogicFunctionAction = z.infer<
export type WorkflowSendEmailAction = z.infer<
typeof workflowSendEmailActionSchema
>;
export type WorkflowDraftEmailAction = z.infer<
typeof workflowDraftEmailActionSchema
>;
export type WorkflowCreateRecordAction = z.infer<
typeof workflowCreateRecordActionSchema
>;
@@ -69,6 +73,7 @@ export type WorkflowAction =
| WorkflowCodeAction
| WorkflowLogicFunctionAction
| WorkflowSendEmailAction
| WorkflowDraftEmailAction
| WorkflowCreateRecordAction
| WorkflowUpdateRecordAction
| WorkflowDeleteRecordAction
@@ -32,7 +32,8 @@ export const WorkflowDiagramStepNodeIcon = ({
switch (data.actionType) {
case 'CODE':
case 'HTTP_REQUEST':
case 'SEND_EMAIL': {
case 'SEND_EMAIL':
case 'DRAFT_EMAIL': {
return (
<Icon
size={theme.icon.size.md}
@@ -9,7 +9,7 @@ import { WorkflowActionCode } from '@/workflow/workflow-steps/workflow-actions/c
import { WorkflowEditActionCreateRecord } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionCreateRecord';
import { WorkflowEditActionDeleteRecord } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionDeleteRecord';
import { WorkflowEditActionEmpty } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionEmpty';
import { WorkflowEditActionSendEmail } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionSendEmail';
import { WorkflowEditActionEmailBase } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionEmailBase';
import { WorkflowEditActionUpdateRecord } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionUpdateRecord';
import { WorkflowEditActionUpsertRecord } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionUpsertRecord';
import { WorkflowEditActionDelay } from '@/workflow/workflow-steps/workflow-actions/delay-actions/components/WorkflowEditActionDelay';
@@ -127,9 +127,10 @@ export const WorkflowRunStepNodeDetail = ({
/>
);
}
case 'SEND_EMAIL': {
case 'SEND_EMAIL':
case 'DRAFT_EMAIL': {
return (
<WorkflowEditActionSendEmail
<WorkflowEditActionEmailBase
key={stepId}
action={stepDefinition.definition}
actionOptions={{
@@ -8,7 +8,7 @@ import { WorkflowActionCode } from '@/workflow/workflow-steps/workflow-actions/c
import { WorkflowEditActionCreateRecord } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionCreateRecord';
import { WorkflowEditActionDeleteRecord } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionDeleteRecord';
import { WorkflowEditActionEmpty } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionEmpty';
import { WorkflowEditActionSendEmail } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionSendEmail';
import { WorkflowEditActionEmailBase } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionEmailBase';
import { WorkflowEditActionUpdateRecord } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionUpdateRecord';
import { WorkflowEditActionUpsertRecord } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionUpsertRecord';
import { WorkflowEditActionDelay } from '@/workflow/workflow-steps/workflow-actions/delay-actions/components/WorkflowEditActionDelay';
@@ -125,9 +125,10 @@ export const WorkflowStepDetail = ({
/>
);
}
case 'SEND_EMAIL': {
case 'SEND_EMAIL':
case 'DRAFT_EMAIL': {
return (
<WorkflowEditActionSendEmail
<WorkflowEditActionEmailBase
key={stepId}
action={stepDefinition.definition}
actionOptions={props}
@@ -1,6 +1,5 @@
import { GMAIL_SEND_SCOPE } from '@/accounts/constants/GmailSendScope';
import { MICROSOFT_SEND_SCOPE } from '@/accounts/constants/MicrosoftSendScope';
import { type ConnectedAccount } from '@/accounts/types/ConnectedAccount';
import { getMissingDraftEmailScopes } from '@/accounts/utils/hasMissingDraftEmailScopes';
import { useUploadAttachmentFile } from '@/activities/files/hooks/useUploadAttachmentFile';
import { WorkflowSendEmailAttachments } from '@/advanced-text-editor/components/WorkflowSendEmailAttachments';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
@@ -21,60 +20,42 @@ import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState';
import { type WorkflowSendEmailAction } from '@/workflow/types/Workflow';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { WorkflowStepFooter } from '@/workflow/workflow-steps/components/WorkflowStepFooter';
import { type WorkflowEmailAction } from '@/workflow/workflow-steps/workflow-actions/email-action/types/WorkflowEmailAction';
import { useEmailForm } from '@/workflow/workflow-steps/workflow-actions/hooks/useEmailForm';
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
import { useTheme } from '@emotion/react';
import { t } from '@lingui/core/macro';
import { useEffect, useState } from 'react';
import { useRecoilValue } from 'recoil';
import { ConnectedAccountProvider, SettingsPath } from 'twenty-shared/types';
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
import { type EmailRecipients } from 'twenty-shared/workflow';
import { IconPlus } from 'twenty-ui/display';
import { isDefined } from 'twenty-shared/utils';
import { Callout, IconPlus } from 'twenty-ui/display';
import { Button, type SelectOption } from 'twenty-ui/input';
import { MenuItem } from 'twenty-ui/navigation';
import { type JsonValue } from 'type-fest';
import { useDebouncedCallback } from 'use-debounce';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
const EMAIL_EDITOR_MIN_HEIGHT = 340;
const EMAIL_EDITOR_MAX_WIDTH = 600;
type WorkflowEditActionSendEmailProps = {
action: WorkflowSendEmailAction;
type WorkflowEditActionEmailBaseProps = {
action: WorkflowEmailAction;
actionOptions:
| {
readonly: true;
}
| {
readonly?: false;
onActionUpdate: (action: WorkflowSendEmailAction) => void;
onActionUpdate: (action: WorkflowEmailAction) => void;
};
};
type WorkflowFile = {
id: string;
name: string;
size: number;
type: string;
createdAt: string;
};
type SendEmailFormData = {
connectedAccountId: string;
recipients: Required<EmailRecipients>;
subject: string;
body: string;
files: WorkflowFile[];
};
export const WorkflowEditActionSendEmail = ({
export const WorkflowEditActionEmailBase = ({
action,
actionOptions,
}: WorkflowEditActionSendEmailProps) => {
}: WorkflowEditActionEmailBaseProps) => {
const theme = useTheme();
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const { triggerApisOAuth } = useTriggerApisOAuth();
@@ -89,20 +70,13 @@ export const WorkflowEditActionSendEmail = ({
const redirectUrl = `/object/workflow/${workflowVisualizerWorkflowId}`;
const [formData, setFormData] = useState<SendEmailFormData>(() => {
const inputRecipients = action.settings.input.recipients;
return {
connectedAccountId: action.settings.input.connectedAccountId,
recipients: {
to: inputRecipients?.to ?? '',
cc: inputRecipients?.cc ?? '',
bcc: inputRecipients?.bcc ?? '',
},
subject: action.settings.input.subject ?? '',
body: action.settings.input.body ?? '',
files: action.settings.input.files ?? [],
};
const { formData, handleFieldChange, saveAction } = useEmailForm({
action,
onActionUpdate:
actionOptions.readonly === true
? undefined
: actionOptions.onActionUpdate,
readonly: actionOptions.readonly === true,
});
const [visibleAdvancedFields, setVisibleAdvancedFields] = useState<{
@@ -119,95 +93,24 @@ export const WorkflowEditActionSendEmail = ({
const { closeDropdown } = useCloseDropdown();
const advancedOptionsDropdownId = 'send-email-advanced-options';
const advancedOptionsDropdownId = `${action.id}-email-advanced-options`;
const hasAvailableAdvancedOptions =
!visibleAdvancedFields.cc || !visibleAdvancedFields.bcc;
const checkConnectedAccountScopes = async (
connectedAccountId: string | null,
) => {
const connectedAccount = accounts.find(
(account) => account.id === connectedAccountId,
);
if (!isDefined(connectedAccount)) {
const handleReauthorize = async () => {
if (!isDefined(missingScopes)) {
return;
}
const scopes = connectedAccount.scopes;
const hasSendScope = (
connectedAccount: ConnectedAccount,
scopes: string[],
): boolean => {
switch (connectedAccount.provider) {
case ConnectedAccountProvider.GOOGLE:
return scopes.some((scope) => scope === GMAIL_SEND_SCOPE);
case ConnectedAccountProvider.MICROSOFT:
return scopes.some((scope) => scope === MICROSOFT_SEND_SCOPE);
case ConnectedAccountProvider.IMAP_SMTP_CALDAV:
return isDefined(connectedAccount.connectionParameters?.SMTP);
default:
assertUnreachable(
connectedAccount.provider,
'Provider not yet supported for sending emails',
);
}
};
if (
connectedAccount.provider !== ConnectedAccountProvider.IMAP_SMTP_CALDAV &&
(!isDefined(scopes) || !hasSendScope(connectedAccount, scopes))
) {
await triggerApisOAuth(connectedAccount.provider, {
redirectLocation: redirectUrl,
loginHint: connectedAccount.handle,
});
}
await triggerApisOAuth(missingScopes.provider, {
redirectLocation: redirectUrl,
loginHint: missingScopes.loginHint,
});
};
const saveAction = useDebouncedCallback(
async (formData: SendEmailFormData) => {
if (actionOptions.readonly === true) {
return;
}
actionOptions.onActionUpdate({
...action,
settings: {
...action.settings,
input: {
connectedAccountId: formData.connectedAccountId,
recipients: formData.recipients,
subject: formData.subject,
body: formData.body,
files: formData.files,
},
},
});
await checkConnectedAccountScopes(formData.connectedAccountId);
},
1_000,
);
useEffect(() => {
return () => {
saveAction.flush();
};
}, [saveAction]);
const handleFieldChange = (
fieldName: keyof SendEmailFormData,
updatedValue: JsonValue,
) => {
const newFormData: SendEmailFormData = {
...formData,
[fieldName]: updatedValue,
};
setFormData(newFormData);
saveAction(newFormData);
const handleConnectedAccountChange = (connectedAccountId: string | null) => {
handleFieldChange('connectedAccountId', connectedAccountId);
};
const handleUploadAttachment = async (file: File) => {
@@ -263,6 +166,24 @@ export const WorkflowEditActionSendEmail = ({
},
});
const selectedAccount = accounts.find(
(account) => account.id === formData.connectedAccountId,
);
const missingDraftScopes = isDefined(selectedAccount)
? getMissingDraftEmailScopes(selectedAccount)
: [];
const missingScopes =
isDefined(selectedAccount) &&
selectedAccount.provider !== ConnectedAccountProvider.IMAP_SMTP_CALDAV &&
missingDraftScopes.length > 0
? {
provider: selectedAccount.provider,
loginHint: selectedAccount.handle,
}
: null;
let emptyOption: SelectOption<string | null> = {
label: t`None`,
value: null,
@@ -284,8 +205,6 @@ export const WorkflowEditActionSendEmail = ({
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;
}
});
@@ -294,6 +213,12 @@ export const WorkflowEditActionSendEmail = ({
const { closeCommandMenu } = useCommandMenu();
useEffect(() => {
return () => {
saveAction.flush();
};
}, [saveAction]);
return (
!loading && (
<>
@@ -314,12 +239,25 @@ export const WorkflowEditActionSendEmail = ({
text: t`Add account`,
}}
onChange={(connectedAccountId) => {
handleFieldChange('connectedAccountId', connectedAccountId);
handleConnectedAccountChange(connectedAccountId);
}}
disabled={actionOptions.readonly}
dropdownOffset={{ y: parseInt(theme.spacing(1), 10) }}
dropdownWidth={GenericDropdownContentWidth.ExtraLarge}
/>
{isDefined(missingScopes) && (
<>
<Callout
variant={'error'}
title={t`Missing email draft permission.`}
description={t`This account is connected, but we don't have permission to draft emails on your behalf yet. You'll be redirected to approve this access.`}
action={{
label: t`Reauthorize`,
onClick: handleReauthorize,
}}
/>
</>
)}
<FormMultiTextFieldInput
label={t`To`}
placeholder={t`Enter emails, comma-separated`}
@@ -434,7 +372,7 @@ export const WorkflowEditActionSendEmail = ({
href: '#',
},
{
children: isDefined(action.name) ? action.name : t`Send Email`,
children: isDefined(action.name) ? action.name : t`Email`,
href: '#',
},
{
@@ -1,5 +1,8 @@
import { type WorkflowSendEmailAction } from '@/workflow/types/Workflow';
import { WorkflowEditActionSendEmail } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionSendEmail';
import {
type WorkflowDraftEmailAction,
type WorkflowSendEmailAction,
} from '@/workflow/types/Workflow';
import { WorkflowEditActionEmailBase } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionEmailBase';
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { graphql, HttpResponse } from 'msw';
import { expect, fn, within } from 'storybook/test';
@@ -16,7 +19,7 @@ import {
} from '~/testing/mock-data/connected-accounts';
import { getWorkflowNodeIdMock } from '~/testing/mock-data/workflow';
const DEFAULT_ACTION: WorkflowSendEmailAction = {
const DEFAULT_SEND_EMAIL_ACTION: WorkflowSendEmailAction = {
id: getWorkflowNodeIdMock(),
name: 'Send Email',
type: 'SEND_EMAIL',
@@ -45,7 +48,7 @@ const DEFAULT_ACTION: WorkflowSendEmailAction = {
},
};
const CONFIGURED_ACTION: WorkflowSendEmailAction = {
const CONFIGURED_SEND_EMAIL_ACTION: WorkflowSendEmailAction = {
id: getWorkflowNodeIdMock(),
name: 'Send Welcome Email',
type: 'SEND_EMAIL',
@@ -74,9 +77,38 @@ const CONFIGURED_ACTION: WorkflowSendEmailAction = {
},
};
const meta: Meta<typeof WorkflowEditActionSendEmail> = {
title: 'Modules/Workflow/Actions/SendEmail/EditAction',
component: WorkflowEditActionSendEmail,
const DEFAULT_DRAFT_EMAIL_ACTION: WorkflowDraftEmailAction = {
id: getWorkflowNodeIdMock(),
name: 'Draft Email',
type: 'DRAFT_EMAIL',
valid: false,
settings: {
input: {
connectedAccountId: '',
recipients: {
to: '',
cc: '',
bcc: '',
},
subject: '',
body: '',
files: [],
},
outputSchema: {},
errorHandlingOptions: {
retryOnFailure: {
value: false,
},
continueOnFailure: {
value: false,
},
},
},
};
const meta: Meta<typeof WorkflowEditActionEmailBase> = {
title: 'Modules/Workflow/Actions/Email/EditAction',
component: WorkflowEditActionEmailBase,
parameters: {
msw: {
handlers: [
@@ -92,7 +124,7 @@ const meta: Meta<typeof WorkflowEditActionSendEmail> = {
},
},
args: {
action: DEFAULT_ACTION,
action: DEFAULT_SEND_EMAIL_ACTION,
},
decorators: [
WorkflowStepActionDrawerDecorator,
@@ -107,7 +139,7 @@ const meta: Meta<typeof WorkflowEditActionSendEmail> = {
export default meta;
type Story = StoryObj<typeof WorkflowEditActionSendEmail>;
type Story = StoryObj<typeof WorkflowEditActionEmailBase>;
export const Default: Story = {
args: {
@@ -128,7 +160,7 @@ export const Default: Story = {
export const Configured: Story = {
args: {
action: CONFIGURED_ACTION,
action: CONFIGURED_SEND_EMAIL_ACTION,
actionOptions: {
onActionUpdate: fn(),
},
@@ -146,3 +178,21 @@ export const Configured: Story = {
expect(subjectInput).toBeVisible();
},
};
export const DraftEmail: Story = {
args: {
action: DEFAULT_DRAFT_EMAIL_ACTION,
actionOptions: {
onActionUpdate: fn(),
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(await canvas.findByText('Account')).toBeVisible();
expect(await canvas.findByText('To')).toBeVisible();
expect(await canvas.findByText('Subject')).toBeVisible();
expect(await canvas.findByText('Body')).toBeVisible();
expect(await canvas.findByText('Advanced options')).toBeVisible();
},
};
@@ -1,10 +1,14 @@
import { type WorkflowActionType } from '@/workflow/types/Workflow';
import { CODE_ACTION } from '@/workflow/workflow-steps/workflow-actions/constants/actions/CodeAction';
import { DRAFT_EMAIL_ACTION } from '@/workflow/workflow-steps/workflow-actions/constants/actions/DraftEmailAction';
import { HTTP_REQUEST_ACTION } from '@/workflow/workflow-steps/workflow-actions/constants/actions/HttpRequestAction';
import { SEND_EMAIL_ACTION } from '@/workflow/workflow-steps/workflow-actions/constants/actions/SendEmailAction';
export const CORE_ACTIONS: Array<{
defaultLabel: string;
type: Extract<WorkflowActionType, 'CODE' | 'SEND_EMAIL' | 'HTTP_REQUEST'>;
type: Extract<
WorkflowActionType,
'CODE' | 'SEND_EMAIL' | 'DRAFT_EMAIL' | 'HTTP_REQUEST'
>;
icon: string;
}> = [SEND_EMAIL_ACTION, CODE_ACTION, HTTP_REQUEST_ACTION];
}> = [SEND_EMAIL_ACTION, DRAFT_EMAIL_ACTION, CODE_ACTION, HTTP_REQUEST_ACTION];
@@ -0,0 +1,11 @@
import { type WorkflowActionType } from '@/workflow/types/Workflow';
export const DRAFT_EMAIL_ACTION: {
defaultLabel: string;
type: Extract<WorkflowActionType, 'DRAFT_EMAIL'>;
icon: string;
} = {
defaultLabel: 'Draft Email',
type: 'DRAFT_EMAIL',
icon: 'IconMailPlus',
};
@@ -0,0 +1,10 @@
import { type WorkflowAttachmentType } from '@/workflow/workflow-steps/workflow-actions/email-action/types/WorkflowAttachmentType';
import { type EmailRecipients } from 'twenty-shared/workflow';
export type EmailFormData = {
connectedAccountId: string;
recipients: Required<EmailRecipients>;
subject: string;
body: string;
files: WorkflowAttachmentType[];
};
@@ -0,0 +1,8 @@
import {
type WorkflowDraftEmailAction,
type WorkflowSendEmailAction,
} from '@/workflow/types/Workflow';
export type WorkflowEmailAction =
| WorkflowSendEmailAction
| WorkflowDraftEmailAction;
@@ -222,14 +222,19 @@ export const WorkflowEditActionFormBuilder = ({
<StyledWorkflowStepBody>
{triggerType && triggerType !== 'MANUAL' && isCalloutVisible && (
<Callout
learnMoreText={t`Learn more`}
variant={'warning'}
title={t`This form will appear in workflow runs.`}
description={t`Because this workflow is not using a manual trigger, the form will not open on top of the interface. To fill it, open the corresponding workflow run and complete the form there.`}
onClose={() => setIsCalloutVisible(false)}
learnMoreUrl={
'https://docs.twenty.com/user-guide/workflows/capabilities/workflow-actions#form'
}
action={{
label: t`Learn more`,
onClick: () =>
window.open(
'https://docs.twenty.com/user-guide/workflows/capabilities/workflow-actions#form',
'_blank',
'noopener,noreferrer',
),
}}
/>
)}
{formData.length === 0 && (
@@ -0,0 +1,72 @@
import { type EmailFormData } from '@/workflow/workflow-steps/workflow-actions/email-action/types/EmailFormData';
import { type WorkflowEmailAction } from '@/workflow/workflow-steps/workflow-actions/email-action/types/WorkflowEmailAction';
import { useState } from 'react';
import { type JsonValue } from 'type-fest';
import { useDebouncedCallback } from 'use-debounce';
type UseEmailFormParams = {
action: WorkflowEmailAction;
onActionUpdate?: (action: WorkflowEmailAction) => void;
readonly: boolean;
};
export const useEmailForm = ({
action,
onActionUpdate,
readonly,
}: UseEmailFormParams) => {
const [formData, setFormData] = useState<EmailFormData>(() => {
const inputRecipients = action.settings.input.recipients;
return {
connectedAccountId: action.settings.input.connectedAccountId,
recipients: {
to: inputRecipients?.to ?? '',
cc: inputRecipients?.cc ?? '',
bcc: inputRecipients?.bcc ?? '',
},
subject: action.settings.input.subject ?? '',
body: action.settings.input.body ?? '',
files: action.settings.input.files ?? [],
};
});
const saveAction = useDebouncedCallback((formData: EmailFormData) => {
if (readonly) {
return;
}
onActionUpdate?.({
...action,
settings: {
...action.settings,
input: {
connectedAccountId: formData.connectedAccountId,
recipients: formData.recipients,
subject: formData.subject,
body: formData.body,
files: formData.files,
},
},
});
}, 1_000);
const handleFieldChange = (
fieldName: keyof EmailFormData,
updatedValue: JsonValue,
) => {
const newFormData: EmailFormData = {
...formData,
[fieldName]: updatedValue,
};
setFormData(newFormData);
saveAction(newFormData);
};
return {
formData,
handleFieldChange,
saveAction,
};
};
@@ -18,6 +18,7 @@ export const getActionIcon = (actionType: WorkflowActionType) => {
case 'CODE':
case 'HTTP_REQUEST':
case 'SEND_EMAIL':
case 'DRAFT_EMAIL':
return CORE_ACTIONS.find((item) => item.type === actionType)?.icon;
case 'LOGIC_FUNCTION':
return 'IconFunction';
@@ -14,6 +14,7 @@ export const getActionIconColorOrThrow = ({
case 'LOGIC_FUNCTION':
case 'HTTP_REQUEST':
case 'SEND_EMAIL':
case 'DRAFT_EMAIL':
return theme.color.red;
case 'CREATE_RECORD':
case 'UPDATE_RECORD':
@@ -191,7 +191,8 @@ export const computeStepOutputSchema = ({
return generateFormOutputSchema(formFields, objectMetadataItems);
}
case 'SEND_EMAIL': {
case 'SEND_EMAIL':
case 'DRAFT_EMAIL': {
return {
success: {
isLeaf: true,
@@ -98,6 +98,7 @@ describe('GoogleAPIScopesService', () => {
'https://www.googleapis.com/auth/calendar.events',
'https://www.googleapis.com/auth/gmail.readonly',
'https://www.googleapis.com/auth/gmail.send',
'https://www.googleapis.com/auth/gmail.compose',
'https://www.googleapis.com/auth/profile.emails.read',
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
@@ -98,6 +98,7 @@ describe('GoogleAPIScopesService', () => {
'https://www.googleapis.com/auth/calendar.events',
'https://www.googleapis.com/auth/gmail.readonly',
'https://www.googleapis.com/auth/gmail.send',
'https://www.googleapis.com/auth/gmail.compose',
'https://www.googleapis.com/auth/profile.emails.read',
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
@@ -9,5 +9,6 @@ export const getGoogleApisOauthScopes = () => {
'https://www.googleapis.com/auth/calendar.events',
'https://www.googleapis.com/auth/profile.emails.read',
'https://www.googleapis.com/auth/gmail.send',
'https://www.googleapis.com/auth/gmail.compose',
];
};
@@ -19,4 +19,5 @@ export enum FeatureFlagKey {
IS_COMMAND_MENU_ITEM_ENABLED = 'IS_COMMAND_MENU_ITEM_ENABLED',
IS_NAVIGATION_MENU_ITEM_ENABLED = 'IS_NAVIGATION_MENU_ITEM_ENABLED',
IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED = 'IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED',
IS_DRAFT_EMAIL_ENABLED = 'IS_DRAFT_EMAIL_ENABLED',
}
@@ -21,7 +21,8 @@ import {
import { CodeInterpreterTool } from 'src/engine/core-modules/tool/tools/code-interpreter-tool/code-interpreter-tool';
import { HttpTool } from 'src/engine/core-modules/tool/tools/http-tool/http-tool';
import { SearchHelpCenterTool } from 'src/engine/core-modules/tool/tools/search-help-center-tool/search-help-center-tool';
import { SendEmailTool } from 'src/engine/core-modules/tool/tools/send-email-tool/send-email-tool';
import { DraftEmailTool } from 'src/engine/core-modules/tool/tools/email-tool/draft-email-tool';
import { SendEmailTool } from 'src/engine/core-modules/tool/tools/email-tool/send-email-tool';
import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type';
import { type Tool } from 'src/engine/core-modules/tool/types/tool.type';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
@@ -35,6 +36,7 @@ export class ActionToolProvider implements ToolProvider {
constructor(
private readonly httpTool: HttpTool,
private readonly sendEmailTool: SendEmailTool,
private readonly draftEmailTool: DraftEmailTool,
private readonly searchHelpCenterTool: SearchHelpCenterTool,
private readonly codeInterpreterTool: CodeInterpreterTool,
private readonly permissionsService: PermissionsService,
@@ -43,6 +45,7 @@ export class ActionToolProvider implements ToolProvider {
this.toolMap = new Map<string, Tool>([
['http_request', this.httpTool],
['send_email', this.sendEmailTool],
['draft_email', this.draftEmailTool],
['search_help_center', this.searchHelpCenterTool],
['code_interpreter', this.codeInterpreterTool],
]);
@@ -96,6 +99,13 @@ export class ActionToolProvider implements ToolProvider {
descriptors.push(
this.buildDescriptor('send_email', this.sendEmailTool, includeSchemas),
);
descriptors.push(
this.buildDescriptor(
'draft_email',
this.draftEmailTool,
includeSchemas,
),
);
}
descriptors.push(
@@ -6,14 +6,18 @@ import { FileModule } from 'src/engine/core-modules/file/file.module';
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
import { SecureHttpClientModule } from 'src/engine/core-modules/secure-http-client/secure-http-client.module';
import { CodeInterpreterTool } from 'src/engine/core-modules/tool/tools/code-interpreter-tool/code-interpreter-tool';
import { DraftEmailTool } from 'src/engine/core-modules/tool/tools/email-tool/draft-email-tool';
import { EmailComposerService } from 'src/engine/core-modules/tool/tools/email-tool/email-composer.service';
import { SendEmailTool } from 'src/engine/core-modules/tool/tools/email-tool/send-email-tool';
import { HttpTool } from 'src/engine/core-modules/tool/tools/http-tool/http-tool';
import { SearchHelpCenterTool } from 'src/engine/core-modules/tool/tools/search-help-center-tool/search-help-center-tool';
import { SendEmailTool } from 'src/engine/core-modules/tool/tools/send-email-tool/send-email-tool';
import { MessagingImportManagerModule } from 'src/modules/messaging/message-import-manager/messaging-import-manager.module';
import { MessagingSendManagerModule } from 'src/modules/messaging/message-outbound-manager/messaging-send-manager.module';
@Module({
imports: [
MessagingImportManagerModule,
MessagingSendManagerModule,
TypeOrmModule.forFeature([FileEntity]),
FileModule,
JwtModule,
@@ -22,9 +26,18 @@ import { MessagingImportManagerModule } from 'src/modules/messaging/message-impo
providers: [
HttpTool,
SendEmailTool,
DraftEmailTool,
EmailComposerService,
SearchHelpCenterTool,
CodeInterpreterTool,
],
exports: [
HttpTool,
SendEmailTool,
DraftEmailTool,
EmailComposerService,
SearchHelpCenterTool,
CodeInterpreterTool,
],
exports: [HttpTool, SendEmailTool, SearchHelpCenterTool, CodeInterpreterTool],
})
export class ToolModule {}
@@ -0,0 +1,96 @@
import { Injectable, Logger } from '@nestjs/common';
import { EmailComposerService } from 'src/engine/core-modules/tool/tools/email-tool/email-composer.service';
import { EmailToolInputZodSchema } from 'src/engine/core-modules/tool/tools/email-tool/email-tool.schema';
import { EmailToolException } from 'src/engine/core-modules/tool/tools/email-tool/exceptions/email-tool.exception';
import { type ComposedEmail } from 'src/engine/core-modules/tool/tools/email-tool/types/composed-email.type';
import { type EmailToolInput } from 'src/engine/core-modules/tool/tools/email-tool/types/email-tool-input.type';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
import {
type Tool,
type ToolExecutionContext,
} from 'src/engine/core-modules/tool/types/tool.type';
import { MessagingMessageOutboundService } from 'src/modules/messaging/message-outbound-manager/services/messaging-message-outbound.service';
@Injectable()
export class DraftEmailTool implements Tool {
private readonly logger = new Logger(DraftEmailTool.name);
description =
'Create a draft email using a connected account. The email will be saved as a draft, not sent.';
inputSchema = EmailToolInputZodSchema;
constructor(
private readonly emailComposerService: EmailComposerService,
private readonly messageOutboundService: MessagingMessageOutboundService,
) {}
async execute(
parameters: EmailToolInput,
context: ToolExecutionContext,
): Promise<ToolOutput> {
try {
const result = await this.emailComposerService.composeEmail(
parameters,
context,
);
if (!result.success) {
return result.output;
}
const { data } = result;
await this.createDraft(data);
this.logger.log(
`Draft created successfully for ${data.toRecipientsDisplay}${data.attachments.length > 0 ? ` with ${data.attachments.length} attachments` : ''}`,
);
return {
success: true,
message: `Draft created successfully for ${data.toRecipientsDisplay}`,
result: {
recipients: data.recipients.to,
ccRecipients: data.recipients.cc,
bccRecipients: data.recipients.bcc,
subject: data.sanitizedSubject,
connectedAccountId: data.connectedAccount.id,
attachmentCount: data.attachments.length,
},
};
} catch (error) {
if (error instanceof EmailToolException) {
return {
success: false,
message: 'Failed to create draft',
error: error.message,
};
}
this.logger.error(`Failed to create draft: ${error}`);
return {
success: false,
message: 'Failed to create draft',
error:
error instanceof Error ? error.message : 'Failed to create draft',
};
}
}
private async createDraft(data: ComposedEmail): Promise<void> {
await this.messageOutboundService.createDraft(
{
to: data.recipients.to,
cc: data.recipients.cc.length > 0 ? data.recipients.cc : undefined,
bcc: data.recipients.bcc.length > 0 ? data.recipients.bcc : undefined,
subject: data.sanitizedSubject,
body: data.plainTextBody,
html: data.sanitizedHtmlBody,
attachments: data.attachments,
},
data.connectedAccount,
);
}
}
@@ -15,37 +15,27 @@ import { z } from 'zod';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import {
SendEmailToolException,
SendEmailToolExceptionCode,
} from 'src/engine/core-modules/tool/tools/send-email-tool/exceptions/send-email-tool.exception';
import { SendEmailInputZodSchema } from 'src/engine/core-modules/tool/tools/send-email-tool/send-email-tool.schema';
import { type SendEmailInput } from 'src/engine/core-modules/tool/tools/send-email-tool/types/send-email-input.type';
import { parseCommaSeparatedEmails } from 'src/engine/core-modules/tool/tools/send-email-tool/utils/parse-comma-separated-emails.util';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
import {
type Tool,
type ToolExecutionContext,
} from 'src/engine/core-modules/tool/types/tool.type';
EmailToolException,
EmailToolExceptionCode,
} from 'src/engine/core-modules/tool/tools/email-tool/exceptions/email-tool.exception';
import { type EmailComposerResult } from 'src/engine/core-modules/tool/tools/email-tool/types/email-composer-result.type';
import { type EmailToolInput } from 'src/engine/core-modules/tool/tools/email-tool/types/email-tool-input.type';
import { parseCommaSeparatedEmails } from 'src/engine/core-modules/tool/tools/email-tool/utils/parse-comma-separated-emails.util';
import { type ToolExecutionContext } from 'src/engine/core-modules/tool/types/tool.type';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { MessagingAccountAuthenticationService } from 'src/modules/messaging/message-import-manager/services/messaging-account-authentication.service';
import { MessagingSendMessageService } from 'src/modules/messaging/message-import-manager/services/messaging-send-message.service';
import { type MessageAttachment } from 'src/modules/messaging/message-import-manager/types/message';
import { parseEmailBody } from 'src/utils/parse-email-body';
import { streamToBuffer } from 'src/utils/stream-to-buffer';
@Injectable()
export class SendEmailTool implements Tool {
private readonly logger = new Logger(SendEmailTool.name);
description =
'Send an email using a connected account. Requires SEND_EMAIL_TOOL permission.';
inputSchema = SendEmailInputZodSchema;
export class EmailComposerService {
private readonly logger = new Logger(EmailComposerService.name);
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly sendMessageService: MessagingSendMessageService,
private readonly messagingAccountAuthenticationService: MessagingAccountAuthenticationService,
@InjectRepository(FileEntity)
private readonly fileRepository: Repository<FileEntity>,
@@ -57,9 +47,9 @@ export class SendEmailTool implements Tool {
workspaceId: string,
) {
if (!isValidUuid(connectedAccountId)) {
throw new SendEmailToolException(
throw new EmailToolException(
`Connected Account ID is not a valid UUID`,
SendEmailToolExceptionCode.INVALID_CONNECTED_ACCOUNT_ID,
EmailToolExceptionCode.INVALID_CONNECTED_ACCOUNT_ID,
);
}
@@ -83,9 +73,9 @@ export class SendEmailTool implements Tool {
});
if (!isDefined(connectedAccount)) {
throw new SendEmailToolException(
throw new EmailToolException(
`Connected Account '${connectedAccountId}' not found`,
SendEmailToolExceptionCode.CONNECTED_ACCOUNT_NOT_FOUND,
EmailToolExceptionCode.CONNECTED_ACCOUNT_NOT_FOUND,
);
}
@@ -110,9 +100,9 @@ export class SendEmailTool implements Tool {
const allAccounts = await connectedAccountRepository.find();
if (!allAccounts || allAccounts.length === 0) {
throw new SendEmailToolException(
throw new EmailToolException(
'No connected accounts found for this workspace',
SendEmailToolExceptionCode.CONNECTED_ACCOUNT_NOT_FOUND,
EmailToolExceptionCode.CONNECTED_ACCOUNT_NOT_FOUND,
);
}
@@ -122,7 +112,7 @@ export class SendEmailTool implements Tool {
);
}
private normalizeRecipients(parameters: SendEmailInput): {
private normalizeRecipients(parameters: EmailToolInput): {
to: string[];
cc: string[];
bcc: string[];
@@ -132,18 +122,18 @@ export class SendEmailTool implements Tool {
!parameters.recipients.to ||
parameters.recipients.to.trim().length === 0
) {
throw new SendEmailToolException(
throw new EmailToolException(
'No recipients specified',
SendEmailToolExceptionCode.INVALID_EMAIL,
EmailToolExceptionCode.INVALID_EMAIL,
);
}
const to = parseCommaSeparatedEmails(parameters.recipients.to);
if (to.length === 0) {
throw new SendEmailToolException(
throw new EmailToolException(
'No valid recipients specified',
SendEmailToolExceptionCode.INVALID_EMAIL,
EmailToolExceptionCode.INVALID_EMAIL,
);
}
@@ -202,9 +192,9 @@ export class SendEmailTool implements Tool {
}
if (filesNotFound.length > 0) {
throw new SendEmailToolException(
throw new EmailToolException(
`Files not found: ${filesNotFound.join(', ')}`,
SendEmailToolExceptionCode.FILE_NOT_FOUND,
EmailToolExceptionCode.FILE_NOT_FOUND,
);
}
@@ -235,10 +225,10 @@ export class SendEmailTool implements Tool {
return attachments;
}
async execute(
parameters: SendEmailInput,
async composeEmail(
parameters: EmailToolInput,
context: ToolExecutionContext,
): Promise<ToolOutput> {
): Promise<EmailComposerResult> {
const { workspaceId } = context;
const { subject, body, files } = parameters;
let { connectedAccountId } = parameters;
@@ -248,11 +238,16 @@ export class SendEmailTool implements Tool {
try {
recipients = this.normalizeRecipients(parameters);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Invalid recipients';
return {
success: false,
message: 'No recipients specified',
error:
error instanceof Error ? error.message : 'No recipients specified',
output: {
success: false,
message: errorMessage,
error: errorMessage,
},
};
}
@@ -261,108 +256,76 @@ export class SendEmailTool implements Tool {
if (invalidEmails.length > 0) {
return {
success: false,
message: `Invalid email addresses: ${invalidEmails.join(', ')}`,
error: `Invalid email addresses: ${invalidEmails.join(', ')}`,
output: {
success: false,
message: `Invalid email addresses: ${invalidEmails.join(', ')}`,
error: `Invalid email addresses: ${invalidEmails.join(', ')}`,
},
};
}
const toRecipientsDisplay = recipients.to.join(', ');
try {
if (!connectedAccountId) {
connectedAccountId =
await this.getOrThrowFirstConnectedAccountId(workspaceId);
}
const connectedAccount = await this.getConnectedAccount(
connectedAccountId,
workspaceId,
);
const messageChannel = connectedAccount.messageChannels.find(
(channel) => channel.handle === connectedAccount.handle,
);
if (!isDefined(messageChannel)) {
throw new SendEmailToolException(
`No message channel found for connected account '${connectedAccountId}'`,
SendEmailToolExceptionCode.CONNECTED_ACCOUNT_NOT_FOUND,
);
}
const { accessToken, refreshToken } =
await this.messagingAccountAuthenticationService.validateAndRefreshConnectedAccountAuthentication(
{
connectedAccount,
workspaceId,
messageChannelId: messageChannel.id,
},
);
const connectedAccountWithFreshTokens = {
...connectedAccount,
accessToken,
refreshToken,
};
const attachments = await this.getAttachments(files || [], workspaceId);
const parsedBody = parseEmailBody(body);
const reactMarkup = reactMarkupFromJSON(parsedBody);
const htmlBody = await render(reactMarkup);
const textBody = toPlainText(htmlBody);
const { JSDOM } = await import('jsdom');
const window = new JSDOM('').window;
const purify = DOMPurify(window);
const safeHtmlBody = purify.sanitize(htmlBody || '');
const safeSubject = purify.sanitize(subject || '');
await this.sendMessageService.sendMessage(
{
to: recipients.to,
cc: recipients.cc.length > 0 ? recipients.cc : undefined,
bcc: recipients.bcc.length > 0 ? recipients.bcc : undefined,
subject: safeSubject,
body: textBody,
html: safeHtmlBody,
attachments,
},
connectedAccountWithFreshTokens,
);
this.logger.log(
`Email sent successfully to ${toRecipientsDisplay}${attachments.length > 0 ? ` with ${attachments.length} attachments` : ''}`,
);
return {
success: true,
message: `Email sent successfully to ${toRecipientsDisplay}`,
result: {
recipients: recipients.to,
ccRecipients: recipients.cc,
bccRecipients: recipients.bcc,
subject: safeSubject,
connectedAccountId,
attachmentCount: attachments.length,
},
};
} catch (error) {
if (error instanceof SendEmailToolException) {
return {
success: false,
message: `Failed to send email to ${toRecipientsDisplay}`,
error: error.message,
};
}
this.logger.error(`Failed to send email: ${error}`);
return {
success: false,
message: `Failed to send email to ${toRecipientsDisplay}`,
error: error instanceof Error ? error.message : 'Failed to send email',
};
if (!connectedAccountId) {
connectedAccountId =
await this.getOrThrowFirstConnectedAccountId(workspaceId);
}
const connectedAccount = await this.getConnectedAccount(
connectedAccountId,
workspaceId,
);
const messageChannel = connectedAccount.messageChannels.find(
(channel) => channel.handle === connectedAccount.handle,
);
if (!isDefined(messageChannel)) {
throw new EmailToolException(
`No message channel found for connected account '${connectedAccountId}'`,
EmailToolExceptionCode.CONNECTED_ACCOUNT_NOT_FOUND,
);
}
const { accessToken, refreshToken } =
await this.messagingAccountAuthenticationService.validateAndRefreshConnectedAccountAuthentication(
{
connectedAccount,
workspaceId,
messageChannelId: messageChannel.id,
},
);
const connectedAccountWithFreshTokens = {
...connectedAccount,
accessToken,
refreshToken,
};
const attachments = await this.getAttachments(files || [], workspaceId);
const parsedBody = parseEmailBody(body);
const reactMarkup = reactMarkupFromJSON(parsedBody);
const htmlBody = await render(reactMarkup);
const plainTextBody = toPlainText(htmlBody);
const { JSDOM } = await import('jsdom');
const window = new JSDOM('').window;
const purify = DOMPurify(window);
const sanitizedHtmlBody = purify.sanitize(htmlBody || '');
const sanitizedSubject = purify.sanitize(subject || '');
return {
success: true,
data: {
recipients,
toRecipientsDisplay,
sanitizedSubject,
plainTextBody,
sanitizedHtmlBody,
attachments,
connectedAccount: connectedAccountWithFreshTokens,
},
};
}
}
@@ -19,7 +19,7 @@ const EmailRecipientsZodSchema = z.object({
.default(''),
});
export const SendEmailInputZodSchema = z.object({
export const EmailToolInputZodSchema = z.object({
recipients: EmailRecipientsZodSchema.describe(
'Recipients object with to, cc, and bcc fields (comma-separated)',
),
@@ -4,7 +4,7 @@ import { assertUnreachable } from 'twenty-shared/utils';
import { CustomException } from 'src/utils/custom-exception';
export enum SendEmailToolExceptionCode {
export enum EmailToolExceptionCode {
INVALID_CONNECTED_ACCOUNT_ID = 'INVALID_CONNECTED_ACCOUNT_ID',
CONNECTED_ACCOUNT_NOT_FOUND = 'CONNECTED_ACCOUNT_NOT_FOUND',
INVALID_EMAIL = 'INVALID_EMAIL',
@@ -13,37 +13,36 @@ export enum SendEmailToolExceptionCode {
INVALID_FILE_ID = 'INVALID_FILE_ID',
}
const getSendEmailToolExceptionUserFriendlyMessage = (
code: SendEmailToolExceptionCode,
const getEmailToolExceptionUserFriendlyMessage = (
code: EmailToolExceptionCode,
) => {
switch (code) {
case SendEmailToolExceptionCode.INVALID_CONNECTED_ACCOUNT_ID:
case EmailToolExceptionCode.INVALID_CONNECTED_ACCOUNT_ID:
return msg`Invalid connected account ID.`;
case SendEmailToolExceptionCode.CONNECTED_ACCOUNT_NOT_FOUND:
case EmailToolExceptionCode.CONNECTED_ACCOUNT_NOT_FOUND:
return msg`Connected account not found.`;
case SendEmailToolExceptionCode.INVALID_EMAIL:
case EmailToolExceptionCode.INVALID_EMAIL:
return msg`Invalid email address.`;
case SendEmailToolExceptionCode.WORKSPACE_ID_NOT_FOUND:
case EmailToolExceptionCode.WORKSPACE_ID_NOT_FOUND:
return msg`Workspace not found.`;
case SendEmailToolExceptionCode.FILE_NOT_FOUND:
case EmailToolExceptionCode.FILE_NOT_FOUND:
return msg`File not found.`;
case SendEmailToolExceptionCode.INVALID_FILE_ID:
case EmailToolExceptionCode.INVALID_FILE_ID:
return msg`Invalid file ID.`;
default:
assertUnreachable(code);
}
};
export class SendEmailToolException extends CustomException<SendEmailToolExceptionCode> {
export class EmailToolException extends CustomException<EmailToolExceptionCode> {
constructor(
message: string,
code: SendEmailToolExceptionCode,
code: EmailToolExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {},
) {
super(message, code, {
userFriendlyMessage:
userFriendlyMessage ??
getSendEmailToolExceptionUserFriendlyMessage(code),
userFriendlyMessage ?? getEmailToolExceptionUserFriendlyMessage(code),
});
}
}
@@ -0,0 +1,95 @@
import { Injectable, Logger } from '@nestjs/common';
import { EmailComposerService } from 'src/engine/core-modules/tool/tools/email-tool/email-composer.service';
import { EmailToolInputZodSchema } from 'src/engine/core-modules/tool/tools/email-tool/email-tool.schema';
import { EmailToolException } from 'src/engine/core-modules/tool/tools/email-tool/exceptions/email-tool.exception';
import { type ComposedEmail } from 'src/engine/core-modules/tool/tools/email-tool/types/composed-email.type';
import { type EmailToolInput } from 'src/engine/core-modules/tool/tools/email-tool/types/email-tool-input.type';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
import {
type Tool,
type ToolExecutionContext,
} from 'src/engine/core-modules/tool/types/tool.type';
import { MessagingMessageOutboundService } from 'src/modules/messaging/message-outbound-manager/services/messaging-message-outbound.service';
@Injectable()
export class SendEmailTool implements Tool {
private readonly logger = new Logger(SendEmailTool.name);
description =
'Send an email using a connected account. Requires SEND_EMAIL_TOOL permission.';
inputSchema = EmailToolInputZodSchema;
constructor(
private readonly emailComposerService: EmailComposerService,
private readonly messageOutboundService: MessagingMessageOutboundService,
) {}
async execute(
parameters: EmailToolInput,
context: ToolExecutionContext,
): Promise<ToolOutput> {
try {
const result = await this.emailComposerService.composeEmail(
parameters,
context,
);
if (!result.success) {
return result.output;
}
const { data } = result;
await this.sendEmail(data);
this.logger.log(
`Email sent successfully to ${data.toRecipientsDisplay}${data.attachments.length > 0 ? ` with ${data.attachments.length} attachments` : ''}`,
);
return {
success: true,
message: `Email sent successfully to ${data.toRecipientsDisplay}`,
result: {
recipients: data.recipients.to,
ccRecipients: data.recipients.cc,
bccRecipients: data.recipients.bcc,
subject: data.sanitizedSubject,
connectedAccountId: data.connectedAccount.id,
attachmentCount: data.attachments.length,
},
};
} catch (error) {
if (error instanceof EmailToolException) {
return {
success: false,
message: 'Failed to send email',
error: error.message,
};
}
this.logger.error(`Failed to send email: ${error}`);
return {
success: false,
message: 'Failed to send email',
error: error instanceof Error ? error.message : 'Failed to send email',
};
}
}
private async sendEmail(data: ComposedEmail): Promise<void> {
await this.messageOutboundService.sendMessage(
{
to: data.recipients.to,
cc: data.recipients.cc.length > 0 ? data.recipients.cc : undefined,
bcc: data.recipients.bcc.length > 0 ? data.recipients.bcc : undefined,
subject: data.sanitizedSubject,
body: data.plainTextBody,
html: data.sanitizedHtmlBody,
attachments: data.attachments,
},
data.connectedAccount,
);
}
}
@@ -0,0 +1,12 @@
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { type MessageAttachment } from 'src/modules/messaging/message-import-manager/types/message';
export type ComposedEmail = {
recipients: { to: string[]; cc: string[]; bcc: string[] };
toRecipientsDisplay: string;
sanitizedSubject: string;
plainTextBody: string;
sanitizedHtmlBody: string;
attachments: MessageAttachment[];
connectedAccount: ConnectedAccountWorkspaceEntity;
};
@@ -0,0 +1,7 @@
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
import { type ComposedEmail } from './composed-email.type';
export type EmailComposerResult =
| { success: true; data: ComposedEmail }
| { success: false; output: ToolOutput };
@@ -0,0 +1,5 @@
import { type z } from 'zod';
import { type EmailToolInputZodSchema } from 'src/engine/core-modules/tool/tools/email-tool/email-tool.schema';
export type EmailToolInput = z.infer<typeof EmailToolInputZodSchema>;
@@ -1,4 +1,4 @@
import { parseCommaSeparatedEmails } from 'src/engine/core-modules/tool/tools/send-email-tool/utils/parse-comma-separated-emails.util';
import { parseCommaSeparatedEmails } from 'src/engine/core-modules/tool/tools/email-tool/utils/parse-comma-separated-emails.util';
describe('SendEmailTool - parseCommaSeparatedEmails', () => {
it('should parse comma-separated emails into array', () => {
@@ -1,5 +0,0 @@
import { type z } from 'zod';
import { type SendEmailInputZodSchema } from 'src/engine/core-modules/tool/tools/send-email-tool/send-email-tool.schema';
export type SendEmailInput = z.infer<typeof SendEmailInputZodSchema>;
@@ -244,6 +244,7 @@ describe('WorkspaceEntityManager', () => {
IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED: false,
IS_MARKETPLACE_ENABLED: false,
IS_FILES_FIELD_MIGRATED: false,
IS_DRAFT_EMAIL_ENABLED: false,
},
userWorkspaceRoleMap: {},
eventEmitterService: {
@@ -10,6 +10,7 @@ import { BlocklistWorkspaceEntity } from 'src/modules/blocklist/standard-objects
import { EmailAliasManagerModule } from 'src/modules/connected-account/email-alias-manager/email-alias-manager.module';
import { MessagingCommonModule } from 'src/modules/messaging/common/messaging-common.module';
import { ImapClientProvider } from 'src/modules/messaging/message-import-manager/drivers/imap/providers/imap-client.provider';
import { ImapFindDraftsFolderService } from 'src/modules/messaging/message-import-manager/drivers/imap/services/imap-find-drafts-folder.service';
import { ImapFindSentFolderService } from 'src/modules/messaging/message-import-manager/drivers/imap/services/imap-find-sent-folder.service';
import { ImapGetMessageListService } from 'src/modules/messaging/message-import-manager/drivers/imap/services/imap-get-message-list.service';
import { ImapGetMessagesService } from 'src/modules/messaging/message-import-manager/drivers/imap/services/imap-get-messages.service';
@@ -39,6 +40,7 @@ import { MessageParticipantManagerModule } from 'src/modules/messaging/message-p
ImapMessagesImportErrorHandler,
ImapSyncService,
ImapMessageParserService,
ImapFindDraftsFolderService,
ImapFindSentFolderService,
ImapMessageTextExtractorService,
],
@@ -46,6 +48,7 @@ import { MessageParticipantManagerModule } from 'src/modules/messaging/message-p
ImapGetMessagesService,
ImapGetMessageListService,
ImapClientProvider,
ImapFindDraftsFolderService,
ImapFindSentFolderService,
],
})
@@ -0,0 +1,101 @@
import { Injectable, Logger } from '@nestjs/common';
import { type ImapFlow, type ListResponse } from 'imapflow';
import { StandardFolder } from 'src/modules/messaging/message-import-manager/drivers/types/standard-folder';
import { getStandardFolderByRegex } from 'src/modules/messaging/message-import-manager/drivers/utils/get-standard-folder-by-regex';
type DraftsFolderResult = {
name: string;
path: string;
} | null;
@Injectable()
export class ImapFindDraftsFolderService {
private readonly logger = new Logger(ImapFindDraftsFolderService.name);
public async findOrCreateDraftsFolder(
client: ImapFlow,
): Promise<DraftsFolderResult> {
try {
const list = await client.list();
const specialUseDraftsFolder = this.findDraftsFolderBySpecialUse(list);
if (specialUseDraftsFolder) {
return specialUseDraftsFolder;
}
const regexDraftsFolder = this.findDraftsFolderByRegex(list);
if (regexDraftsFolder) {
return regexDraftsFolder;
}
return await this.createDraftsFolder(client);
} catch (error) {
this.logger.error(
`Error finding drafts folder: ${error instanceof Error ? error.message : error}`,
);
return null;
}
}
private findDraftsFolderBySpecialUse(
list: ListResponse[],
): DraftsFolderResult {
for (const folder of list) {
if (folder.specialUse && folder.specialUse.includes('\\Drafts')) {
this.logger.debug(
`Found drafts folder via special-use flag: ${folder.path}`,
);
return {
name: folder.name,
path: folder.path,
};
}
}
return null;
}
private findDraftsFolderByRegex(list: ListResponse[]): DraftsFolderResult {
for (const folder of list) {
if (getStandardFolderByRegex(folder.name) === StandardFolder.DRAFTS) {
this.logger.debug(
`Found drafts folder via pattern match: ${folder.path}`,
);
return {
name: folder.name,
path: folder.path,
};
}
}
return null;
}
private async createDraftsFolder(
client: ImapFlow,
): Promise<DraftsFolderResult> {
try {
await client.mailboxCreate('Drafts');
this.logger.debug('Created drafts folder: Drafts');
return {
name: 'Drafts',
path: 'Drafts',
};
} catch (error) {
this.logger.error(
`Failed to create drafts folder: ${error instanceof Error ? error.message : error}`,
);
return null;
}
}
}
@@ -68,7 +68,7 @@ export class ImapFindSentFolderService {
): Promise<SentFolderResult> {
for (const folder of list) {
if (folder.specialUse && folder.specialUse.includes('\\Sent')) {
this.logger.log(
this.logger.debug(
`Found sent folder via special-use flag: ${folder.path}`,
);
@@ -108,7 +108,7 @@ export class ImapFindSentFolderService {
);
if (messageCount > 0) {
this.logger.log(
this.logger.debug(
`Selected sent folder via pattern match: ${folder.path}`,
);
@@ -120,7 +120,7 @@ export class ImapFindSentFolderService {
}
if (regexCandidateFolders.length > 0) {
this.logger.log(
this.logger.debug(
`Using first regex candidate sent folder: ${regexCandidateFolders[0].path} (no messages found in any regex candidate)`,
);
@@ -48,7 +48,6 @@ import { MessagingMessagesImportService } from 'src/modules/messaging/message-im
import { MessagingProcessFolderActionsService } from 'src/modules/messaging/message-import-manager/services/messaging-process-folder-actions.service';
import { MessagingProcessGroupEmailActionsService } from 'src/modules/messaging/message-import-manager/services/messaging-process-group-email-actions.service';
import { MessagingSaveMessagesAndEnqueueContactCreationService } from 'src/modules/messaging/message-import-manager/services/messaging-save-messages-and-enqueue-contact-creation.service';
import { MessagingSendMessageService } from 'src/modules/messaging/message-import-manager/services/messaging-send-message.service';
import { MessageParticipantManagerModule } from 'src/modules/messaging/message-participant-manager/message-participant-manager.module';
import { MessagingMonitoringModule } from 'src/modules/messaging/monitoring/messaging-monitoring.module';
@Module({
@@ -101,7 +100,6 @@ import { MessagingMonitoringModule } from 'src/modules/messaging/monitoring/mess
MessagingGetMessagesService,
MessageImportExceptionHandlerService,
MessagingCursorService,
MessagingSendMessageService,
MessagingAccountAuthenticationService,
MessagingProcessFolderActionsService,
MessagingProcessGroupEmailActionsService,
@@ -109,7 +107,6 @@ import { MessagingMonitoringModule } from 'src/modules/messaging/monitoring/mess
MessagingDeleteGroupEmailMessagesService,
],
exports: [
MessagingSendMessageService,
MessagingAccountAuthenticationService,
MessagingMessageListFetchCronCommand,
MessagingMessagesImportCronCommand,
@@ -1,225 +0,0 @@
import { Injectable } from '@nestjs/common';
import { google } from 'googleapis';
import MailComposer from 'nodemailer/lib/mail-composer';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
import { z } from 'zod';
import { OAuth2ClientManagerService } from 'src/modules/connected-account/oauth2-client-manager/services/oauth2-client-manager.service';
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import {
MessageImportDriverException,
MessageImportDriverExceptionCode,
} from 'src/modules/messaging/message-import-manager/drivers/exceptions/message-import-driver.exception';
import { ImapClientProvider } from 'src/modules/messaging/message-import-manager/drivers/imap/providers/imap-client.provider';
import { SmtpClientProvider } from 'src/modules/messaging/message-import-manager/drivers/smtp/providers/smtp-client.provider';
import { mimeEncode } from 'src/modules/messaging/message-import-manager/utils/mime-encode.util';
import { toMicrosoftRecipients } from 'src/modules/messaging/message-import-manager/utils/to-microsoft-recipients.util';
type EmailAddress = string | string[];
type SendMessageInput = {
body: string;
subject: string;
to: EmailAddress;
cc?: EmailAddress;
bcc?: EmailAddress;
html: string;
attachments?: {
filename: string;
content: Buffer;
contentType: string;
}[];
};
@Injectable()
export class MessagingSendMessageService {
constructor(
private readonly oAuth2ClientManagerService: OAuth2ClientManagerService,
private readonly smtpClientProvider: SmtpClientProvider,
private readonly imapClientProvider: ImapClientProvider,
) {}
public async sendMessage(
sendMessageInput: SendMessageInput,
connectedAccount: ConnectedAccountWorkspaceEntity,
): Promise<void> {
switch (connectedAccount.provider) {
case ConnectedAccountProvider.GOOGLE: {
const oAuth2Client =
await this.oAuth2ClientManagerService.getGoogleOAuth2Client(
connectedAccount,
);
const gmailClient = google.gmail({
version: 'v1',
auth: oAuth2Client,
});
const peopleClient = google.people({
version: 'v1',
auth: oAuth2Client,
});
const { data: gmailData } = await gmailClient.users.getProfile({
userId: 'me',
});
const fromEmail = gmailData.emailAddress;
const { data: peopleData } = await peopleClient.people.get({
resourceName: 'people/me',
personFields: 'names',
});
const fromName = peopleData?.names?.[0]?.displayName;
const mail = new MailComposer({
from: isDefined(fromName)
? `"${mimeEncode(fromName)}" <${fromEmail}>`
: `${fromEmail}`,
to: sendMessageInput.to,
cc: sendMessageInput.cc,
bcc: sendMessageInput.bcc,
subject: sendMessageInput.subject,
text: sendMessageInput.body,
html: sendMessageInput.html,
...(sendMessageInput.attachments &&
sendMessageInput.attachments.length > 0
? {
attachments: sendMessageInput.attachments.map((attachment) => ({
filename: attachment.filename,
content: attachment.content,
contentType: attachment.contentType,
})),
}
: {}),
});
const compiledMessage = mail.compile();
compiledMessage.keepBcc = true;
const messageBuffer = await compiledMessage.build();
const encodedMessage = Buffer.from(messageBuffer).toString('base64');
await gmailClient.users.messages.send({
userId: 'me',
requestBody: {
raw: encodedMessage,
},
});
break;
}
case ConnectedAccountProvider.MICROSOFT: {
const microsoftClient =
await this.oAuth2ClientManagerService.getMicrosoftOAuth2Client(
connectedAccount,
);
const message = {
subject: sendMessageInput.subject,
body: {
contentType: 'HTML',
content: sendMessageInput.html,
},
toRecipients: toMicrosoftRecipients(sendMessageInput.to),
ccRecipients: toMicrosoftRecipients(sendMessageInput.cc),
bccRecipients: toMicrosoftRecipients(sendMessageInput.bcc),
...(sendMessageInput.attachments &&
sendMessageInput.attachments.length > 0
? {
attachments: sendMessageInput.attachments.map((attachment) => ({
'@odata.type': '#microsoft.graph.fileAttachment',
name: attachment.filename,
contentType: attachment.contentType,
contentBytes: attachment.content.toString('base64'),
})),
}
: {}),
};
const response = await microsoftClient
.api(`/me/messages`)
.post(message);
z.string().parse(response.id);
await microsoftClient.api(`/me/messages/${response.id}/send`).post({});
break;
}
case ConnectedAccountProvider.IMAP_SMTP_CALDAV: {
const { handle, connectionParameters, messageChannels } =
connectedAccount;
const smtpClient =
await this.smtpClientProvider.getSmtpClient(connectedAccount);
if (!isDefined(handle)) {
throw new MessageImportDriverException(
'Handle is required',
MessageImportDriverExceptionCode.CHANNEL_MISCONFIGURED,
);
}
const mail = new MailComposer({
from: handle,
to: sendMessageInput.to,
cc: sendMessageInput.cc,
bcc: sendMessageInput.bcc,
subject: sendMessageInput.subject,
text: sendMessageInput.body,
html: sendMessageInput.html,
...(sendMessageInput.attachments &&
sendMessageInput.attachments.length > 0
? {
attachments: sendMessageInput.attachments.map((attachment) => ({
filename: attachment.filename,
content: attachment.content,
contentType: attachment.contentType,
})),
}
: {}),
});
const messageBuffer = await mail.compile().build();
await smtpClient.sendMail({
from: handle,
to: sendMessageInput.to,
cc: sendMessageInput.cc,
bcc: sendMessageInput.bcc,
raw: messageBuffer,
});
if (isDefined(connectionParameters?.IMAP)) {
const imapClient =
await this.imapClientProvider.getClient(connectedAccount);
const messageChannel = messageChannels.find(
(channel) => channel.handle === handle,
);
const sentFolder = messageChannel?.messageFolders.find(
(messageFolder) => messageFolder.isSentFolder,
);
if (isDefined(sentFolder) && isDefined(sentFolder.name)) {
await imapClient.append(sentFolder.name, messageBuffer);
}
await this.imapClientProvider.closeClient(imapClient);
}
break;
}
default:
assertUnreachable(
connectedAccount.provider,
`Provider ${connectedAccount.provider} not supported for sending messages`,
);
}
}
}
@@ -4,9 +4,7 @@ import { google } from 'googleapis';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { OAuth2ClientManagerService } from 'src/modules/connected-account/oauth2-client-manager/services/oauth2-client-manager.service';
import { ImapClientProvider } from 'src/modules/messaging/message-import-manager/drivers/imap/providers/imap-client.provider';
import { SmtpClientProvider } from 'src/modules/messaging/message-import-manager/drivers/smtp/providers/smtp-client.provider';
import { MessagingSendMessageService } from 'src/modules/messaging/message-import-manager/services/messaging-send-message.service';
import { GmailMessageOutboundService } from 'src/modules/messaging/message-outbound-manager/drivers/gmail/services/gmail-message-outbound.service';
jest.mock('nodemailer/lib/mail-composer', () => {
return jest.fn().mockImplementation(() => ({
@@ -16,8 +14,8 @@ jest.mock('nodemailer/lib/mail-composer', () => {
}));
});
describe('MessagingSendMessageService - Gmail HTML Support', () => {
let service: MessagingSendMessageService;
describe('GmailMessageOutboundService', () => {
let service: GmailMessageOutboundService;
const mockSend = jest.fn().mockResolvedValue({ data: { id: 'message-id' } });
@@ -54,7 +52,7 @@ describe('MessagingSendMessageService - Gmail HTML Support', () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
MessagingSendMessageService,
GmailMessageOutboundService,
{
provide: OAuth2ClientManagerService,
useValue: {
@@ -63,19 +61,11 @@ describe('MessagingSendMessageService - Gmail HTML Support', () => {
.mockResolvedValue(mockOAuth2Client),
},
},
{
provide: SmtpClientProvider,
useValue: {},
},
{
provide: ImapClientProvider,
useValue: {},
},
],
}).compile();
service = module.get<MessagingSendMessageService>(
MessagingSendMessageService,
service = module.get<GmailMessageOutboundService>(
GmailMessageOutboundService,
);
});
@@ -105,7 +95,7 @@ describe('MessagingSendMessageService - Gmail HTML Support', () => {
expect(mockSend).toHaveBeenCalledWith({
userId: 'me',
requestBody: {
raw: Buffer.from('mocked-email-content').toString('base64'),
raw: Buffer.from('mocked-email-content').toString('base64url'),
},
});
});
@@ -137,7 +127,7 @@ describe('MessagingSendMessageService - Gmail HTML Support', () => {
expect(mockSend).toHaveBeenCalledWith({
userId: 'me',
requestBody: {
raw: Buffer.from('mocked-email-content').toString('base64'),
raw: Buffer.from('mocked-email-content').toString('base64url'),
},
});
});
@@ -0,0 +1,109 @@
import { Injectable } from '@nestjs/common';
import { type gmail_v1, google } from 'googleapis';
import MailComposer from 'nodemailer/lib/mail-composer';
import { isDefined } from 'twenty-shared/utils';
import { type MessageOutboundDriver } from 'src/modules/messaging/message-outbound-manager/interfaces/message-outbound-driver.interface';
import { OAuth2ClientManagerService } from 'src/modules/connected-account/oauth2-client-manager/services/oauth2-client-manager.service';
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { mimeEncode } from 'src/modules/messaging/message-import-manager/utils/mime-encode.util';
import { type SendMessageInput } from 'src/modules/messaging/message-outbound-manager/types/send-message-input.type';
import { toMailComposerOptions } from 'src/modules/messaging/message-outbound-manager/utils/to-mail-composer-options.util';
@Injectable()
export class GmailMessageOutboundService implements MessageOutboundDriver {
constructor(
private readonly oAuth2ClientManagerService: OAuth2ClientManagerService,
) {}
async sendMessage(
sendMessageInput: SendMessageInput,
connectedAccount: ConnectedAccountWorkspaceEntity,
): Promise<void> {
const { gmailClient, encodedMessage } = await this.composeGmailMessage(
connectedAccount,
sendMessageInput,
);
await gmailClient.users.messages.send({
userId: 'me',
requestBody: {
raw: encodedMessage,
},
});
}
async createDraft(
sendMessageInput: SendMessageInput,
connectedAccount: ConnectedAccountWorkspaceEntity,
): Promise<void> {
const { gmailClient, encodedMessage } = await this.composeGmailMessage(
connectedAccount,
sendMessageInput,
);
await gmailClient.users.drafts.create({
userId: 'me',
requestBody: {
message: {
raw: encodedMessage,
},
},
});
}
private async composeGmailMessage(
connectedAccount: ConnectedAccountWorkspaceEntity,
sendMessageInput: SendMessageInput,
): Promise<{
gmailClient: gmail_v1.Gmail;
encodedMessage: string;
}> {
const oAuth2Client =
await this.oAuth2ClientManagerService.getGoogleOAuth2Client(
connectedAccount,
);
const gmailClient = google.gmail({
version: 'v1',
auth: oAuth2Client,
});
const peopleClient = google.people({
version: 'v1',
auth: oAuth2Client,
});
const { data: gmailData } = await gmailClient.users.getProfile({
userId: 'me',
});
const fromEmail = gmailData.emailAddress;
const { data: peopleData } = await peopleClient.people.get({
resourceName: 'people/me',
personFields: 'names',
});
const fromName = peopleData?.names?.[0]?.displayName;
const from = isDefined(fromName)
? `"${mimeEncode(fromName)}" <${fromEmail}>`
: `${fromEmail}`;
const mail = new MailComposer(
toMailComposerOptions(from, sendMessageInput),
);
const compiledMessage = mail.compile();
compiledMessage.keepBcc = true;
const messageBuffer = await compiledMessage.build();
const encodedMessage = Buffer.from(messageBuffer).toString('base64url');
return { gmailClient, encodedMessage };
}
}
@@ -0,0 +1,122 @@
import { Injectable } from '@nestjs/common';
import MailComposer from 'nodemailer/lib/mail-composer';
import { isDefined } from 'twenty-shared/utils';
import { type MessageOutboundDriver } from 'src/modules/messaging/message-outbound-manager/interfaces/message-outbound-driver.interface';
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { ImapClientProvider } from 'src/modules/messaging/message-import-manager/drivers/imap/providers/imap-client.provider';
import { ImapFindDraftsFolderService } from 'src/modules/messaging/message-import-manager/drivers/imap/services/imap-find-drafts-folder.service';
import { SmtpClientProvider } from 'src/modules/messaging/message-import-manager/drivers/smtp/providers/smtp-client.provider';
import { type SendMessageInput } from 'src/modules/messaging/message-outbound-manager/types/send-message-input.type';
import { toMailComposerOptions } from 'src/modules/messaging/message-outbound-manager/utils/to-mail-composer-options.util';
@Injectable()
export class ImapSmtpMessageOutboundService implements MessageOutboundDriver {
constructor(
private readonly smtpClientProvider: SmtpClientProvider,
private readonly imapClientProvider: ImapClientProvider,
private readonly imapFindDraftsFolderService: ImapFindDraftsFolderService,
) {}
async sendMessage(
sendMessageInput: SendMessageInput,
connectedAccount: ConnectedAccountWorkspaceEntity,
): Promise<void> {
const { handle, connectionParameters, messageChannels } = connectedAccount;
const smtpClient =
await this.smtpClientProvider.getSmtpClient(connectedAccount);
this.assertHandleIsDefined(handle);
const messageBuffer = await this.compileRawMessage(
handle,
sendMessageInput,
);
await smtpClient.sendMail({
from: handle,
to: sendMessageInput.to,
cc: sendMessageInput.cc,
bcc: sendMessageInput.bcc,
raw: messageBuffer,
});
if (isDefined(connectionParameters?.IMAP)) {
const imapClient =
await this.imapClientProvider.getClient(connectedAccount);
const messageChannel = messageChannels.find(
(channel) => channel.handle === handle,
);
const sentFolder = messageChannel?.messageFolders.find(
(messageFolder) => messageFolder.isSentFolder,
);
if (isDefined(sentFolder) && isDefined(sentFolder.name)) {
await imapClient.append(sentFolder.name, messageBuffer);
}
await this.imapClientProvider.closeClient(imapClient);
}
}
async createDraft(
sendMessageInput: SendMessageInput,
connectedAccount: ConnectedAccountWorkspaceEntity,
): Promise<void> {
const { handle, connectionParameters } = connectedAccount;
this.assertHandleIsDefined(handle);
if (!isDefined(connectionParameters?.IMAP)) {
throw new Error('IMAP connection is required to create drafts');
}
const messageBuffer = await this.compileRawMessage(
handle,
sendMessageInput,
);
const imapClient =
await this.imapClientProvider.getClient(connectedAccount);
try {
const draftsFolder =
await this.imapFindDraftsFolderService.findOrCreateDraftsFolder(
imapClient,
);
if (!isDefined(draftsFolder)) {
throw new Error('No drafts folder found and could not create one');
}
const DRAFT_FLAG = '\\Draft';
await imapClient.append(draftsFolder.path, messageBuffer, [DRAFT_FLAG]);
} finally {
await this.imapClientProvider.closeClient(imapClient);
}
}
private async compileRawMessage(
from: string,
sendMessageInput: SendMessageInput,
): Promise<Buffer> {
const mail = new MailComposer(
toMailComposerOptions(from, sendMessageInput),
);
return mail.compile().build();
}
private assertHandleIsDefined(
handle: string | null,
): asserts handle is string {
if (!isDefined(handle)) {
throw new Error('Handle is required');
}
}
}
@@ -0,0 +1,75 @@
import { Injectable } from '@nestjs/common';
import { z } from 'zod';
import { type MessageOutboundDriver } from 'src/modules/messaging/message-outbound-manager/interfaces/message-outbound-driver.interface';
import { OAuth2ClientManagerService } from 'src/modules/connected-account/oauth2-client-manager/services/oauth2-client-manager.service';
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { toMicrosoftRecipients } from 'src/modules/messaging/message-import-manager/utils/to-microsoft-recipients.util';
import { type SendMessageInput } from 'src/modules/messaging/message-outbound-manager/types/send-message-input.type';
@Injectable()
export class MicrosoftMessageOutboundService implements MessageOutboundDriver {
constructor(
private readonly oAuth2ClientManagerService: OAuth2ClientManagerService,
) {}
async sendMessage(
sendMessageInput: SendMessageInput,
connectedAccount: ConnectedAccountWorkspaceEntity,
): Promise<void> {
const microsoftClient =
await this.oAuth2ClientManagerService.getMicrosoftOAuth2Client(
connectedAccount,
);
const message = this.composeMicrosoftMessage(sendMessageInput);
const response = await microsoftClient.api(`/me/messages`).post(message);
z.string().parse(response.id);
await microsoftClient.api(`/me/messages/${response.id}/send`).post({});
}
async createDraft(
sendMessageInput: SendMessageInput,
connectedAccount: ConnectedAccountWorkspaceEntity,
): Promise<void> {
const microsoftClient =
await this.oAuth2ClientManagerService.getMicrosoftOAuth2Client(
connectedAccount,
);
const message = this.composeMicrosoftMessage(sendMessageInput);
await microsoftClient.api(`/me/messages`).post(message);
}
private composeMicrosoftMessage(
sendMessageInput: SendMessageInput,
): Record<string, unknown> {
return {
subject: sendMessageInput.subject,
body: {
contentType: 'HTML',
content: sendMessageInput.html,
},
toRecipients: toMicrosoftRecipients(sendMessageInput.to),
ccRecipients: toMicrosoftRecipients(sendMessageInput.cc),
bccRecipients: toMicrosoftRecipients(sendMessageInput.bcc),
...(sendMessageInput.attachments &&
sendMessageInput.attachments.length > 0
? {
attachments: sendMessageInput.attachments.map((attachment) => ({
'@odata.type': '#microsoft.graph.fileAttachment',
name: attachment.filename,
contentType: attachment.contentType,
contentBytes: attachment.content.toString('base64'),
})),
}
: {}),
};
}
}
@@ -0,0 +1,14 @@
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { type SendMessageInput } from 'src/modules/messaging/message-outbound-manager/types/send-message-input.type';
export type MessageOutboundDriver = {
sendMessage(
sendMessageInput: SendMessageInput,
connectedAccount: ConnectedAccountWorkspaceEntity,
): Promise<void>;
createDraft(
sendMessageInput: SendMessageInput,
connectedAccount: ConnectedAccountWorkspaceEntity,
): Promise<void>;
};
@@ -0,0 +1,25 @@
import { Module } from '@nestjs/common';
import { OAuth2ClientManagerModule } from 'src/modules/connected-account/oauth2-client-manager/oauth2-client-manager.module';
import { MessagingIMAPDriverModule } from 'src/modules/messaging/message-import-manager/drivers/imap/messaging-imap-driver.module';
import { MessagingSmtpDriverModule } from 'src/modules/messaging/message-import-manager/drivers/smtp/messaging-smtp-driver.module';
import { GmailMessageOutboundService } from 'src/modules/messaging/message-outbound-manager/drivers/gmail/services/gmail-message-outbound.service';
import { ImapSmtpMessageOutboundService } from 'src/modules/messaging/message-outbound-manager/drivers/imap/services/imap-smtp-message-outbound.service';
import { MicrosoftMessageOutboundService } from 'src/modules/messaging/message-outbound-manager/drivers/microsoft/services/microsoft-message-outbound.service';
import { MessagingMessageOutboundService } from 'src/modules/messaging/message-outbound-manager/services/messaging-message-outbound.service';
@Module({
imports: [
OAuth2ClientManagerModule,
MessagingIMAPDriverModule,
MessagingSmtpDriverModule,
],
providers: [
GmailMessageOutboundService,
MicrosoftMessageOutboundService,
ImapSmtpMessageOutboundService,
MessagingMessageOutboundService,
],
exports: [MessagingMessageOutboundService],
})
export class MessagingSendManagerModule {}
@@ -0,0 +1,75 @@
import { Injectable } from '@nestjs/common';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { assertUnreachable } from 'twenty-shared/utils';
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { GmailMessageOutboundService } from 'src/modules/messaging/message-outbound-manager/drivers/gmail/services/gmail-message-outbound.service';
import { ImapSmtpMessageOutboundService } from 'src/modules/messaging/message-outbound-manager/drivers/imap/services/imap-smtp-message-outbound.service';
import { MicrosoftMessageOutboundService } from 'src/modules/messaging/message-outbound-manager/drivers/microsoft/services/microsoft-message-outbound.service';
import { SendMessageInput } from 'src/modules/messaging/message-outbound-manager/types/send-message-input.type';
@Injectable()
export class MessagingMessageOutboundService {
constructor(
private readonly gmailMessageOutboundService: GmailMessageOutboundService,
private readonly microsoftMessageOutboundService: MicrosoftMessageOutboundService,
private readonly imapSmtpMessageOutboundService: ImapSmtpMessageOutboundService,
) {}
public async sendMessage(
sendMessageInput: SendMessageInput,
connectedAccount: ConnectedAccountWorkspaceEntity,
): Promise<void> {
switch (connectedAccount.provider) {
case ConnectedAccountProvider.GOOGLE:
return this.gmailMessageOutboundService.sendMessage(
sendMessageInput,
connectedAccount,
);
case ConnectedAccountProvider.MICROSOFT:
return this.microsoftMessageOutboundService.sendMessage(
sendMessageInput,
connectedAccount,
);
case ConnectedAccountProvider.IMAP_SMTP_CALDAV:
return this.imapSmtpMessageOutboundService.sendMessage(
sendMessageInput,
connectedAccount,
);
default:
assertUnreachable(
connectedAccount.provider,
`Provider ${connectedAccount.provider} not supported for sending messages`,
);
}
}
public async createDraft(
sendMessageInput: SendMessageInput,
connectedAccount: ConnectedAccountWorkspaceEntity,
): Promise<void> {
switch (connectedAccount.provider) {
case ConnectedAccountProvider.GOOGLE:
return this.gmailMessageOutboundService.createDraft(
sendMessageInput,
connectedAccount,
);
case ConnectedAccountProvider.MICROSOFT:
return this.microsoftMessageOutboundService.createDraft(
sendMessageInput,
connectedAccount,
);
case ConnectedAccountProvider.IMAP_SMTP_CALDAV:
return this.imapSmtpMessageOutboundService.createDraft(
sendMessageInput,
connectedAccount,
);
default:
assertUnreachable(
connectedAccount.provider,
`Provider ${connectedAccount.provider} not supported for creating drafts`,
);
}
}
}
@@ -0,0 +1,15 @@
type EmailAddress = string | string[];
export type SendMessageInput = {
body: string;
subject: string;
to: EmailAddress;
cc?: EmailAddress;
bcc?: EmailAddress;
html: string;
attachments?: {
filename: string;
content: Buffer;
contentType: string;
}[];
};
@@ -0,0 +1,25 @@
import { type SendMessageInput } from 'src/modules/messaging/message-outbound-manager/types/send-message-input.type';
export const toMailComposerOptions = (
from: string,
sendMessageInput: SendMessageInput,
) => {
return {
from,
to: sendMessageInput.to,
cc: sendMessageInput.cc,
bcc: sendMessageInput.bcc,
subject: sendMessageInput.subject,
text: sendMessageInput.body,
html: sendMessageInput.html,
...(sendMessageInput.attachments && sendMessageInput.attachments.length > 0
? {
attachments: sendMessageInput.attachments.map((attachment) => ({
filename: attachment.filename,
content: attachment.content,
contentType: attachment.contentType,
})),
}
: {}),
};
};
@@ -249,6 +249,28 @@ export class WorkflowVersionStepOperationsWorkspaceService {
},
};
}
case WorkflowActionType.DRAFT_EMAIL: {
return {
builtStep: {
...baseStep,
name: 'Draft Email',
type: WorkflowActionType.DRAFT_EMAIL,
settings: {
...BASE_STEP_DEFINITION,
input: {
connectedAccountId: '',
recipients: {
to: '',
cc: '',
bcc: '',
},
subject: '',
body: '',
},
},
},
};
}
case WorkflowActionType.CREATE_RECORD: {
const activeObjectMetadataItem =
await this.objectMetadataRepository.findOne({
@@ -51,6 +51,8 @@ export class WorkflowActionFactory {
return this.logicFunctionWorkflowAction;
case WorkflowActionType.SEND_EMAIL:
return this.toolExecutorWorkflowAction;
case WorkflowActionType.DRAFT_EMAIL:
return this.toolExecutorWorkflowAction;
case WorkflowActionType.CREATE_RECORD:
return this.createRecordWorkflowAction;
case WorkflowActionType.UPSERT_RECORD:
@@ -0,0 +1,11 @@
import {
type WorkflowAction,
WorkflowActionType,
type WorkflowDraftEmailAction,
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
export const isWorkflowDraftEmailAction = (
action: WorkflowAction,
): action is WorkflowDraftEmailAction => {
return action.type === WorkflowActionType.DRAFT_EMAIL;
};
@@ -4,8 +4,9 @@ import { resolveInput, resolveRichTextVariables } from 'twenty-shared/utils';
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface';
import { DraftEmailTool } from 'src/engine/core-modules/tool/tools/email-tool/draft-email-tool';
import { HttpTool } from 'src/engine/core-modules/tool/tools/http-tool/http-tool';
import { SendEmailTool } from 'src/engine/core-modules/tool/tools/send-email-tool/send-email-tool';
import { SendEmailTool } from 'src/engine/core-modules/tool/tools/email-tool/send-email-tool';
import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type';
import { type Tool } from 'src/engine/core-modules/tool/types/tool.type';
import { type WorkflowActionInput } from 'src/modules/workflow/workflow-executor/types/workflow-action-input';
@@ -20,10 +21,12 @@ export class ToolExecutorWorkflowAction implements WorkflowAction {
constructor(
private readonly httpTool: HttpTool,
private readonly sendEmailTool: SendEmailTool,
private readonly draftEmailTool: DraftEmailTool,
) {
this.toolsByActionType = new Map<WorkflowActionType, Tool>([
[WorkflowActionType.HTTP_REQUEST, this.httpTool],
[WorkflowActionType.SEND_EMAIL, this.sendEmailTool],
[WorkflowActionType.DRAFT_EMAIL, this.draftEmailTool],
]);
}
@@ -47,13 +50,16 @@ export class ToolExecutorWorkflowAction implements WorkflowAction {
let toolInput = step.settings.input;
if (step.type === WorkflowActionType.SEND_EMAIL) {
const sendEmailInput = toolInput as WorkflowSendEmailActionInput;
if (
step.type === WorkflowActionType.SEND_EMAIL ||
step.type === WorkflowActionType.DRAFT_EMAIL
) {
const emailInput = toolInput as WorkflowSendEmailActionInput;
if (sendEmailInput.body) {
if (emailInput.body) {
toolInput = {
...sendEmailInput,
body: resolveRichTextVariables(sendEmailInput.body, context),
...emailInput,
body: resolveRichTextVariables(emailInput.body, context),
};
}
}
@@ -2,6 +2,7 @@ export enum WorkflowActionType {
CODE = 'CODE',
LOGIC_FUNCTION = 'LOGIC_FUNCTION',
SEND_EMAIL = 'SEND_EMAIL',
DRAFT_EMAIL = 'DRAFT_EMAIL',
CREATE_RECORD = 'CREATE_RECORD',
UPDATE_RECORD = 'UPDATE_RECORD',
DELETE_RECORD = 'DELETE_RECORD',
@@ -51,6 +51,11 @@ export type WorkflowSendEmailAction = BaseWorkflowAction & {
settings: WorkflowSendEmailActionSettings;
};
export type WorkflowDraftEmailAction = BaseWorkflowAction & {
type: WorkflowActionType.DRAFT_EMAIL;
settings: WorkflowSendEmailActionSettings;
};
export type WorkflowCreateRecordAction = BaseWorkflowAction & {
type: WorkflowActionType.CREATE_RECORD;
settings: WorkflowCreateRecordActionSettings;
@@ -119,6 +124,7 @@ export type WorkflowAction =
| WorkflowCodeAction
| WorkflowLogicFunctionAction
| WorkflowSendEmailAction
| WorkflowDraftEmailAction
| WorkflowCreateRecordAction
| WorkflowUpdateRecordAction
| WorkflowDeleteRecordAction
@@ -24,6 +24,7 @@ export { workflowCronTriggerSchema } from './schemas/cron-trigger-schema';
export { workflowDatabaseEventTriggerSchema } from './schemas/database-event-trigger-schema';
export { workflowDeleteRecordActionSchema } from './schemas/delete-record-action-schema';
export { workflowDeleteRecordActionSettingsSchema } from './schemas/delete-record-action-settings-schema';
export { workflowDraftEmailActionSchema } from './schemas/draft-email-action-schema';
export { workflowEmptyActionSchema } from './schemas/empty-action-schema';
export { workflowEmptyActionSettingsSchema } from './schemas/empty-action-settings-schema';
export { workflowFilterActionSchema } from './schemas/filter-action-schema';
@@ -0,0 +1,8 @@
import { z } from 'zod';
import { baseWorkflowActionSchema } from './base-workflow-action-schema';
import { workflowSendEmailActionSettingsSchema } from './send-email-action-settings-schema';
export const workflowDraftEmailActionSchema = baseWorkflowActionSchema.extend({
type: z.literal('DRAFT_EMAIL'),
settings: workflowSendEmailActionSettingsSchema,
});
@@ -3,6 +3,7 @@ import { workflowAiAgentActionSchema } from './ai-agent-action-schema';
import { workflowCodeActionSchema } from './code-action-schema';
import { workflowCreateRecordActionSchema } from './create-record-action-schema';
import { workflowDeleteRecordActionSchema } from './delete-record-action-schema';
import { workflowDraftEmailActionSchema } from './draft-email-action-schema';
import { workflowEmptyActionSchema } from './empty-action-schema';
import { workflowFilterActionSchema } from './filter-action-schema';
import { workflowFindRecordsActionSchema } from './find-records-action-schema';
@@ -20,6 +21,7 @@ export const workflowActionSchema = z.discriminatedUnion('type', [
workflowCodeActionSchema,
workflowLogicFunctionActionSchema,
workflowSendEmailActionSchema,
workflowDraftEmailActionSchema,
workflowCreateRecordActionSchema,
workflowUpdateRecordActionSchema,
workflowDeleteRecordActionSchema,
@@ -1,6 +1,7 @@
import styled from '@emotion/styled';
import { IconHelp, IconX } from '@ui/display/icon/components/TablerIcons';
import { IconButton, LightButton } from '@ui/input';
import { isDefined } from 'twenty-shared/utils';
export type CalloutVariant =
| 'info'
@@ -10,6 +11,7 @@ export type CalloutVariant =
| 'success';
const StyledCalloutContainer = styled.div<{ variant: CalloutVariant }>`
align-items: flex-start;
background-color: ${({ theme, variant }) =>
variant === 'info'
? theme.color.blue1
@@ -34,15 +36,26 @@ const StyledCalloutContainer = styled.div<{ variant: CalloutVariant }>`
border-radius: ${({ theme }) => theme.border.radius.md};
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2)};
padding: ${({ theme }) => theme.spacing(4)};
position: relative;
margin-bottom: ${({ theme }) => theme.spacing(2)};
margin-left: ${({ theme }) => theme.spacing(7)};
padding: ${({ theme }) =>
`${theme.spacing(3)} ${theme.spacing(3)} ${theme.spacing(2)}`};
`;
const StyledHeader = styled.div`
align-items: center;
align-self: stretch;
display: flex;
flex-direction: row;
gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledIconContainer = styled.div<{ variant: CalloutVariant }>`
align-items: flex-start;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: ${({ theme, variant }) =>
variant === 'info'
? theme.color.blue9
@@ -53,91 +66,85 @@ const StyledIconContainer = styled.div<{ variant: CalloutVariant }>`
: variant === 'error'
? theme.color.red9
: theme.color.gray9};
display: flex;
flex-shrink: 0;
padding-top: ${({ theme }) => theme.spacing(0.5)};
`;
const StyledContent = styled.div`
display: flex;
flex-direction: column;
flex-grow: 1;
gap: ${({ theme }) => theme.spacing(2)};
min-width: 0;
`;
const StyledTitle = styled.div`
flex: 1;
color: ${({ theme }) => theme.font.color.primary};
font-family: ${({ theme }) => theme.font.family};
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.medium};
line-height: 1.5;
line-height: 1.4;
`;
const StyledDescriptionWrapper = styled.div`
display: flex;
padding-left: ${({ theme }) => theme.spacing(6)};
padding-bottom: ${({ theme }) => theme.spacing(2)};
align-self: stretch;
`;
const StyledDescription = styled.div`
flex: 1;
color: ${({ theme }) => theme.font.color.tertiary};
font-family: ${({ theme }) => theme.font.family};
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.regular};
line-height: 15px;
line-height: 1.4;
`;
const StyledFooter = styled.div`
align-items: center;
align-self: stretch;
display: flex;
justify-content: flex-end;
margin-top: ${({ theme }) => theme.spacing(1)};
`;
const StyledCloseButton = styled(IconButton)`
position: absolute;
right: ${({ theme }) => theme.spacing(3)};
top: ${({ theme }) => theme.spacing(3)};
`;
export type CalloutProps = {
variant: CalloutVariant;
title: string;
description: string;
learnMoreText: string;
learnMoreUrl: string;
onClose: () => void;
action?: {
label: string;
onClick: () => void;
};
onClose?: () => void;
};
export const Callout = ({
variant,
title,
description,
learnMoreText,
learnMoreUrl,
action,
onClose,
}: CalloutProps) => {
return (
<StyledCalloutContainer variant={variant}>
<StyledIconContainer variant={variant}>
<IconHelp size={16} />
</StyledIconContainer>
<StyledContent>
<StyledHeader>
<StyledIconContainer variant={variant}>
<IconHelp size={16} />
</StyledIconContainer>
<StyledTitle>{title}</StyledTitle>
<IconButton
Icon={IconX}
size="small"
variant="tertiary"
ariaLabel="Close"
onClick={onClose}
/>
</StyledHeader>
<StyledDescriptionWrapper>
<StyledDescription>{description}</StyledDescription>
</StyledDescriptionWrapper>
{isDefined(action) && (
<StyledFooter>
<LightButton
type={'button'}
title={learnMoreText}
onClick={() => {
window.open(learnMoreUrl, '_blank', 'noopener noreferrer');
}}
type="button"
title={action.label}
onClick={action.onClick}
/>
</StyledFooter>
</StyledContent>
<StyledCloseButton
Icon={IconX}
size="small"
variant="tertiary"
ariaLabel={`Close`}
onClick={onClose}
/>
)}
</StyledCalloutContainer>
);
};
@@ -21,7 +21,10 @@ export const Default: Story = {
variant: 'neutral',
title: 'An callout component',
description: 'Description of callout component',
learnMoreText: 'Learn more link',
action: {
label: 'Learn more link',
onClick: () => {},
},
},
decorators: [ComponentDecorator],
};
@@ -30,7 +33,10 @@ export const Catalog: CatalogStory<Story, typeof Callout> = {
args: {
title: 'An callout component',
description: 'Description of callout component',
learnMoreText: 'Learn more link',
action: {
label: 'Learn more link',
onClick: () => {},
},
},
argTypes: {
variant: { control: false },