/** Prépare le HTML d'un mail pour affichage dans une iframe sandboxée. */ import { stripHiddenEmailHtml } from "@/lib/strip-hidden-email-html" import { stripExecutableEmailHtml } from "@/lib/strip-executable-email-html" import { preprocessEmailHtmlForTheme } from "@/lib/email-preview-dark-styles" import { resolveCidReference } from "@/lib/mail-cid" const REMOTE_URL = /^https?:\/\//i const PROTOCOL_RELATIVE = /^\/\// export type ParsedEmailHtml = { headMarkup: string bodyHtml: string /** Uniquement si le mail définit explicitement . */ documentBaseHref?: string /** Base pour résoudre les URLs relatives dans le HTML. */ resolveBaseHref?: string } function escapeHtmlAttr(value: string): string { return value .replace(/&/g, "&") .replace(/"/g, """) .replace(/]+href=["']([^"']+)["']/i) if (baseMatch?.[1]) { try { return new URL(baseMatch[1]).href } catch { return baseMatch[1] } } const stylesheetMatch = html.match( /]+rel=["'][^"']*stylesheet[^"']*["'][^>]+href=["']([^"']+)["']/i ) ?? html.match( /]+href=["']([^"']+)["'][^>]+rel=["'][^"']*stylesheet/i ) if (stylesheetMatch?.[1]) { try { const u = new URL(stylesheetMatch[1], "https://example.com/") if (stylesheetMatch[1].startsWith("http") || stylesheetMatch[1].startsWith("//")) { return `${u.origin}${u.pathname.replace(/\/[^/]*$/, "/")}` } } catch { /* ignore */ } } const absoluteMatch = html.match( /(?:src|href)=["'](https?:\/\/[^/"']+)[^"']*["']/i ) if (absoluteMatch?.[1]) { try { const u = new URL(absoluteMatch[1]) return `${u.origin}/` } catch { /* ignore */ } } return undefined } /** Extrait styles/CSS + body d'un document HTML complet ou fragment. */ export function parseEmailHtmlForIframe( html: string, resolveBaseHref?: string ): ParsedEmailHtml { if (!html.trim()) { return { headMarkup: "", bodyHtml: "" } } if (typeof DOMParser === "undefined") { return { headMarkup: "", bodyHtml: html } } try { const doc = new DOMParser().parseFromString(html, "text/html") const explicitBase = doc.querySelector("base[href]")?.getAttribute("href") ?? undefined const documentBaseHref = explicitBase ? resolveUrl(explicitBase, undefined) : undefined doc.querySelector("base[href]")?.remove() const headMarkup = collectHeadResourceMarkup(doc, resolveBaseHref) let bodyHtml = doc.body?.innerHTML?.trim() ?? "" if (!bodyHtml) bodyHtml = html.trim() return { headMarkup, bodyHtml, documentBaseHref, resolveBaseHref, } } catch { return { headMarkup: "", bodyHtml: html } } } function rewriteUrlList(value: string, baseHref?: string): string { return value .split(",") .map((part) => { const trimmed = part.trim() const space = trimmed.indexOf(" ") const url = space === -1 ? trimmed : trimmed.slice(0, space) const descriptor = space === -1 ? "" : trimmed.slice(space + 1) const resolved = resolveUrl(url, baseHref) return descriptor ? `${resolved} ${descriptor}` : resolved }) .join(", ") } function rewriteStyleRemoteUrls(style: string, baseHref?: string): string { return style.replace( /url\s*\(\s*['"]?([^'")]+)['"]?\s*\)/gi, (_match, url: string) => { const trimmed = url.trim() if ( trimmed.startsWith("data:") || trimmed.startsWith("#") || trimmed.startsWith("cid:") ) { return `url(${trimmed})` } return `url(${resolveUrl(trimmed, baseHref)})` } ) } function rewriteHeadMarkupUrls(headMarkup: string, baseHref?: string): string { if (!headMarkup.trim() || !baseHref) return headMarkup return headMarkup .replace( /(]+href=)(["'])([^"']+)\2/gi, (_m, prefix: string, quote: string, href: string) => `${prefix}${quote}${resolveUrl(href, baseHref)}${quote}` ) .replace( /(]*>)([\s\S]*?)(<\/style>)/gi, (_m, open: string, css: string, close: string) => `${open}${rewriteStyleRemoteUrls(css, baseHref)}${close}` ) } const REMOTE_CSS_URL = /url\s*\(\s*['"]?(?:https?:\/\/|\/\/|[^'"data:#][^'")]*)/i function neutralizeRemoteUrlsInCss(css: string): string { return css.replace( /url\s*\(\s*['"]?([^'")]+)['"]?\s*\)/gi, (_match, url: string) => { const trimmed = url.trim() if ( trimmed.startsWith("data:") || trimmed.startsWith("#") || trimmed.startsWith("cid:") ) { return `url(${trimmed})` } if (REMOTE_URL.test(trimmed) || PROTOCOL_RELATIVE.test(trimmed)) { return "url(about:blank)" } return `url(${trimmed})` } ) } /** Keep inline ` : "" const fontLinks = options.loadAppFont ? EMAIL_PREVIEW_GEIST_FONT_LINKS : "" return ( "" + '' + `` + baseTag + fontLinks + `` + headMarkup + "" + bodyHtml + bodyTailStyle + "" ) } /** Replace cid: references with blob URLs, or a data placeholder until blobs load. */ export function rewriteCidUrlsInHtml( html: string, cidUrlMap: Record | undefined ): string { if (!html.trim() || typeof DOMParser === "undefined") return html if (!html.includes("cid:")) return html const resolveCid = (value: string): string => resolveCidReference(cidUrlMap, value) const CID_ATTRS = [ "src", "data-src", "data-original", "data-lazy-src", "background", "poster", ] as const try { const doc = new DOMParser().parseFromString(html, "text/html") for (const img of doc.querySelectorAll("img, video, source, table, td, th, div, span")) { for (const attr of CID_ATTRS) { const value = img.getAttribute(attr) if (value?.toLowerCase().includes("cid:")) { img.setAttribute( attr, value.replace(/cid:[^\s'")]+/gi, (match) => resolveCid(match)) ) } } const srcset = img.getAttribute("srcset") if (srcset?.includes("cid:")) { img.setAttribute( "srcset", srcset.replace(/cid:[^\s,]+/gi, (match) => resolveCid(match)) ) } } for (const el of doc.querySelectorAll("[style]")) { const style = el.getAttribute("style") if (style?.includes("cid:")) { el.setAttribute( "style", style.replace(/cid:[^\s'")]+/gi, (match) => resolveCid(match)) ) } } return doc.body.innerHTML } catch { return html } } function maybePrependPlainTextFallback( bodyHtml: string, plainTextFallback?: string ): string { const fallback = plainTextFallback?.trim() if (!fallback || htmlHasMeaningfulVisibleText(bodyHtml)) { return bodyHtml } return plainTextFallbackHtml(fallback) + (bodyHtml || "") } export function prepareEmailHtmlForIframe( html: string, options: { blockRemoteContent: boolean isDark: boolean senderEmail?: string cidUrlMap?: Record plainTextFallback?: string } ): ParsedEmailHtml { if (!html.trim()) { const fallback = options.plainTextFallback?.trim() if (fallback) { return { headMarkup: "", bodyHtml: plainTextFallbackHtml(fallback) } } return { headMarkup: "", bodyHtml: "" } } const resolveBaseHref = inferEmailResolveBaseHref(html) ?? (options.senderEmail ? `https://${options.senderEmail.split("@")[1]?.trim().toLowerCase()}/` : undefined) if (options.blockRemoteContent) { const parsed = parseEmailHtmlForIframe(html, resolveBaseHref) let bodyHtml = stripHiddenEmailHtml(parsed.bodyHtml || html) bodyHtml = stripRemoteResourcesForPreview(bodyHtml) bodyHtml = rewriteCidUrlsInHtml( preprocessEmailHtmlForTheme(bodyHtml, options.isDark), options.cidUrlMap ) const headMarkup = rewriteCidUrlsInHtml( filterHeadMarkupForBlockedRemote(parsed.headMarkup), options.cidUrlMap ) bodyHtml = maybePrependPlainTextFallback(bodyHtml, options.plainTextFallback) return { headMarkup, bodyHtml, resolveBaseHref, } } const parsed = parseEmailHtmlForIframe(html, resolveBaseHref) let bodyHtml = stripHiddenEmailHtml(parsed.bodyHtml) bodyHtml = activateRemoteResourcesInHtml(bodyHtml, { baseHref: resolveBaseHref, }) bodyHtml = rewriteCidUrlsInHtml(bodyHtml, options.cidUrlMap) bodyHtml = maybePrependPlainTextFallback(bodyHtml, options.plainTextFallback) const headMarkup = rewriteCidUrlsInHtml( rewriteHeadMarkupUrls(parsed.headMarkup, resolveBaseHref), options.cidUrlMap ) return { headMarkup, bodyHtml, documentBaseHref: parsed.documentBaseHref, resolveBaseHref, } }