From f623c912e49b1bd84b197e10977615bc205e4b9e Mon Sep 17 00:00:00 2001 From: Ahmet Kilinc Date: Fri, 4 Jul 2025 03:17:25 +0100 Subject: [PATCH 1/8] new email renderer (#1584) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # 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 ## 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. --- apps/mail/components/mail/mail-content.tsx | 198 +++++++++++++++++++++ apps/mail/components/mail/mail-display.tsx | 6 +- apps/mail/components/mail/mail-iframe.tsx | 174 ------------------ apps/mail/lib/email-utils.ts | 12 +- apps/server/src/lib/email-processor.ts | 136 ++++++++++++++ apps/server/src/trpc/routes/mail.ts | 37 +++- 6 files changed, 381 insertions(+), 182 deletions(-) create mode 100644 apps/mail/components/mail/mail-content.tsx delete mode 100644 apps/mail/components/mail/mail-iframe.tsx create mode 100644 apps/server/src/lib/email-processor.ts diff --git a/apps/mail/components/mail/mail-content.tsx b/apps/mail/components/mail/mail-content.tsx new file mode 100644 index 0000000000..ba48df31d8 --- /dev/null +++ b/apps/mail/components/mail/mail-content.tsx @@ -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(null); + const shadowRootRef = useRef(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 && ( +
+

{m['common.actions.hiddenImagesWarning']()}

+ + +
+ )} +
+ + ); +} diff --git a/apps/mail/components/mail/mail-display.tsx b/apps/mail/components/mail/mail-display.tsx index b910af69dc..288559e39b 100644 --- a/apps/mail/components/mail/mail-display.tsx +++ b/apps/mail/components/mail/mail-display.tsx @@ -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 }: @@ -1768,7 +1768,7 @@ const MailDisplay = ({ emailData, index, totalEmails, demo, threadAttachments }:
{/* mail main body */} {emailData?.decodedBody ? ( - + ) : null} {/* mail attachments */} {emailData?.attachments && emailData?.attachments.length > 0 ? ( diff --git a/apps/mail/components/mail/mail-iframe.tsx b/apps/mail/components/mail/mail-iframe.tsx deleted file mode 100644 index e29fd805a2..0000000000 --- a/apps/mail/components/mail/mail-iframe.tsx +++ /dev/null @@ -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(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 && ( -
-

{m['common.actions.hiddenImagesWarning']()}

- - -
- )} -