ultisuite-client/lib/mail-html-iframe.ts
2026-05-25 13:52:40 +02:00

503 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/** Prépare le HTML d'un mail pour affichage dans une iframe sandboxée. */
import { stripHiddenEmailHtml } from "@/lib/strip-hidden-email-html"
import { preprocessEmailHtmlForTheme } from "@/lib/email-preview-dark-styles"
const REMOTE_URL = /^https?:\/\//i
const PROTOCOL_RELATIVE = /^\/\//
export type ParsedEmailHtml = {
headMarkup: string
bodyHtml: string
/** Uniquement si le mail définit explicitement <base href>. */
documentBaseHref?: string
/** Base pour résoudre les URLs relatives dans le HTML. */
resolveBaseHref?: string
}
function escapeHtmlAttr(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
}
export { escapeHtmlAttr }
function resolveUrl(url: string, baseHref?: string): string {
const trimmed = url.trim()
if (!trimmed || trimmed.startsWith("data:") || trimmed.startsWith("cid:")) {
return trimmed
}
if (trimmed.startsWith("mailto:") || trimmed.startsWith("#")) return trimmed
if (REMOTE_URL.test(trimmed)) {
return trimmed.startsWith("http://")
? `https://${trimmed.slice("http://".length)}`
: trimmed
}
if (PROTOCOL_RELATIVE.test(trimmed)) {
return `https:${trimmed}`
}
if (!baseHref) return trimmed
try {
const resolved = new URL(trimmed, baseHref).href
return resolved.startsWith("http://")
? `https://${resolved.slice("http://".length)}`
: resolved
} catch {
return trimmed
}
}
function isStylesheetLink(el: Element): boolean {
if (el.tagName.toLowerCase() !== "link") return false
const rel = (el.getAttribute("rel") ?? "").toLowerCase()
const type = (el.getAttribute("type") ?? "").toLowerCase()
return rel.includes("stylesheet") || type === "text/css"
}
const LAZY_IMG_SRC_ATTRS = [
"data-src",
"data-original",
"data-lazy-src",
"data-url",
"data-href",
"data-image",
"originalsrc",
] as const
function firstLazyImageSrc(img: Element): string | null {
for (const name of LAZY_IMG_SRC_ATTRS) {
const value = img.getAttribute(name)?.trim()
if (value) return value
}
return null
}
/** Known 1×1 / spacer filenames — not arbitrary URL substrings. */
function isPlaceholderImageSrc(src: string): boolean {
const t = src.trim().toLowerCase()
if (!t || t === "about:blank" || t === "#") return true
if (t.startsWith("data:")) return false
try {
const path = new URL(t, "https://example.invalid/").pathname
const file = path.split("/").pop() ?? ""
return /^(?:spacer|blank|pixel|1x1|clear|dot|transparent|spacer\.gif)\.(?:gif|png|jpe?g|svg|webp)$/i.test(
file
)
} catch {
return /(?:^|\/)(?:spacer|blank|pixel|1x1)\.(?:gif|png|jpe?g)/i.test(t)
}
}
function collectHeadResourceMarkup(
doc: Document,
resolveBaseHref?: string
): string {
const parts: string[] = []
const nodes = [
...doc.head.querySelectorAll("style, link[rel]"),
...doc.body.querySelectorAll("style, link[rel]"),
]
for (const node of nodes) {
const tag = node.tagName.toLowerCase()
if (tag === "style") {
parts.push(node.outerHTML)
node.remove()
continue
}
if (isStylesheetLink(node)) {
const href = node.getAttribute("href")
if (href && resolveBaseHref) {
node.setAttribute("href", resolveUrl(href, resolveBaseHref))
}
parts.push(node.outerHTML)
node.remove()
}
}
return parts.join("\n")
}
export function inferEmailResolveBaseHref(html: string): string | undefined {
const baseMatch = html.match(/<base[^>]+href=["']([^"']+)["']/i)
if (baseMatch?.[1]) {
try {
return new URL(baseMatch[1]).href
} catch {
return baseMatch[1]
}
}
const stylesheetMatch = html.match(
/<link[^>]+rel=["'][^"']*stylesheet[^"']*["'][^>]+href=["']([^"']+)["']/i
) ?? html.match(
/<link[^>]+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(
/(<link[^>]+href=)(["'])([^"']+)\2/gi,
(_m, prefix: string, quote: string, href: string) =>
`${prefix}${quote}${resolveUrl(href, baseHref)}${quote}`
)
.replace(
/(<style[^>]*>)([\s\S]*?)(<\/style>)/gi,
(_m, open: string, css: string, close: string) =>
`${open}${rewriteStyleRemoteUrls(css, baseHref)}${close}`
)
}
function isRemoteOrRelativeUrl(url: string): boolean {
const t = url.trim()
return (
REMOTE_URL.test(t) ||
PROTOCOL_RELATIVE.test(t) ||
(!t.startsWith("data:") &&
!t.startsWith("cid:") &&
!t.startsWith("mailto:") &&
!t.startsWith("#") &&
!t.startsWith("javascript:"))
)
}
/** Active images lazy-load et résout les URLs relatives / http. */
export function activateRemoteResourcesInHtml(
html: string,
options: { baseHref?: string } = {}
): string {
const { baseHref } = options
if (!html.trim() || typeof DOMParser === "undefined") return html
try {
const doc = new DOMParser().parseFromString(html, "text/html")
for (const img of doc.querySelectorAll("img")) {
const lazySrc = firstLazyImageSrc(img)
const src = img.getAttribute("src") ?? ""
const srcIsUseful =
!!src.trim() &&
!isPlaceholderImageSrc(src) &&
(src.startsWith("data:") ||
src.startsWith("cid:") ||
isRemoteOrRelativeUrl(src))
if (lazySrc && isRemoteOrRelativeUrl(lazySrc) && !srcIsUseful) {
img.setAttribute("src", resolveUrl(lazySrc, baseHref))
} else if (src.trim() && (src.startsWith("data:") || src.startsWith("cid:"))) {
/* keep inline / embedded sources */
} else if (src && isRemoteOrRelativeUrl(src)) {
img.setAttribute("src", resolveUrl(src, baseHref))
} else if (lazySrc && isRemoteOrRelativeUrl(lazySrc)) {
img.setAttribute("src", resolveUrl(lazySrc, baseHref))
}
const srcset = img.getAttribute("srcset")
if (srcset) img.setAttribute("srcset", rewriteUrlList(srcset, baseHref))
const dataSrcset = img.getAttribute("data-srcset")
if (dataSrcset && !img.getAttribute("srcset")) {
img.setAttribute("srcset", rewriteUrlList(dataSrcset, baseHref))
}
img.setAttribute("referrerpolicy", "no-referrer")
}
for (const el of doc.querySelectorAll("[background]")) {
const bg = el.getAttribute("background")
if (!bg || !isRemoteOrRelativeUrl(bg)) continue
const resolved = resolveUrl(bg, baseHref)
el.removeAttribute("background")
const existing = el.getAttribute("style")?.trim() ?? ""
const bgRule = `background-image:url(${resolved})`
const nextStyle = existing
? existing.endsWith(";")
? `${existing}${bgRule}`
: `${existing};${bgRule}`
: bgRule
el.setAttribute("style", nextStyle)
}
for (const el of doc.querySelectorAll("[style]")) {
const style = el.getAttribute("style")
if (style?.includes("url(")) {
el.setAttribute("style", rewriteStyleRemoteUrls(style, baseHref))
}
}
for (const source of doc.querySelectorAll("source[src], source[srcset]")) {
const src = source.getAttribute("src")
if (src && isRemoteOrRelativeUrl(src)) {
source.setAttribute("src", resolveUrl(src, baseHref))
}
const srcset = source.getAttribute("srcset")
if (srcset) source.setAttribute("srcset", rewriteUrlList(srcset, baseHref))
}
return doc.body.innerHTML
} catch {
return html
}
}
/** Injecte le mail dans le document iframe sans doc.write (préserve les <style>). */
export function injectEmailHtmlIntoDocument(
doc: Document,
options: {
csp: string
/** Explicit <base href> from the message, if any. */
documentBaseHref?: string
/** Inferred origin for relative assets when the message has no <base>. */
resolveBaseHref?: string
headMarkup: string
bodyHtml: string
wrapperCss: string
}
): void {
doc.open()
doc.write("<!DOCTYPE html><html><head></head><body></body></html>")
doc.close()
const head = doc.head
const body = doc.body
if (!head || !body) return
const charset = doc.createElement("meta")
charset.setAttribute("charset", "utf-8")
head.appendChild(charset)
const csp = doc.createElement("meta")
csp.setAttribute("http-equiv", "Content-Security-Policy")
csp.setAttribute("content", options.csp)
head.appendChild(csp)
const baseHref = options.documentBaseHref ?? options.resolveBaseHref
if (baseHref) {
const base = doc.createElement("base")
base.setAttribute("href", baseHref)
base.setAttribute("target", "_blank")
head.appendChild(base)
}
const wrapper = doc.createElement("style")
wrapper.setAttribute("data-ultimail-wrapper", "true")
wrapper.textContent = options.wrapperCss
head.appendChild(wrapper)
if (options.headMarkup.trim()) {
const tpl = doc.createElement("template")
tpl.innerHTML = options.headMarkup
head.append(...Array.from(tpl.content.childNodes))
}
body.replaceChildren()
if (options.bodyHtml.trim()) {
const tpl = doc.createElement("template")
tpl.innerHTML = options.bodyHtml
body.append(...Array.from(tpl.content.childNodes))
} else {
const empty = doc.createElement("p")
empty.textContent = "Ce message n'a pas de contenu affichable."
empty.setAttribute(
"style",
"color:#5f6368;font:14px sans-serif;margin:0;"
)
body.appendChild(empty)
}
}
/** Replace cid: image references with resolved blob or API URLs. */
export function rewriteCidUrlsInHtml(
html: string,
cidUrlMap: Record<string, string> | undefined
): string {
if (!html.trim() || !cidUrlMap || Object.keys(cidUrlMap).length === 0) {
return html
}
if (typeof DOMParser === "undefined") return html
const resolveCid = (value: string): string => {
const trimmed = value.trim()
if (!trimmed.toLowerCase().startsWith("cid:")) return trimmed
const key = trimmed.slice(4).trim()
return cidUrlMap[key] ?? cidUrlMap[trimmed] ?? trimmed
}
try {
const doc = new DOMParser().parseFromString(html, "text/html")
for (const img of doc.querySelectorAll("img")) {
for (const attr of ["src", "data-src", "data-original", "data-lazy-src"] as const) {
const value = img.getAttribute(attr)
if (value?.toLowerCase().startsWith("cid:")) {
img.setAttribute(attr, resolveCid(value))
}
}
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
}
}
export function prepareEmailHtmlForIframe(
html: string,
options: {
blockRemoteContent: boolean
isDark: boolean
senderEmail?: string
cidUrlMap?: Record<string, string>
}
): ParsedEmailHtml {
if (!html.trim()) {
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)
const bodyHtml = rewriteCidUrlsInHtml(
preprocessEmailHtmlForTheme(parsed.bodyHtml || html, options.isDark),
options.cidUrlMap
)
return {
headMarkup: "",
bodyHtml,
}
}
const parsed = parseEmailHtmlForIframe(html, resolveBaseHref)
let bodyHtml = stripHiddenEmailHtml(parsed.bodyHtml)
bodyHtml = activateRemoteResourcesInHtml(bodyHtml, {
baseHref: resolveBaseHref,
})
bodyHtml = rewriteCidUrlsInHtml(bodyHtml, options.cidUrlMap)
const headMarkup = rewriteHeadMarkupUrls(parsed.headMarkup, resolveBaseHref)
return {
headMarkup,
bodyHtml,
documentBaseHref: parsed.documentBaseHref,
resolveBaseHref,
}
}