Expose sent message identifiers in workflow send-email step output (#22520)

## Context

First step toward thread-continuity / follow-up email steps in workflows
(email sequences). The outbound send pipeline already knows the sent
email's RFC-822 Message-ID, the provider thread id, and the persisted
message/thread records — but none of it was surfaced in the send-email
step output, so a later step had no way to reference the email that was
sent.

## What changed

- `saveMessagesWithinTransaction` also returns a `messageExternalId →
messageThreadId` map, and `saveMessagesAndEnqueueContactCreation`
returns the message/thread id maps (both other call sites ignore the
return value)
- `SentMessagePersistenceService.persistSentMessage` and
`SendEmailService.persistSentMessage` return the persisted `{ messageId,
messageThreadId }` (`undefined` when persistence is skipped or fails —
sending still succeeds)
- `SendEmailTool` result now includes `headerMessageId`,
`threadExternalId`, `messageId` and `messageThreadId`
- SEND_EMAIL step output schema (server + frontend) declares
`headerMessageId`/`messageId`/`messageThreadId` so they show up in the
variable picker; DRAFT_EMAIL keeps its success-only schema since draft
creation returns no identifiers yet

This already enables manual thread continuity today: wire
`{{sendEmailStep.headerMessageId}}` into a later email step's
In-Reply-To advanced field — the composer resolves the References chain
and provider thread from it.

## Tests

- New `send-email-tool.spec.ts` covering identifiers in the result,
persistence disabled, and persistence failure
- Extended save-messages spec with the new map, updated frontend
`computeStepOutputSchema` tests

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22520?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:
Félix Malfait
2026-07-03 18:01:40 +02:00
committed by GitHub
parent 3cf04bea28
commit 9f4efa57ff
11 changed files with 314 additions and 29 deletions
@@ -0,0 +1,126 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { EmailComposerService } from 'src/engine/core-modules/tool/tools/email-tool/email-composer.service';
import { SendEmailTool } from 'src/engine/core-modules/tool/tools/email-tool/send-email-tool';
import { type EmailToolInput } from 'src/engine/core-modules/tool/tools/email-tool/types/email-tool-input.type';
import { SendEmailService } from 'src/modules/messaging/message-outbound-manager/services/send-email.service';
const buildComposedEmail = (shouldPersistMessage: boolean) => ({
recipients: { to: ['test@example.com'], cc: [], bcc: [] },
toRecipientsDisplay: 'test@example.com',
sanitizedSubject: 'Subject',
plainTextBody: 'body',
sanitizedHtmlBody: '<p>body</p>',
attachments: [],
connectedAccount: { id: 'account-1' },
messageChannelId: 'channel-1',
shouldPersistMessage,
});
const sendResult = {
headerMessageId: '<sent-message@mail.example.com>',
messageExternalId: 'provider-message-id',
threadExternalId: 'provider-thread-id',
};
const baseInput: EmailToolInput = {
recipients: { to: 'test@example.com', cc: '', bcc: '' },
subject: 'Subject',
body: '<p>body</p>',
files: [],
};
describe('SendEmailTool', () => {
let tool: SendEmailTool;
let mockComposeEmail: jest.Mock;
let mockSendComposedEmail: jest.Mock;
let mockPersistSentMessage: jest.Mock;
beforeEach(async () => {
jest.clearAllMocks();
mockComposeEmail = jest.fn();
mockSendComposedEmail = jest.fn().mockResolvedValue(sendResult);
mockPersistSentMessage = jest.fn().mockResolvedValue({
messageId: 'message-record-id',
messageThreadId: 'message-thread-record-id',
});
const module: TestingModule = await Test.createTestingModule({
providers: [
SendEmailTool,
{
provide: EmailComposerService,
useValue: { composeEmail: mockComposeEmail },
},
{
provide: SendEmailService,
useValue: {
sendComposedEmail: mockSendComposedEmail,
persistSentMessage: mockPersistSentMessage,
},
},
],
}).compile();
tool = module.get(SendEmailTool);
});
it('returns the sent message identifiers when the message is persisted', async () => {
mockComposeEmail.mockResolvedValue({
success: true,
data: buildComposedEmail(true),
});
const result = await tool.execute(baseInput, {
workspaceId: 'workspace-1',
});
expect(result.success).toBe(true);
expect(result.result).toMatchObject({
headerMessageId: '<sent-message@mail.example.com>',
threadExternalId: 'provider-thread-id',
messageId: 'message-record-id',
messageThreadId: 'message-thread-record-id',
});
});
it('returns the send identifiers without record ids when persistence is disabled', async () => {
mockComposeEmail.mockResolvedValue({
success: true,
data: buildComposedEmail(false),
});
const result = await tool.execute(baseInput, {
workspaceId: 'workspace-1',
});
expect(mockPersistSentMessage).not.toHaveBeenCalled();
expect(result.success).toBe(true);
expect(result.result).toMatchObject({
headerMessageId: '<sent-message@mail.example.com>',
threadExternalId: 'provider-thread-id',
messageId: undefined,
messageThreadId: undefined,
});
});
it('still succeeds without record ids when persistence fails', async () => {
mockComposeEmail.mockResolvedValue({
success: true,
data: buildComposedEmail(true),
});
mockPersistSentMessage.mockResolvedValue(undefined);
const result = await tool.execute(baseInput, {
workspaceId: 'workspace-1',
});
expect(result.success).toBe(true);
expect(result.result).toMatchObject({
headerMessageId: '<sent-message@mail.example.com>',
messageId: undefined,
messageThreadId: undefined,
});
});
});
@@ -41,13 +41,13 @@ export class SendEmailTool implements Tool {
const sendResult = await this.sendEmailService.sendComposedEmail(data);
if (data.shouldPersistMessage) {
await this.sendEmailService.persistSentMessage(
sendResult,
data,
context.workspaceId,
);
}
const persistedMessage = data.shouldPersistMessage
? await this.sendEmailService.persistSentMessage(
sendResult,
data,
context.workspaceId,
)
: undefined;
this.logger.log(
`Email sent successfully to ${data.toRecipientsDisplay}${data.attachments.length > 0 ? ` with ${data.attachments.length} attachments` : ''}`,
@@ -65,6 +65,10 @@ export class SendEmailTool implements Tool {
plainTextBody: data.plainTextBody,
connectedAccountId: data.connectedAccount.id,
attachmentCount: data.attachments.length,
headerMessageId: sendResult.headerMessageId,
threadExternalId: sendResult.threadExternalId,
messageId: persistedMessage?.messageId,
messageThreadId: persistedMessage?.messageThreadId,
},
};
} catch (error) {