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:
+2
-11
@@ -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: () => {
|
||||
|
||||
+35
-6
@@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
+48
-10
@@ -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', () => {
|
||||
|
||||
-7
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user