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
@@ -0,0 +1,98 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { DraftEmailTool } from 'src/engine/core-modules/tool/tools/email-tool/draft-email-tool';
import { EmailComposerService } from 'src/engine/core-modules/tool/tools/email-tool/email-composer.service';
import { type EmailToolInput } from 'src/engine/core-modules/tool/tools/email-tool/types/email-tool-input.type';
import { MessagingMessageOutboundService } from 'src/modules/messaging/message-outbound-manager/services/messaging-message-outbound.service';
const GMAIL_COMPOSE_SCOPE = 'https://www.googleapis.com/auth/gmail.compose';
const buildComposedEmail = (connectedAccount: {
id: string;
provider: ConnectedAccountProvider;
scopes: string[] | null;
}) => ({
recipients: { to: ['test@example.com'], cc: [], bcc: [] },
toRecipientsDisplay: 'test@example.com',
sanitizedSubject: 'Subject',
plainTextBody: 'body',
sanitizedHtmlBody: '<p>body</p>',
attachments: [],
connectedAccount,
shouldPersistMessage: true,
});
const baseInput: EmailToolInput = {
recipients: { to: 'test@example.com', cc: '', bcc: '' },
subject: 'Subject',
body: '<p>body</p>',
files: [],
};
describe('DraftEmailTool', () => {
let tool: DraftEmailTool;
let mockComposeEmail: jest.Mock;
let mockCreateDraft: jest.Mock;
beforeEach(async () => {
jest.clearAllMocks();
mockComposeEmail = jest.fn();
mockCreateDraft = jest.fn();
const module: TestingModule = await Test.createTestingModule({
providers: [
DraftEmailTool,
{
provide: EmailComposerService,
useValue: { composeEmail: mockComposeEmail },
},
{
provide: MessagingMessageOutboundService,
useValue: { createDraft: mockCreateDraft },
},
],
}).compile();
tool = module.get(DraftEmailTool);
});
it('fails without drafting when the resolved account lacks the compose scope', async () => {
mockComposeEmail.mockResolvedValue({
success: true,
data: buildComposedEmail({
id: 'account-1',
provider: ConnectedAccountProvider.GOOGLE,
scopes: [],
}),
});
const result = await tool.execute(baseInput, {
workspaceId: 'workspace-1',
});
expect(result.success).toBe(false);
expect(result.message).toContain('insufficient permissions');
expect(mockCreateDraft).not.toHaveBeenCalled();
});
it('creates the draft when the resolved account has the compose scope', async () => {
mockComposeEmail.mockResolvedValue({
success: true,
data: buildComposedEmail({
id: 'account-1',
provider: ConnectedAccountProvider.GOOGLE,
scopes: [GMAIL_COMPOSE_SCOPE],
}),
});
const result = await tool.execute(baseInput, {
workspaceId: 'workspace-1',
});
expect(result.success).toBe(true);
expect(mockCreateDraft).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,94 @@
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { EmailComposerService } from 'src/engine/core-modules/tool/tools/email-tool/email-composer.service';
const WORKSPACE_ID = '20202020-0000-4000-8000-000000000000';
const CONNECTED_ACCOUNT_ID = '20202020-1111-4111-8111-111111111111';
const buildAccount = (id: string) => ({
id,
handle: 'tim@apple.dev',
provider: ConnectedAccountProvider.GOOGLE,
scopes: ['email'],
connectionParameters: null,
messageChannels: [{ id: 'message-channel-1', handle: 'tim@apple.dev' }],
});
const baseParams = {
recipients: { to: 'test@example.com' },
subject: 'Subject',
body: '<p>body</p>',
files: [],
};
const context = { workspaceId: WORKSPACE_ID };
describe('EmailComposerService connected account resolution', () => {
let service: EmailComposerService;
let connectedAccountRepository: {
findOne: jest.Mock;
find: jest.Mock;
};
let globalWorkspaceOrmManager: {
executeInWorkspaceContext: jest.Mock;
getRepository: jest.Mock;
};
beforeEach(() => {
jest.clearAllMocks();
connectedAccountRepository = { findOne: jest.fn(), find: jest.fn() };
globalWorkspaceOrmManager = {
executeInWorkspaceContext: jest.fn((callback) => callback()),
getRepository: jest.fn(),
};
service = new EmailComposerService(
globalWorkspaceOrmManager as never,
connectedAccountRepository as never,
{ find: jest.fn() } as never,
{} as never,
);
});
it('uses the connected account matching the provided id', async () => {
connectedAccountRepository.findOne.mockResolvedValue(
buildAccount(CONNECTED_ACCOUNT_ID),
);
const result = await service.composeEmail(
{ ...baseParams, connectedAccountId: CONNECTED_ACCOUNT_ID },
context,
);
expect(result.success).toBe(true);
expect(result.success && result.data.connectedAccount.id).toBe(
CONNECTED_ACCOUNT_ID,
);
expect(connectedAccountRepository.findOne).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: CONNECTED_ACCOUNT_ID, workspaceId: WORKSPACE_ID },
}),
);
});
it('throws when the id is not a valid UUID', async () => {
await expect(
service.composeEmail(
{ ...baseParams, connectedAccountId: 'not-a-uuid' },
context,
),
).rejects.toThrow('Connected account id is not a valid UUID');
});
it('throws when no connected account matches the provided id', async () => {
connectedAccountRepository.findOne.mockResolvedValue(null);
await expect(
service.composeEmail(
{ ...baseParams, connectedAccountId: CONNECTED_ACCOUNT_ID },
context,
),
).rejects.toThrow(`No connected account found for id`);
});
});
@@ -3,6 +3,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { EmailComposerService } from 'src/engine/core-modules/tool/tools/email-tool/email-composer.service';
import { EmailToolInputZodSchema } from 'src/engine/core-modules/tool/tools/email-tool/email-tool.schema';
import { EmailToolException } from 'src/engine/core-modules/tool/tools/email-tool/exceptions/email-tool.exception';
import { getMissingDraftEmailScopes } from 'src/engine/core-modules/tool/tools/email-tool/utils/get-missing-draft-email-scopes.util';
import { isInsufficientPermissionsError } from 'src/engine/core-modules/tool/tools/email-tool/utils/is-insufficient-permissions-error.util';
import { type ComposedEmail } from 'src/engine/core-modules/tool/tools/email-tool/types/composed-email.type';
import { type EmailToolInput } from 'src/engine/core-modules/tool/tools/email-tool/types/email-tool-input.type';
@@ -40,6 +41,19 @@ export class DraftEmailTool implements Tool {
const { data } = result;
const missingDraftScopes = getMissingDraftEmailScopes(
data.connectedAccount,
);
if (missingDraftScopes.length > 0) {
return {
success: false,
message: 'Failed to create draft due to insufficient permissions',
error:
'The connected email account does not have permission to create drafts.',
};
}
await this.createDraft(data);
this.logger.log(
@@ -76,8 +90,7 @@ export class DraftEmailTool implements Tool {
success: false,
message: 'Failed to create draft due to insufficient permissions',
error:
'The connected email account does not have permission to create drafts. ' +
'The user should disconnect and reconnect their account in Settings > Accounts to grant the required permissions.',
'The connected email account does not have permission to create drafts.',
};
}
@@ -52,13 +52,13 @@ export class EmailComposerService {
private readonly fileService: FileService,
) {}
private async getConnectedAccount(
private async getConnectedAccountOrThrow(
connectedAccountId: string,
workspaceId: string,
) {
): Promise<ConnectedAccountEntity> {
if (!isValidUuid(connectedAccountId)) {
throw new EmailToolException(
`Connected Account ID is not a valid UUID`,
`Connected account id is not a valid UUID`,
EmailToolExceptionCode.INVALID_CONNECTED_ACCOUNT_ID,
);
}
@@ -78,7 +78,7 @@ export class EmailComposerService {
if (!isDefined(connectedAccount)) {
throw new EmailToolException(
`Connected Account '${connectedAccountId}' not found`,
`No connected account found for id '${connectedAccountId}'`,
EmailToolExceptionCode.CONNECTED_ACCOUNT_NOT_FOUND,
);
}
@@ -355,7 +355,7 @@ export class EmailComposerService {
await this.getOrThrowFirstConnectedAccountId(workspaceId);
}
const connectedAccount = await this.getConnectedAccount(
const connectedAccount = await this.getConnectedAccountOrThrow(
connectedAccountId,
workspaceId,
);
@@ -83,8 +83,7 @@ export class SendEmailTool implements Tool {
success: false,
message: 'Failed to send email due to insufficient permissions',
error:
'The connected email account does not have permission to send emails. ' +
'The user should disconnect and reconnect their account in Settings > Accounts to grant the required permissions.',
'The connected email account does not have permission to send emails.',
};
}
@@ -0,0 +1,71 @@
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { getMissingDraftEmailScopes } from 'src/engine/core-modules/tool/tools/email-tool/utils/get-missing-draft-email-scopes.util';
const GMAIL_COMPOSE_SCOPE = 'https://www.googleapis.com/auth/gmail.compose';
const MICROSOFT_SEND_SCOPE = 'Mail.Send';
describe('getMissingDraftEmailScopes', () => {
describe('Google provider', () => {
it('returns the compose scope when missing', () => {
expect(
getMissingDraftEmailScopes({
provider: ConnectedAccountProvider.GOOGLE,
scopes: ['email', 'profile'],
}),
).toEqual([GMAIL_COMPOSE_SCOPE]);
});
it('returns the compose scope when scopes are null', () => {
expect(
getMissingDraftEmailScopes({
provider: ConnectedAccountProvider.GOOGLE,
scopes: null,
}),
).toEqual([GMAIL_COMPOSE_SCOPE]);
});
it('returns nothing when the compose scope is present', () => {
expect(
getMissingDraftEmailScopes({
provider: ConnectedAccountProvider.GOOGLE,
scopes: ['email', GMAIL_COMPOSE_SCOPE],
}),
).toEqual([]);
});
});
describe('Microsoft provider', () => {
it('returns the send scope when missing', () => {
expect(
getMissingDraftEmailScopes({
provider: ConnectedAccountProvider.MICROSOFT,
scopes: ['Mail.ReadWrite'],
}),
).toEqual([MICROSOFT_SEND_SCOPE]);
});
it('returns nothing when the send scope is present', () => {
expect(
getMissingDraftEmailScopes({
provider: ConnectedAccountProvider.MICROSOFT,
scopes: [MICROSOFT_SEND_SCOPE],
}),
).toEqual([]);
});
});
describe('non-OAuth providers', () => {
it.each([
ConnectedAccountProvider.IMAP_SMTP_CALDAV,
ConnectedAccountProvider.EMAIL_GROUP,
ConnectedAccountProvider.APP,
ConnectedAccountProvider.OIDC,
ConnectedAccountProvider.SAML,
])('never requires scopes for %s accounts', (provider) => {
expect(getMissingDraftEmailScopes({ provider, scopes: null })).toEqual(
[],
);
});
});
});
@@ -0,0 +1,39 @@
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
const GMAIL_COMPOSE_SCOPE = 'https://www.googleapis.com/auth/gmail.compose';
const MICROSOFT_SEND_SCOPE = 'Mail.Send';
export const getMissingDraftEmailScopes = (connectedAccount: {
provider: ConnectedAccountProvider;
scopes: string[] | null;
}): string[] => {
const scopes = connectedAccount.scopes;
switch (connectedAccount.provider) {
case ConnectedAccountProvider.GOOGLE: {
const hasScope =
isDefined(scopes) && scopes.includes(GMAIL_COMPOSE_SCOPE);
return hasScope ? [] : [GMAIL_COMPOSE_SCOPE];
}
case ConnectedAccountProvider.MICROSOFT: {
const hasScope =
isDefined(scopes) && scopes.includes(MICROSOFT_SEND_SCOPE);
return hasScope ? [] : [MICROSOFT_SEND_SCOPE];
}
// Non-OAuth providers do not rely on OAuth scopes to draft emails.
case ConnectedAccountProvider.IMAP_SMTP_CALDAV:
case ConnectedAccountProvider.EMAIL_GROUP:
case ConnectedAccountProvider.APP:
case ConnectedAccountProvider.OIDC:
case ConnectedAccountProvider.SAML:
return [];
default:
return assertUnreachable(
connectedAccount.provider,
`Unhandled connected account provider for draft email scopes: ${connectedAccount.provider}`,
);
}
};