Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ba5d1c2ed1 | |||
| 4929b9e954 | |||
| 45b7e3d6aa | |||
| 50f068d640 |
+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} />
|
||||
)}
|
||||
|
||||
+75
-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,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>
|
||||
);
|
||||
};
|
||||
|
||||
+87
@@ -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
|
||||
|
||||
+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,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 };
|
||||
};
|
||||
+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,
|
||||
|
||||
+217
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+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 };
|
||||
}
|
||||
}
|
||||
+308
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user