Some checks are pending
E2E / Playwright e2e (push) Waiting to run
- Introduced turbopack alias for canvas in next.config.mjs. - Updated package.json scripts for development and branding tasks. - Added new dependencies for Tiptap extensions. - Implemented new demo layouts for agenda, contacts, drive, and mail applications. - Enhanced globals.css for improved theming and splash screen animations. - Added OAuth callback handling for drive mounts. - Updated layout components to integrate new demo shells and improve structure.
678 lines
20 KiB
TypeScript
678 lines
20 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 { 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 <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}`
|
||
)
|
||
}
|
||
|
||
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 <style>; drop external stylesheets when remote content is blocked. */
|
||
export function filterHeadMarkupForBlockedRemote(headMarkup: string): string {
|
||
if (!headMarkup.trim() || typeof DOMParser === "undefined") {
|
||
return headMarkup.replace(/<link\b[^>]*>/gi, "")
|
||
}
|
||
try {
|
||
const doc = new DOMParser().parseFromString(
|
||
`<div id="head-root">${headMarkup}</div>`,
|
||
"text/html"
|
||
)
|
||
const root = doc.getElementById("head-root")
|
||
if (!root) return headMarkup
|
||
|
||
for (const link of root.querySelectorAll("link")) {
|
||
if (isStylesheetLink(link)) link.remove()
|
||
}
|
||
for (const style of root.querySelectorAll("style")) {
|
||
const css = style.textContent ?? ""
|
||
if (css) style.textContent = neutralizeRemoteUrlsInCss(css)
|
||
}
|
||
return root.innerHTML.trim()
|
||
} catch {
|
||
return headMarkup.replace(/<link\b[^>]*>/gi, "")
|
||
}
|
||
}
|
||
|
||
function clearRemoteImageAttrs(img: Element): void {
|
||
for (const name of [
|
||
"src",
|
||
"srcset",
|
||
"sizes",
|
||
"background",
|
||
...LAZY_IMG_SRC_ATTRS,
|
||
] as const) {
|
||
img.removeAttribute(name)
|
||
}
|
||
}
|
||
|
||
/** Newsletter HTML: keep layout/text; block remote images and external CSS. */
|
||
export function stripRemoteResourcesForPreview(html: string): string {
|
||
if (!html.trim() || typeof DOMParser === "undefined") return html
|
||
|
||
try {
|
||
const doc = new DOMParser().parseFromString(html, "text/html")
|
||
|
||
for (const link of doc.querySelectorAll("link")) {
|
||
if (isStylesheetLink(link)) link.remove()
|
||
}
|
||
|
||
for (const el of doc.querySelectorAll("[style]")) {
|
||
const style = el.getAttribute("style")
|
||
if (style && REMOTE_CSS_URL.test(style)) {
|
||
el.setAttribute("style", neutralizeRemoteUrlsInCss(style))
|
||
}
|
||
}
|
||
|
||
for (const el of doc.querySelectorAll("[background]")) {
|
||
const bg = el.getAttribute("background")?.trim() ?? ""
|
||
if (bg && isRemoteOrRelativeUrl(bg)) el.removeAttribute("background")
|
||
}
|
||
|
||
for (const img of doc.querySelectorAll("img")) {
|
||
const src = img.getAttribute("src") ?? ""
|
||
const lazy = firstLazyImageSrc(img)
|
||
const remote =
|
||
(src.trim() && isRemoteOrRelativeUrl(src) && !src.startsWith("cid:")) ||
|
||
Boolean(lazy && isRemoteOrRelativeUrl(lazy))
|
||
|
||
if (!remote) continue
|
||
|
||
const alt = img.getAttribute("alt")?.trim()
|
||
clearRemoteImageAttrs(img)
|
||
img.setAttribute(
|
||
"style",
|
||
`${img.getAttribute("style") ?? ""};max-width:100%;height:auto;object-fit:contain;background:#e8eaed;border-radius:4px;`.replace(
|
||
/^;/,
|
||
""
|
||
)
|
||
)
|
||
|
||
if (alt) {
|
||
const caption = doc.createElement("p")
|
||
caption.setAttribute(
|
||
"style",
|
||
"margin:4px 0 12px;font:13px/1.4 sans-serif;color:#5f6368"
|
||
)
|
||
caption.textContent = alt
|
||
img.insertAdjacentElement("afterend", caption)
|
||
}
|
||
}
|
||
|
||
return doc.body.innerHTML
|
||
} catch {
|
||
return html
|
||
}
|
||
}
|
||
|
||
export function htmlHasMeaningfulVisibleText(html: string, minChars = 24): boolean {
|
||
if (!html.trim() || typeof DOMParser === "undefined") {
|
||
return html.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim().length >= minChars
|
||
}
|
||
try {
|
||
const doc = new DOMParser().parseFromString(html, "text/html")
|
||
const text = (doc.body?.textContent ?? "").replace(/\s+/g, " ").trim()
|
||
return text.length >= minChars
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
export function plainTextFallbackHtml(text: string): string {
|
||
const trimmed = text.trim()
|
||
if (!trimmed) return ""
|
||
const escaped = trimmed
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
return (
|
||
`<div class="ultimail-plain-fallback" style="margin:0 0 16px;padding:12px 14px;` +
|
||
`border-radius:8px;background:var(--muted,#f1f3f4);color:inherit">` +
|
||
`<pre style="white-space:pre-wrap;font:14px/1.5 inherit;margin:0">` +
|
||
`${escaped}</pre></div>`
|
||
)
|
||
}
|
||
|
||
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
|
||
}
|
||
}
|
||
|
||
const EMAIL_PREVIEW_GEIST_FONT_LINKS =
|
||
'<link rel="preconnect" href="https://fonts.googleapis.com">' +
|
||
'<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>' +
|
||
'<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&display=swap">'
|
||
|
||
/** Full HTML document for iframe srcDoc (scripts stripped; no live DOM injection). */
|
||
export function buildEmailPreviewSrcdoc(
|
||
parsed: ParsedEmailHtml,
|
||
options: {
|
||
csp: string
|
||
wrapperCss: string
|
||
plainTextFallback?: string
|
||
/** CSS injecté en fin de body (après styles expéditeur inline). */
|
||
bodyTailCss?: string
|
||
/** Charge Geist (CSP doit autoriser fonts.googleapis.com). */
|
||
loadAppFont?: boolean
|
||
}
|
||
): string {
|
||
const baseHref = parsed.documentBaseHref ?? parsed.resolveBaseHref
|
||
// headMarkup is CSS (<style> blocks) — only strip script-like tags via regex
|
||
const headMarkup = (parsed.headMarkup ?? "")
|
||
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, "")
|
||
.replace(/<script\b[^>]*\/>/gi, "")
|
||
let bodyHtml = stripExecutableEmailHtml(parsed.bodyHtml)
|
||
|
||
if (!bodyHtml.trim() && options.plainTextFallback?.trim()) {
|
||
bodyHtml = plainTextFallbackHtml(options.plainTextFallback)
|
||
}
|
||
if (!bodyHtml.trim()) {
|
||
bodyHtml =
|
||
'<p style="color:#3c4043;font:14px ui-sans-serif,system-ui,sans-serif;margin:0">Ce message n\'a pas de contenu affichable.</p>'
|
||
}
|
||
|
||
const baseTag = baseHref
|
||
? `<base href="${escapeHtmlAttr(baseHref)}" target="_blank">`
|
||
: ""
|
||
|
||
const bodyTailStyle = options.bodyTailCss
|
||
? `<style data-ultimail-tail="true">${options.bodyTailCss}</style>`
|
||
: ""
|
||
|
||
const fontLinks = options.loadAppFont ? EMAIL_PREVIEW_GEIST_FONT_LINKS : ""
|
||
|
||
return (
|
||
"<!DOCTYPE html><html><head>" +
|
||
'<meta charset="utf-8">' +
|
||
`<meta http-equiv="Content-Security-Policy" content="${escapeHtmlAttr(options.csp)}">` +
|
||
baseTag +
|
||
fontLinks +
|
||
`<style data-ultimail-wrapper="true">${options.wrapperCss}</style>` +
|
||
headMarkup +
|
||
"</head><body>" +
|
||
`<div data-ultimail-measure-root>${bodyHtml}</div>` +
|
||
bodyTailStyle +
|
||
"</body></html>"
|
||
)
|
||
}
|
||
|
||
/** Replace cid: references with blob URLs, or a data placeholder until blobs load. */
|
||
export function rewriteCidUrlsInHtml(
|
||
html: string,
|
||
cidUrlMap: Record<string, string> | 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<string, string>
|
||
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,
|
||
}
|
||
}
|