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:
+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();
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user