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({