initial POC

This commit is contained in:
neo773
2026-02-26 02:48:51 +05:30
parent 4ae8381ffc
commit 50f068d640
21 changed files with 782 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,95 @@ 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(2)};
`;
type EmailThreadMessageBodyProps = {
body: string;
isDisplayed: boolean;
messageId?: string;
canShowHtmlPreview?: boolean;
};
export const EmailThreadMessageBody = ({
body,
isDisplayed,
messageId,
canShowHtmlPreview = false,
}: EmailThreadMessageBodyProps) => {
const [showHtml, setShowHtml] = useState(false);
const { html, isLoading, error, fetchHtmlPreview, clearPreview } =
useEmailHtmlPreview(messageId ?? '');
const handleViewOriginal = async () => {
await fetchHtmlPreview();
setShowHtml(true);
};
const handleShowPlainText = () => {
setShowHtml(false);
clearPreview();
};
return (
<AnimatedEaseInOut isOpen={isDisplayed} duration="fast">
<StyledThreadMessageBody>
<Linkify
options={{
target: '_blank',
rel: 'noopener noreferrer',
}}
>
{body}
</Linkify>
</StyledThreadMessageBody>
{showHtml && html ? (
<>
<EmailThreadMessageHtmlPreview html={html} />
<StyledToggleLink onClick={handleShowPlainText}>
<Trans>Show plain text</Trans>
</StyledToggleLink>
</>
) : (
<StyledThreadMessageBody>
<Linkify
options={{
target: '_blank',
rel: 'noopener noreferrer',
}}
>
{body}
</Linkify>
{canShowHtmlPreview && messageId && (
<>
{isLoading ? (
<StyledLoaderContainer>
<Loader />
</StyledLoaderContainer>
) : error ? (
<StyledErrorText>{error}</StyledErrorText>
) : (
<StyledToggleLink onClick={handleViewOriginal}>
<Trans>View original</Trans>
</StyledToggleLink>
)}
</>
)}
</StyledThreadMessageBody>
)}
</AnimatedEaseInOut>
);
};
@@ -0,0 +1,54 @@
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { useCallback, useRef, useState } from 'react';
const StyledIframeContainer = styled.div`
border-radius: ${({ theme }) => theme.border.radius.sm};
border: 1px solid ${({ theme }) => theme.border.color.medium};
overflow: hidden;
`;
const StyledIframe = styled.iframe`
border: none;
width: 100%;
display: block;
`;
const MAX_IFRAME_HEIGHT = 600;
type EmailThreadMessageHtmlPreviewProps = {
html: string;
};
export const EmailThreadMessageHtmlPreview = ({
html,
}: EmailThreadMessageHtmlPreviewProps) => {
const iframeRef = useRef<HTMLIFrameElement>(null);
const [height, setHeight] = useState(200);
const { t } = useLingui();
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>
<StyledIframe
ref={iframeRef}
srcDoc={html}
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,63 @@
import { useCallback, useState } from 'react';
import { getMessageHtmlPreview } from '@/activities/emails/graphql/queries/getMessageHtmlPreview';
import { emailHtmlPreviewCacheState } from '@/activities/emails/states/emailHtmlPreviewCacheState';
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
import { useSetAtomFamilyState } from '@/ui/utilities/state/jotai/hooks/useSetAtomFamilyState';
export const useEmailHtmlPreview = (messageId: string) => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const cachedHtml = useAtomFamilyStateValue(
emailHtmlPreviewCacheState,
messageId,
);
const setCachedHtml = useSetAtomFamilyState(
emailHtmlPreviewCacheState,
messageId,
);
const apolloCoreClient = useApolloCoreClient();
const fetchHtmlPreview = useCallback(async () => {
if (cachedHtml) {
return;
}
setIsLoading(true);
setError(null);
try {
const { data } = await apolloCoreClient.query({
query: getMessageHtmlPreview,
variables: { messageId },
fetchPolicy: 'no-cache',
});
const html = data?.getMessageHtmlPreview?.html ?? null;
setCachedHtml(html);
} catch (err) {
setError(
err instanceof Error ? err.message : 'Failed to load HTML preview',
);
} finally {
setIsLoading(false);
}
}, [messageId, cachedHtml, apolloCoreClient, setCachedHtml]);
const clearPreview = useCallback(() => {
setCachedHtml(null);
setError(null);
}, [setCachedHtml]);
return {
html: cachedHtml,
isLoading,
error,
fetchHtmlPreview,
clearPreview,
};
};
@@ -0,0 +1,58 @@
import { useCallback, useRef } from 'react';
import { getMessageHtmlPreviewBatch } from '@/activities/emails/graphql/queries/getMessageHtmlPreviewBatch';
import { emailHtmlPreviewCacheState } from '@/activities/emails/states/emailHtmlPreviewCacheState';
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useStore } from 'jotai';
type MessageHtmlPreview = {
messageId: string;
html: string | null;
};
export const usePrefetchEmailHtml = () => {
const apolloCoreClient = useApolloCoreClient();
const store = useStore();
const prefetchedThreadIdsRef = useRef(new Set<string>());
const prefetchThreadsHtml = useCallback(
async (messageThreadIds: string[]) => {
const newThreadIds = messageThreadIds.filter(
(id) => !prefetchedThreadIdsRef.current.has(id),
);
if (newThreadIds.length === 0) {
return;
}
for (const id of newThreadIds) {
prefetchedThreadIdsRef.current.add(id);
}
try {
const { data } = await apolloCoreClient.query({
query: getMessageHtmlPreviewBatch,
variables: { messageThreadIds: newThreadIds },
fetchPolicy: 'no-cache',
});
const previews: MessageHtmlPreview[] =
data?.getMessageHtmlPreviewBatch?.previews ?? [];
for (const preview of previews) {
if (preview.html) {
store.set(
emailHtmlPreviewCacheState.atomFamily(preview.messageId),
preview.html,
);
}
}
} catch {
// Prefetch failures are non-critical — user can still fetch on demand
}
},
[apolloCoreClient, store],
);
return { prefetchThreadsHtml };
};
@@ -0,0 +1,9 @@
import { createAtomFamilyState } from '@/ui/utilities/state/jotai/utils/createAtomFamilyState';
export const emailHtmlPreviewCacheState = createAtomFamilyState<
string | null,
string
>({
key: 'emailHtmlPreviewCacheState',
defaultValue: null,
});
@@ -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,45 @@
import { Injectable } from '@nestjs/common';
import { google } from 'googleapis';
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';
@Injectable()
export class GmailHtmlPreviewService {
constructor(
private readonly oAuth2ClientManagerService: OAuth2ClientManagerService,
) {}
async getMessageHtml(
messageExternalId: string,
connectedAccount: Pick<
ConnectedAccountWorkspaceEntity,
'provider' | 'refreshToken'
>,
): Promise<string | null> {
const oAuth2Client =
await this.oAuth2ClientManagerService.getGoogleOAuth2Client(
connectedAccount,
);
const gmailClient = google.gmail({
version: 'v1',
auth: oAuth2Client,
});
const response = await gmailClient.users.messages.get({
userId: 'me',
id: messageExternalId,
});
const htmlData = getHtmlBodyData(response.data);
if (!htmlData) {
return null;
}
return Buffer.from(htmlData, 'base64').toString('utf-8');
}
}
@@ -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,251 @@
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;
};
@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 authContext = buildSystemAuthContext(workspaceId);
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
async () => {
const association = await this.getMessageAssociation(
messageId,
workspaceId,
);
if (!association?.messageExternalId) {
return null;
}
const connectedAccount = await this.getConnectedAccountForChannel(
association.messageChannelId,
workspaceId,
);
if (!connectedAccount) {
return null;
}
await this.connectedAccountRefreshTokensService.refreshAndSaveTokens(
connectedAccount,
workspaceId,
);
return this.fetchHtmlFromProvider(
connectedAccount,
association.messageExternalId,
);
},
authContext,
);
}
async getThreadMessagesHtml(
messageThreadIds: string[],
workspaceId: string,
): Promise<MessageHtmlResult[]> {
const authContext = buildSystemAuthContext(workspaceId);
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
async () => {
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, messageThreadId: true },
});
if (messages.length === 0) {
return [];
}
const messageIds = messages.map((message) => message.id);
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,
},
});
// Group by connected account to reuse OAuth clients
const channelIds = [
...new Set(associations.map((a) => a.messageChannelId)),
];
const connectedAccountByChannelId = new Map<
string,
ConnectedAccountWorkspaceEntity
>();
for (const channelId of channelIds) {
const account = await this.getConnectedAccountForChannel(
channelId,
workspaceId,
);
if (isDefined(account)) {
await this.connectedAccountRefreshTokensService.refreshAndSaveTokens(
account,
workspaceId,
);
connectedAccountByChannelId.set(channelId, account);
}
}
const results: MessageHtmlResult[] = [];
for (const association of associations) {
if (!association.messageExternalId) {
continue;
}
const connectedAccount = connectedAccountByChannelId.get(
association.messageChannelId,
);
if (!connectedAccount) {
continue;
}
try {
const html = await this.fetchHtmlFromProvider(
connectedAccount,
association.messageExternalId,
);
results.push({ messageId: association.messageId, html });
} catch (error) {
this.logger.warn(
`Failed to fetch HTML for message ${association.messageId}: ${error}`,
);
results.push({ messageId: association.messageId, html: null });
}
}
return results;
},
authContext,
);
}
private async getMessageAssociation(
messageId: string,
workspaceId: string,
): Promise<MessageChannelMessageAssociationWorkspaceEntity | null> {
const repository =
await this.globalWorkspaceOrmManager.getRepository<MessageChannelMessageAssociationWorkspaceEntity>(
workspaceId,
'messageChannelMessageAssociation',
{ shouldBypassPermissionChecks: true },
);
return repository.findOne({
where: { messageId },
select: {
messageExternalId: true,
messageChannelId: true,
},
});
}
private async getConnectedAccountForChannel(
messageChannelId: string,
workspaceId: string,
): Promise<ConnectedAccountWorkspaceEntity | null> {
const channelRepository =
await this.globalWorkspaceOrmManager.getRepository<MessageChannelWorkspaceEntity>(
workspaceId,
'messageChannel',
{ shouldBypassPermissionChecks: true },
);
const channel = await channelRepository.findOne({
where: { id: messageChannelId },
select: { connectedAccountId: true },
});
if (!channel) {
return null;
}
const accountRepository =
await this.globalWorkspaceOrmManager.getRepository<ConnectedAccountWorkspaceEntity>(
workspaceId,
'connectedAccount',
{ shouldBypassPermissionChecks: true },
);
return accountRepository.findOne({
where: { id: channel.connectedAccountId },
});
}
private async fetchHtmlFromProvider(
connectedAccount: ConnectedAccountWorkspaceEntity,
messageExternalId: string,
): Promise<string | null> {
switch (connectedAccount.provider) {
case ConnectedAccountProvider.GOOGLE:
return this.gmailHtmlPreviewService.getMessageHtml(
messageExternalId,
connectedAccount,
);
case ConnectedAccountProvider.MICROSOFT:
return this.microsoftHtmlPreviewService.getMessageHtml(
messageExternalId,
);
case ConnectedAccountProvider.IMAP_SMTP_CALDAV:
return this.imapHtmlPreviewService.getMessageHtml(messageExternalId);
default:
return null;
}
}
}