Fix threaded draft email replies (#22175)

## 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`

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22175?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. -->

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Félix Malfait <FelixMalfait@users.noreply.github.com>
This commit is contained in:
Thomas des Francs
2026-06-25 18:39:01 +02:00
committed by GitHub
parent 1076866820
commit eedd838189
6 changed files with 190 additions and 8 deletions
@@ -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: '<parent@example.com>',
threadExternalId: 'thread-external-id',
references: ['<ancestor@example.com>', '<parent@example.com>'],
};
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,
);
});
});
@@ -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,
);