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:
+14
@@ -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,
|
||||
|
||||
+12
-1
@@ -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 () => {
|
||||
|
||||
+30
-10
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+5
-2
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+25
-6
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export type PersistedSentMessage = {
|
||||
messageId: string;
|
||||
messageThreadId: string;
|
||||
};
|
||||
+21
-1
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user