"use client" import { useCallback, useEffect, 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, } 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", } function documentIsDark(): boolean { return document.documentElement.classList.contains("dark") } 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 contentGenerationRef = useRef(0) 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 syncHeight = useCallback((generation: number) => { if (generation !== contentGenerationRef.current) return const doc = iframeRef.current?.contentDocument if (!doc?.body) return const next = measureEmailPreviewIframeHeight(doc) setHeight((prev) => (prev === next ? prev : next)) }, []) const scheduleHeightSync = useCallback( (generation: number) => { requestAnimationFrame(() => { requestAnimationFrame(() => { syncHeight(generation) }) }) }, [syncHeight] ) 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 resizeObserverRef.current?.disconnect() if (doc?.body) { resizeObserverRef.current = new ResizeObserver(() => { syncHeight(generation) }) resizeObserverRef.current.observe(doc.body) } 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, syncHeight]) useLayoutEffect(() => { contentGenerationRef.current += 1 setHeight(EMAIL_PREVIEW_MIN_IFRAME_HEIGHT) contrastLoggedKeyRef.current = null resizeObserverRef.current?.disconnect() resizeObserverRef.current = null if (contrastDelayTimerRef.current !== null) { window.clearTimeout(contrastDelayTimerRef.current) contrastDelayTimerRef.current = null } }, [srcdoc, messageId]) return (