Support sender variable on workflow send-email action (#22512)

## Context

Draft-email workflow steps already accept a workspace member variable as
the sender: the step input's `connectedAccountId` can hold a workflow
variable that resolves to a workspace member id, which the action then
maps to that member's first connected account. Send-email steps were
gated out of this and only accepted a static connected account pick.

This enables the same dynamic sender resolution on send-email, e.g.
sending from the assignee/owner of the record that triggered the
workflow.

## What changed

- Removed the draft-only gate in
`EmailWorkflowActionBase.postprocessInput` so send-email resolves a
workspace member id to a connected account the same way draft-email does
- Exposed the variable picker and hint on the Account field for both
email actions in the workflow step editor
- Updated the send-email action spec to cover sender resolution (mirrors
the draft-email spec) and replaced the `SendEmailHasNoVariablePicker`
story with a variable-sender story for `SEND_EMAIL`

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22512?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. -->
This commit is contained in:
Félix Malfait
2026-07-03 17:53:07 +02:00
committed by GitHub
parent 13f380b80d
commit 3cf04bea28
4 changed files with 85 additions and 34 deletions
@@ -121,9 +121,6 @@ export const WorkflowEditActionEmailBase = ({
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(
@@ -205,17 +202,11 @@ export const WorkflowEditActionEmailBase = ({
<FormSelectFieldInput
key={`connected-account-${formData.connectedAccountId ?? 'none'}`}
label={t`Account`}
hint={
isSenderVariableEnabled
? t`Pick a connected account or set a workspace member as variable`
: undefined
}
hint={t`Pick a connected account or set a workspace member as variable`}
defaultValue={formData.connectedAccountId}
options={connectedAccountOptions}
onChange={handleConnectedAccountChange}
VariablePicker={
isSenderVariableEnabled ? WorkflowVariablePicker : undefined
}
VariablePicker={WorkflowVariablePicker}
readonly={actionOptions.readonly}
callToActionButton={{
onClick: () => {
@@ -128,6 +128,36 @@ const DEFAULT_DRAFT_EMAIL_ACTION: WorkflowDraftEmailAction = {
},
};
const VARIABLE_SENDER_SEND_EMAIL_ACTION: WorkflowSendEmailAction = {
id: getWorkflowNodeIdMock(),
name: 'Send Email',
type: 'SEND_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 VARIABLE_SENDER_DRAFT_EMAIL_ACTION: WorkflowDraftEmailAction = {
id: getWorkflowNodeIdMock(),
name: 'Draft Email',
@@ -283,11 +313,9 @@ export const VariableSender: Story = {
},
};
// 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 = {
export const VariableSenderSendEmail: Story = {
args: {
action: DEFAULT_SEND_EMAIL_ACTION,
action: VARIABLE_SENDER_SEND_EMAIL_ACTION,
actionOptions: {
onActionUpdate: fn(),
},
@@ -296,10 +324,11 @@ export const SendEmailHasNoVariablePicker: Story = {
const canvas = within(canvasElement);
expect(await canvas.findByText('Account')).toBeVisible();
expect(await canvas.findByLabelText('Remove variable')).toBeInTheDocument();
expect(
canvas.queryByText(
await canvas.findByText(
'Pick a connected account or set a workspace member as variable',
),
).not.toBeInTheDocument();
).toBeVisible();
},
};
@@ -47,12 +47,15 @@ const buildSendEmailStep = (input: Record<string, unknown>): WorkflowAction =>
}) 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('SendEmailWorkflowAction', () => {
let action: SendEmailWorkflowAction;
let mockSendEmailTool: jest.Mocked<Pick<SendEmailTool, 'execute'>>;
let connectedAccountRepository: { findOne: jest.Mock };
let getRepository: jest.Mock;
let userWorkspaceRepository: { findOne: jest.Mock };
let workspaceMemberRepository: { findOne: jest.Mock };
beforeEach(async () => {
jest.clearAllMocks();
@@ -64,7 +67,8 @@ describe('SendEmailWorkflowAction', () => {
}),
};
connectedAccountRepository = { findOne: jest.fn() };
getRepository = jest.fn();
userWorkspaceRepository = { findOne: jest.fn() };
workspaceMemberRepository = { findOne: jest.fn() };
const module: TestingModule = await Test.createTestingModule({
providers: [
@@ -78,7 +82,9 @@ describe('SendEmailWorkflowAction', () => {
provide: GlobalWorkspaceOrmManager,
useValue: {
executeInWorkspaceContext: jest.fn((callback) => callback()),
getRepository,
getRepository: jest
.fn()
.mockResolvedValue(workspaceMemberRepository),
},
},
{
@@ -87,7 +93,7 @@ describe('SendEmailWorkflowAction', () => {
},
{
provide: getRepositoryToken(UserWorkspaceEntity),
useValue: { findOne: jest.fn() },
useValue: userWorkspaceRepository,
},
],
}).compile();
@@ -197,14 +203,12 @@ 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({
const executeWithSender = (connectedAccountId: string) =>
action.execute({
currentStepId: 'step-1',
steps: [
buildSendEmailStep({
connectedAccountId: WORKSPACE_MEMBER_ID,
connectedAccountId,
recipients: { to: 'test@example.com' },
subject: 'Test',
body: 'hi',
@@ -214,13 +218,47 @@ describe('SendEmailWorkflowAction', () => {
runInfo: { workspaceId: 'workspace-1', workflowRunId: 'run-1' },
});
expect(getRepository).not.toHaveBeenCalled();
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(mockSendEmailTool.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(mockSendEmailTool.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(mockSendEmailTool.execute).not.toHaveBeenCalled();
});
});
describe('step type guard', () => {
@@ -57,13 +57,6 @@ export abstract class EmailWorkflowActionBase extends ToolBackedWorkflowAction<W
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,