Add email thread widget and message thread record page layout (#19351)

## Summary
- Move email thread display from side panel to a dedicated record page
with a new `EMAIL_THREAD` widget type
- Add message thread as a standard object with page layout, subject
field, and backfill command
- Add reply-to-email command menu item for message thread records
- Remove old side panel message thread components in favor of the new
widget-based approach

## Type fixes
- Add `EMAIL_THREAD` to `WidgetConfigurationType`, `WidgetType`, and all
configuration/validator maps
- Create `EmailThreadConfigurationDTO` and shared
`EmailThreadConfiguration` type
- Register EMAIL_THREAD in widget type validators, configuration
resolvers, and standard widget mappings

## Test plan
- [ ] Verify message thread record pages render with the email thread
widget
- [ ] Verify email thread preview navigates to the record page instead
of opening side panel
- [ ] Verify reply-to-email command appears for message thread records
- [ ] Verify typecheck passes for both twenty-front and twenty-server
- [ ] Run existing test suites to check for regressions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Félix Malfait
2026-04-06 12:02:04 +02:00
committed by GitHub
parent 4aa1d71b12
commit 8acfacc69c
56 changed files with 2441 additions and 2371 deletions
@@ -1,7 +1,17 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
import { type FieldTypeAndNameMetadata } from 'src/engine/workspace-manager/utils/get-ts-vector-column-expression.util';
import { type EntityRelation } from 'src/engine/workspace-manager/workspace-migration/types/entity-relation.interface';
import { type MessageWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message.workspace-entity';
const SUBJECT_FIELD_NAME = 'subject';
export const SEARCH_FIELDS_FOR_MESSAGE_THREAD: FieldTypeAndNameMetadata[] = [
{ name: SUBJECT_FIELD_NAME, type: FieldMetadataType.TEXT },
];
export class MessageThreadWorkspaceEntity extends BaseWorkspaceEntity {
subject: string | null;
messages: EntityRelation<MessageWorkspaceEntity[]>;
}
@@ -25,7 +25,7 @@ type MessageAccumulator = {
| 'text'
| 'messageThreadId'
>;
threadToCreate?: Pick<MessageThreadWorkspaceEntity, 'id'>;
threadToCreate?: Pick<MessageThreadWorkspaceEntity, 'id' | 'subject'>;
messageChannelMessageAssociationToCreate?: Pick<
MessageChannelMessageAssociationWorkspaceEntity,
| 'id'
@@ -199,10 +199,52 @@ export class MessagingMessageService {
.map((accumulator) => accumulator.threadToCreate)
.filter(isDefined);
await messageThreadRepository.insert(
messageThreadsToCreate,
transactionManager,
);
const threadSubjectUpdates = new Map<
string,
{ subject: string; receivedAt: number }
>();
for (const message of messages) {
const messageAccumulator = messageAccumulatorMap.get(
message.externalId,
);
if (!isDefined(messageAccumulator)) {
continue;
}
if (
isDefined(messageAccumulator.existingThreadInDB) &&
isDefined(messageAccumulator.messageToCreate) &&
isDefined(message.subject)
) {
const threadId = messageAccumulator.existingThreadInDB.id;
const existing = threadSubjectUpdates.get(threadId);
const receivedAt = message.receivedAt?.getTime() ?? 0;
if (!isDefined(existing) || receivedAt > existing.receivedAt) {
threadSubjectUpdates.set(threadId, {
subject: message.subject,
receivedAt,
});
}
}
}
const threadsToUpsert = [
...messageThreadsToCreate,
...Array.from(threadSubjectUpdates.entries()).map(
([id, { subject }]) => ({ id, subject }),
),
];
if (threadsToUpsert.length > 0) {
await messageThreadRepository.upsert(
threadsToUpsert,
['id'],
transactionManager,
);
}
const messagesToCreate = Array.from(messageAccumulatorMap.values())
.map((accumulator) => accumulator.messageToCreate)
@@ -451,6 +493,7 @@ export class MessagingMessageService {
messageAccumulator.threadToCreate = {
id: newOrExistingMessageThreadId,
subject: message.subject,
};
}