feat(workflow): use workspace member as variable sender for emails (#21582)
## Summary
Lets the email workflow node sender be driven by a variable: the
connected-account field accepts a `{{variable}}`, and the backend
resolves it to a connected account at run time.
<img width="436" height="195" alt="Capture d’écran 2026-06-16 à 11 59
27"
src="https://github.com/user-attachments/assets/18eee21e-aed6-4447-9bf4-5cb0e2cfc371"
/>
### Email sender by variable
- The connected-account field now accepts a `{{variable}}` via the
variable picker (uses `FormSelectFieldInput` with
`WorkflowVariablePicker`), with a hint to pick a connected account or
set a workspace member as a variable.
- The email workflow action resolves the stored sender value explicitly:
if it is a `workspaceMemberId` (a UUID matching a workspace member), it
resolves that member's first connected account; otherwise the value is
used directly as a `connectedAccountId`.
- Resolution lives in `EmailWorkflowActionBase` and applies to both
`SEND_EMAIL` and `DRAFT_EMAIL`. If a matching member has no connected
account, the run fails fast with a clear message (no silent fallback).
- `DRAFT_EMAIL` also fails fast when the resolved connected account is
missing the required OAuth scopes (`gmail.compose` / `Mail.Send`), via a
server-side `getMissingDraftEmailScopes` util that mirrors the front-end
check.
- Existing workflows with a hardcoded `connectedAccountId` keep working
unchanged (no migration needed).
> Note: exposing the running workspace member as a manual-trigger
variable (`_metadata.workspaceMemberId`) is split into a follow-up PR.
## Test plan
- [x] Backend unit tests for `draft-email-tool`,
`get-missing-draft-email-scopes`, and the `send-email` / `draft-email`
workflow actions (incl. workspace-member sender resolution)
- [x] Lints clean on all changed files
- [x] Manual: configure an email node with a workspace-member variable
sender and confirm it resolves and drafts/sends
- [x] Manual: confirm a member lacking compose permission fails the run
with the permission message
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21582?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
---
### Update — scoped to Draft Email only
The sender variable picker is now exposed **only on the Draft Email
node**. The Send Email node keeps a plain account select (no variable
picker, no variable hint) until we enable it there in a follow-up.
Backend resolution still lives in `EmailWorkflowActionBase` and remains
generic, so enabling the picker for Send Email later requires no backend
change.
This commit is contained in:
+4
-1
@@ -6,7 +6,7 @@ import { VariableChipStandalone } from '@/object-record/record-field/ui/form-typ
|
||||
import { type VariablePickerComponent } from '@/object-record/record-field/ui/form-types/types/VariablePickerComponent';
|
||||
import { InputHint } from '@/ui/input/components/InputHint';
|
||||
import { InputLabel } from '@/ui/input/components/InputLabel';
|
||||
import { Select } from '@/ui/input/components/Select';
|
||||
import { type CallToActionButton, Select } from '@/ui/input/components/Select';
|
||||
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
|
||||
import { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackById';
|
||||
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
|
||||
@@ -26,6 +26,7 @@ type FormSelectFieldInputProps = {
|
||||
VariablePicker?: VariablePickerComponent;
|
||||
options: SelectOption[];
|
||||
readonly?: boolean;
|
||||
callToActionButton?: CallToActionButton;
|
||||
};
|
||||
|
||||
export const FormSelectFieldInput = ({
|
||||
@@ -36,6 +37,7 @@ export const FormSelectFieldInput = ({
|
||||
VariablePicker,
|
||||
options,
|
||||
readonly,
|
||||
callToActionButton,
|
||||
}: FormSelectFieldInputProps) => {
|
||||
const { theme } = useContext(ThemeContext);
|
||||
const instanceId = useId();
|
||||
@@ -139,6 +141,7 @@ export const FormSelectFieldInput = ({
|
||||
value={selectedOption?.value}
|
||||
onChange={onSelect}
|
||||
emptyOption={defaultEmptyOption}
|
||||
callToActionButton={callToActionButton}
|
||||
fullWidth
|
||||
hasRightElement={isDefined(VariablePicker) && !readonly}
|
||||
withSearchInput
|
||||
|
||||
@@ -26,7 +26,7 @@ import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
export type SelectSizeVariant = 'small' | 'default';
|
||||
|
||||
type CallToActionButton = {
|
||||
export type CallToActionButton = {
|
||||
text: string;
|
||||
onClick: (event: MouseEvent<HTMLDivElement>) => void;
|
||||
Icon?: IconComponent;
|
||||
|
||||
+27
-23
@@ -3,12 +3,12 @@ import { getMissingDraftEmailScopes } from '@/accounts/utils/hasMissingDraftEmai
|
||||
import { WorkflowSendEmailAttachments } from '@/advanced-text-editor/components/WorkflowSendEmailAttachments';
|
||||
import { FormAdvancedTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormAdvancedTextFieldInput';
|
||||
import { FormMultiTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormMultiTextFieldInput';
|
||||
import { FormSelectFieldInput } from '@/object-record/record-field/ui/form-types/components/FormSelectFieldInput';
|
||||
import { FormTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormTextFieldInput';
|
||||
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
|
||||
import { useMyConnectedAccounts } from '@/settings/accounts/hooks/useMyConnectedAccounts';
|
||||
import { useTriggerApisOAuth } from '@/settings/accounts/hooks/useTriggerApiOAuth';
|
||||
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
|
||||
import { Select } from '@/ui/input/components/Select';
|
||||
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
|
||||
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
|
||||
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
|
||||
@@ -19,6 +19,7 @@ import { WORKFLOW_STEP_CONNECTED_ACCOUNT_HANDLE } from '@/workflow/graphql/queri
|
||||
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
|
||||
import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState';
|
||||
import { type WorkflowEmailAction } from '@/workflow/types/WorkflowEmailAction';
|
||||
import { isStandaloneVariableString } from '@/workflow/utils/isStandaloneVariableString';
|
||||
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
|
||||
import { WorkflowStepFooter } from '@/workflow/workflow-steps/components/WorkflowStepFooter';
|
||||
import { useEmailForm } from '@/workflow/workflow-steps/workflow-actions/hooks/useEmailForm';
|
||||
@@ -112,10 +113,18 @@ export const WorkflowEditActionEmailBase = ({
|
||||
|
||||
const apolloCoreClient = useApolloCoreClient();
|
||||
|
||||
const navigate = useNavigateSettings();
|
||||
|
||||
const { closeSidePanelMenu } = useSidePanelMenu();
|
||||
|
||||
const { accounts: myAccounts, loading: myAccountsLoading } =
|
||||
useMyConnectedAccounts();
|
||||
|
||||
// Sender variables are only enabled for DRAFT_EMAIL for now; SEND_EMAIL keeps a plain account select.
|
||||
const isSenderVariableEnabled = action.type === 'DRAFT_EMAIL';
|
||||
|
||||
const configuredAccountId = formData.connectedAccountId;
|
||||
const isSenderVariable = isStandaloneVariableString(configuredAccountId);
|
||||
const isConfiguredAccountMine = myAccounts.some(
|
||||
(account) => account.id === configuredAccountId,
|
||||
);
|
||||
@@ -131,6 +140,7 @@ export const WorkflowEditActionEmailBase = ({
|
||||
skip:
|
||||
!isDefined(configuredAccountId) ||
|
||||
configuredAccountId === '' ||
|
||||
isSenderVariable ||
|
||||
isConfiguredAccountMine,
|
||||
});
|
||||
|
||||
@@ -158,11 +168,7 @@ export const WorkflowEditActionEmailBase = ({
|
||||
}
|
||||
: null;
|
||||
|
||||
let emptyOption: SelectOption<string | null> = {
|
||||
label: t`None`,
|
||||
value: null,
|
||||
};
|
||||
const connectedAccountOptions: SelectOption<string | null>[] = [];
|
||||
const connectedAccountOptions: SelectOption<string>[] = [];
|
||||
|
||||
myAccounts.forEach((account) => {
|
||||
if (
|
||||
@@ -179,16 +185,12 @@ export const WorkflowEditActionEmailBase = ({
|
||||
});
|
||||
|
||||
if (isDefined(otherAccount)) {
|
||||
emptyOption = {
|
||||
connectedAccountOptions.push({
|
||||
label: otherAccount.handle,
|
||||
value: otherAccount.id,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const navigate = useNavigateSettings();
|
||||
|
||||
const { closeSidePanelMenu } = useSidePanelMenu();
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
saveAction.flush();
|
||||
@@ -199,13 +201,21 @@ export const WorkflowEditActionEmailBase = ({
|
||||
!loading && (
|
||||
<>
|
||||
<WorkflowStepBody>
|
||||
<Select
|
||||
dropdownId="select-connected-account-id"
|
||||
<FormSelectFieldInput
|
||||
key={`connected-account-${formData.connectedAccountId ?? 'none'}`}
|
||||
label={t`Account`}
|
||||
fullWidth
|
||||
emptyOption={emptyOption}
|
||||
value={formData.connectedAccountId}
|
||||
hint={
|
||||
isSenderVariableEnabled
|
||||
? t`Pick a connected account or set a workspace member as variable`
|
||||
: undefined
|
||||
}
|
||||
defaultValue={formData.connectedAccountId}
|
||||
options={connectedAccountOptions}
|
||||
onChange={handleConnectedAccountChange}
|
||||
VariablePicker={
|
||||
isSenderVariableEnabled ? WorkflowVariablePicker : undefined
|
||||
}
|
||||
readonly={actionOptions.readonly}
|
||||
callToActionButton={{
|
||||
onClick: () => {
|
||||
closeSidePanelMenu();
|
||||
@@ -214,12 +224,6 @@ export const WorkflowEditActionEmailBase = ({
|
||||
Icon: IconPlus,
|
||||
text: t`Add account`,
|
||||
}}
|
||||
onChange={(connectedAccountId) => {
|
||||
handleConnectedAccountChange(connectedAccountId);
|
||||
}}
|
||||
disabled={actionOptions.readonly}
|
||||
dropdownOffset={{ y: 4 }}
|
||||
dropdownWidth={GenericDropdownContentWidth.ExtraLarge}
|
||||
/>
|
||||
{isDefined(missingScopes) && (
|
||||
<>
|
||||
|
||||
+71
@@ -128,6 +128,36 @@ const DEFAULT_DRAFT_EMAIL_ACTION: WorkflowDraftEmailAction = {
|
||||
},
|
||||
};
|
||||
|
||||
const VARIABLE_SENDER_DRAFT_EMAIL_ACTION: WorkflowDraftEmailAction = {
|
||||
id: getWorkflowNodeIdMock(),
|
||||
name: 'Draft Email',
|
||||
type: 'DRAFT_EMAIL',
|
||||
valid: true,
|
||||
settings: {
|
||||
input: {
|
||||
connectedAccountId: '{{trigger._metadata.workspaceMemberId}}',
|
||||
recipients: {
|
||||
to: 'test@twenty.com',
|
||||
cc: '',
|
||||
bcc: '',
|
||||
},
|
||||
subject: 'Welcome to Twenty!',
|
||||
body: 'Hello',
|
||||
files: [],
|
||||
inReplyTo: '',
|
||||
},
|
||||
outputSchema: {},
|
||||
errorHandlingOptions: {
|
||||
retryOnFailure: {
|
||||
value: false,
|
||||
},
|
||||
continueOnFailure: {
|
||||
value: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const meta: Meta<typeof WorkflowEditActionEmailBase> = {
|
||||
title: 'Modules/Workflow/Actions/Email/EditAction',
|
||||
component: WorkflowEditActionEmailBase,
|
||||
@@ -232,3 +262,44 @@ export const DraftEmail: Story = {
|
||||
expect(await canvas.findByText('Advanced options')).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
export const VariableSender: Story = {
|
||||
args: {
|
||||
action: VARIABLE_SENDER_DRAFT_EMAIL_ACTION,
|
||||
actionOptions: {
|
||||
onActionUpdate: fn(),
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
expect(await canvas.findByText('Account')).toBeVisible();
|
||||
expect(await canvas.findByLabelText('Remove variable')).toBeInTheDocument();
|
||||
expect(
|
||||
await canvas.findByText(
|
||||
'Pick a connected account or set a workspace member as variable',
|
||||
),
|
||||
).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
// SEND_EMAIL does not expose the sender variable picker yet (DRAFT_EMAIL only),
|
||||
// so the account field stays a plain select with no variable hint.
|
||||
export const SendEmailHasNoVariablePicker: Story = {
|
||||
args: {
|
||||
action: DEFAULT_SEND_EMAIL_ACTION,
|
||||
actionOptions: {
|
||||
onActionUpdate: fn(),
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
expect(await canvas.findByText('Account')).toBeVisible();
|
||||
expect(
|
||||
canvas.queryByText(
|
||||
'Pick a connected account or set a workspace member as variable',
|
||||
),
|
||||
).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { ConnectedAccountProvider } from 'twenty-shared/types';
|
||||
|
||||
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 { type EmailToolInput } from 'src/engine/core-modules/tool/tools/email-tool/types/email-tool-input.type';
|
||||
import { MessagingMessageOutboundService } from 'src/modules/messaging/message-outbound-manager/services/messaging-message-outbound.service';
|
||||
|
||||
const GMAIL_COMPOSE_SCOPE = 'https://www.googleapis.com/auth/gmail.compose';
|
||||
|
||||
const buildComposedEmail = (connectedAccount: {
|
||||
id: string;
|
||||
provider: ConnectedAccountProvider;
|
||||
scopes: string[] | null;
|
||||
}) => ({
|
||||
recipients: { to: ['test@example.com'], cc: [], bcc: [] },
|
||||
toRecipientsDisplay: 'test@example.com',
|
||||
sanitizedSubject: 'Subject',
|
||||
plainTextBody: 'body',
|
||||
sanitizedHtmlBody: '<p>body</p>',
|
||||
attachments: [],
|
||||
connectedAccount,
|
||||
shouldPersistMessage: true,
|
||||
});
|
||||
|
||||
const baseInput: EmailToolInput = {
|
||||
recipients: { to: 'test@example.com', cc: '', bcc: '' },
|
||||
subject: 'Subject',
|
||||
body: '<p>body</p>',
|
||||
files: [],
|
||||
};
|
||||
|
||||
describe('DraftEmailTool', () => {
|
||||
let tool: DraftEmailTool;
|
||||
let mockComposeEmail: jest.Mock;
|
||||
let mockCreateDraft: jest.Mock;
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
mockComposeEmail = jest.fn();
|
||||
mockCreateDraft = jest.fn();
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
DraftEmailTool,
|
||||
{
|
||||
provide: EmailComposerService,
|
||||
useValue: { composeEmail: mockComposeEmail },
|
||||
},
|
||||
{
|
||||
provide: MessagingMessageOutboundService,
|
||||
useValue: { createDraft: mockCreateDraft },
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
tool = module.get(DraftEmailTool);
|
||||
});
|
||||
|
||||
it('fails without drafting when the resolved account lacks the compose scope', async () => {
|
||||
mockComposeEmail.mockResolvedValue({
|
||||
success: true,
|
||||
data: buildComposedEmail({
|
||||
id: 'account-1',
|
||||
provider: ConnectedAccountProvider.GOOGLE,
|
||||
scopes: [],
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await tool.execute(baseInput, {
|
||||
workspaceId: 'workspace-1',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('insufficient permissions');
|
||||
expect(mockCreateDraft).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates the draft when the resolved account has the compose scope', async () => {
|
||||
mockComposeEmail.mockResolvedValue({
|
||||
success: true,
|
||||
data: buildComposedEmail({
|
||||
id: 'account-1',
|
||||
provider: ConnectedAccountProvider.GOOGLE,
|
||||
scopes: [GMAIL_COMPOSE_SCOPE],
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await tool.execute(baseInput, {
|
||||
workspaceId: 'workspace-1',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockCreateDraft).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
import { ConnectedAccountProvider } from 'twenty-shared/types';
|
||||
|
||||
import { EmailComposerService } from 'src/engine/core-modules/tool/tools/email-tool/email-composer.service';
|
||||
|
||||
const WORKSPACE_ID = '20202020-0000-4000-8000-000000000000';
|
||||
const CONNECTED_ACCOUNT_ID = '20202020-1111-4111-8111-111111111111';
|
||||
|
||||
const buildAccount = (id: string) => ({
|
||||
id,
|
||||
handle: 'tim@apple.dev',
|
||||
provider: ConnectedAccountProvider.GOOGLE,
|
||||
scopes: ['email'],
|
||||
connectionParameters: null,
|
||||
messageChannels: [{ id: 'message-channel-1', handle: 'tim@apple.dev' }],
|
||||
});
|
||||
|
||||
const baseParams = {
|
||||
recipients: { to: 'test@example.com' },
|
||||
subject: 'Subject',
|
||||
body: '<p>body</p>',
|
||||
files: [],
|
||||
};
|
||||
|
||||
const context = { workspaceId: WORKSPACE_ID };
|
||||
|
||||
describe('EmailComposerService connected account resolution', () => {
|
||||
let service: EmailComposerService;
|
||||
let connectedAccountRepository: {
|
||||
findOne: jest.Mock;
|
||||
find: jest.Mock;
|
||||
};
|
||||
let globalWorkspaceOrmManager: {
|
||||
executeInWorkspaceContext: jest.Mock;
|
||||
getRepository: jest.Mock;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
connectedAccountRepository = { findOne: jest.fn(), find: jest.fn() };
|
||||
globalWorkspaceOrmManager = {
|
||||
executeInWorkspaceContext: jest.fn((callback) => callback()),
|
||||
getRepository: jest.fn(),
|
||||
};
|
||||
|
||||
service = new EmailComposerService(
|
||||
globalWorkspaceOrmManager as never,
|
||||
connectedAccountRepository as never,
|
||||
{ find: jest.fn() } as never,
|
||||
{} as never,
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the connected account matching the provided id', async () => {
|
||||
connectedAccountRepository.findOne.mockResolvedValue(
|
||||
buildAccount(CONNECTED_ACCOUNT_ID),
|
||||
);
|
||||
|
||||
const result = await service.composeEmail(
|
||||
{ ...baseParams, connectedAccountId: CONNECTED_ACCOUNT_ID },
|
||||
context,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.success && result.data.connectedAccount.id).toBe(
|
||||
CONNECTED_ACCOUNT_ID,
|
||||
);
|
||||
expect(connectedAccountRepository.findOne).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: CONNECTED_ACCOUNT_ID, workspaceId: WORKSPACE_ID },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when the id is not a valid UUID', async () => {
|
||||
await expect(
|
||||
service.composeEmail(
|
||||
{ ...baseParams, connectedAccountId: 'not-a-uuid' },
|
||||
context,
|
||||
),
|
||||
).rejects.toThrow('Connected account id is not a valid UUID');
|
||||
});
|
||||
|
||||
it('throws when no connected account matches the provided id', async () => {
|
||||
connectedAccountRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.composeEmail(
|
||||
{ ...baseParams, connectedAccountId: CONNECTED_ACCOUNT_ID },
|
||||
context,
|
||||
),
|
||||
).rejects.toThrow(`No connected account found for id`);
|
||||
});
|
||||
});
|
||||
+15
-2
@@ -3,6 +3,7 @@ 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 { getMissingDraftEmailScopes } from 'src/engine/core-modules/tool/tools/email-tool/utils/get-missing-draft-email-scopes.util';
|
||||
import { isInsufficientPermissionsError } from 'src/engine/core-modules/tool/tools/email-tool/utils/is-insufficient-permissions-error.util';
|
||||
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';
|
||||
@@ -40,6 +41,19 @@ export class DraftEmailTool implements Tool {
|
||||
|
||||
const { data } = result;
|
||||
|
||||
const missingDraftScopes = getMissingDraftEmailScopes(
|
||||
data.connectedAccount,
|
||||
);
|
||||
|
||||
if (missingDraftScopes.length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Failed to create draft due to insufficient permissions',
|
||||
error:
|
||||
'The connected email account does not have permission to create drafts.',
|
||||
};
|
||||
}
|
||||
|
||||
await this.createDraft(data);
|
||||
|
||||
this.logger.log(
|
||||
@@ -76,8 +90,7 @@ export class DraftEmailTool implements Tool {
|
||||
success: false,
|
||||
message: 'Failed to create draft due to insufficient permissions',
|
||||
error:
|
||||
'The connected email account does not have permission to create drafts. ' +
|
||||
'The user should disconnect and reconnect their account in Settings > Accounts to grant the required permissions.',
|
||||
'The connected email account does not have permission to create drafts.',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+5
-5
@@ -52,13 +52,13 @@ export class EmailComposerService {
|
||||
private readonly fileService: FileService,
|
||||
) {}
|
||||
|
||||
private async getConnectedAccount(
|
||||
private async getConnectedAccountOrThrow(
|
||||
connectedAccountId: string,
|
||||
workspaceId: string,
|
||||
) {
|
||||
): Promise<ConnectedAccountEntity> {
|
||||
if (!isValidUuid(connectedAccountId)) {
|
||||
throw new EmailToolException(
|
||||
`Connected Account ID is not a valid UUID`,
|
||||
`Connected account id is not a valid UUID`,
|
||||
EmailToolExceptionCode.INVALID_CONNECTED_ACCOUNT_ID,
|
||||
);
|
||||
}
|
||||
@@ -78,7 +78,7 @@ export class EmailComposerService {
|
||||
|
||||
if (!isDefined(connectedAccount)) {
|
||||
throw new EmailToolException(
|
||||
`Connected Account '${connectedAccountId}' not found`,
|
||||
`No connected account found for id '${connectedAccountId}'`,
|
||||
EmailToolExceptionCode.CONNECTED_ACCOUNT_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
@@ -355,7 +355,7 @@ export class EmailComposerService {
|
||||
await this.getOrThrowFirstConnectedAccountId(workspaceId);
|
||||
}
|
||||
|
||||
const connectedAccount = await this.getConnectedAccount(
|
||||
const connectedAccount = await this.getConnectedAccountOrThrow(
|
||||
connectedAccountId,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
+1
-2
@@ -83,8 +83,7 @@ export class SendEmailTool implements Tool {
|
||||
success: false,
|
||||
message: 'Failed to send email due to insufficient permissions',
|
||||
error:
|
||||
'The connected email account does not have permission to send emails. ' +
|
||||
'The user should disconnect and reconnect their account in Settings > Accounts to grant the required permissions.',
|
||||
'The connected email account does not have permission to send emails.',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
import { ConnectedAccountProvider } from 'twenty-shared/types';
|
||||
|
||||
import { getMissingDraftEmailScopes } from 'src/engine/core-modules/tool/tools/email-tool/utils/get-missing-draft-email-scopes.util';
|
||||
|
||||
const GMAIL_COMPOSE_SCOPE = 'https://www.googleapis.com/auth/gmail.compose';
|
||||
const MICROSOFT_SEND_SCOPE = 'Mail.Send';
|
||||
|
||||
describe('getMissingDraftEmailScopes', () => {
|
||||
describe('Google provider', () => {
|
||||
it('returns the compose scope when missing', () => {
|
||||
expect(
|
||||
getMissingDraftEmailScopes({
|
||||
provider: ConnectedAccountProvider.GOOGLE,
|
||||
scopes: ['email', 'profile'],
|
||||
}),
|
||||
).toEqual([GMAIL_COMPOSE_SCOPE]);
|
||||
});
|
||||
|
||||
it('returns the compose scope when scopes are null', () => {
|
||||
expect(
|
||||
getMissingDraftEmailScopes({
|
||||
provider: ConnectedAccountProvider.GOOGLE,
|
||||
scopes: null,
|
||||
}),
|
||||
).toEqual([GMAIL_COMPOSE_SCOPE]);
|
||||
});
|
||||
|
||||
it('returns nothing when the compose scope is present', () => {
|
||||
expect(
|
||||
getMissingDraftEmailScopes({
|
||||
provider: ConnectedAccountProvider.GOOGLE,
|
||||
scopes: ['email', GMAIL_COMPOSE_SCOPE],
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Microsoft provider', () => {
|
||||
it('returns the send scope when missing', () => {
|
||||
expect(
|
||||
getMissingDraftEmailScopes({
|
||||
provider: ConnectedAccountProvider.MICROSOFT,
|
||||
scopes: ['Mail.ReadWrite'],
|
||||
}),
|
||||
).toEqual([MICROSOFT_SEND_SCOPE]);
|
||||
});
|
||||
|
||||
it('returns nothing when the send scope is present', () => {
|
||||
expect(
|
||||
getMissingDraftEmailScopes({
|
||||
provider: ConnectedAccountProvider.MICROSOFT,
|
||||
scopes: [MICROSOFT_SEND_SCOPE],
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('non-OAuth providers', () => {
|
||||
it.each([
|
||||
ConnectedAccountProvider.IMAP_SMTP_CALDAV,
|
||||
ConnectedAccountProvider.EMAIL_GROUP,
|
||||
ConnectedAccountProvider.APP,
|
||||
ConnectedAccountProvider.OIDC,
|
||||
ConnectedAccountProvider.SAML,
|
||||
])('never requires scopes for %s accounts', (provider) => {
|
||||
expect(getMissingDraftEmailScopes({ provider, scopes: null })).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import { ConnectedAccountProvider } from 'twenty-shared/types';
|
||||
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
|
||||
|
||||
const GMAIL_COMPOSE_SCOPE = 'https://www.googleapis.com/auth/gmail.compose';
|
||||
const MICROSOFT_SEND_SCOPE = 'Mail.Send';
|
||||
|
||||
export const getMissingDraftEmailScopes = (connectedAccount: {
|
||||
provider: ConnectedAccountProvider;
|
||||
scopes: string[] | null;
|
||||
}): string[] => {
|
||||
const scopes = connectedAccount.scopes;
|
||||
|
||||
switch (connectedAccount.provider) {
|
||||
case ConnectedAccountProvider.GOOGLE: {
|
||||
const hasScope =
|
||||
isDefined(scopes) && scopes.includes(GMAIL_COMPOSE_SCOPE);
|
||||
|
||||
return hasScope ? [] : [GMAIL_COMPOSE_SCOPE];
|
||||
}
|
||||
case ConnectedAccountProvider.MICROSOFT: {
|
||||
const hasScope =
|
||||
isDefined(scopes) && scopes.includes(MICROSOFT_SEND_SCOPE);
|
||||
|
||||
return hasScope ? [] : [MICROSOFT_SEND_SCOPE];
|
||||
}
|
||||
// Non-OAuth providers do not rely on OAuth scopes to draft emails.
|
||||
case ConnectedAccountProvider.IMAP_SMTP_CALDAV:
|
||||
case ConnectedAccountProvider.EMAIL_GROUP:
|
||||
case ConnectedAccountProvider.APP:
|
||||
case ConnectedAccountProvider.OIDC:
|
||||
case ConnectedAccountProvider.SAML:
|
||||
return [];
|
||||
default:
|
||||
return assertUnreachable(
|
||||
connectedAccount.provider,
|
||||
`Unhandled connected account provider for draft email scopes: ${connectedAccount.provider}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
+90
@@ -1,7 +1,11 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import { WorkflowActionType } from 'twenty-shared/workflow';
|
||||
import { DraftEmailTool } from 'src/engine/core-modules/tool/tools/email-tool/draft-email-tool';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
|
||||
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
|
||||
import { DraftEmailWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/draft-email.workflow-action';
|
||||
import { type WorkflowActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type';
|
||||
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
@@ -32,10 +36,17 @@ const buildDraftEmailStep = (input: Record<string, unknown>): WorkflowAction =>
|
||||
settings: { ...baseSettings, input },
|
||||
}) as WorkflowAction;
|
||||
|
||||
const WORKSPACE_MEMBER_ID = '20202020-2222-4222-8222-222222222222';
|
||||
const USER_WORKSPACE_ID = '20202020-3333-4333-8333-333333333333';
|
||||
const MEMBER_ACCOUNT_ID = '20202020-5555-4555-8555-555555555555';
|
||||
|
||||
describe('DraftEmailWorkflowAction', () => {
|
||||
let action: DraftEmailWorkflowAction;
|
||||
let mockDraftEmailTool: jest.Mocked<Pick<DraftEmailTool, 'execute'>>;
|
||||
let mockSetStepLog: jest.Mock;
|
||||
let connectedAccountRepository: { findOne: jest.Mock };
|
||||
let userWorkspaceRepository: { findOne: jest.Mock };
|
||||
let workspaceMemberRepository: { findOne: jest.Mock };
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
@@ -47,6 +58,9 @@ describe('DraftEmailWorkflowAction', () => {
|
||||
}),
|
||||
};
|
||||
mockSetStepLog = jest.fn();
|
||||
connectedAccountRepository = { findOne: jest.fn() };
|
||||
userWorkspaceRepository = { findOne: jest.fn() };
|
||||
workspaceMemberRepository = { findOne: jest.fn() };
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
@@ -56,6 +70,23 @@ describe('DraftEmailWorkflowAction', () => {
|
||||
provide: WorkflowRunStepLogWorkspaceService,
|
||||
useValue: { setStepLog: mockSetStepLog },
|
||||
},
|
||||
{
|
||||
provide: GlobalWorkspaceOrmManager,
|
||||
useValue: {
|
||||
executeInWorkspaceContext: jest.fn((callback) => callback()),
|
||||
getRepository: jest
|
||||
.fn()
|
||||
.mockResolvedValue(workspaceMemberRepository),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(ConnectedAccountEntity),
|
||||
useValue: connectedAccountRepository,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(UserWorkspaceEntity),
|
||||
useValue: userWorkspaceRepository,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
@@ -113,6 +144,65 @@ describe('DraftEmailWorkflowAction', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('sender resolution', () => {
|
||||
const executeWithSender = (connectedAccountId: string) =>
|
||||
action.execute({
|
||||
currentStepId: 'step-1',
|
||||
steps: [
|
||||
buildDraftEmailStep({
|
||||
connectedAccountId,
|
||||
recipients: { to: 'test@example.com' },
|
||||
subject: 'Draft Test',
|
||||
body: 'hello',
|
||||
}),
|
||||
],
|
||||
context: {},
|
||||
runInfo: { workspaceId: 'workspace-1', workflowRunId: 'run-1' },
|
||||
});
|
||||
|
||||
it("resolves a workspace member id to the member's first connected account", async () => {
|
||||
workspaceMemberRepository.findOne.mockResolvedValue({ userId: 'user-1' });
|
||||
userWorkspaceRepository.findOne.mockResolvedValue({
|
||||
id: USER_WORKSPACE_ID,
|
||||
});
|
||||
connectedAccountRepository.findOne.mockResolvedValue({
|
||||
id: MEMBER_ACCOUNT_ID,
|
||||
});
|
||||
|
||||
await executeWithSender(WORKSPACE_MEMBER_ID);
|
||||
|
||||
expect(mockDraftEmailTool.execute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ connectedAccountId: MEMBER_ACCOUNT_ID }),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('passes the id through unchanged when it is not a workspace member', async () => {
|
||||
workspaceMemberRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await executeWithSender(WORKSPACE_MEMBER_ID);
|
||||
|
||||
expect(connectedAccountRepository.findOne).not.toHaveBeenCalled();
|
||||
expect(mockDraftEmailTool.execute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ connectedAccountId: WORKSPACE_MEMBER_ID }),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when the workspace member has no connected account', async () => {
|
||||
workspaceMemberRepository.findOne.mockResolvedValue({ userId: 'user-1' });
|
||||
userWorkspaceRepository.findOne.mockResolvedValue({
|
||||
id: USER_WORKSPACE_ID,
|
||||
});
|
||||
connectedAccountRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(executeWithSender(WORKSPACE_MEMBER_ID)).rejects.toThrow(
|
||||
`No connected account found for workspace member '${WORKSPACE_MEMBER_ID}'`,
|
||||
);
|
||||
expect(mockDraftEmailTool.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when the current step is not a draft-email action', async () => {
|
||||
await expect(
|
||||
action.execute({
|
||||
|
||||
+52
@@ -1,7 +1,11 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import { WorkflowActionType } from 'twenty-shared/workflow';
|
||||
import { SendEmailTool } from 'src/engine/core-modules/tool/tools/email-tool/send-email-tool';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
|
||||
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
|
||||
import { SendEmailWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/send-email.workflow-action';
|
||||
import { type WorkflowActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type';
|
||||
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
@@ -42,9 +46,13 @@ const buildSendEmailStep = (input: Record<string, unknown>): WorkflowAction =>
|
||||
settings: { ...baseSettings, input },
|
||||
}) as WorkflowAction;
|
||||
|
||||
const WORKSPACE_MEMBER_ID = '20202020-2222-4222-8222-222222222222';
|
||||
|
||||
describe('SendEmailWorkflowAction', () => {
|
||||
let action: SendEmailWorkflowAction;
|
||||
let mockSendEmailTool: jest.Mocked<Pick<SendEmailTool, 'execute'>>;
|
||||
let connectedAccountRepository: { findOne: jest.Mock };
|
||||
let getRepository: jest.Mock;
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
@@ -55,6 +63,8 @@ describe('SendEmailWorkflowAction', () => {
|
||||
error: undefined,
|
||||
}),
|
||||
};
|
||||
connectedAccountRepository = { findOne: jest.fn() };
|
||||
getRepository = jest.fn();
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
@@ -64,6 +74,21 @@ describe('SendEmailWorkflowAction', () => {
|
||||
provide: WorkflowRunStepLogWorkspaceService,
|
||||
useValue: { setStepLog: jest.fn() },
|
||||
},
|
||||
{
|
||||
provide: GlobalWorkspaceOrmManager,
|
||||
useValue: {
|
||||
executeInWorkspaceContext: jest.fn((callback) => callback()),
|
||||
getRepository,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(ConnectedAccountEntity),
|
||||
useValue: connectedAccountRepository,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(UserWorkspaceEntity),
|
||||
useValue: { findOne: jest.fn() },
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
@@ -171,6 +196,33 @@ describe('SendEmailWorkflowAction', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('sender resolution', () => {
|
||||
// Sender-as-variable (workspace member) is draft-only for now, so
|
||||
// send-email must never resolve the value as a workspace member id.
|
||||
it('does not resolve a workspace member id and passes the value through', async () => {
|
||||
await action.execute({
|
||||
currentStepId: 'step-1',
|
||||
steps: [
|
||||
buildSendEmailStep({
|
||||
connectedAccountId: WORKSPACE_MEMBER_ID,
|
||||
recipients: { to: 'test@example.com' },
|
||||
subject: 'Test',
|
||||
body: 'hi',
|
||||
}),
|
||||
],
|
||||
context: {},
|
||||
runInfo: { workspaceId: 'workspace-1', workflowRunId: 'run-1' },
|
||||
});
|
||||
|
||||
expect(getRepository).not.toHaveBeenCalled();
|
||||
expect(connectedAccountRepository.findOne).not.toHaveBeenCalled();
|
||||
expect(mockSendEmailTool.execute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ connectedAccountId: WORKSPACE_MEMBER_ID }),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('step type guard', () => {
|
||||
it('throws when the current step is not a send-email action', async () => {
|
||||
await expect(
|
||||
|
||||
+18
-1
@@ -1,7 +1,13 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { type Repository } from 'typeorm';
|
||||
|
||||
import { DraftEmailTool } from 'src/engine/core-modules/tool/tools/email-tool/draft-email-tool';
|
||||
import { type Tool } from 'src/engine/core-modules/tool/types/tool.type';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
|
||||
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
|
||||
import {
|
||||
WorkflowStepExecutorException,
|
||||
WorkflowStepExecutorExceptionCode,
|
||||
@@ -17,8 +23,19 @@ export class DraftEmailWorkflowAction extends EmailWorkflowActionBase {
|
||||
constructor(
|
||||
private readonly draftEmailTool: DraftEmailTool,
|
||||
workflowRunStepLogService: WorkflowRunStepLogWorkspaceService,
|
||||
globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
|
||||
@InjectRepository(ConnectedAccountEntity)
|
||||
connectedAccountRepository: Repository<ConnectedAccountEntity>,
|
||||
@InjectRepository(UserWorkspaceEntity)
|
||||
userWorkspaceRepository: Repository<UserWorkspaceEntity>,
|
||||
) {
|
||||
super(DraftEmailWorkflowAction.name, workflowRunStepLogService);
|
||||
super(
|
||||
DraftEmailWorkflowAction.name,
|
||||
workflowRunStepLogService,
|
||||
globalWorkspaceOrmManager,
|
||||
connectedAccountRepository,
|
||||
userWorkspaceRepository,
|
||||
);
|
||||
}
|
||||
|
||||
protected getTool(): Tool {
|
||||
|
||||
+130
-1
@@ -1,8 +1,17 @@
|
||||
import { type WorkflowRunStepLog } from 'twenty-shared/workflow';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { isDefined, isValidUuid } from 'twenty-shared/utils';
|
||||
import { IsNull, type Repository } from 'typeorm';
|
||||
|
||||
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
|
||||
import { type UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
|
||||
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 {
|
||||
WorkflowStepExecutorException,
|
||||
WorkflowStepExecutorExceptionCode,
|
||||
} from 'src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception';
|
||||
import { type WorkflowSendEmailActionInput } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/types/workflow-send-email-action-input.type';
|
||||
import {
|
||||
buildEmailStepLog,
|
||||
@@ -11,8 +20,20 @@ import {
|
||||
import { resolveEmailBody } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/utils/resolve-email-body.util';
|
||||
import { resolveEmailFiles } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/utils/resolve-email-files.util';
|
||||
import { ToolBackedWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/tool-backed/tool-backed.workflow-action';
|
||||
import { WorkflowRunStepLogWorkspaceService } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run-step-log.workspace-service';
|
||||
import { type WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
|
||||
|
||||
export abstract class EmailWorkflowActionBase extends ToolBackedWorkflowAction<WorkflowSendEmailActionInput> {
|
||||
protected constructor(
|
||||
loggerName: string,
|
||||
workflowRunStepLogService: WorkflowRunStepLogWorkspaceService,
|
||||
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
|
||||
private readonly connectedAccountRepository: Repository<ConnectedAccountEntity>,
|
||||
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
|
||||
) {
|
||||
super(loggerName, workflowRunStepLogService);
|
||||
}
|
||||
|
||||
protected abstract getMode(): EmailStepLogMode;
|
||||
|
||||
protected override async preprocessInput(
|
||||
@@ -28,6 +49,114 @@ export abstract class EmailWorkflowActionBase extends ToolBackedWorkflowAction<W
|
||||
return { ...rawInput, body, files };
|
||||
}
|
||||
|
||||
protected override async postprocessInput(
|
||||
resolvedInput: WorkflowSendEmailActionInput,
|
||||
workspaceId: string,
|
||||
): Promise<WorkflowSendEmailActionInput> {
|
||||
if (!isDefined(resolvedInput.connectedAccountId)) {
|
||||
return resolvedInput;
|
||||
}
|
||||
|
||||
// Sender-as-variable (a workspace member id resolved to a connected
|
||||
// account) is only supported for drafts for now; send-email keeps the
|
||||
// configured value as a plain connected account id.
|
||||
if (this.getMode() !== 'DRAFT') {
|
||||
return resolvedInput;
|
||||
}
|
||||
|
||||
const connectedAccountId = await this.resolveSenderConnectedAccountId(
|
||||
resolvedInput.connectedAccountId,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
return { ...resolvedInput, connectedAccountId };
|
||||
}
|
||||
|
||||
// The sender configured on an email step is either a connected account id
|
||||
// (static pick) or a workspace member id (from a resolved workflow variable).
|
||||
// When it is a workspace member id, resolve that member's first connected
|
||||
// account; otherwise return it unchanged so the regular connected account
|
||||
// flow applies. Only meaningful inside workflow email actions.
|
||||
protected async resolveSenderConnectedAccountId(
|
||||
senderId: string,
|
||||
workspaceId: string,
|
||||
): Promise<string> {
|
||||
if (!isValidUuid(senderId)) {
|
||||
return senderId;
|
||||
}
|
||||
|
||||
const authContext = buildSystemAuthContext(workspaceId);
|
||||
|
||||
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
|
||||
async () => {
|
||||
const workspaceMember = await this.findWorkspaceMemberById(
|
||||
senderId,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (!isDefined(workspaceMember)) {
|
||||
return senderId;
|
||||
}
|
||||
|
||||
const connectedAccountId =
|
||||
await this.findFirstConnectedAccountIdByWorkspaceMember(
|
||||
workspaceMember,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (!isDefined(connectedAccountId)) {
|
||||
throw new WorkflowStepExecutorException(
|
||||
`No connected account found for workspace member '${senderId}'`,
|
||||
WorkflowStepExecutorExceptionCode.INVALID_STEP_INPUT,
|
||||
);
|
||||
}
|
||||
|
||||
return connectedAccountId;
|
||||
},
|
||||
authContext,
|
||||
);
|
||||
}
|
||||
|
||||
private async findWorkspaceMemberById(
|
||||
workspaceMemberId: string,
|
||||
workspaceId: string,
|
||||
): Promise<WorkspaceMemberWorkspaceEntity | null> {
|
||||
const workspaceMemberRepository =
|
||||
await this.globalWorkspaceOrmManager.getRepository<WorkspaceMemberWorkspaceEntity>(
|
||||
workspaceId,
|
||||
'workspaceMember',
|
||||
{ shouldBypassPermissionChecks: true },
|
||||
);
|
||||
|
||||
return workspaceMemberRepository.findOne({
|
||||
where: { id: workspaceMemberId },
|
||||
});
|
||||
}
|
||||
|
||||
private async findFirstConnectedAccountIdByWorkspaceMember(
|
||||
workspaceMember: WorkspaceMemberWorkspaceEntity,
|
||||
workspaceId: string,
|
||||
): Promise<string | null> {
|
||||
const userWorkspace = await this.userWorkspaceRepository.findOne({
|
||||
where: { userId: workspaceMember.userId, workspaceId },
|
||||
});
|
||||
|
||||
if (!isDefined(userWorkspace)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const connectedAccount = await this.connectedAccountRepository.findOne({
|
||||
where: {
|
||||
userWorkspaceId: userWorkspace.id,
|
||||
workspaceId,
|
||||
archivedAt: IsNull(),
|
||||
},
|
||||
order: { createdAt: 'ASC' },
|
||||
});
|
||||
|
||||
return connectedAccount?.id ?? null;
|
||||
}
|
||||
|
||||
protected buildStepLog({
|
||||
input,
|
||||
output,
|
||||
|
||||
+8
-1
@@ -1,12 +1,19 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { ToolModule } from 'src/engine/core-modules/tool/tool.module';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
|
||||
import { DraftEmailWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/draft-email.workflow-action';
|
||||
import { SendEmailWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/send-email.workflow-action';
|
||||
import { WorkflowRunModule } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run.module';
|
||||
|
||||
@Module({
|
||||
imports: [ToolModule, WorkflowRunModule],
|
||||
imports: [
|
||||
ToolModule,
|
||||
WorkflowRunModule,
|
||||
TypeOrmModule.forFeature([ConnectedAccountEntity, UserWorkspaceEntity]),
|
||||
],
|
||||
providers: [SendEmailWorkflowAction, DraftEmailWorkflowAction],
|
||||
exports: [SendEmailWorkflowAction, DraftEmailWorkflowAction],
|
||||
})
|
||||
|
||||
+18
-1
@@ -1,7 +1,13 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { type Repository } from 'typeorm';
|
||||
|
||||
import { SendEmailTool } from 'src/engine/core-modules/tool/tools/email-tool/send-email-tool';
|
||||
import { type Tool } from 'src/engine/core-modules/tool/types/tool.type';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
|
||||
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
|
||||
import {
|
||||
WorkflowStepExecutorException,
|
||||
WorkflowStepExecutorExceptionCode,
|
||||
@@ -17,8 +23,19 @@ export class SendEmailWorkflowAction extends EmailWorkflowActionBase {
|
||||
constructor(
|
||||
private readonly sendEmailTool: SendEmailTool,
|
||||
workflowRunStepLogService: WorkflowRunStepLogWorkspaceService,
|
||||
globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
|
||||
@InjectRepository(ConnectedAccountEntity)
|
||||
connectedAccountRepository: Repository<ConnectedAccountEntity>,
|
||||
@InjectRepository(UserWorkspaceEntity)
|
||||
userWorkspaceRepository: Repository<UserWorkspaceEntity>,
|
||||
) {
|
||||
super(SendEmailWorkflowAction.name, workflowRunStepLogService);
|
||||
super(
|
||||
SendEmailWorkflowAction.name,
|
||||
workflowRunStepLogService,
|
||||
globalWorkspaceOrmManager,
|
||||
connectedAccountRepository,
|
||||
userWorkspaceRepository,
|
||||
);
|
||||
}
|
||||
|
||||
protected getTool(): Tool {
|
||||
|
||||
+11
-1
@@ -42,6 +42,13 @@ export abstract class ToolBackedWorkflowAction<
|
||||
return rawInput;
|
||||
}
|
||||
|
||||
protected async postprocessInput(
|
||||
resolvedInput: TInput,
|
||||
_workspaceId: string,
|
||||
): Promise<TInput> {
|
||||
return resolvedInput;
|
||||
}
|
||||
|
||||
protected abstract buildStepLog(
|
||||
args: BuildStepLogArgs<TInput>,
|
||||
): WorkflowRunStepLog;
|
||||
@@ -58,7 +65,10 @@ export abstract class ToolBackedWorkflowAction<
|
||||
|
||||
const rawInput = step.settings.input as TInput;
|
||||
const preprocessed = await this.preprocessInput(rawInput, context);
|
||||
const resolvedInput = resolveInput(preprocessed, context) as TInput;
|
||||
const resolvedInput = await this.postprocessInput(
|
||||
resolveInput(preprocessed, context) as TInput,
|
||||
runInfo.workspaceId,
|
||||
);
|
||||
|
||||
const startedAt = Date.now();
|
||||
const toolOutput = await this.getTool().execute(resolvedInput, {
|
||||
|
||||
Reference in New Issue
Block a user