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
@@ -425,12 +425,48 @@ describe('computeStepOutputSchema', () => {
});
describe('SEND_EMAIL step', () => {
it('should return success boolean schema', () => {
it('should return success and sent message identifier schema', () => {
const result = computeStepOutputSchema({
step: { type: 'SEND_EMAIL', settings: {} } as any,
objectMetadataItems: [],
});
expect(result).toEqual({
success: {
isLeaf: true,
type: FieldMetadataType.BOOLEAN,
label: 'Success',
value: true,
},
headerMessageId: {
isLeaf: true,
type: FieldMetadataType.TEXT,
label: 'Message-ID header',
value: '',
},
messageId: {
isLeaf: true,
type: FieldMetadataType.TEXT,
label: 'Message record ID',
value: '',
},
messageThreadId: {
isLeaf: true,
type: FieldMetadataType.TEXT,
label: 'Message thread ID',
value: '',
},
});
});
});
describe('DRAFT_EMAIL step', () => {
it('should return success boolean schema', () => {
const result = computeStepOutputSchema({
step: { type: 'DRAFT_EMAIL', settings: {} } as any,
objectMetadataItems: [],
});
expect(result).toEqual({
success: {
isLeaf: true,
@@ -218,7 +218,35 @@ export const computeStepOutputSchema = ({
return generateFormOutputSchema(formFields, objectMetadataItems);
}
case 'SEND_EMAIL':
case 'SEND_EMAIL': {
return {
success: {
isLeaf: true,
type: FieldMetadataType.BOOLEAN,
label: 'Success',
value: true,
},
headerMessageId: {
isLeaf: true,
type: FieldMetadataType.TEXT,
label: 'Message-ID header',
value: '',
},
messageId: {
isLeaf: true,
type: FieldMetadataType.TEXT,
label: 'Message record ID',
value: '',
},
messageThreadId: {
isLeaf: true,
type: FieldMetadataType.TEXT,
label: 'Message thread ID',
value: '',
},
};
}
case 'DRAFT_EMAIL': {
return {
success: {
@@ -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) {
@@ -57,6 +57,7 @@ export class MessagingMessageService {
string,
string
>;
messageExternalIdToMessageThreadIdMap: Map<string, string>;
}> {
const authContext = buildSystemAuthContext(workspaceId);
@@ -273,6 +274,7 @@ export class MessagingMessageService {
const messageExternalIdsAndIdsMap = new Map<string, string>();
const messageExternalIdToMessageChannelMessageAssociationIdMap =
new Map<string, string>();
const messageExternalIdToMessageThreadIdMap = new Map<string, string>();
for (const [
externalId,
@@ -292,6 +294,17 @@ export class MessagingMessageService {
);
}
const messageThreadId =
accumulator.messageToCreate?.messageThreadId ??
accumulator.existingMessageInDB?.messageThreadId;
if (isDefined(messageThreadId)) {
messageExternalIdToMessageThreadIdMap.set(
externalId,
messageThreadId,
);
}
const createdAssociationId =
accumulator.messageChannelMessageAssociationToCreate?.id;
const existingAssociationId =
@@ -310,6 +323,7 @@ export class MessagingMessageService {
createdMessages: messagesToCreate,
messageExternalIdsAndIdsMap,
messageExternalIdToMessageChannelMessageAssociationIdMap,
messageExternalIdToMessageThreadIdMap,
};
},
authContext,
@@ -142,6 +142,10 @@ describe('MessagingSaveMessagesAndEnqueueContactCreationService', () => {
['message-1', 'db-message-id-1'],
['message-2', 'db-message-id-2'],
]),
messageExternalIdToMessageThreadIdMap: new Map([
['message-1', 'db-thread-id-1'],
['message-2', 'db-thread-id-1'],
]),
createdMessages: [
{ id: 'db-message-id-1' },
{ id: 'db-message-id-2' },
@@ -192,7 +196,7 @@ describe('MessagingSaveMessagesAndEnqueueContactCreationService', () => {
});
it('should save messages and enqueue contact creation', async () => {
await service.saveMessagesAndEnqueueContactCreation(
const result = await service.saveMessagesAndEnqueueContactCreation(
mockMessages,
mockMessageChannel,
mockConnectedAccount,
@@ -210,6 +214,13 @@ describe('MessagingSaveMessagesAndEnqueueContactCreationService', () => {
messageParticipantService.saveMessageParticipants,
).toHaveBeenCalled();
expect(messageQueueService.add).toHaveBeenCalled();
expect(result?.messageExternalIdsAndIdsMap.get('message-1')).toBe(
'db-message-id-1',
);
expect(result?.messageExternalIdToMessageThreadIdMap.get('message-1')).toBe(
'db-thread-id-1',
);
});
it('should not enqueue contact creation when it is disabled', async () => {
@@ -48,11 +48,17 @@ export class MessagingSaveMessagesAndEnqueueContactCreationService {
messageChannel: MessageChannelEntity,
connectedAccount: ConnectedAccountEntity,
workspaceId: string,
) {
): Promise<
| {
messageExternalIdsAndIdsMap: Map<string, string>;
messageExternalIdToMessageThreadIdMap: Map<string, string>;
}
| undefined
> {
const handleAliases = connectedAccount.handleAliases || [];
const authContext = buildSystemAuthContext(workspaceId);
const participantsWithMessageId =
const savedMessagesResult =
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(
async () => {
const workspaceDataSource =
@@ -63,6 +69,7 @@ export class MessagingSaveMessagesAndEnqueueContactCreationService {
const {
messageExternalIdsAndIdsMap,
messageExternalIdToMessageChannelMessageAssociationIdMap,
messageExternalIdToMessageThreadIdMap,
} = await this.messageService.saveMessagesWithinTransaction(
messagesToSave,
messageChannel.id,
@@ -155,7 +162,11 @@ export class MessagingSaveMessagesAndEnqueueContactCreationService {
transactionManager,
);
return participantsWithMessageId;
return {
participantsWithMessageId,
messageExternalIdsAndIdsMap,
messageExternalIdToMessageThreadIdMap,
};
},
);
},
@@ -163,13 +174,11 @@ export class MessagingSaveMessagesAndEnqueueContactCreationService {
{ lite: true },
);
if (
messageChannel.isContactAutoCreationEnabled &&
participantsWithMessageId
) {
const contactsToCreate = participantsWithMessageId.filter(
(participant) => participant.shouldCreateContact,
);
if (messageChannel.isContactAutoCreationEnabled && savedMessagesResult) {
const contactsToCreate =
savedMessagesResult.participantsWithMessageId.filter(
(participant) => participant.shouldCreateContact,
);
await this.messageQueueService.add<CreateCompanyAndContactJobData>(
CreateCompanyAndContactJob.name,
@@ -181,5 +190,16 @@ export class MessagingSaveMessagesAndEnqueueContactCreationService {
},
);
}
if (!isDefined(savedMessagesResult)) {
return undefined;
}
return {
messageExternalIdsAndIdsMap:
savedMessagesResult.messageExternalIdsAndIdsMap,
messageExternalIdToMessageThreadIdMap:
savedMessagesResult.messageExternalIdToMessageThreadIdMap,
};
}
}
@@ -4,6 +4,7 @@ import { type ComposedEmail } from 'src/engine/core-modules/tool/tools/email-too
import { MessagingDraftSendService } from 'src/modules/messaging/message-outbound-manager/services/messaging-draft-send.service';
import { MessagingMessageOutboundService } from 'src/modules/messaging/message-outbound-manager/services/messaging-message-outbound.service';
import { SentMessagePersistenceService } from 'src/modules/messaging/message-outbound-manager/services/sent-message-persistence.service';
import { type PersistedSentMessage } from 'src/modules/messaging/message-outbound-manager/types/persisted-sent-message.type';
import { type SendMessageInput } from 'src/modules/messaging/message-outbound-manager/types/send-message-input.type';
import { type SendMessageResult } from 'src/modules/messaging/message-outbound-manager/types/send-message-result.type';
@@ -78,9 +79,9 @@ export class SendEmailService {
sendResult: SendMessageResult,
data: ComposedEmail,
workspaceId: string,
): Promise<void> {
): Promise<PersistedSentMessage | undefined> {
try {
await this.sentMessagePersistenceService.persistSentMessage({
return await this.sentMessagePersistenceService.persistSentMessage({
sendResult,
subject: data.sanitizedSubject,
body: data.plainTextBody,
@@ -95,6 +96,8 @@ export class SendEmailService {
this.logger.warn(
`Failed to persist sent message (sync will recover): ${persistenceError}`,
);
return undefined;
}
}
}
@@ -1,11 +1,13 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
import { MessagingSaveMessagesAndEnqueueContactCreationService } from 'src/modules/messaging/message-import-manager/services/messaging-save-messages-and-enqueue-contact-creation.service';
import { type PersistSentMessageInput } from 'src/modules/messaging/message-outbound-manager/types/persist-sent-message-input.type';
import { type PersistedSentMessage } from 'src/modules/messaging/message-outbound-manager/types/persisted-sent-message.type';
import { formatSentMessage } from 'src/modules/messaging/message-outbound-manager/utils/format-sent-message.util';
@Injectable()
@@ -16,7 +18,9 @@ export class SentMessagePersistenceService {
private readonly saveMessagesAndEnqueueContactCreationService: MessagingSaveMessagesAndEnqueueContactCreationService,
) {}
async persistSentMessage(input: PersistSentMessageInput): Promise<void> {
async persistSentMessage(
input: PersistSentMessageInput,
): Promise<PersistedSentMessage | undefined> {
const messageChannel = await this.messageChannelRepository.findOneOrFail({
where: {
id: input.messageChannelId,
@@ -27,11 +31,26 @@ export class SentMessagePersistenceService {
const messageToSave = formatSentMessage(input);
await this.saveMessagesAndEnqueueContactCreationService.saveMessagesAndEnqueueContactCreation(
[messageToSave],
messageChannel,
messageChannel.connectedAccount,
input.workspaceId,
const savedMessagesResult =
await this.saveMessagesAndEnqueueContactCreationService.saveMessagesAndEnqueueContactCreation(
[messageToSave],
messageChannel,
messageChannel.connectedAccount,
input.workspaceId,
);
const messageId = savedMessagesResult?.messageExternalIdsAndIdsMap.get(
messageToSave.externalId,
);
const messageThreadId =
savedMessagesResult?.messageExternalIdToMessageThreadIdMap.get(
messageToSave.externalId,
);
if (!isDefined(messageId) || !isDefined(messageThreadId)) {
return undefined;
}
return { messageId, messageThreadId };
}
}
@@ -0,0 +1,4 @@
export type PersistedSentMessage = {
messageId: string;
messageThreadId: string;
};
@@ -380,7 +380,27 @@ export class WorkflowSchemaWorkspaceService {
}
private computeSendEmailActionOutputSchema(): OutputSchema {
return { success: { isLeaf: true, type: 'boolean', value: true } };
return {
success: { isLeaf: true, type: 'boolean', value: true },
headerMessageId: {
isLeaf: true,
type: 'string',
label: 'Message-ID header',
value: '<message-id@mail.example.com>',
},
messageId: {
isLeaf: true,
type: 'string',
label: 'Message record ID',
value: '',
},
messageThreadId: {
isLeaf: true,
type: 'string',
label: 'Message thread ID',
value: '',
},
};
}
private async computeAiAgentActionOutputSchema({