ultisuite-client/components/gmail/email-view/sandboxed-content.tsx
2026-05-25 13:52:40 +02:00

159 lines
4.1 KiB
TypeScript

"use client"
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type CSSProperties,
} from "react"
import { useTheme } from "next-themes"
import {
emailPreviewBaseCss,
emailPreviewDarkOverrideCss,
emailPreviewLightOverrideCss,
emailPreviewWrapperCss,
} from "@/lib/email-preview-dark-styles"
import {
prepareEmailHtmlForIframe,
injectEmailHtmlIntoDocument,
} from "@/lib/mail-html-iframe"
import { buildEmailPreviewCsp } from "@/lib/mail-remote-content"
const EMAIL_PREVIEW_IFRAME_STYLE: CSSProperties = {
display: "block",
background: "transparent",
}
function documentIsDark(): boolean {
return document.documentElement.classList.contains("dark")
}
function measureIframeContentHeight(doc: Document): number {
const body = doc.body
const root = doc.documentElement
if (!body) return 60
const heights = [
body.scrollHeight,
body.offsetHeight,
root?.scrollHeight ?? 0,
root?.clientHeight ?? 0,
]
return Math.max(60, ...heights) + 2
}
export function SandboxedContent({
html,
blockRemoteContent,
restrictPopups = false,
senderEmail,
cidUrlMap,
}: {
html: string
blockRemoteContent: boolean
restrictPopups?: boolean
senderEmail?: string
cidUrlMap?: Record<string, string>
}) {
const iframeRef = useRef<HTMLIFrameElement>(null)
const [height, setHeight] = useState(120)
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,
}),
[html, blockRemoteContent, isDark, senderEmail, cidUrlMap]
)
const themeCss = useMemo(() => {
if (!blockRemoteContent) return emailPreviewWrapperCss()
return `${emailPreviewBaseCss(isDark)}${
isDark ? emailPreviewDarkOverrideCss() : emailPreviewLightOverrideCss()
}`
}, [blockRemoteContent, isDark])
const cspContent = useMemo(
() => buildEmailPreviewCsp(blockRemoteContent),
[blockRemoteContent]
)
const injectContent = useCallback(() => {
const iframe = iframeRef.current
if (!iframe) return
const doc = iframe.contentDocument
if (!doc) return
injectEmailHtmlIntoDocument(doc, {
csp: cspContent,
documentBaseHref: parsedEmail.documentBaseHref,
resolveBaseHref: parsedEmail.resolveBaseHref,
headMarkup: parsedEmail.headMarkup,
bodyHtml: parsedEmail.bodyHtml,
wrapperCss: themeCss,
})
const syncHeight = () => {
const liveDoc = iframe.contentDocument
if (!liveDoc) return
const next = measureIframeContentHeight(liveDoc)
setHeight((prev) => (prev === next ? prev : next))
}
const resizeObserver = new ResizeObserver(syncHeight)
if (doc.body) {
resizeObserver.observe(doc.body)
for (const img of doc.images) {
if (!img.complete) {
img.addEventListener("load", syncHeight, { once: true })
img.addEventListener("error", syncHeight, { once: true })
}
}
for (const link of doc.querySelectorAll('link[rel~="stylesheet"]')) {
link.addEventListener("load", syncHeight, { once: true })
link.addEventListener("error", syncHeight, { once: true })
}
syncHeight()
requestAnimationFrame(syncHeight)
setTimeout(syncHeight, 250)
setTimeout(syncHeight, 1000)
}
return () => resizeObserver.disconnect()
}, [parsedEmail, themeCss, cspContent])
useEffect(() => {
const cleanup = injectContent()
return () => cleanup?.()
}, [injectContent])
return (
<iframe
key={blockRemoteContent ? "remote-blocked" : "remote-allowed"}
ref={iframeRef}
sandbox={sandboxValue}
title="Contenu du message"
className="w-full border-0 bg-transparent"
style={{ ...EMAIL_PREVIEW_IFRAME_STYLE, height: `${height}px` }}
tabIndex={-1}
/>
)
}