diff --git a/apps/mail/app/(routes)/settings/privacy/page.tsx b/apps/mail/app/(routes)/settings/privacy/page.tsx index 3ae49d0173..b95fafaa67 100644 --- a/apps/mail/app/(routes)/settings/privacy/page.tsx +++ b/apps/mail/app/(routes)/settings/privacy/page.tsx @@ -15,11 +15,11 @@ import { useTRPC } from '@/providers/query-provider'; import { useMutation } from '@tanstack/react-query'; // import { saveUserSettings } from '@/actions/settings'; import { useSettings } from '@/hooks/use-settings'; -import { Button } from '@/components/ui/button'; import { Switch } from '@/components/ui/switch'; +import { Button } from '@/components/ui/button'; import { useState, useEffect } from 'react'; -import { m } from '@/paraglide/messages'; import { useForm } from 'react-hook-form'; +import { m } from '@/paraglide/messages'; import { XIcon } from 'lucide-react'; import { toast } from 'sonner'; import * as z from 'zod'; @@ -32,17 +32,16 @@ export default function PrivacyPage() { const form = useForm>({ resolver: zodResolver(userSettingsSchema), - defaultValues: { - externalImages: true, - trustedSenders: [], - }, }); - const externalImages = form.watch('externalImages'); - + const externalImages = data?.settings.externalImages; useEffect(() => { if (data) { - form.reset(data.settings); + form.reset({ + ...data.settings, + trustedSenders: data.settings.trustedSenders, + externalImages: !!data.settings.externalImages, + }); } }, [form, data]); @@ -87,10 +86,10 @@ export default function PrivacyPage() {
- {m['pages.settings.privacy.externalImages']()} + {m['pages.settings.privacy.externalImages']()} - {m['pages.settings.privacy.externalImagesDescription']()} + {m['pages.settings.privacy.externalImagesDescription']()}
@@ -107,10 +106,10 @@ export default function PrivacyPage() {
- {m['pages.settings.privacy.trustedSenders']()} + {m['pages.settings.privacy.trustedSenders']()} - {m['pages.settings.privacy.trustedSendersDescription']()} + {m['pages.settings.privacy.trustedSendersDescription']()}
diff --git a/apps/mail/components/mail/mail-content.tsx b/apps/mail/components/mail/mail-content.tsx new file mode 100644 index 0000000000..9f4c02b0f8 --- /dev/null +++ b/apps/mail/components/mail/mail-content.tsx @@ -0,0 +1,206 @@ +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 { + id: string; + html: string; + senderEmail: string; +} + +export function MailContent({ id, 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', id, isTrustedSender || temporaryImagesEnabled, resolvedTheme], + queryFn: async () => { + const result = await processEmailContent({ + html, + shouldLoadImages: isTrustedSender || temporaryImagesEnabled, + theme: (resolvedTheme as 'light' | 'dark') || 'light', + }); + + return { + html: result.processedHtml, + hasBlockedImages: result.hasBlockedImages, + }; + }, + staleTime: 30 * 60 * 1000, + gcTime: 60 * 60 * 1000, + refetchOnWindowFocus: false, + refetchOnMount: false, + }); + + useEffect(() => { + if (processedData) { + if (processedData.hasBlockedImages) { + setCspViolation(true); + } + } + }, [processedData]); + + useEffect(() => { + if (!hostRef.current || shadowRootRef.current) return; + + shadowRootRef.current = hostRef.current.attachShadow({ mode: 'open' }); + }, []); + + useEffect(() => { + if (!shadowRootRef.current || !processedData) return; + + shadowRootRef.current.innerHTML = processedData.html; + }, [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..53306ced90 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,11 @@ 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']()}

- - -
- )} -