/** 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(
/(`
: ""
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,
}
}