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
@@ -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({
@@ -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(
@@ -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 {
@@ -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,
@@ -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],
})
@@ -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 {
@@ -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, {