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:
Thomas Trompette
2026-06-17 15:57:22 +02:00
committed by GitHub
parent 3ee93b5ec9
commit 8130fa1c45
18 changed files with 753 additions and 39 deletions
@@ -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) && (
<>
@@ -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();
},
};