From 84afbb4d2c28c4c869570a80417f471eb8d60ae5 Mon Sep 17 00:00:00 2001 From: neo773 <62795688+neo773@users.noreply.github.com> Date: Fri, 13 Feb 2026 22:50:20 +0530 Subject: [PATCH] feat: add draft email workflow action (#17793) Co-authored-by: Claude Opus 4.6 Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- .../src/generated-metadata/graphql.ts | 1 + .../twenty-front/src/generated/graphql.ts | 1 + .../accounts/constants/GmailComposeScope.ts | 2 + .../hasMissingDraftEmailScopes.test.ts | 84 +++++++ .../utils/hasMissingDraftEmailScopes.ts | 35 +++ .../CommandMenuWorkflowSelectAction.tsx | 9 +- .../src/modules/workflow/types/Workflow.ts | 5 + .../WorkflowDiagramStepNodeIcon.tsx | 3 +- .../components/WorkflowRunStepNodeDetail.tsx | 7 +- .../components/WorkflowStepDetail.tsx | 7 +- ...il.tsx => WorkflowEditActionEmailBase.tsx} | 192 +++++---------- ...> WorkflowEditActionEmailBase.stories.tsx} | 70 +++++- .../workflow-actions/constants/CoreActions.ts | 8 +- .../constants/actions/DraftEmailAction.ts | 11 + .../email-action/types/EmailFormData.ts | 10 + .../email-action/types/WorkflowEmailAction.ts | 8 + .../WorkflowEditActionFormBuilder.tsx | 13 +- .../workflow-actions/hooks/useEmailForm.ts | 72 ++++++ .../workflow-actions/utils/getActionIcon.ts | 1 + .../utils/getActionIconColorOrThrow.ts | 1 + .../utils/generate/computeStepOutputSchema.ts | 3 +- .../google-apis-scopes.service.spec.ts | 1 + .../google-apis-scopes.service.util.spec.ts | 1 + .../utils/get-google-apis-oauth-scopes.ts | 1 + .../enums/feature-flag-key.enum.ts | 1 + .../providers/action-tool.provider.ts | 12 +- .../engine/core-modules/tool/tool.module.ts | 17 +- .../tool/tools/email-tool/draft-email-tool.ts | 96 ++++++++ .../email-composer.service.ts} | 233 ++++++++---------- .../email-tool.schema.ts} | 2 +- .../exceptions/email-tool.exception.ts} | 25 +- .../tool/tools/email-tool/send-email-tool.ts | 95 +++++++ .../email-tool/types/composed-email.type.ts | 12 + .../types/email-composer-result.type.ts | 7 + .../email-tool/types/email-tool-input.type.ts | 5 + .../parse-comma-separated-emails.util.ts | 0 .../__tests__/send-email-tool.spec.ts | 2 +- .../types/send-email-input.type.ts | 5 - .../workspace-entity-manager.spec.ts | 1 + .../imap/messaging-imap-driver.module.ts | 3 + .../imap-find-drafts-folder.service.ts | 101 ++++++++ .../services/imap-find-sent-folder.service.ts | 6 +- .../messaging-import-manager.module.ts | 3 - .../messaging-send-message.service.ts | 225 ----------------- .../gmail-message-outbound.service.spec.ts} | 26 +- .../gmail-message-outbound.service.ts | 109 ++++++++ .../imap-smtp-message-outbound.service.ts | 122 +++++++++ .../microsoft-message-outbound.service.ts | 75 ++++++ .../message-outbound-driver.interface.ts | 14 ++ .../messaging-send-manager.module.ts | 25 ++ .../messaging-message-outbound.service.ts | 75 ++++++ .../types/send-message-input.type.ts | 15 ++ .../utils/to-mail-composer-options.util.ts | 25 ++ ...rsion-step-operations.workspace-service.ts | 22 ++ .../factories/workflow-action.factory.ts | 2 + .../is-workflow-draft-email-action.guard.ts | 11 + .../tool-executor-workflow-action.ts | 18 +- .../types/workflow-action-type.enum.ts | 1 + .../types/workflow-action.type.ts | 6 + packages/twenty-shared/src/workflow/index.ts | 1 + .../schemas/draft-email-action-schema.ts | 8 + .../schemas/workflow-action-schema.ts | 2 + .../twenty-ui/src/display/callout/Callout.tsx | 101 ++++---- .../callout/__stories__/Callout.stories.tsx | 10 +- 64 files changed, 1451 insertions(+), 614 deletions(-) create mode 100644 packages/twenty-front/src/modules/accounts/constants/GmailComposeScope.ts create mode 100644 packages/twenty-front/src/modules/accounts/utils/__tests__/hasMissingDraftEmailScopes.test.ts create mode 100644 packages/twenty-front/src/modules/accounts/utils/hasMissingDraftEmailScopes.ts rename packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/{WorkflowEditActionSendEmail.tsx => WorkflowEditActionEmailBase.tsx} (74%) rename packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/__stories__/{WorkflowEditActionSendEmail.stories.tsx => WorkflowEditActionEmailBase.stories.tsx} (67%) create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/constants/actions/DraftEmailAction.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/types/EmailFormData.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/types/WorkflowEmailAction.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/hooks/useEmailForm.ts create mode 100644 packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/draft-email-tool.ts rename packages/twenty-server/src/engine/core-modules/tool/tools/{send-email-tool/send-email-tool.ts => email-tool/email-composer.service.ts} (56%) rename packages/twenty-server/src/engine/core-modules/tool/tools/{send-email-tool/send-email-tool.schema.ts => email-tool/email-tool.schema.ts} (95%) rename packages/twenty-server/src/engine/core-modules/tool/tools/{send-email-tool/exceptions/send-email-tool.exception.ts => email-tool/exceptions/email-tool.exception.ts} (59%) create mode 100644 packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/send-email-tool.ts create mode 100644 packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/types/composed-email.type.ts create mode 100644 packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/types/email-composer-result.type.ts create mode 100644 packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/types/email-tool-input.type.ts rename packages/twenty-server/src/engine/core-modules/tool/tools/{send-email-tool => email-tool}/utils/parse-comma-separated-emails.util.ts (100%) delete mode 100644 packages/twenty-server/src/engine/core-modules/tool/tools/send-email-tool/types/send-email-input.type.ts create mode 100644 packages/twenty-server/src/modules/messaging/message-import-manager/drivers/imap/services/imap-find-drafts-folder.service.ts delete mode 100644 packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-send-message.service.ts rename packages/twenty-server/src/modules/messaging/{message-import-manager/services/__tests__/messaging-send-message-gmail.spec.ts => message-outbound-manager/drivers/gmail/services/__tests__/gmail-message-outbound.service.spec.ts} (80%) create mode 100644 packages/twenty-server/src/modules/messaging/message-outbound-manager/drivers/gmail/services/gmail-message-outbound.service.ts create mode 100644 packages/twenty-server/src/modules/messaging/message-outbound-manager/drivers/imap/services/imap-smtp-message-outbound.service.ts create mode 100644 packages/twenty-server/src/modules/messaging/message-outbound-manager/drivers/microsoft/services/microsoft-message-outbound.service.ts create mode 100644 packages/twenty-server/src/modules/messaging/message-outbound-manager/interfaces/message-outbound-driver.interface.ts create mode 100644 packages/twenty-server/src/modules/messaging/message-outbound-manager/messaging-send-manager.module.ts create mode 100644 packages/twenty-server/src/modules/messaging/message-outbound-manager/services/messaging-message-outbound.service.ts create mode 100644 packages/twenty-server/src/modules/messaging/message-outbound-manager/types/send-message-input.type.ts create mode 100644 packages/twenty-server/src/modules/messaging/message-outbound-manager/utils/to-mail-composer-options.util.ts create mode 100644 packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/mail-sender/guards/is-workflow-draft-email-action.guard.ts create mode 100644 packages/twenty-shared/src/workflow/schemas/draft-email-action-schema.ts diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 20e5518bc3..7a2d83f893 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -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', diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index ef0594f26c..12520090d5 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -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', diff --git a/packages/twenty-front/src/modules/accounts/constants/GmailComposeScope.ts b/packages/twenty-front/src/modules/accounts/constants/GmailComposeScope.ts new file mode 100644 index 0000000000..9192a5b8e1 --- /dev/null +++ b/packages/twenty-front/src/modules/accounts/constants/GmailComposeScope.ts @@ -0,0 +1,2 @@ +export const GMAIL_COMPOSE_SCOPE = + 'https://www.googleapis.com/auth/gmail.compose'; diff --git a/packages/twenty-front/src/modules/accounts/utils/__tests__/hasMissingDraftEmailScopes.test.ts b/packages/twenty-front/src/modules/accounts/utils/__tests__/hasMissingDraftEmailScopes.test.ts new file mode 100644 index 0000000000..afc55cd99d --- /dev/null +++ b/packages/twenty-front/src/modules/accounts/utils/__tests__/hasMissingDraftEmailScopes.test.ts @@ -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 => + ({ + 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([]); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/accounts/utils/hasMissingDraftEmailScopes.ts b/packages/twenty-front/src/modules/accounts/utils/hasMissingDraftEmailScopes.ts new file mode 100644 index 0000000000..03ce55c822 --- /dev/null +++ b/packages/twenty-front/src/modules/accounts/utils/hasMissingDraftEmailScopes.ts @@ -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', + ); + } +}; diff --git a/packages/twenty-front/src/modules/command-menu/pages/workflow/action/components/CommandMenuWorkflowSelectAction.tsx b/packages/twenty-front/src/modules/command-menu/pages/workflow/action/components/CommandMenuWorkflowSelectAction.tsx index f853365be0..e0fbb35754 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/workflow/action/components/CommandMenuWorkflowSelectAction.tsx +++ b/packages/twenty-front/src/modules/command-menu/pages/workflow/action/components/CommandMenuWorkflowSelectAction.tsx @@ -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`} diff --git a/packages/twenty-front/src/modules/workflow/types/Workflow.ts b/packages/twenty-front/src/modules/workflow/types/Workflow.ts index dfcb6bb82a..528b21eedb 100644 --- a/packages/twenty-front/src/modules/workflow/types/Workflow.ts +++ b/packages/twenty-front/src/modules/workflow/types/Workflow.ts @@ -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 diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/workflow-nodes/components/WorkflowDiagramStepNodeIcon.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/workflow-nodes/components/WorkflowDiagramStepNodeIcon.tsx index cab7c1e6cc..b6b7317df1 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/workflow-nodes/components/WorkflowDiagramStepNodeIcon.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/workflow-nodes/components/WorkflowDiagramStepNodeIcon.tsx @@ -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 ( ); } - case 'SEND_EMAIL': { + case 'SEND_EMAIL': + case 'DRAFT_EMAIL': { return ( - ); } - case 'SEND_EMAIL': { + case 'SEND_EMAIL': + case 'DRAFT_EMAIL': { return ( - void; + onActionUpdate: (action: WorkflowEmailAction) => void; }; }; -type WorkflowFile = { - id: string; - name: string; - size: number; - type: string; - createdAt: string; -}; - -type SendEmailFormData = { - connectedAccountId: string; - recipients: Required; - 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(() => { - 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 = { 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) && ( + <> + + + )} = { - 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 = { + title: 'Modules/Workflow/Actions/Email/EditAction', + component: WorkflowEditActionEmailBase, parameters: { msw: { handlers: [ @@ -92,7 +124,7 @@ const meta: Meta = { }, }, args: { - action: DEFAULT_ACTION, + action: DEFAULT_SEND_EMAIL_ACTION, }, decorators: [ WorkflowStepActionDrawerDecorator, @@ -107,7 +139,7 @@ const meta: Meta = { export default meta; -type Story = StoryObj; +type Story = StoryObj; 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(); + }, +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/constants/CoreActions.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/constants/CoreActions.ts index 6ab6ee9619..e5be93bf42 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/constants/CoreActions.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/constants/CoreActions.ts @@ -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; + 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]; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/constants/actions/DraftEmailAction.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/constants/actions/DraftEmailAction.ts new file mode 100644 index 0000000000..54dc1ebc62 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/constants/actions/DraftEmailAction.ts @@ -0,0 +1,11 @@ +import { type WorkflowActionType } from '@/workflow/types/Workflow'; + +export const DRAFT_EMAIL_ACTION: { + defaultLabel: string; + type: Extract; + icon: string; +} = { + defaultLabel: 'Draft Email', + type: 'DRAFT_EMAIL', + icon: 'IconMailPlus', +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/types/EmailFormData.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/types/EmailFormData.ts new file mode 100644 index 0000000000..7f3452db8e --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/types/EmailFormData.ts @@ -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; + subject: string; + body: string; + files: WorkflowAttachmentType[]; +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/types/WorkflowEmailAction.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/types/WorkflowEmailAction.ts new file mode 100644 index 0000000000..3e004a9a04 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/types/WorkflowEmailAction.ts @@ -0,0 +1,8 @@ +import { + type WorkflowDraftEmailAction, + type WorkflowSendEmailAction, +} from '@/workflow/types/Workflow'; + +export type WorkflowEmailAction = + | WorkflowSendEmailAction + | WorkflowDraftEmailAction; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowEditActionFormBuilder.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowEditActionFormBuilder.tsx index 40942223a3..c03215288d 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowEditActionFormBuilder.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowEditActionFormBuilder.tsx @@ -222,14 +222,19 @@ export const WorkflowEditActionFormBuilder = ({ {triggerType && triggerType !== 'MANUAL' && isCalloutVisible && ( 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 && ( diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/hooks/useEmailForm.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/hooks/useEmailForm.ts new file mode 100644 index 0000000000..7341397c17 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/hooks/useEmailForm.ts @@ -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(() => { + 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, + }; +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/getActionIcon.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/getActionIcon.ts index f2159449b3..211154f73f 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/getActionIcon.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/getActionIcon.ts @@ -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'; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/getActionIconColorOrThrow.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/getActionIconColorOrThrow.ts index 5eb21311bb..1bd5bd1d00 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/getActionIconColorOrThrow.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/getActionIconColorOrThrow.ts @@ -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': diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/generate/computeStepOutputSchema.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/generate/computeStepOutputSchema.ts index 420adcde01..bf7dd13520 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/generate/computeStepOutputSchema.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/generate/computeStepOutputSchema.ts @@ -191,7 +191,8 @@ export const computeStepOutputSchema = ({ return generateFormOutputSchema(formFields, objectMetadataItems); } - case 'SEND_EMAIL': { + case 'SEND_EMAIL': + case 'DRAFT_EMAIL': { return { success: { isLeaf: true, diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/google-apis-scopes.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/services/google-apis-scopes.service.spec.ts index c034605d89..5aaa83a139 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/google-apis-scopes.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/google-apis-scopes.service.spec.ts @@ -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', diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/google-apis-scopes.service.util.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/services/google-apis-scopes.service.util.spec.ts index c034605d89..5aaa83a139 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/google-apis-scopes.service.util.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/google-apis-scopes.service.util.spec.ts @@ -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', diff --git a/packages/twenty-server/src/engine/core-modules/auth/utils/get-google-apis-oauth-scopes.ts b/packages/twenty-server/src/engine/core-modules/auth/utils/get-google-apis-oauth-scopes.ts index a0baf1c80f..bf15abced7 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/utils/get-google-apis-oauth-scopes.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/utils/get-google-apis-oauth-scopes.ts @@ -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', ]; }; diff --git a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts index ccf071186e..215a1e6ee1 100644 --- a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts +++ b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts @@ -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', } diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/action-tool.provider.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/action-tool.provider.ts index 4385f894c5..51dd44fd8b 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/action-tool.provider.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/action-tool.provider.ts @@ -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([ ['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( diff --git a/packages/twenty-server/src/engine/core-modules/tool/tool.module.ts b/packages/twenty-server/src/engine/core-modules/tool/tool.module.ts index 9698fa9c57..8ae06cf2c2 100644 --- a/packages/twenty-server/src/engine/core-modules/tool/tool.module.ts +++ b/packages/twenty-server/src/engine/core-modules/tool/tool.module.ts @@ -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 {} diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/draft-email-tool.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/draft-email-tool.ts new file mode 100644 index 0000000000..9945c0dacb --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/draft-email-tool.ts @@ -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 { + 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 { + 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, + ); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/send-email-tool/send-email-tool.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/email-composer.service.ts similarity index 56% rename from packages/twenty-server/src/engine/core-modules/tool/tools/send-email-tool/send-email-tool.ts rename to packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/email-composer.service.ts index 49c9d97bb2..0a1878af87 100644 --- a/packages/twenty-server/src/engine/core-modules/tool/tools/send-email-tool/send-email-tool.ts +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/email-composer.service.ts @@ -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, @@ -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 { + ): Promise { 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, + }, + }; } } diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/send-email-tool/send-email-tool.schema.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/email-tool.schema.ts similarity index 95% rename from packages/twenty-server/src/engine/core-modules/tool/tools/send-email-tool/send-email-tool.schema.ts rename to packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/email-tool.schema.ts index 0cddc04458..374b36c061 100644 --- a/packages/twenty-server/src/engine/core-modules/tool/tools/send-email-tool/send-email-tool.schema.ts +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/email-tool.schema.ts @@ -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)', ), diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/send-email-tool/exceptions/send-email-tool.exception.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/exceptions/email-tool.exception.ts similarity index 59% rename from packages/twenty-server/src/engine/core-modules/tool/tools/send-email-tool/exceptions/send-email-tool.exception.ts rename to packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/exceptions/email-tool.exception.ts index eb761f8fad..324a92e0f8 100644 --- a/packages/twenty-server/src/engine/core-modules/tool/tools/send-email-tool/exceptions/send-email-tool.exception.ts +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/exceptions/email-tool.exception.ts @@ -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 { +export class EmailToolException extends CustomException { constructor( message: string, - code: SendEmailToolExceptionCode, + code: EmailToolExceptionCode, { userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {}, ) { super(message, code, { userFriendlyMessage: - userFriendlyMessage ?? - getSendEmailToolExceptionUserFriendlyMessage(code), + userFriendlyMessage ?? getEmailToolExceptionUserFriendlyMessage(code), }); } } diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/send-email-tool.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/send-email-tool.ts new file mode 100644 index 0000000000..dda6be2939 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/send-email-tool.ts @@ -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 { + 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 { + 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, + ); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/types/composed-email.type.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/types/composed-email.type.ts new file mode 100644 index 0000000000..1b12f6ca85 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/types/composed-email.type.ts @@ -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; +}; diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/types/email-composer-result.type.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/types/email-composer-result.type.ts new file mode 100644 index 0000000000..09dee66a2a --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/types/email-composer-result.type.ts @@ -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 }; diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/types/email-tool-input.type.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/types/email-tool-input.type.ts new file mode 100644 index 0000000000..00f4cfc97d --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/types/email-tool-input.type.ts @@ -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; diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/send-email-tool/utils/parse-comma-separated-emails.util.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/utils/parse-comma-separated-emails.util.ts similarity index 100% rename from packages/twenty-server/src/engine/core-modules/tool/tools/send-email-tool/utils/parse-comma-separated-emails.util.ts rename to packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/utils/parse-comma-separated-emails.util.ts diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/send-email-tool/__tests__/send-email-tool.spec.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/send-email-tool/__tests__/send-email-tool.spec.ts index 0b8d806121..a09f8da20d 100644 --- a/packages/twenty-server/src/engine/core-modules/tool/tools/send-email-tool/__tests__/send-email-tool.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/send-email-tool/__tests__/send-email-tool.spec.ts @@ -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', () => { diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/send-email-tool/types/send-email-input.type.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/send-email-tool/types/send-email-input.type.ts deleted file mode 100644 index 0fcfbb2a8e..0000000000 --- a/packages/twenty-server/src/engine/core-modules/tool/tools/send-email-tool/types/send-email-input.type.ts +++ /dev/null @@ -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; diff --git a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts index 5e6189bdd9..41e4db7dec 100644 --- a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts +++ b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts @@ -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: { diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/imap/messaging-imap-driver.module.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/imap/messaging-imap-driver.module.ts index e44928f20e..4db7a4647c 100644 --- a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/imap/messaging-imap-driver.module.ts +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/imap/messaging-imap-driver.module.ts @@ -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, ], }) diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/imap/services/imap-find-drafts-folder.service.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/imap/services/imap-find-drafts-folder.service.ts new file mode 100644 index 0000000000..b8d6ca0255 --- /dev/null +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/imap/services/imap-find-drafts-folder.service.ts @@ -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 { + 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 { + 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; + } + } +} diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/imap/services/imap-find-sent-folder.service.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/imap/services/imap-find-sent-folder.service.ts index 31fba9a038..1a6581cf9a 100644 --- a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/imap/services/imap-find-sent-folder.service.ts +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/imap/services/imap-find-sent-folder.service.ts @@ -68,7 +68,7 @@ export class ImapFindSentFolderService { ): Promise { 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)`, ); diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/messaging-import-manager.module.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/messaging-import-manager.module.ts index 6e33aef7b4..5b616fcb87 100644 --- a/packages/twenty-server/src/modules/messaging/message-import-manager/messaging-import-manager.module.ts +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/messaging-import-manager.module.ts @@ -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, diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-send-message.service.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-send-message.service.ts deleted file mode 100644 index 3bbd770e98..0000000000 --- a/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-send-message.service.ts +++ /dev/null @@ -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 { - 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`, - ); - } - } -} diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/services/__tests__/messaging-send-message-gmail.spec.ts b/packages/twenty-server/src/modules/messaging/message-outbound-manager/drivers/gmail/services/__tests__/gmail-message-outbound.service.spec.ts similarity index 80% rename from packages/twenty-server/src/modules/messaging/message-import-manager/services/__tests__/messaging-send-message-gmail.spec.ts rename to packages/twenty-server/src/modules/messaging/message-outbound-manager/drivers/gmail/services/__tests__/gmail-message-outbound.service.spec.ts index 2628c9c2c4..e41e452d5b 100644 --- a/packages/twenty-server/src/modules/messaging/message-import-manager/services/__tests__/messaging-send-message-gmail.spec.ts +++ b/packages/twenty-server/src/modules/messaging/message-outbound-manager/drivers/gmail/services/__tests__/gmail-message-outbound.service.spec.ts @@ -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, + service = module.get( + 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'), }, }); }); diff --git a/packages/twenty-server/src/modules/messaging/message-outbound-manager/drivers/gmail/services/gmail-message-outbound.service.ts b/packages/twenty-server/src/modules/messaging/message-outbound-manager/drivers/gmail/services/gmail-message-outbound.service.ts new file mode 100644 index 0000000000..acc1124e32 --- /dev/null +++ b/packages/twenty-server/src/modules/messaging/message-outbound-manager/drivers/gmail/services/gmail-message-outbound.service.ts @@ -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 { + 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 { + 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 }; + } +} diff --git a/packages/twenty-server/src/modules/messaging/message-outbound-manager/drivers/imap/services/imap-smtp-message-outbound.service.ts b/packages/twenty-server/src/modules/messaging/message-outbound-manager/drivers/imap/services/imap-smtp-message-outbound.service.ts new file mode 100644 index 0000000000..f97cbc9212 --- /dev/null +++ b/packages/twenty-server/src/modules/messaging/message-outbound-manager/drivers/imap/services/imap-smtp-message-outbound.service.ts @@ -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 { + 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 { + 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 { + 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'); + } + } +} diff --git a/packages/twenty-server/src/modules/messaging/message-outbound-manager/drivers/microsoft/services/microsoft-message-outbound.service.ts b/packages/twenty-server/src/modules/messaging/message-outbound-manager/drivers/microsoft/services/microsoft-message-outbound.service.ts new file mode 100644 index 0000000000..d3c2161b8f --- /dev/null +++ b/packages/twenty-server/src/modules/messaging/message-outbound-manager/drivers/microsoft/services/microsoft-message-outbound.service.ts @@ -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 { + 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 { + const microsoftClient = + await this.oAuth2ClientManagerService.getMicrosoftOAuth2Client( + connectedAccount, + ); + + const message = this.composeMicrosoftMessage(sendMessageInput); + + await microsoftClient.api(`/me/messages`).post(message); + } + + private composeMicrosoftMessage( + sendMessageInput: SendMessageInput, + ): Record { + 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'), + })), + } + : {}), + }; + } +} diff --git a/packages/twenty-server/src/modules/messaging/message-outbound-manager/interfaces/message-outbound-driver.interface.ts b/packages/twenty-server/src/modules/messaging/message-outbound-manager/interfaces/message-outbound-driver.interface.ts new file mode 100644 index 0000000000..be7c8a8e13 --- /dev/null +++ b/packages/twenty-server/src/modules/messaging/message-outbound-manager/interfaces/message-outbound-driver.interface.ts @@ -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; + + createDraft( + sendMessageInput: SendMessageInput, + connectedAccount: ConnectedAccountWorkspaceEntity, + ): Promise; +}; diff --git a/packages/twenty-server/src/modules/messaging/message-outbound-manager/messaging-send-manager.module.ts b/packages/twenty-server/src/modules/messaging/message-outbound-manager/messaging-send-manager.module.ts new file mode 100644 index 0000000000..d71272236c --- /dev/null +++ b/packages/twenty-server/src/modules/messaging/message-outbound-manager/messaging-send-manager.module.ts @@ -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 {} diff --git a/packages/twenty-server/src/modules/messaging/message-outbound-manager/services/messaging-message-outbound.service.ts b/packages/twenty-server/src/modules/messaging/message-outbound-manager/services/messaging-message-outbound.service.ts new file mode 100644 index 0000000000..589e9ab9cd --- /dev/null +++ b/packages/twenty-server/src/modules/messaging/message-outbound-manager/services/messaging-message-outbound.service.ts @@ -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 { + 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 { + 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`, + ); + } + } +} diff --git a/packages/twenty-server/src/modules/messaging/message-outbound-manager/types/send-message-input.type.ts b/packages/twenty-server/src/modules/messaging/message-outbound-manager/types/send-message-input.type.ts new file mode 100644 index 0000000000..a4a3575afd --- /dev/null +++ b/packages/twenty-server/src/modules/messaging/message-outbound-manager/types/send-message-input.type.ts @@ -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; + }[]; +}; diff --git a/packages/twenty-server/src/modules/messaging/message-outbound-manager/utils/to-mail-composer-options.util.ts b/packages/twenty-server/src/modules/messaging/message-outbound-manager/utils/to-mail-composer-options.util.ts new file mode 100644 index 0000000000..c8557c11f3 --- /dev/null +++ b/packages/twenty-server/src/modules/messaging/message-outbound-manager/utils/to-mail-composer-options.util.ts @@ -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, + })), + } + : {}), + }; +}; diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service.ts index 1c69d28cee..3ced764bd0 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service.ts @@ -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({ diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/factories/workflow-action.factory.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/factories/workflow-action.factory.ts index 227a40b61a..b887c2e352 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/factories/workflow-action.factory.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/factories/workflow-action.factory.ts @@ -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: diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/mail-sender/guards/is-workflow-draft-email-action.guard.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/mail-sender/guards/is-workflow-draft-email-action.guard.ts new file mode 100644 index 0000000000..ca1c141b8c --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/mail-sender/guards/is-workflow-draft-email-action.guard.ts @@ -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; +}; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/tool-executor-workflow-action.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/tool-executor-workflow-action.ts index c16e916753..57864d2ba3 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/tool-executor-workflow-action.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/tool-executor-workflow-action.ts @@ -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.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), }; } } diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-type.enum.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-type.enum.ts index 79716ffb28..fd933daeea 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-type.enum.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-type.enum.ts @@ -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', diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type.ts index fc755f4c45..b1fed6362d 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type.ts @@ -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 diff --git a/packages/twenty-shared/src/workflow/index.ts b/packages/twenty-shared/src/workflow/index.ts index 69e829f558..02bceeceff 100644 --- a/packages/twenty-shared/src/workflow/index.ts +++ b/packages/twenty-shared/src/workflow/index.ts @@ -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'; diff --git a/packages/twenty-shared/src/workflow/schemas/draft-email-action-schema.ts b/packages/twenty-shared/src/workflow/schemas/draft-email-action-schema.ts new file mode 100644 index 0000000000..60b8a95923 --- /dev/null +++ b/packages/twenty-shared/src/workflow/schemas/draft-email-action-schema.ts @@ -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, +}); diff --git a/packages/twenty-shared/src/workflow/schemas/workflow-action-schema.ts b/packages/twenty-shared/src/workflow/schemas/workflow-action-schema.ts index ba50ecc8c4..0097fe7539 100644 --- a/packages/twenty-shared/src/workflow/schemas/workflow-action-schema.ts +++ b/packages/twenty-shared/src/workflow/schemas/workflow-action-schema.ts @@ -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, diff --git a/packages/twenty-ui/src/display/callout/Callout.tsx b/packages/twenty-ui/src/display/callout/Callout.tsx index 5f48a1d5cc..8083642491 100644 --- a/packages/twenty-ui/src/display/callout/Callout.tsx +++ b/packages/twenty-ui/src/display/callout/Callout.tsx @@ -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 ( - - - - - + + + + {title} + + + {description} + + {isDefined(action) && ( { - window.open(learnMoreUrl, '_blank', 'noopener noreferrer'); - }} + type="button" + title={action.label} + onClick={action.onClick} /> - - + )} ); }; diff --git a/packages/twenty-ui/src/display/callout/__stories__/Callout.stories.tsx b/packages/twenty-ui/src/display/callout/__stories__/Callout.stories.tsx index 4161d82eb3..3e10d43b7e 100644 --- a/packages/twenty-ui/src/display/callout/__stories__/Callout.stories.tsx +++ b/packages/twenty-ui/src/display/callout/__stories__/Callout.stories.tsx @@ -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 = { args: { title: 'An callout component', description: 'Description of callout component', - learnMoreText: 'Learn more link', + action: { + label: 'Learn more link', + onClick: () => {}, + }, }, argTypes: { variant: { control: false },