503 lines
14 KiB
TypeScript
503 lines
14 KiB
TypeScript
/** 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, "&")
|
||
.replace(/"/g, """)
|
||
.replace(/</g, "<")
|
||
}
|
||
|
||
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,
|
||
}
|
||
}
|