initial POC
This commit is contained in:
+10
-1
@@ -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} />
|
||||
)}
|
||||
|
||||
+84
-10
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+54
@@ -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
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const getMessageHtmlPreview = gql`
|
||||
query GetMessageHtmlPreview($messageId: UUID!) {
|
||||
getMessageHtmlPreview(messageId: $messageId) {
|
||||
messageId
|
||||
html
|
||||
}
|
||||
}
|
||||
`;
|
||||
+12
@@ -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 };
|
||||
};
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import { createAtomFamilyState } from '@/ui/utilities/state/jotai/utils/createAtomFamilyState';
|
||||
|
||||
export const emailHtmlPreviewCacheState = createAtomFamilyState<
|
||||
string | null,
|
||||
string
|
||||
>({
|
||||
key: 'emailHtmlPreviewCacheState',
|
||||
defaultValue: null,
|
||||
});
|
||||
+4
@@ -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}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
|
||||
+5
@@ -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,
|
||||
|
||||
+45
@@ -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');
|
||||
}
|
||||
}
|
||||
+24
@@ -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;
|
||||
};
|
||||
+9
@@ -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;
|
||||
}
|
||||
}
|
||||
+11
@@ -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;
|
||||
}
|
||||
}
|
||||
+18
@@ -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[];
|
||||
}
|
||||
+15
@@ -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[];
|
||||
}
|
||||
+30
@@ -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 {}
|
||||
+52
@@ -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 };
|
||||
}
|
||||
}
|
||||
+251
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user