Send Email UI IMAP/SMTP message threading fix (#20784)

**Problem:**

When using Twenty Send Email UI IMAP/SMTP message threading is broken on
Twenty side as well as recipient email client

**Twenty side fix:**

- SMTP has no concept of `externalThreadId` sendEmail resolver always
returns null, this breaks threading
Fix is to pass `parentThreadExternalId` to
`resolveOutboundThreadExternalId` for SMTP/IMAP path

**Recipient email client fix:**

- Fetch associated threads as per RFC spec to write `References` header
 ```
From: johndoe@domain.com
To: janedoe@domain.com
Subject: Test
References: <root@...> <mid1@...> <parent@...>
```
This commit is contained in:
neo773
2026-05-22 00:46:27 +05:30
committed by GitHub
parent 11b9f708d6
commit 1e2ae5342b
10 changed files with 101 additions and 29 deletions
@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { toPlainText } from '@react-email/render';
import { isNonEmptyString } from '@sniptt/guards';
import DOMPurify from 'dompurify';
import { MAX_EMAIL_RECIPIENTS } from 'twenty-shared/constants';
import {
@@ -10,7 +11,7 @@ import {
FileFolder,
} from 'twenty-shared/types';
import { isDefined, isValidUuid } from 'twenty-shared/utils';
import { In, type Repository } from 'typeorm';
import { In, LessThanOrEqual, type Repository } from 'typeorm';
import { z } from 'zod';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
@@ -19,18 +20,24 @@ import {
EmailToolException,
EmailToolExceptionCode,
} from 'src/engine/core-modules/tool/tools/email-tool/exceptions/email-tool.exception';
import { EmailComposerResult } from 'src/engine/core-modules/tool/tools/email-tool/types/email-composer-result.type';
import { type ComposeEmailParams } from 'src/engine/core-modules/tool/tools/email-tool/types/compose-email-params.type';
import { EmailComposerResult } from 'src/engine/core-modules/tool/tools/email-tool/types/email-composer-result.type';
import { parseCommaSeparatedEmails } from 'src/engine/core-modules/tool/tools/email-tool/utils/parse-comma-separated-emails.util';
import { type ToolExecutionContext } from 'src/engine/core-modules/tool/types/tool-execution-context.type';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { MessagingAccountAuthenticationService } from 'src/modules/messaging/message-import-manager/services/messaging-account-authentication.service';
import { type MessageChannelMessageAssociationWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel-message-association.workspace-entity';
import { type MessageWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message.workspace-entity';
import { MessagingAccountAuthenticationService } from 'src/modules/messaging/message-import-manager/services/messaging-account-authentication.service';
import { type MessageAttachment } from 'src/modules/messaging/message-import-manager/types/message';
import { streamToBuffer } from 'src/utils/stream-to-buffer';
type ParentThreadContext = {
threadExternalId?: string;
references?: string[];
};
@Injectable()
export class EmailComposerService {
private readonly logger = new Logger(EmailComposerService.name);
@@ -232,13 +239,13 @@ export class EmailComposerService {
return attachments;
}
// Look up the provider-specific thread ID (e.g. Gmail threadId) from the
// parent message so replies can be explicitly threaded in the provider API.
private async getThreadExternalId(
// Resolve parent's root thread id (Gmail/MS native or stored) + RFC 5322 §3.6.4
// References chain so replies thread on both Twenty and recipient mail clients.
private async getParentThreadContext(
workspaceId: string,
inReplyTo: string,
messageChannelId: string,
): Promise<string | undefined> {
): Promise<ParentThreadContext> {
const authContext = buildSystemAuthContext(workspaceId);
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
@@ -253,8 +260,12 @@ export class EmailComposerService {
where: { headerMessageId: inReplyTo },
});
if (!parentMessage) {
return undefined;
if (
!isDefined(parentMessage) ||
!isDefined(parentMessage.messageThreadId) ||
!isDefined(parentMessage.receivedAt)
) {
return {};
}
const associationRepository =
@@ -263,14 +274,29 @@ export class EmailComposerService {
'messageChannelMessageAssociation',
);
const association = await associationRepository.findOne({
where: {
messageId: parentMessage.id,
messageChannelId,
},
});
const [association, ancestorMessages] = await Promise.all([
associationRepository.findOne({
where: { messageId: parentMessage.id, messageChannelId },
select: { messageThreadExternalId: true },
}),
messageRepository.find({
where: {
messageThreadId: parentMessage.messageThreadId,
receivedAt: LessThanOrEqual(parentMessage.receivedAt),
},
select: { headerMessageId: true },
order: { receivedAt: 'ASC' },
}),
]);
return association?.messageThreadExternalId ?? undefined;
const references = ancestorMessages
.map((message) => message.headerMessageId)
.filter(isNonEmptyString);
return {
threadExternalId: association?.messageThreadExternalId ?? undefined,
references: references.length > 0 ? references : undefined,
};
},
authContext,
);
@@ -397,15 +423,14 @@ export class EmailComposerService {
const plainTextBody = toPlainText(sanitizedHtmlBody);
const sanitizedSubject = purify.sanitize(subject || '');
let threadExternalId: string | undefined;
if (inReplyTo && isDefined(messageChannel)) {
threadExternalId = await this.getThreadExternalId(
workspaceId,
inReplyTo,
messageChannel.id,
);
}
const { threadExternalId, references } =
isDefined(inReplyTo) && isDefined(messageChannel)
? await this.getParentThreadContext(
workspaceId,
inReplyTo,
messageChannel.id,
)
: {};
return {
success: true,
@@ -421,6 +446,7 @@ export class EmailComposerService {
shouldPersistMessage: isDefined(messageChannel),
inReplyTo,
threadExternalId,
references,
},
};
}
@@ -13,4 +13,5 @@ export type ComposedEmail = {
shouldPersistMessage: boolean;
inReplyTo?: string;
threadExternalId?: string;
references?: string[];
};