mirror of
https://github.com/Mail-0/Zero.git
synced 2026-07-01 08:16:28 +00:00
new email renderer (#1584)
# Replace iFrame with Shadow DOM for Email Content Rendering ## Description Replaced the `MailIframe` component with a new `MailContent` component that uses Shadow DOM instead of iframes for rendering email content. This approach provides better security isolation while maintaining styling control and performance. The implementation uses DOMPurify for sanitization and includes proper handling of external images, trusted senders, and theme adaptation. ## Type of Change - ✨ New feature (non-breaking change which adds functionality) - 🔒 Security enhancement - ⚡ Performance improvement <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Introduced a new email content viewer that securely displays HTML emails with improved security, style isolation, and theme support. * Added user controls to manage image loading and trust email senders directly from the email view. * **Refactor** * Replaced the previous email iframe viewer with the new secure content viewer, streamlining functionality and enhancing user experience. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
198
apps/mail/components/mail/mail-content.tsx
Normal file
198
apps/mail/components/mail/mail-content.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
||||
import { defaultUserSettings } from '@zero/server/schemas';
|
||||
import { fixNonReadableColors } from '@/lib/email-utils';
|
||||
import { useTRPC } from '@/providers/query-provider';
|
||||
import { getBrowserTimezone } from '@/lib/timezones';
|
||||
import { useSettings } from '@/hooks/use-settings';
|
||||
import { m } from '@/paraglide/messages';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface MailContentProps {
|
||||
html: string;
|
||||
senderEmail: string;
|
||||
}
|
||||
|
||||
export function MailContent({ html, senderEmail }: MailContentProps) {
|
||||
const { data, refetch } = useSettings();
|
||||
const queryClient = useQueryClient();
|
||||
const isTrustedSender = useMemo(
|
||||
() => data?.settings?.externalImages || data?.settings?.trustedSenders?.includes(senderEmail),
|
||||
[data?.settings, senderEmail],
|
||||
);
|
||||
const [cspViolation, setCspViolation] = useState(false);
|
||||
const [temporaryImagesEnabled, setTemporaryImagesEnabled] = useState(false);
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const shadowRootRef = useRef<ShadowRoot | null>(null);
|
||||
const { resolvedTheme } = useTheme();
|
||||
const trpc = useTRPC();
|
||||
|
||||
const { mutateAsync: saveUserSettings } = useMutation({
|
||||
...trpc.settings.save.mutationOptions(),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['settings'] });
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: trustSender } = useMutation({
|
||||
mutationFn: async () => {
|
||||
const existingSettings = data?.settings ?? {
|
||||
...defaultUserSettings,
|
||||
timezone: getBrowserTimezone(),
|
||||
};
|
||||
|
||||
const { success } = await saveUserSettings({
|
||||
...existingSettings,
|
||||
trustedSenders: data?.settings?.trustedSenders
|
||||
? data.settings.trustedSenders.concat(senderEmail)
|
||||
: [senderEmail],
|
||||
});
|
||||
|
||||
if (!success) {
|
||||
throw new Error('Failed to trust sender');
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
refetch();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to trust sender');
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: processEmailContent } = useMutation(
|
||||
trpc.mail.processEmailContent.mutationOptions(),
|
||||
);
|
||||
|
||||
const { data: processedData } = useQuery({
|
||||
queryKey: ['email-content', html, isTrustedSender || temporaryImagesEnabled, resolvedTheme],
|
||||
queryFn: async () => {
|
||||
const result = await processEmailContent({
|
||||
html,
|
||||
shouldLoadImages: isTrustedSender || temporaryImagesEnabled,
|
||||
theme: (resolvedTheme as 'light' | 'dark') || 'light',
|
||||
});
|
||||
|
||||
if (result.hasBlockedImages) {
|
||||
setCspViolation(true);
|
||||
}
|
||||
|
||||
return result.processedHtml;
|
||||
},
|
||||
staleTime: 30 * 60 * 1000,
|
||||
gcTime: 60 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!hostRef.current || shadowRootRef.current) return;
|
||||
|
||||
shadowRootRef.current = hostRef.current.attachShadow({ mode: 'open' });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shadowRootRef.current || !processedData) return;
|
||||
|
||||
shadowRootRef.current.innerHTML = processedData;
|
||||
}, [processedData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shadowRootRef.current) return;
|
||||
|
||||
const root = shadowRootRef.current;
|
||||
|
||||
const applyFix: () => void = () => {
|
||||
const topLevelEls = Array.from(root.children) as HTMLElement[];
|
||||
topLevelEls.forEach((el) => {
|
||||
try {
|
||||
fixNonReadableColors(el, {
|
||||
defaultBackground: resolvedTheme === 'dark' ? 'rgb(10,10,10)' : '#ffffff',
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to fix colors in email content:', err);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
requestAnimationFrame(applyFix);
|
||||
}, [processedData, resolvedTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isTrustedSender || temporaryImagesEnabled) {
|
||||
setCspViolation(false);
|
||||
}
|
||||
}, [isTrustedSender, temporaryImagesEnabled]);
|
||||
|
||||
const handleImageError = useCallback(
|
||||
(e: Event) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
if (target.tagName === 'IMG') {
|
||||
if (!(isTrustedSender || temporaryImagesEnabled)) {
|
||||
setCspViolation(true);
|
||||
}
|
||||
target.style.display = 'none';
|
||||
}
|
||||
},
|
||||
[isTrustedSender, temporaryImagesEnabled],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shadowRootRef.current) return;
|
||||
|
||||
shadowRootRef.current.addEventListener('error', handleImageError, true);
|
||||
|
||||
const handleClick = (e: Event) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName === 'A') {
|
||||
e.preventDefault();
|
||||
const href = target.getAttribute('href');
|
||||
if (href && (href.startsWith('http://') || href.startsWith('https://'))) {
|
||||
window.open(href, '_blank', 'noopener,noreferrer');
|
||||
} else if (href && href.startsWith('mailto:')) {
|
||||
window.location.href = href;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
shadowRootRef.current.addEventListener('click', handleClick);
|
||||
|
||||
return () => {
|
||||
shadowRootRef.current?.removeEventListener('error', handleImageError, true);
|
||||
shadowRootRef.current?.removeEventListener('click', handleClick);
|
||||
};
|
||||
}, [handleImageError, processedData]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{cspViolation && !isTrustedSender && !data?.settings?.externalImages && (
|
||||
<div className="flex items-center justify-start bg-amber-600/20 px-2 py-1 text-sm text-amber-600">
|
||||
<p>{m['common.actions.hiddenImagesWarning']()}</p>
|
||||
<button
|
||||
onClick={() => setTemporaryImagesEnabled(!temporaryImagesEnabled)}
|
||||
className="ml-2 cursor-pointer underline"
|
||||
>
|
||||
{temporaryImagesEnabled
|
||||
? m['common.actions.disableImages']()
|
||||
: m['common.actions.showImages']()}
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await trustSender();
|
||||
} catch (error) {
|
||||
console.error('Error trusting sender:', error);
|
||||
}
|
||||
}}
|
||||
className="ml-2 cursor-pointer underline"
|
||||
>
|
||||
{m['common.actions.trustSender']()}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div ref={hostRef} className={cn('mail-content w-full flex-1 overflow-hidden')} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -47,7 +47,7 @@ import { Markdown } from '@react-email/components';
|
||||
import { useSummary } from '@/hooks/use-summary';
|
||||
import { TextShimmer } from '../ui/text-shimmer';
|
||||
import { RenderLabels } from './render-labels';
|
||||
import { MailIframe } from './mail-iframe';
|
||||
import { MailContent } from './mail-content';
|
||||
import { m } from '@/paraglide/messages';
|
||||
import { useParams } from 'react-router';
|
||||
import { FileText } from 'lucide-react';
|
||||
@@ -1205,7 +1205,7 @@ const MailDisplay = ({ emailData, index, totalEmails, demo, threadAttachments }:
|
||||
<!-- Email Body -->
|
||||
<div class="email-body">
|
||||
<div class="email-content">
|
||||
${escapeHtml(emailData.decodedBody) || '<p><em>No email content available</em></p>'}
|
||||
${escapeHtml(emailData?.decodedBody || '') || '<p><em>No email content available</em></p>'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1768,7 +1768,7 @@ const MailDisplay = ({ emailData, index, totalEmails, demo, threadAttachments }:
|
||||
<div className="h-fit w-full p-0">
|
||||
{/* mail main body */}
|
||||
{emailData?.decodedBody ? (
|
||||
<MailIframe html={emailData?.decodedBody} senderEmail={emailData.sender.email} />
|
||||
<MailContent html={emailData?.decodedBody} senderEmail={emailData.sender.email} />
|
||||
) : null}
|
||||
{/* mail attachments */}
|
||||
{emailData?.attachments && emailData?.attachments.length > 0 ? (
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
import { addStyleTags, doesContainStyleTags, template } from '@/lib/email-utils.client';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
||||
import { defaultUserSettings } from '@zero/server/schemas';
|
||||
import { fixNonReadableColors } from '@/lib/email-utils';
|
||||
import { useTRPC } from '@/providers/query-provider';
|
||||
import { getBrowserTimezone } from '@/lib/timezones';
|
||||
import { useSettings } from '@/hooks/use-settings';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toast } from 'sonner';
|
||||
import { m } from '@/paraglide/messages';
|
||||
|
||||
export function MailIframe({ html, senderEmail }: { html: string; senderEmail: string }) {
|
||||
const { data, refetch } = useSettings();
|
||||
const queryClient = useQueryClient();
|
||||
const isTrustedSender = useMemo(
|
||||
() => data?.settings?.externalImages || data?.settings?.trustedSenders?.includes(senderEmail),
|
||||
[data?.settings, senderEmail],
|
||||
);
|
||||
const [cspViolation, setCspViolation] = useState(false);
|
||||
const [temporaryImagesEnabled, setTemporaryImagesEnabled] = useState(false);
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const [height, setHeight] = useState(0);
|
||||
const { resolvedTheme } = useTheme();
|
||||
const trpc = useTRPC();
|
||||
|
||||
const { mutateAsync: saveUserSettings } = useMutation({
|
||||
...trpc.settings.save.mutationOptions(),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['settings'] });
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: trustSender } = useMutation({
|
||||
mutationFn: async () => {
|
||||
const existingSettings = data?.settings ?? {
|
||||
...defaultUserSettings,
|
||||
timezone: getBrowserTimezone(),
|
||||
};
|
||||
|
||||
const { success } = await saveUserSettings({
|
||||
...existingSettings,
|
||||
trustedSenders: data?.settings.trustedSenders
|
||||
? data.settings.trustedSenders.concat(senderEmail)
|
||||
: [senderEmail],
|
||||
});
|
||||
|
||||
if (!success) {
|
||||
throw new Error('Failed to trust sender');
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
refetch();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to trust sender');
|
||||
},
|
||||
});
|
||||
|
||||
const { data: processedHtml } = useQuery({
|
||||
queryKey: ['email-template', html, isTrustedSender || temporaryImagesEnabled],
|
||||
queryFn: () => template(html, isTrustedSender || temporaryImagesEnabled),
|
||||
staleTime: 30 * 60 * 1000, // Increase cache time to 30 minutes
|
||||
gcTime: 60 * 60 * 1000, // Keep in cache for 1 hour
|
||||
refetchOnWindowFocus: false, // Don't refetch on window focus
|
||||
refetchOnMount: false, // Don't refetch on mount if data exists
|
||||
});
|
||||
|
||||
|
||||
|
||||
const calculateAndSetHeight = useCallback(() => {
|
||||
if (!iframeRef.current?.contentWindow?.document.body) return;
|
||||
|
||||
const body = iframeRef.current.contentWindow.document.body;
|
||||
const boundingRectHeight = body.getBoundingClientRect().height;
|
||||
const scrollHeight = body.scrollHeight;
|
||||
|
||||
if (body.innerText.trim() === '') {
|
||||
setHeight(0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the larger of the two values to ensure all content is visible
|
||||
const newHeight = Math.max(boundingRectHeight, scrollHeight);
|
||||
setHeight(newHeight);
|
||||
}, [iframeRef, setHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!iframeRef.current || !processedHtml) return;
|
||||
|
||||
let finalHtml = processedHtml;
|
||||
const containsStyleTags = doesContainStyleTags(processedHtml);
|
||||
if (!containsStyleTags) {
|
||||
finalHtml = addStyleTags(processedHtml);
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(new Blob([finalHtml], { type: 'text/html' }));
|
||||
iframeRef.current.src = url;
|
||||
|
||||
const handler = () => {
|
||||
if (iframeRef.current?.contentWindow?.document.body) {
|
||||
calculateAndSetHeight();
|
||||
fixNonReadableColors(iframeRef.current.contentWindow.document.body);
|
||||
}
|
||||
setTimeout(calculateAndSetHeight, 500);
|
||||
};
|
||||
|
||||
iframeRef.current.onload = handler;
|
||||
|
||||
return () => {
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
}, [processedHtml, calculateAndSetHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
if (iframeRef.current?.contentWindow?.document.body) {
|
||||
const body = iframeRef.current.contentWindow.document.body;
|
||||
body.style.backgroundColor =
|
||||
resolvedTheme === 'dark' ? 'rgb(10, 10, 10)' : 'rgb(245, 245, 245)';
|
||||
requestAnimationFrame(() => {
|
||||
fixNonReadableColors(body);
|
||||
});
|
||||
}
|
||||
}, [resolvedTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
const ctrl = new AbortController();
|
||||
window.addEventListener(
|
||||
'message',
|
||||
(event) => {
|
||||
if (event.data.type === 'csp-violation') {
|
||||
setCspViolation(true);
|
||||
}
|
||||
},
|
||||
{ signal: ctrl.signal },
|
||||
);
|
||||
|
||||
return () => ctrl.abort();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{cspViolation && !isTrustedSender && !data?.settings?.externalImages && (
|
||||
<div className="flex items-center justify-start bg-amber-600/20 px-2 py-1 text-sm text-amber-600">
|
||||
<p>{m['common.actions.hiddenImagesWarning']()}</p>
|
||||
<button
|
||||
onClick={() => setTemporaryImagesEnabled(!temporaryImagesEnabled)}
|
||||
className="ml-2 cursor-pointer underline"
|
||||
>
|
||||
{temporaryImagesEnabled
|
||||
? m['common.actions.disableImages']()
|
||||
: m['common.actions.showImages']()}
|
||||
</button>
|
||||
<button onClick={() => void trustSender()} className="ml-2 cursor-pointer underline">
|
||||
{m['common.actions.trustSender']()}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<iframe
|
||||
height={height}
|
||||
ref={iframeRef}
|
||||
className={cn(
|
||||
'!min-h-0 w-full flex-1 overflow-hidden px-4 transition-opacity duration-200',
|
||||
)}
|
||||
title="Email Content"
|
||||
style={{
|
||||
width: '100%',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,11 @@ import * as emailAddresses from 'email-addresses';
|
||||
import type { Sender } from '@/types';
|
||||
import Color from 'color';
|
||||
|
||||
export const fixNonReadableColors = (rootElement: HTMLElement, minContrast = 3.5) => {
|
||||
export const fixNonReadableColors = (
|
||||
rootElement: HTMLElement,
|
||||
options?: { minContrast?: number; defaultBackground?: string },
|
||||
) => {
|
||||
const { minContrast = 3.5, defaultBackground = '#ffffff' } = options || {};
|
||||
const elements = Array.from<HTMLElement>(rootElement.querySelectorAll('*'));
|
||||
elements.unshift(rootElement);
|
||||
|
||||
@@ -21,7 +25,7 @@ export const fixNonReadableColors = (rootElement: HTMLElement, minContrast = 3.5
|
||||
|
||||
try {
|
||||
const textColor = Color(style.color);
|
||||
const effectiveBg = getEffectiveBackgroundColor(el);
|
||||
const effectiveBg = getEffectiveBackgroundColor(el, defaultBackground);
|
||||
|
||||
const blendedText =
|
||||
textColor.alpha() < 1 ? effectiveBg.mix(textColor, effectiveBg.alpha()) : textColor;
|
||||
@@ -38,14 +42,14 @@ export const fixNonReadableColors = (rootElement: HTMLElement, minContrast = 3.5
|
||||
}
|
||||
};
|
||||
|
||||
const getEffectiveBackgroundColor = (element: HTMLElement) => {
|
||||
const getEffectiveBackgroundColor = (element: HTMLElement, defaultBackground: string) => {
|
||||
let current: HTMLElement | null = element;
|
||||
while (current) {
|
||||
const bg = Color(getComputedStyle(current).backgroundColor);
|
||||
if (bg.alpha() >= 1) return bg.rgb();
|
||||
current = current.parentElement;
|
||||
}
|
||||
return Color('#ffffff');
|
||||
return Color(defaultBackground);
|
||||
};
|
||||
|
||||
type ListUnsubscribeAction =
|
||||
|
||||
136
apps/server/src/lib/email-processor.ts
Normal file
136
apps/server/src/lib/email-processor.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
import * as cheerio from 'cheerio';
|
||||
|
||||
interface ProcessEmailOptions {
|
||||
html: string;
|
||||
shouldLoadImages: boolean;
|
||||
theme: 'light' | 'dark';
|
||||
}
|
||||
|
||||
export function processEmailHtml({ html, shouldLoadImages, theme }: ProcessEmailOptions): {
|
||||
processedHtml: string;
|
||||
hasBlockedImages: boolean;
|
||||
} {
|
||||
let hasBlockedImages = false;
|
||||
|
||||
const sanitizeConfig: sanitizeHtml.IOptions = {
|
||||
allowedTags: false,
|
||||
allowedAttributes: false,
|
||||
allowedSchemes: shouldLoadImages
|
||||
? ['http', 'https', 'mailto', 'tel', 'data', 'cid', 'blob']
|
||||
: ['http', 'https', 'mailto', 'tel', 'cid'],
|
||||
allowedSchemesByTag: {
|
||||
img: shouldLoadImages ? ['http', 'https', 'data', 'cid', 'blob'] : ['cid'],
|
||||
},
|
||||
transformTags: {
|
||||
img: (tagName, attribs) => {
|
||||
if (!shouldLoadImages && attribs.src && !attribs.src.startsWith('cid:')) {
|
||||
hasBlockedImages = true;
|
||||
return { tagName: 'span', attribs: { style: 'display:none;' } };
|
||||
}
|
||||
return { tagName, attribs };
|
||||
},
|
||||
a: (tagName, attribs) => {
|
||||
return {
|
||||
tagName,
|
||||
attribs: {
|
||||
...attribs,
|
||||
target: attribs.target || '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const sanitized = sanitizeHtml(html, sanitizeConfig);
|
||||
|
||||
const $ = cheerio.load(sanitized);
|
||||
|
||||
$('img[width="1"][height="1"]').remove();
|
||||
$('img[width="0"][height="0"]').remove();
|
||||
|
||||
$('.preheader, .preheaderText, [class*="preheader"]').each((_, el) => {
|
||||
const $el = $(el);
|
||||
const style = $el.attr('style') || '';
|
||||
if (
|
||||
style.includes('display:none') ||
|
||||
style.includes('display: none') ||
|
||||
style.includes('font-size:0') ||
|
||||
style.includes('font-size: 0') ||
|
||||
style.includes('line-height:0') ||
|
||||
style.includes('line-height: 0') ||
|
||||
style.includes('max-height:0') ||
|
||||
style.includes('max-height: 0') ||
|
||||
style.includes('mso-hide:all') ||
|
||||
style.includes('opacity:0') ||
|
||||
style.includes('opacity: 0')
|
||||
) {
|
||||
$el.remove();
|
||||
}
|
||||
});
|
||||
|
||||
const minimalStyles = `
|
||||
<style type="text/css">
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
font-family: 'Geist', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif !important;
|
||||
line-height: 1.5;
|
||||
background-color: ${theme === 'dark' ? '#1A1A1A' : '#ffffff'};
|
||||
color: ${theme === 'dark' ? '#ffffff' : '#000000'};
|
||||
}
|
||||
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
cursor: pointer;
|
||||
color: ${theme === 'dark' ? '#60a5fa' : '#2563eb'};
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: ${theme === 'dark' ? '#93bbfc' : '#1d4ed8'};
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.gmail_quote {
|
||||
margin: 1em 0;
|
||||
padding-left: 1em;
|
||||
border-left: 1px solid ${theme === 'dark' ? '#000' : '#ccc'};
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: #b3d4fc;
|
||||
text-shadow: none;
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
|
||||
const fullHtml = $.html();
|
||||
|
||||
const finalHtml = `${minimalStyles}${fullHtml}`;
|
||||
|
||||
return {
|
||||
processedHtml: finalHtml,
|
||||
hasBlockedImages,
|
||||
};
|
||||
}
|
||||
@@ -1,11 +1,18 @@
|
||||
import { activeDriverProcedure, createRateLimiterMiddleware, router } from '../trpc';
|
||||
import {
|
||||
activeDriverProcedure,
|
||||
createRateLimiterMiddleware,
|
||||
router,
|
||||
privateProcedure,
|
||||
} from '../trpc';
|
||||
import { updateWritingStyleMatrix } from '../../services/writing-style-service';
|
||||
import { deserializeFiles, serializedFileSchema } from '../../lib/schemas';
|
||||
import { defaultPageSize, FOLDERS, LABELS } from '../../lib/utils';
|
||||
import { IGetThreadResponseSchema } from '../../lib/driver/types';
|
||||
import { processEmailHtml } from '../../lib/email-processor';
|
||||
import type { DeleteAllSpamResponse } from '../../types';
|
||||
import { getZeroAgent } from '../../lib/server-utils';
|
||||
import { env } from 'cloudflare:workers';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
const senderSchema = z.object({
|
||||
@@ -395,4 +402,32 @@ export const mailRouter = router({
|
||||
const agent = await getZeroAgent(activeConnection.id);
|
||||
return agent.getEmailAliases();
|
||||
}),
|
||||
processEmailContent: privateProcedure
|
||||
.input(
|
||||
z.object({
|
||||
html: z.string(),
|
||||
shouldLoadImages: z.boolean(),
|
||||
theme: z.enum(['light', 'dark']),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
const { processedHtml, hasBlockedImages } = processEmailHtml({
|
||||
html: input.html,
|
||||
shouldLoadImages: input.shouldLoadImages,
|
||||
theme: input.theme,
|
||||
});
|
||||
|
||||
return {
|
||||
processedHtml,
|
||||
hasBlockedImages,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error processing email content:', error);
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Failed to process email content',
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user