Compare commits

...

4 Commits

Author SHA1 Message Date
neo773 ba5d1c2ed1 batch 2026-02-26 03:31:27 +05:30
neo773 4929b9e954 wip 2026-02-26 03:02:31 +05:30
neo773 45b7e3d6aa wip 2026-02-26 02:59:23 +05:30
neo773 50f068d640 initial POC 2026-02-26 02:48:51 +05:30
20 changed files with 998 additions and 11 deletions
@@ -37,6 +37,8 @@ type EmailThreadMessageProps = {
sender: EmailThreadMessageParticipant;
participants: EmailThreadMessageParticipant[];
isExpanded?: boolean;
messageId?: string;
canShowHtmlPreview?: boolean;
};
export const EmailThreadMessage = ({
@@ -45,6 +47,8 @@ export const EmailThreadMessage = ({
sender,
participants,
isExpanded = false,
messageId,
canShowHtmlPreview = false,
}: EmailThreadMessageProps) => {
const [isOpen, setIsOpen] = useState(isExpanded);
@@ -74,7 +78,12 @@ export const EmailThreadMessage = ({
visibility={MessageChannelVisibility.METADATA}
/>
) : isOpen ? (
<EmailThreadMessageBody body={body} isDisplayed />
<EmailThreadMessageBody
body={body}
isDisplayed
messageId={messageId}
canShowHtmlPreview={canShowHtmlPreview}
/>
) : (
<EmailThreadMessageBodyPreview body={body} />
)}
@@ -1,8 +1,14 @@
import styled from '@emotion/styled';
import { motion } from 'framer-motion';
import Linkify from 'linkify-react';
import { useState } from 'react';
import { AnimatedEaseInOut } from 'twenty-ui/utilities';
import { EmailThreadMessageHtmlPreview } from '@/activities/emails/components/EmailThreadMessageHtmlPreview';
import { useEmailHtmlPreview } from '@/activities/emails/hooks/useEmailHtmlPreview';
import { Trans } from '@lingui/react/macro';
import { Loader } from 'twenty-ui/feedback';
const StyledThreadMessageBody = styled(motion.div)`
color: ${({ theme }) => theme.font.color.primary};
display: flex;
@@ -24,27 +30,86 @@ const StyledThreadMessageBody = styled(motion.div)`
}
`;
const StyledToggleLink = styled.span`
color: ${({ theme }) => theme.font.color.tertiary};
cursor: pointer;
font-size: ${({ theme }) => theme.font.size.sm};
margin-top: ${({ theme }) => theme.spacing(2)};
&:hover {
color: ${({ theme }) => theme.font.color.primary};
text-decoration: underline;
}
`;
const StyledErrorText = styled.span`
color: ${({ theme }) => theme.color.red};
font-size: ${({ theme }) => theme.font.size.sm};
margin-top: ${({ theme }) => theme.spacing(2)};
`;
const StyledLoaderContainer = styled.div`
display: flex;
align-items: center;
gap: ${({ theme }) => theme.spacing(2)};
margin-top: ${({ theme }) => theme.spacing(4)};
`;
type EmailThreadMessageBodyProps = {
body: string;
isDisplayed: boolean;
messageId?: string;
canShowHtmlPreview?: boolean;
};
export const EmailThreadMessageBody = ({
body,
isDisplayed,
messageId,
canShowHtmlPreview = false,
}: EmailThreadMessageBodyProps) => {
const [showPlainText, setShowPlainText] = useState(false);
const shouldFetchHtml = canShowHtmlPreview && isDisplayed && !showPlainText;
const { html, isLoading, error } = useEmailHtmlPreview(
messageId ?? '',
!shouldFetchHtml,
);
const showingHtml = shouldFetchHtml && html !== null;
return (
<AnimatedEaseInOut isOpen={isDisplayed} duration="fast">
<StyledThreadMessageBody>
<Linkify
options={{
target: '_blank',
rel: 'noopener noreferrer',
}}
>
{body}
</Linkify>
</StyledThreadMessageBody>
{isLoading && shouldFetchHtml ? (
<StyledLoaderContainer>
<Loader />
</StyledLoaderContainer>
) : showingHtml ? (
<>
<EmailThreadMessageHtmlPreview html={html} />
<StyledToggleLink onClick={() => setShowPlainText(true)}>
<Trans>Show plain text</Trans>
</StyledToggleLink>
</>
) : (
<StyledThreadMessageBody>
<Linkify
options={{
target: '_blank',
rel: 'noopener noreferrer',
}}
>
{body}
</Linkify>
{error !== null && <StyledErrorText>{error}</StyledErrorText>}
{canShowHtmlPreview && showPlainText && (
<StyledToggleLink onClick={() => setShowPlainText(false)}>
<Trans>View original</Trans>
</StyledToggleLink>
)}
</StyledThreadMessageBody>
)}
</AnimatedEaseInOut>
);
};
@@ -0,0 +1,87 @@
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { useCallback, useMemo, useRef, useState } from 'react';
const StyledIframeContainer = styled.div`
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.sm};
margin-top: ${({ theme }) => theme.spacing(4)};
overflow: hidden;
`;
const StyledIframe = styled.iframe`
border: none;
width: 100%;
display: block;
`;
const MAX_IFRAME_HEIGHT = 600;
// TODO: Check Gmail in dev tools and see how they do it based on the app's dark/light mode.
// eslint-disable-next-line twenty/no-hardcoded-colors
const STYLE_RESET = `
<style>
html, body {
background-color: #ffffff !important;
color: #1a1a1a !important;
margin: 0;
padding: 8px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
line-height: 1.5;
overflow-wrap: break-word;
word-break: break-word;
}
img {
max-width: 100%;
height: auto;
}
</style>
`;
type EmailThreadMessageHtmlPreviewProps = {
html: string;
};
export const EmailThreadMessageHtmlPreview = ({
html,
}: EmailThreadMessageHtmlPreviewProps) => {
const iframeRef = useRef<HTMLIFrameElement>(null);
const [height, setHeight] = useState(200);
const { t } = useLingui();
const wrappedHtml = useMemo(() => {
// Inject reset styles before closing </head> or at the start of the document
if (html.includes('</head>')) {
return html.replace('</head>', `${STYLE_RESET}</head>`);
}
return `${STYLE_RESET}${html}`;
}, [html]);
const handleLoad = useCallback(() => {
const iframe = iframeRef.current;
if (!iframe?.contentDocument?.body) {
return;
}
const contentHeight = iframe.contentDocument.body.scrollHeight;
setHeight(Math.min(contentHeight, MAX_IFRAME_HEIGHT));
}, []);
return (
<StyledIframeContainer>
{/* TODO: Research more on spec + see Gmail approach for additional sandboxing features */}
<StyledIframe
ref={iframeRef}
srcDoc={wrappedHtml}
sandbox="allow-same-origin"
onLoad={handleLoad}
height={height}
title={t`Email HTML preview`}
/>
</StyledIframeContainer>
);
};
@@ -1,4 +1,5 @@
import styled from '@emotion/styled';
import { useEffect } from 'react';
import { ActivityList } from '@/activities/components/ActivityList';
import { CustomResolverFetchMoreLoader } from '@/activities/components/CustomResolverFetchMoreLoader';
@@ -8,10 +9,12 @@ import { TIMELINE_THREADS_DEFAULT_PAGE_SIZE } from '@/activities/emails/constant
import { getTimelineThreadsFromCompanyId } from '@/activities/emails/graphql/queries/getTimelineThreadsFromCompanyId';
import { getTimelineThreadsFromOpportunityId } from '@/activities/emails/graphql/queries/getTimelineThreadsFromOpportunityId';
import { getTimelineThreadsFromPersonId } from '@/activities/emails/graphql/queries/getTimelineThreadsFromPersonId';
import { usePrefetchEmailHtml } from '@/activities/emails/hooks/usePrefetchEmailHtml';
import { useCustomResolver } from '@/activities/hooks/useCustomResolver';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord';
import { Trans } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared/utils';
import { H1Title, H1TitleFontColor } from 'twenty-ui/display';
import {
AnimatedPlaceholder,
@@ -67,7 +70,20 @@ export const EmailsCard = () => {
TIMELINE_THREADS_DEFAULT_PAGE_SIZE,
);
const { prefetchThreadsHtml } = usePrefetchEmailHtml();
const { totalNumberOfThreads, timelineThreads } = data?.[queryName] ?? {};
useEffect(() => {
if (isDefined(timelineThreads) && timelineThreads.length > 0) {
const threadIds = timelineThreads.map(
(thread: TimelineThread) => thread.id,
);
prefetchThreadsHtml(threadIds);
}
}, [timelineThreads, prefetchThreadsHtml]);
const hasMoreTimelineThreads =
timelineThreads && totalNumberOfThreads
? timelineThreads?.length < totalNumberOfThreads
@@ -0,0 +1,10 @@
import { gql } from '@apollo/client';
export const getMessageHtmlPreview = gql`
query GetMessageHtmlPreview($messageId: UUID!) {
getMessageHtmlPreview(messageId: $messageId) {
messageId
html
}
}
`;
@@ -0,0 +1,12 @@
import { gql } from '@apollo/client';
export const getMessageHtmlPreviewBatch = gql`
query GetMessageHtmlPreviewBatch($messageThreadIds: [UUID!]!) {
getMessageHtmlPreviewBatch(messageThreadIds: $messageThreadIds) {
previews {
messageId
html
}
}
}
`;
@@ -0,0 +1,22 @@
import { useQuery } from '@apollo/client';
import { getMessageHtmlPreview } from '@/activities/emails/graphql/queries/getMessageHtmlPreview';
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { isNonEmptyString } from '@sniptt/guards';
export const useEmailHtmlPreview = (messageId: string, skip: boolean) => {
const apolloCoreClient = useApolloCoreClient();
const { data, loading, error } = useQuery(getMessageHtmlPreview, {
client: apolloCoreClient,
variables: { messageId },
skip: skip || !isNonEmptyString(messageId),
fetchPolicy: 'cache-first',
});
return {
html: data?.getMessageHtmlPreview?.html ?? null,
isLoading: loading,
error: error?.message ?? null,
};
};
@@ -0,0 +1,71 @@
import { useCallback, useState } from 'react';
import { getMessageHtmlPreviewBatch } from '@/activities/emails/graphql/queries/getMessageHtmlPreviewBatch';
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { getMessageHtmlPreview } from '@/activities/emails/graphql/queries/getMessageHtmlPreview';
type MessageHtmlPreview = {
messageId: string;
html: string | null;
};
export const usePrefetchEmailHtml = () => {
const apolloCoreClient = useApolloCoreClient();
const [prefetchedThreadIds, setPrefetchedThreadIds] = useState(
() => new Set<string>(),
);
const prefetchThreadsHtml = useCallback(
async (messageThreadIds: string[]) => {
const newThreadIds = messageThreadIds.filter(
(id) => !prefetchedThreadIds.has(id),
);
if (newThreadIds.length === 0) {
return;
}
setPrefetchedThreadIds((prev) => {
const next = new Set(prev);
for (const id of newThreadIds) {
next.add(id);
}
return next;
});
try {
const { data } = await apolloCoreClient.query({
query: getMessageHtmlPreviewBatch,
variables: { messageThreadIds: newThreadIds },
fetchPolicy: 'no-cache',
});
const previews: MessageHtmlPreview[] =
data?.getMessageHtmlPreviewBatch?.previews ?? [];
// Write each preview into Apollo's cache as individual query results
// so useQuery(getMessageHtmlPreview) picks them up via cache-first
for (const preview of previews) {
apolloCoreClient.writeQuery({
query: getMessageHtmlPreview,
variables: { messageId: preview.messageId },
data: {
getMessageHtmlPreview: {
__typename: 'MessageHtmlPreview',
messageId: preview.messageId,
html: preview.html,
},
},
});
}
} catch {
// Prefetch failures are non-critical — user can still fetch on demand
}
},
[apolloCoreClient, prefetchedThreadIds],
);
return { prefetchThreadsHtml };
};
@@ -13,8 +13,10 @@ const StyledButtonContainer = styled.div`
export const CommandMenuMessageThreadIntermediaryMessages = ({
messages,
canShowHtmlPreview = false,
}: {
messages: EmailThreadMessageWithSender[];
canShowHtmlPreview?: boolean;
}) => {
const [areMessagesOpen, setAreMessagesOpen] = useState(false);
@@ -30,6 +32,8 @@ export const CommandMenuMessageThreadIntermediaryMessages = ({
participants={message.messageParticipants}
body={message.text}
sentAt={message.receivedAt}
messageId={message.id}
canShowHtmlPreview={canShowHtmlPreview}
/>
))
) : (
@@ -145,10 +145,13 @@ export const CommandMenuMessageThreadPage = () => {
participants={message.messageParticipants}
body={message.text}
sentAt={message.receivedAt}
messageId={message.id}
canShowHtmlPreview={connectedAccountProvider !== null}
/>
))}
<CommandMenuMessageThreadIntermediaryMessages
messages={intermediaryMessages}
canShowHtmlPreview={connectedAccountProvider !== null}
/>
<EmailThreadMessage
key={lastMessage.id}
@@ -157,6 +160,8 @@ export const CommandMenuMessageThreadPage = () => {
body={lastMessage.text}
sentAt={lastMessage.receivedAt}
isExpanded
messageId={lastMessage.id}
canShowHtmlPreview={connectedAccountProvider !== null}
/>
<CustomResolverFetchMoreLoader
loading={threadLoading}
@@ -38,6 +38,7 @@ import { loggerModuleFactory } from 'src/engine/core-modules/logger/logger.modul
import { MessageQueueModule } from 'src/engine/core-modules/message-queue/message-queue.module';
import { messageQueueModuleFactory } from 'src/engine/core-modules/message-queue/message-queue.module-factory';
import { TimelineMessagingModule } from 'src/engine/core-modules/messaging/timeline-messaging.module';
import { MessageHtmlPreviewModule } from 'src/modules/messaging/message-html-preview/message-html-preview.module';
import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module';
import { MetricsService } from 'src/engine/core-modules/metrics/metrics.service';
import { OpenApiModule } from 'src/engine/core-modules/open-api/open-api.module';
@@ -92,6 +93,7 @@ import { FileModule } from './file/file.module';
ApplicationSyncModule,
AppTokenModule,
TimelineMessagingModule,
MessageHtmlPreviewModule,
TimelineCalendarEventModule,
UserModule,
WorkspaceModule,
@@ -0,0 +1,217 @@
import { Injectable, Logger } from '@nestjs/common';
import { batchFetchImplementation } from '@jrmdayn/googleapis-batcher';
import { type gmail_v1 as gmailV1, google } from 'googleapis';
import { isDefined } from 'twenty-shared/utils';
import { OAuth2ClientManagerService } from 'src/modules/connected-account/oauth2-client-manager/services/oauth2-client-manager.service';
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { getHtmlBodyData } from 'src/modules/messaging/message-html-preview/drivers/gmail/utils/get-html-body-data.util';
const GMAIL_BATCH_REQUEST_MAX_SIZE = 50;
type CidAttachment = {
cid: string;
attachmentId: string;
mimeType: string;
};
type MessageHtmlResult = {
messageExternalId: string;
html: string | null;
};
@Injectable()
export class GmailHtmlPreviewService {
private readonly logger = new Logger(GmailHtmlPreviewService.name);
constructor(
private readonly oAuth2ClientManagerService: OAuth2ClientManagerService,
) {}
async getMessagesHtml(
messageExternalIds: string[],
connectedAccount: Pick<
ConnectedAccountWorkspaceEntity,
'provider' | 'refreshToken'
>,
): Promise<MessageHtmlResult[]> {
const oAuth2Client =
await this.oAuth2ClientManagerService.getGoogleOAuth2Client(
connectedAccount,
);
const gmailClient = google.gmail({
version: 'v1',
auth: oAuth2Client,
fetchImplementation: batchFetchImplementation({
maxBatchSize: GMAIL_BATCH_REQUEST_MAX_SIZE,
}),
});
// Batch-fetch all messages in parallel (auto-batched by googleapis-batcher)
const messageResults = await Promise.all(
messageExternalIds.map((id) =>
gmailClient.users.messages
.get({ userId: 'me', id })
.then((response) => ({ id, data: response.data, error: null }))
.catch((error) => ({ id, data: null, error })),
),
);
// Extract HTML + collect CID attachments for all messages
const messagesNeedingAttachments: {
id: string;
html: string;
cidAttachments: CidAttachment[];
}[] = [];
const results: MessageHtmlResult[] = [];
for (const result of messageResults) {
if (result.error || !result.data) {
this.logger.warn(
`Failed to fetch Gmail message ${result.id}: ${result.error}`,
);
results.push({ messageExternalId: result.id, html: null });
continue;
}
const htmlData = getHtmlBodyData(result.data);
if (!htmlData) {
results.push({ messageExternalId: result.id, html: null });
continue;
}
const html = Buffer.from(htmlData, 'base64').toString('utf-8');
const cidAttachments = this.extractCidAttachments(result.data);
if (cidAttachments.length === 0) {
results.push({ messageExternalId: result.id, html });
} else {
messagesNeedingAttachments.push({
id: result.id,
html,
cidAttachments,
});
}
}
// Batch-fetch all inline attachments across all messages in parallel
if (messagesNeedingAttachments.length > 0) {
const allAttachmentFetches: {
messageId: string;
cid: string;
mimeType: string;
promise: Promise<string | null>;
}[] = [];
for (const message of messagesNeedingAttachments) {
for (const attachment of message.cidAttachments) {
allAttachmentFetches.push({
messageId: message.id,
cid: attachment.cid,
mimeType: attachment.mimeType,
promise: gmailClient.users.messages.attachments
.get({
userId: 'me',
messageId: message.id,
id: attachment.attachmentId,
})
.then((response) => {
const data = response.data.data;
if (!data) {
return null;
}
// Gmail returns URL-safe base64; convert to standard base64
return data.replace(/-/g, '+').replace(/_/g, '/');
})
.catch(() => null),
});
}
}
const attachmentResults = await Promise.all(
allAttachmentFetches.map((fetch) => fetch.promise),
);
// Build a map: messageId -> [{cid, dataUri}]
const resolvedAttachments = new Map<
string,
{ cid: string; dataUri: string }[]
>();
allAttachmentFetches.forEach((fetch, index) => {
const base64Data = attachmentResults[index];
if (!isDefined(base64Data)) {
return;
}
const dataUri = `data:${fetch.mimeType};base64,${base64Data}`;
const existing = resolvedAttachments.get(fetch.messageId) ?? [];
existing.push({ cid: fetch.cid, dataUri });
resolvedAttachments.set(fetch.messageId, existing);
});
for (const message of messagesNeedingAttachments) {
let resolvedHtml = message.html;
const attachments = resolvedAttachments.get(message.id) ?? [];
for (const attachment of attachments) {
resolvedHtml = resolvedHtml.replace(
new RegExp(
`cid:${attachment.cid.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`,
'g',
),
attachment.dataUri,
);
}
results.push({ messageExternalId: message.id, html: resolvedHtml });
}
}
return results;
}
private extractCidAttachments(
message: gmailV1.Schema$Message,
): CidAttachment[] {
const attachments: CidAttachment[] = [];
this.walkParts(message.payload?.parts ?? [], attachments);
return attachments;
}
private walkParts(
parts: gmailV1.Schema$MessagePart[],
attachments: CidAttachment[],
): void {
for (const part of parts) {
const contentIdHeader = part.headers?.find(
(header) => header.name?.toLowerCase() === 'content-id',
);
if (contentIdHeader?.value && part.body?.attachmentId && part.mimeType) {
// Content-ID is wrapped in angle brackets: <image001.png@01DB1234.5678>
const cid = contentIdHeader.value.replace(/^<|>$/g, '');
attachments.push({
cid,
attachmentId: part.body.attachmentId,
mimeType: part.mimeType,
});
}
if (part.parts) {
this.walkParts(part.parts, attachments);
}
}
}
}
@@ -0,0 +1,24 @@
import { type gmail_v1 as gmailV1 } from 'googleapis';
export const getHtmlBodyData = (message: gmailV1.Schema$Message) => {
if (message.payload?.mimeType === 'text/html') {
return message.payload?.body?.data;
}
const firstPart = message.payload?.parts?.[0];
if (firstPart?.mimeType === 'text/html') {
return firstPart?.body?.data;
}
const nestedHtmlPart = firstPart?.parts?.find(
(part) => part.mimeType === 'text/html',
);
if (nestedHtmlPart) {
return nestedHtmlPart?.body?.data;
}
return message.payload?.parts?.find((part) => part.mimeType === 'text/html')
?.body?.data;
};
@@ -0,0 +1,9 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class ImapHtmlPreviewService {
// TODO: Implement IMAP HTML fetch via mailparser
async getMessageHtml(_messageExternalId: string): Promise<string | null> {
return null;
}
}
@@ -0,0 +1,11 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class MicrosoftHtmlPreviewService {
// TODO: Implement Microsoft Graph API HTML fetch
// Microsoft already has response.body?.content with contentType: 'html'
// in microsoft-get-messages.service.ts — return that content directly
async getMessageHtml(_messageExternalId: string): Promise<string | null> {
return null;
}
}
@@ -0,0 +1,18 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@ObjectType('MessageHtmlPreview')
export class MessageHtmlPreviewDTO {
@Field(() => UUIDScalarType)
messageId: string;
@Field(() => String, { nullable: true })
html: string | null;
}
@ObjectType('MessageHtmlPreviewBatch')
export class MessageHtmlPreviewBatchDTO {
@Field(() => [MessageHtmlPreviewDTO])
previews: MessageHtmlPreviewDTO[];
}
@@ -0,0 +1,15 @@
import { ArgsType, Field } from '@nestjs/graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@ArgsType()
export class GetMessageHtmlPreviewArgs {
@Field(() => UUIDScalarType)
messageId: string;
}
@ArgsType()
export class GetMessageHtmlPreviewBatchArgs {
@Field(() => [UUIDScalarType])
messageThreadIds: string[];
}
@@ -0,0 +1,30 @@
import { Module } from '@nestjs/common';
import { UserModule } from 'src/engine/core-modules/user/user.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
import { OAuth2ClientManagerModule } from 'src/modules/connected-account/oauth2-client-manager/oauth2-client-manager.module';
import { RefreshTokensManagerModule } from 'src/modules/connected-account/refresh-tokens-manager/connected-account-refresh-tokens-manager.module';
import { GmailHtmlPreviewService } from 'src/modules/messaging/message-html-preview/drivers/gmail/services/gmail-html-preview.service';
import { ImapHtmlPreviewService } from 'src/modules/messaging/message-html-preview/drivers/imap/services/imap-html-preview.service';
import { MicrosoftHtmlPreviewService } from 'src/modules/messaging/message-html-preview/drivers/microsoft/services/microsoft-html-preview.service';
import { MessageHtmlPreviewResolver } from 'src/modules/messaging/message-html-preview/message-html-preview.resolver';
import { MessageHtmlPreviewService } from 'src/modules/messaging/message-html-preview/services/message-html-preview.service';
@Module({
imports: [
WorkspaceDataSourceModule,
UserModule,
PermissionsModule,
OAuth2ClientManagerModule,
RefreshTokensManagerModule,
],
providers: [
MessageHtmlPreviewResolver,
MessageHtmlPreviewService,
GmailHtmlPreviewService,
MicrosoftHtmlPreviewService,
ImapHtmlPreviewService,
],
})
export class MessageHtmlPreviewModule {}
@@ -0,0 +1,52 @@
import { UseGuards } from '@nestjs/common';
import { Args, Query } from '@nestjs/graphql';
import { CoreResolver } from 'src/engine/api/graphql/graphql-config/decorators/core-resolver.decorator';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { CustomPermissionGuard } from 'src/engine/guards/custom-permission.guard';
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import {
MessageHtmlPreviewBatchDTO,
MessageHtmlPreviewDTO,
} from 'src/modules/messaging/message-html-preview/dtos/message-html-preview.dto';
import {
GetMessageHtmlPreviewArgs,
GetMessageHtmlPreviewBatchArgs,
} from 'src/modules/messaging/message-html-preview/dtos/message-html-preview.input';
import { MessageHtmlPreviewService } from 'src/modules/messaging/message-html-preview/services/message-html-preview.service';
@UseGuards(WorkspaceAuthGuard, UserAuthGuard, CustomPermissionGuard)
@CoreResolver(() => MessageHtmlPreviewDTO)
export class MessageHtmlPreviewResolver {
constructor(
private readonly messageHtmlPreviewService: MessageHtmlPreviewService,
) {}
@Query(() => MessageHtmlPreviewDTO)
async getMessageHtmlPreview(
@AuthWorkspace() workspace: WorkspaceEntity,
@Args() { messageId }: GetMessageHtmlPreviewArgs,
): Promise<MessageHtmlPreviewDTO> {
const html = await this.messageHtmlPreviewService.getMessageHtml(
messageId,
workspace.id,
);
return { messageId, html };
}
@Query(() => MessageHtmlPreviewBatchDTO)
async getMessageHtmlPreviewBatch(
@AuthWorkspace() workspace: WorkspaceEntity,
@Args() { messageThreadIds }: GetMessageHtmlPreviewBatchArgs,
): Promise<MessageHtmlPreviewBatchDTO> {
const previews = await this.messageHtmlPreviewService.getThreadMessagesHtml(
messageThreadIds,
workspace.id,
);
return { previews };
}
}
@@ -0,0 +1,308 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
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 { ConnectedAccountRefreshTokensService } from 'src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service';
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { type MessageChannelMessageAssociationWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel-message-association.workspace-entity';
import { type MessageChannelWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
import { type MessageWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message.workspace-entity';
import { GmailHtmlPreviewService } from 'src/modules/messaging/message-html-preview/drivers/gmail/services/gmail-html-preview.service';
import { ImapHtmlPreviewService } from 'src/modules/messaging/message-html-preview/drivers/imap/services/imap-html-preview.service';
import { MicrosoftHtmlPreviewService } from 'src/modules/messaging/message-html-preview/drivers/microsoft/services/microsoft-html-preview.service';
type MessageHtmlResult = {
messageId: string;
html: string | null;
};
type AssociationWithAccount = {
messageId: string;
messageExternalId: string;
connectedAccount: ConnectedAccountWorkspaceEntity;
};
@Injectable()
export class MessageHtmlPreviewService {
private readonly logger = new Logger(MessageHtmlPreviewService.name);
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly connectedAccountRefreshTokensService: ConnectedAccountRefreshTokensService,
private readonly gmailHtmlPreviewService: GmailHtmlPreviewService,
private readonly microsoftHtmlPreviewService: MicrosoftHtmlPreviewService,
private readonly imapHtmlPreviewService: ImapHtmlPreviewService,
) {}
async getMessageHtml(
messageId: string,
workspaceId: string,
): Promise<string | null> {
const results = await this.getThreadMessagesHtml([], workspaceId, [
messageId,
]);
return results[0]?.html ?? null;
}
async getThreadMessagesHtml(
messageThreadIds: string[],
workspaceId: string,
directMessageIds?: string[],
): Promise<MessageHtmlResult[]> {
const authContext = buildSystemAuthContext(workspaceId);
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
async () => {
// Resolve message IDs from thread IDs or use directly provided ones
const messageIds = isDefined(directMessageIds)
? directMessageIds
: await this.getMessageIdsFromThreads(messageThreadIds, workspaceId);
if (messageIds.length === 0) {
return [];
}
// Get associations with resolved connected accounts in batch
const associations = await this.resolveAssociations(
messageIds,
workspaceId,
);
if (associations.length === 0) {
return [];
}
// Group by provider + account ID for batch fetching
return this.fetchHtmlBatch(associations);
},
authContext,
);
}
private async getMessageIdsFromThreads(
messageThreadIds: string[],
workspaceId: string,
): Promise<string[]> {
const messageRepository =
await this.globalWorkspaceOrmManager.getRepository<MessageWorkspaceEntity>(
workspaceId,
'message',
{ shouldBypassPermissionChecks: true },
);
const messages = await messageRepository.find({
where: messageThreadIds.map((threadId) => ({
messageThreadId: threadId,
})),
select: { id: true },
});
return messages.map((message) => message.id);
}
private async resolveAssociations(
messageIds: string[],
workspaceId: string,
): Promise<AssociationWithAccount[]> {
const associationRepository =
await this.globalWorkspaceOrmManager.getRepository<MessageChannelMessageAssociationWorkspaceEntity>(
workspaceId,
'messageChannelMessageAssociation',
{ shouldBypassPermissionChecks: true },
);
const associations = await associationRepository.find({
where: messageIds.map((id) => ({ messageId: id })),
select: {
messageId: true,
messageExternalId: true,
messageChannelId: true,
},
});
// Resolve connected accounts per channel (deduplicated)
const channelIds = [
...new Set(associations.map((a) => a.messageChannelId)),
];
const accountByChannelId = new Map<
string,
ConnectedAccountWorkspaceEntity
>();
// Fetch all channels in one query
const channelRepository =
await this.globalWorkspaceOrmManager.getRepository<MessageChannelWorkspaceEntity>(
workspaceId,
'messageChannel',
{ shouldBypassPermissionChecks: true },
);
const channels = await channelRepository.find({
where: channelIds.map((id) => ({ id })),
select: { id: true, connectedAccountId: true },
});
const connectedAccountIds = [
...new Set(channels.map((c) => c.connectedAccountId)),
];
// Fetch all connected accounts in one query
const accountRepository =
await this.globalWorkspaceOrmManager.getRepository<ConnectedAccountWorkspaceEntity>(
workspaceId,
'connectedAccount',
{ shouldBypassPermissionChecks: true },
);
const accounts = await accountRepository.find({
where: connectedAccountIds.map((id) => ({ id })),
});
const accountById = new Map(accounts.map((a) => [a.id, a]));
for (const channel of channels) {
const account = accountById.get(channel.connectedAccountId);
if (isDefined(account)) {
accountByChannelId.set(channel.id, account);
}
}
// Refresh tokens per unique account (one refresh per account, not per message)
const refreshedAccountIds = new Set<string>();
for (const account of accountByChannelId.values()) {
if (!refreshedAccountIds.has(account.id)) {
refreshedAccountIds.add(account.id);
try {
await this.connectedAccountRefreshTokensService.refreshAndSaveTokens(
account,
workspaceId,
);
} catch (error) {
this.logger.warn(
`Failed to refresh tokens for account ${account.id}: ${error}`,
);
}
}
}
// Build resolved associations
const result: AssociationWithAccount[] = [];
for (const association of associations) {
if (!association.messageExternalId) {
continue;
}
const account = accountByChannelId.get(association.messageChannelId);
if (!isDefined(account)) {
continue;
}
result.push({
messageId: association.messageId,
messageExternalId: association.messageExternalId,
connectedAccount: account,
});
}
return result;
}
private async fetchHtmlBatch(
associations: AssociationWithAccount[],
): Promise<MessageHtmlResult[]> {
// Group by provider + account ID for batch fetching
const gmailAssociations: AssociationWithAccount[] = [];
const microsoftAssociations: AssociationWithAccount[] = [];
const imapAssociations: AssociationWithAccount[] = [];
for (const association of associations) {
switch (association.connectedAccount.provider) {
case ConnectedAccountProvider.GOOGLE:
gmailAssociations.push(association);
break;
case ConnectedAccountProvider.MICROSOFT:
microsoftAssociations.push(association);
break;
case ConnectedAccountProvider.IMAP_SMTP_CALDAV:
imapAssociations.push(association);
break;
}
}
const results: MessageHtmlResult[] = [];
// Gmail: batch all messages per account through batched client
if (gmailAssociations.length > 0) {
const byAccountId = new Map<string, AssociationWithAccount[]>();
for (const association of gmailAssociations) {
const accountId = association.connectedAccount.id;
const group = byAccountId.get(accountId) ?? [];
group.push(association);
byAccountId.set(accountId, group);
}
for (const [, accountAssociations] of byAccountId) {
const externalIds = accountAssociations.map((a) => a.messageExternalId);
const externalIdToMessageId = new Map(
accountAssociations.map((a) => [a.messageExternalId, a.messageId]),
);
try {
const htmlResults =
await this.gmailHtmlPreviewService.getMessagesHtml(
externalIds,
accountAssociations[0].connectedAccount,
);
for (const htmlResult of htmlResults) {
const messageId = externalIdToMessageId.get(
htmlResult.messageExternalId,
);
if (isDefined(messageId)) {
results.push({ messageId, html: htmlResult.html });
}
}
} catch (error) {
this.logger.warn(`Gmail batch fetch failed: ${error}`);
for (const association of accountAssociations) {
results.push({ messageId: association.messageId, html: null });
}
}
}
}
// Microsoft/IMAP: sequential for now (stubs)
for (const association of microsoftAssociations) {
const html = await this.microsoftHtmlPreviewService.getMessageHtml(
association.messageExternalId,
);
results.push({ messageId: association.messageId, html });
}
for (const association of imapAssociations) {
const html = await this.imapHtmlPreviewService.getMessageHtml(
association.messageExternalId,
);
results.push({ messageId: association.messageId, html });
}
return results;
}
}