From eedd83818900e77d5a72322532c0512c06c74b71 Mon Sep 17 00:00:00 2001 From: Thomas des Francs Date: Thu, 25 Jun 2026 18:39:01 +0200 Subject: [PATCH] Fix threaded draft email replies (#22175) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes Gmail and Microsoft draft replies so workflow-created drafts stay attached to the existing provider thread. Fixes twentyhq/core-team-issues#2597. ## Root cause The email composer already resolved `threadExternalId` and `references` from `inReplyTo`, but `DraftEmailTool` only forwarded `inReplyTo` to the outbound draft service. Gmail therefore created a raw draft without `message.threadId`, which lets the draft appear as a standalone compose instead of an inline thread reply. For Microsoft, the draft path used Graph `createReply`, but parent lookup filtered on a URL-encoded `internetMessageId`. That can miss the parent message and fall back to creating a new draft message instead of a reply draft. ## Changes - Forward `threadExternalId` and `references` from `DraftEmailTool` to outbound draft creation. - Set Gmail draft `message.threadId` when `threadExternalId` is available. - Make Microsoft parent lookup use Graph request query builders with OData string escaping, so `createReply` is reached reliably. - Add targeted Jest coverage for the Draft Email tool, Gmail draft threading, and Microsoft reply-draft creation. ## Validation - `NX_DAEMON=false /Users/thomascolasdesfrancs/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node ../../node_modules/nx/dist/bin/nx.js jest twenty-server -- --runTestsByPath src/engine/core-modules/tool/tools/email-tool/__tests__/draft-email-tool.spec.ts src/modules/messaging/message-outbound-manager/drivers/gmail/services/__tests__/gmail-message-outbound.service.spec.ts src/modules/messaging/message-outbound-manager/drivers/microsoft/services/__tests__/microsoft-message-outbound.service.spec.ts --runInBand` - `NX_DAEMON=false /Users/thomascolasdesfrancs/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node ./node_modules/nx/dist/bin/nx.js lint:diff-with-main twenty-server` Review in cubic --------- Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Félix Malfait --- .../__tests__/draft-email-tool.spec.ts | 21 ++- .../tool/tools/email-tool/draft-email-tool.ts | 2 + .../gmail-message-outbound.service.spec.ts | 37 ++++++ .../gmail-message-outbound.service.ts | 6 +- ...microsoft-message-outbound.service.spec.ts | 123 ++++++++++++++++++ .../microsoft-message-outbound.service.ts | 9 +- 6 files changed, 190 insertions(+), 8 deletions(-) create mode 100644 packages/twenty-server/src/modules/messaging/message-outbound-manager/drivers/microsoft/services/__tests__/microsoft-message-outbound.service.spec.ts diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/__tests__/draft-email-tool.spec.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/__tests__/draft-email-tool.spec.ts index 32a2b32e8b..2dffd89772 100644 --- a/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/__tests__/draft-email-tool.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/__tests__/draft-email-tool.spec.ts @@ -79,13 +79,20 @@ describe('DraftEmailTool', () => { }); it('creates the draft when the resolved account has the compose scope', async () => { - mockComposeEmail.mockResolvedValue({ - success: true, - data: buildComposedEmail({ + const composedEmail = { + ...buildComposedEmail({ id: 'account-1', provider: ConnectedAccountProvider.GOOGLE, scopes: [GMAIL_COMPOSE_SCOPE], }), + inReplyTo: '', + threadExternalId: 'thread-external-id', + references: ['', ''], + }; + + mockComposeEmail.mockResolvedValue({ + success: true, + data: composedEmail, }); const result = await tool.execute(baseInput, { @@ -94,5 +101,13 @@ describe('DraftEmailTool', () => { expect(result.success).toBe(true); expect(mockCreateDraft).toHaveBeenCalledTimes(1); + expect(mockCreateDraft).toHaveBeenCalledWith( + expect.objectContaining({ + inReplyTo: composedEmail.inReplyTo, + threadExternalId: composedEmail.threadExternalId, + references: composedEmail.references, + }), + composedEmail.connectedAccount, + ); }); }); diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/draft-email-tool.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/draft-email-tool.ts index b8dff2bed3..0698866a19 100644 --- a/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/draft-email-tool.ts +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/draft-email-tool.ts @@ -114,6 +114,8 @@ export class DraftEmailTool implements Tool { html: data.sanitizedHtmlBody, attachments: data.attachments, inReplyTo: data.inReplyTo, + threadExternalId: data.threadExternalId, + references: data.references, }, data.connectedAccount, ); diff --git a/packages/twenty-server/src/modules/messaging/message-outbound-manager/drivers/gmail/services/__tests__/gmail-message-outbound.service.spec.ts b/packages/twenty-server/src/modules/messaging/message-outbound-manager/drivers/gmail/services/__tests__/gmail-message-outbound.service.spec.ts index 0dfe52448c..27d2098e2b 100644 --- a/packages/twenty-server/src/modules/messaging/message-outbound-manager/drivers/gmail/services/__tests__/gmail-message-outbound.service.spec.ts +++ b/packages/twenty-server/src/modules/messaging/message-outbound-manager/drivers/gmail/services/__tests__/gmail-message-outbound.service.spec.ts @@ -18,12 +18,18 @@ describe('GmailMessageOutboundService', () => { let service: GmailMessageOutboundService; const mockSend = jest.fn().mockResolvedValue({ data: { id: 'message-id' } }); + const mockCreateDraft = jest + .fn() + .mockResolvedValue({ data: { id: 'draft-id' } }); const mockGmailClient = { users: { messages: { send: mockSend, }, + drafts: { + create: mockCreateDraft, + }, getProfile: jest .fn() .mockResolvedValue({ data: { emailAddress: 'test@example.com' } }), @@ -69,6 +75,7 @@ describe('GmailMessageOutboundService', () => { afterEach(() => { mockSend.mockClear(); + mockCreateDraft.mockClear(); jest.restoreAllMocks(); }); @@ -127,4 +134,34 @@ describe('GmailMessageOutboundService', () => { }, }); }); + + it('should create Gmail drafts in the existing thread when a thread id is provided', async () => { + const sendMessageInput = { + to: 'recipient@example.com', + subject: 'Re: Existing thread', + body: 'Plain text', + html: '

HTML content

', + attachments: [], + inReplyTo: '', + threadExternalId: 'gmail-thread-id', + }; + + const connectedAccount = { + id: 'connected-account-id', + provider: ConnectedAccountProvider.GOOGLE, + } as any; + + await service.createDraft(sendMessageInput, connectedAccount); + + expect(mockCreateDraft).toHaveBeenCalledTimes(1); + expect(mockCreateDraft).toHaveBeenCalledWith({ + userId: 'me', + requestBody: { + message: { + raw: Buffer.from('mocked-email-content').toString('base64url'), + threadId: 'gmail-thread-id', + }, + }, + }); + }); }); diff --git a/packages/twenty-server/src/modules/messaging/message-outbound-manager/drivers/gmail/services/gmail-message-outbound.service.ts b/packages/twenty-server/src/modules/messaging/message-outbound-manager/drivers/gmail/services/gmail-message-outbound.service.ts index 08ede32e69..201d2f3be9 100644 --- a/packages/twenty-server/src/modules/messaging/message-outbound-manager/drivers/gmail/services/gmail-message-outbound.service.ts +++ b/packages/twenty-server/src/modules/messaging/message-outbound-manager/drivers/gmail/services/gmail-message-outbound.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; +import { isNonEmptyString } from '@sniptt/guards'; import { type gmail_v1, google } from 'googleapis'; import MailComposer from 'nodemailer/lib/mail-composer'; import { isDefined } from 'twenty-shared/utils'; @@ -31,7 +32,7 @@ export class GmailMessageOutboundService implements MessageOutboundDriver { userId: 'me', requestBody: { raw: encodedMessage, - ...(sendMessageInput.threadExternalId + ...(isNonEmptyString(sendMessageInput.threadExternalId) ? { threadId: sendMessageInput.threadExternalId } : {}), }, @@ -58,6 +59,9 @@ export class GmailMessageOutboundService implements MessageOutboundDriver { requestBody: { message: { raw: encodedMessage, + ...(isNonEmptyString(sendMessageInput.threadExternalId) + ? { threadId: sendMessageInput.threadExternalId } + : {}), }, }, }); diff --git a/packages/twenty-server/src/modules/messaging/message-outbound-manager/drivers/microsoft/services/__tests__/microsoft-message-outbound.service.spec.ts b/packages/twenty-server/src/modules/messaging/message-outbound-manager/drivers/microsoft/services/__tests__/microsoft-message-outbound.service.spec.ts new file mode 100644 index 0000000000..0cc88609dc --- /dev/null +++ b/packages/twenty-server/src/modules/messaging/message-outbound-manager/drivers/microsoft/services/__tests__/microsoft-message-outbound.service.spec.ts @@ -0,0 +1,123 @@ +import { Test, type TestingModule } from '@nestjs/testing'; + +import { ConnectedAccountProvider } from 'twenty-shared/types'; + +import { MicrosoftOAuth2ClientProvider } from 'src/modules/connected-account/oauth2-client-manager/drivers/microsoft/microsoft-oauth2-client.provider'; +import { MicrosoftMessageOutboundService } from 'src/modules/messaging/message-outbound-manager/drivers/microsoft/services/microsoft-message-outbound.service'; + +describe('MicrosoftMessageOutboundService', () => { + let service: MicrosoftMessageOutboundService; + + const messagesRequest = { + filter: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + top: jest.fn().mockReturnThis(), + get: jest.fn(), + post: jest.fn(), + }; + + const replyRequest = { + post: jest.fn(), + }; + + const draftRequest = { + patch: jest.fn(), + }; + + const mockMicrosoftClient = { + api: jest.fn((path: string) => { + switch (path) { + case '/me/messages': + return messagesRequest; + case '/me/messages/parent-message-id/createReply': + return replyRequest; + case '/me/messages/reply-draft-id': + return draftRequest; + default: + throw new Error(`Unexpected Microsoft Graph path: ${path}`); + } + }), + }; + + beforeEach(async () => { + jest.clearAllMocks(); + + messagesRequest.filter.mockReturnThis(); + messagesRequest.select.mockReturnThis(); + messagesRequest.top.mockReturnThis(); + messagesRequest.get.mockResolvedValue({ + value: [{ id: 'parent-message-id' }], + }); + replyRequest.post.mockResolvedValue({ + id: 'reply-draft-id', + internetMessageId: '', + conversationId: 'conversation-id', + }); + draftRequest.patch.mockResolvedValue({ + id: 'reply-draft-id', + internetMessageId: '', + conversationId: 'conversation-id', + }); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + MicrosoftMessageOutboundService, + { + provide: MicrosoftOAuth2ClientProvider, + useValue: { + getClient: jest.fn().mockResolvedValue(mockMicrosoftClient), + }, + }, + ], + }).compile(); + + service = module.get( + MicrosoftMessageOutboundService, + ); + }); + + it('creates Microsoft drafts as replies when a parent internet message id is provided', async () => { + const connectedAccount = { + id: 'connected-account-id', + provider: ConnectedAccountProvider.MICROSOFT, + } as any; + + await service.createDraft( + { + to: 'recipient@example.com', + subject: 'Re: Existing thread', + body: 'Plain text', + html: '

HTML content

', + attachments: [], + inReplyTo: "", + }, + connectedAccount, + ); + + expect(mockMicrosoftClient.api).toHaveBeenCalledWith('/me/messages'); + expect(messagesRequest.filter).toHaveBeenCalledWith( + "internetMessageId eq ''", + ); + expect(messagesRequest.select).toHaveBeenCalledWith('id'); + expect(messagesRequest.top).toHaveBeenCalledWith(1); + + expect(mockMicrosoftClient.api).toHaveBeenCalledWith( + '/me/messages/parent-message-id/createReply', + ); + expect(replyRequest.post).toHaveBeenCalledWith({}); + + expect(mockMicrosoftClient.api).toHaveBeenCalledWith( + '/me/messages/reply-draft-id', + ); + expect(draftRequest.patch).toHaveBeenCalledWith( + expect.objectContaining({ + subject: 'Re: Existing thread', + body: { + contentType: 'HTML', + content: '

HTML content

', + }, + }), + ); + expect(messagesRequest.post).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/twenty-server/src/modules/messaging/message-outbound-manager/drivers/microsoft/services/microsoft-message-outbound.service.ts b/packages/twenty-server/src/modules/messaging/message-outbound-manager/drivers/microsoft/services/microsoft-message-outbound.service.ts index da248d2762..5cf1a371e6 100644 --- a/packages/twenty-server/src/modules/messaging/message-outbound-manager/drivers/microsoft/services/microsoft-message-outbound.service.ts +++ b/packages/twenty-server/src/modules/messaging/message-outbound-manager/drivers/microsoft/services/microsoft-message-outbound.service.ts @@ -97,12 +97,13 @@ export class MicrosoftMessageOutboundService implements MessageOutboundDriver { microsoftClient: MicrosoftGraphClient, internetMessageId: string, ): Promise { - const encodedId = encodeURIComponent(internetMessageId); + const escapedInternetMessageId = internetMessageId.split("'").join("''"); const response = await microsoftClient - .api( - `/me/messages?$filter=internetMessageId eq '${encodedId}'&$select=id&$top=1`, - ) + .api('/me/messages') + .filter(`internetMessageId eq '${escapedInternetMessageId}'`) + .select('id') + .top(1) .get(); return response?.value?.[0]?.id;