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