diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/generate/__tests__/computeStepOutputSchema.test.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/generate/__tests__/computeStepOutputSchema.test.ts
index bfd28581a4..acafcf7111 100644
--- a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/generate/__tests__/computeStepOutputSchema.test.ts
+++ b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/generate/__tests__/computeStepOutputSchema.test.ts
@@ -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,
diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/generate/computeStepOutputSchema.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/generate/computeStepOutputSchema.ts
index df97d16c05..0ed88f935e 100644
--- a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/generate/computeStepOutputSchema.ts
+++ b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/generate/computeStepOutputSchema.ts
@@ -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: {
diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/__tests__/send-email-tool.spec.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/__tests__/send-email-tool.spec.ts
new file mode 100644
index 0000000000..9294fa2ecb
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/__tests__/send-email-tool.spec.ts
@@ -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: '
body
',
+ attachments: [],
+ connectedAccount: { id: 'account-1' },
+ messageChannelId: 'channel-1',
+ shouldPersistMessage,
+});
+
+const sendResult = {
+ headerMessageId: '',
+ messageExternalId: 'provider-message-id',
+ threadExternalId: 'provider-thread-id',
+};
+
+const baseInput: EmailToolInput = {
+ recipients: { to: 'test@example.com', cc: '', bcc: '' },
+ subject: 'Subject',
+ body: 'body
',
+ 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: '',
+ 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: '',
+ 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: '',
+ messageId: undefined,
+ messageThreadId: undefined,
+ });
+ });
+});
diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/send-email-tool.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/send-email-tool.ts
index 79104998a4..efb467a079 100644
--- a/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/send-email-tool.ts
+++ b/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/send-email-tool.ts
@@ -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) {
diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-message.service.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-message.service.ts
index 59adb7343b..835280ed88 100644
--- a/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-message.service.ts
+++ b/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-message.service.ts
@@ -57,6 +57,7 @@ export class MessagingMessageService {
string,
string
>;
+ messageExternalIdToMessageThreadIdMap: Map;
}> {
const authContext = buildSystemAuthContext(workspaceId);
@@ -273,6 +274,7 @@ export class MessagingMessageService {
const messageExternalIdsAndIdsMap = new Map();
const messageExternalIdToMessageChannelMessageAssociationIdMap =
new Map();
+ const messageExternalIdToMessageThreadIdMap = new Map();
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,
diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-save-messages-and-enqueue-contact-creation.service.spec.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-save-messages-and-enqueue-contact-creation.service.spec.ts
index bdc879c8c9..71bf3a6f6d 100644
--- a/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-save-messages-and-enqueue-contact-creation.service.spec.ts
+++ b/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-save-messages-and-enqueue-contact-creation.service.spec.ts
@@ -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 () => {
diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-save-messages-and-enqueue-contact-creation.service.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-save-messages-and-enqueue-contact-creation.service.ts
index dc833e5e69..cd0e4bd876 100644
--- a/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-save-messages-and-enqueue-contact-creation.service.ts
+++ b/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-save-messages-and-enqueue-contact-creation.service.ts
@@ -48,11 +48,17 @@ export class MessagingSaveMessagesAndEnqueueContactCreationService {
messageChannel: MessageChannelEntity,
connectedAccount: ConnectedAccountEntity,
workspaceId: string,
- ) {
+ ): Promise<
+ | {
+ messageExternalIdsAndIdsMap: Map;
+ messageExternalIdToMessageThreadIdMap: Map;
+ }
+ | 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(
CreateCompanyAndContactJob.name,
@@ -181,5 +190,16 @@ export class MessagingSaveMessagesAndEnqueueContactCreationService {
},
);
}
+
+ if (!isDefined(savedMessagesResult)) {
+ return undefined;
+ }
+
+ return {
+ messageExternalIdsAndIdsMap:
+ savedMessagesResult.messageExternalIdsAndIdsMap,
+ messageExternalIdToMessageThreadIdMap:
+ savedMessagesResult.messageExternalIdToMessageThreadIdMap,
+ };
}
}
diff --git a/packages/twenty-server/src/modules/messaging/message-outbound-manager/services/send-email.service.ts b/packages/twenty-server/src/modules/messaging/message-outbound-manager/services/send-email.service.ts
index 2bfce450bf..e31a2be1a4 100644
--- a/packages/twenty-server/src/modules/messaging/message-outbound-manager/services/send-email.service.ts
+++ b/packages/twenty-server/src/modules/messaging/message-outbound-manager/services/send-email.service.ts
@@ -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 {
+ ): Promise {
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;
}
}
}
diff --git a/packages/twenty-server/src/modules/messaging/message-outbound-manager/services/sent-message-persistence.service.ts b/packages/twenty-server/src/modules/messaging/message-outbound-manager/services/sent-message-persistence.service.ts
index ec93195b8b..c5805ad7f5 100644
--- a/packages/twenty-server/src/modules/messaging/message-outbound-manager/services/sent-message-persistence.service.ts
+++ b/packages/twenty-server/src/modules/messaging/message-outbound-manager/services/sent-message-persistence.service.ts
@@ -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 {
+ async persistSentMessage(
+ input: PersistSentMessageInput,
+ ): Promise {
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 };
}
}
diff --git a/packages/twenty-server/src/modules/messaging/message-outbound-manager/types/persisted-sent-message.type.ts b/packages/twenty-server/src/modules/messaging/message-outbound-manager/types/persisted-sent-message.type.ts
new file mode 100644
index 0000000000..87cc406123
--- /dev/null
+++ b/packages/twenty-server/src/modules/messaging/message-outbound-manager/types/persisted-sent-message.type.ts
@@ -0,0 +1,4 @@
+export type PersistedSentMessage = {
+ messageId: string;
+ messageThreadId: string;
+};
diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.workspace-service.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.workspace-service.ts
index 9a1071e8d0..c20401bad0 100644
--- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.workspace-service.ts
+++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.workspace-service.ts
@@ -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: '',
+ },
+ messageId: {
+ isLeaf: true,
+ type: 'string',
+ label: 'Message record ID',
+ value: '',
+ },
+ messageThreadId: {
+ isLeaf: true,
+ type: 'string',
+ label: 'Message thread ID',
+ value: '',
+ },
+ };
}
private async computeAiAgentActionOutputSchema({