"use client" import { useCallback, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, } from "react" import { useTheme } from "next-themes" import { emailPreviewBaseCss, emailPreviewDarkOverrideCss, emailPreviewDarkRemoteBodyTailCss, emailPreviewDarkTailOverrideCss, emailPreviewLightOverrideCss, emailPreviewWrapperCss, } from "@/lib/email-preview-dark-styles" import { EMAIL_PREVIEW_MIN_IFRAME_HEIGHT, measureEmailPreviewIframeHeight, resolveEmailPreviewMeasureRoot, } from "@/lib/email-preview-iframe-height" import { buildEmailPreviewSrcdoc, prepareEmailHtmlForIframe, } from "@/lib/mail-html-iframe" import { buildEmailPreviewCsp } from "@/lib/mail-remote-content" import { isEmailPreviewContrastDebugEnabled, logEmailPreviewContrastIssues, repairEmailPreviewContrast, } from "@/lib/email-preview-contrast" const EMAIL_PREVIEW_IFRAME_STYLE: CSSProperties = { display: "block", background: "transparent", overflow: "hidden", } function documentIsDark(): boolean { return document.documentElement.classList.contains("dark") } function bindEmailPreviewImageLoads( root: HTMLElement, onChange: () => void ): () => void { const images = root.querySelectorAll("img") if (images.length === 0) return () => {} let pending = 0 const onDone = () => { pending -= 1 if (pending <= 0) onChange() } for (const img of images) { if (img.complete) continue pending += 1 img.addEventListener("load", onDone, { once: true }) img.addEventListener("error", onDone, { once: true }) } if (pending === 0) return () => {} return () => { for (const img of images) { img.removeEventListener("load", onDone) img.removeEventListener("error", onDone) } } } export function SandboxedContent({ html, blockRemoteContent, restrictPopups = false, senderEmail, cidUrlMap, plainTextFallback, messageId, previewPart = "body", }: { html: string blockRemoteContent: boolean restrictPopups?: boolean senderEmail?: string cidUrlMap?: Record /** Plain body when HTML is image-only or empty with remote content blocked. */ plainTextFallback?: string /** Pour les logs dev [email-preview:low-contrast]. */ messageId?: string previewPart?: "body" | "quoted" }) { const iframeRef = useRef(null) const contrastLoggedKeyRef = useRef(null) const contrastDelayTimerRef = useRef(null) const resizeObserverRef = useRef(null) const unbindImageLoadsRef = useRef<(() => void) | null>(null) const contentGenerationRef = useRef(0) const appliedHeightRef = useRef(EMAIL_PREVIEW_MIN_IFRAME_HEIGHT) const syncRafRef = useRef(null) const [height, setHeight] = useState(EMAIL_PREVIEW_MIN_IFRAME_HEIGHT) const sandboxValue = restrictPopups ? "allow-same-origin" : "allow-same-origin allow-popups" const { resolvedTheme } = useTheme() const isDark = resolvedTheme === "dark" || ((resolvedTheme === "system" || resolvedTheme === undefined) && typeof document !== "undefined" && documentIsDark()) const parsedEmail = useMemo( () => prepareEmailHtmlForIframe(html, { blockRemoteContent, isDark, senderEmail, cidUrlMap, plainTextFallback, }), [html, blockRemoteContent, isDark, senderEmail, cidUrlMap, plainTextFallback] ) const themeCss = useMemo(() => { if (!blockRemoteContent) { const wrapper = emailPreviewWrapperCss(isDark) return isDark ? `${wrapper}${emailPreviewDarkOverrideCss()}` : wrapper } return `${emailPreviewBaseCss(isDark)}${ isDark ? emailPreviewDarkOverrideCss() : emailPreviewLightOverrideCss() }` }, [blockRemoteContent, isDark]) const cspContent = useMemo( () => buildEmailPreviewCsp(blockRemoteContent), [blockRemoteContent] ) const srcdoc = useMemo( () => buildEmailPreviewSrcdoc(parsedEmail, { csp: cspContent, wrapperCss: themeCss, plainTextFallback, loadAppFont: !blockRemoteContent, bodyTailCss: isDark ? blockRemoteContent ? emailPreviewDarkTailOverrideCss() : emailPreviewDarkRemoteBodyTailCss() : undefined, }), [ parsedEmail, cspContent, themeCss, plainTextFallback, isDark, blockRemoteContent, ] ) const iframeKey = `${messageId ?? "no-id"}:${previewPart}:${blockRemoteContent ? "remote-blocked" : "remote-allowed"}:${isDark ? "dark" : "light"}` const applyMeasuredHeight = useCallback((generation: number) => { if (generation !== contentGenerationRef.current) return const doc = iframeRef.current?.contentDocument if (!doc) return const next = measureEmailPreviewIframeHeight(doc) if (appliedHeightRef.current === next) return appliedHeightRef.current = next setHeight(next) }, []) const scheduleHeightSync = useCallback( (generation: number) => { if (syncRafRef.current !== null) { cancelAnimationFrame(syncRafRef.current) } syncRafRef.current = requestAnimationFrame(() => { syncRafRef.current = requestAnimationFrame(() => { syncRafRef.current = null applyMeasuredHeight(generation) }) }) }, [applyMeasuredHeight] ) const runContrastPipeline = useCallback( (doc: Document, pass: "initial" | "delayed", generation: number) => { if (!isDark || generation !== contentGenerationRef.current) return const repair = repairEmailPreviewContrast(doc, { isDark: true, repairMode: blockRemoteContent ? "dark-only" : "all", newsletterLightCanvas: false, assumedCanvasRgb: [32, 33, 36], }) if (repair && (repair.lightSurfaces > 0 || repair.darkSurfaces > 0)) { scheduleHeightSync(generation) if (isEmailPreviewContrastDebugEnabled()) { console.info("[email-preview:contrast-repaired]", { messageId, part: previewPart, pass, blockRemoteContent, ...repair, }) } } if (!isEmailPreviewContrastDebugEnabled()) return const logKey = `${messageId ?? "no-id"}:${previewPart}:${blockRemoteContent}:${isDark}:${srcdoc.length}:${pass}` if (contrastLoggedKeyRef.current === logKey) return contrastLoggedKeyRef.current = logKey logEmailPreviewContrastIssues( { messageId, part: previewPart, blockRemoteContent, isDark, senderEmail, }, doc, { newsletterLightCanvas: false, assumedCanvasRgb: [32, 33, 36] } ) }, [ messageId, previewPart, blockRemoteContent, isDark, senderEmail, srcdoc, scheduleHeightSync, ] ) const handleIframeLoad = useCallback(() => { const generation = contentGenerationRef.current const doc = iframeRef.current?.contentDocument const measureRoot = doc ? resolveEmailPreviewMeasureRoot(doc) : null resizeObserverRef.current?.disconnect() unbindImageLoadsRef.current?.() unbindImageLoadsRef.current = null if (measureRoot) { resizeObserverRef.current = new ResizeObserver(() => { scheduleHeightSync(generation) }) resizeObserverRef.current.observe(measureRoot) unbindImageLoadsRef.current = bindEmailPreviewImageLoads(measureRoot, () => { scheduleHeightSync(generation) }) } scheduleHeightSync(generation) if (doc) runContrastPipeline(doc, "initial", generation) if (contrastDelayTimerRef.current !== null) { window.clearTimeout(contrastDelayTimerRef.current) } contrastDelayTimerRef.current = window.setTimeout(() => { contrastDelayTimerRef.current = null if (generation !== contentGenerationRef.current) return const lateDoc = iframeRef.current?.contentDocument if (lateDoc) { scheduleHeightSync(generation) runContrastPipeline(lateDoc, "delayed", generation) } }, 1000) }, [scheduleHeightSync, runContrastPipeline]) useLayoutEffect(() => { contentGenerationRef.current += 1 appliedHeightRef.current = EMAIL_PREVIEW_MIN_IFRAME_HEIGHT setHeight(EMAIL_PREVIEW_MIN_IFRAME_HEIGHT) contrastLoggedKeyRef.current = null resizeObserverRef.current?.disconnect() resizeObserverRef.current = null unbindImageLoadsRef.current?.() unbindImageLoadsRef.current = null if (syncRafRef.current !== null) { cancelAnimationFrame(syncRafRef.current) syncRafRef.current = null } if (contrastDelayTimerRef.current !== null) { window.clearTimeout(contrastDelayTimerRef.current) contrastDelayTimerRef.current = null } }, [srcdoc, messageId]) return (