File
Blob: src/client/lib/email-html/index.ts
| 1 | import { sanitizeStyleSheet } from "./css"; |
| 2 | import { BLOCKED_TAGS, createBlockedImagePlaceholder, sanitizeElementAttributes } from "./dom"; |
| 3 | import { serializeBodyAttributes, serializeStyleTag } from "./serialize"; |
| 4 | import { isAllowedInlineResourceUrl, isRemoteResourceUrl } from "./url-policy"; |
| 5 | |
| 6 | export interface PreparedEmailHtml { |
| 7 | headHtml: string; |
| 8 | html: string; |
| 9 | bodyAttributes: string; |
| 10 | blockedRemoteContent: boolean; |
| 11 | } |
| 12 | |
| 13 | export function prepareEmailHtml(rawHtml: string, allowRemoteContent: boolean): PreparedEmailHtml { |
| 14 | if (typeof DOMParser === "undefined") { |
| 15 | return { |
| 16 | headHtml: "", |
| 17 | html: "", |
| 18 | bodyAttributes: "", |
| 19 | blockedRemoteContent: false, |
| 20 | }; |
| 21 | } |
| 22 | |
| 23 | const parser = new DOMParser(); |
| 24 | const doc = parser.parseFromString(rawHtml, "text/html"); |
| 25 | let blockedRemoteContent = sanitizeElementAttributes(doc.body, allowRemoteContent); |
| 26 | const headStyles: string[] = []; |
| 27 | |
| 28 | for (const element of Array.from(doc.head.querySelectorAll("style"))) { |
| 29 | const sanitized = sanitizeStyleSheet(element.textContent ?? "", allowRemoteContent); |
| 30 | blockedRemoteContent = blockedRemoteContent || sanitized.blockedRemoteContent; |
| 31 | |
| 32 | if (sanitized.css) { |
| 33 | headStyles.push(serializeStyleTag(sanitized.css)); |
| 34 | } |
| 35 | } |
| 36 | |
| 37 | for (const element of Array.from(doc.body.querySelectorAll("*"))) { |
| 38 | const tagName = element.tagName.toLowerCase(); |
| 39 | |
| 40 | if (BLOCKED_TAGS.has(tagName)) { |
| 41 | element.remove(); |
| 42 | continue; |
| 43 | } |
| 44 | |
| 45 | if (tagName === "style") { |
| 46 | const sanitized = sanitizeStyleSheet(element.textContent ?? "", allowRemoteContent); |
| 47 | blockedRemoteContent = blockedRemoteContent || sanitized.blockedRemoteContent; |
| 48 | |
| 49 | if (!sanitized.css) { |
| 50 | element.remove(); |
| 51 | continue; |
| 52 | } |
| 53 | |
| 54 | element.textContent = sanitized.css; |
| 55 | continue; |
| 56 | } |
| 57 | |
| 58 | if (tagName === "img") { |
| 59 | const src = element.getAttribute("src"); |
| 60 | if (src && !isAllowedInlineResourceUrl(src)) { |
| 61 | if (!allowRemoteContent && isRemoteResourceUrl(src)) { |
| 62 | blockedRemoteContent = true; |
| 63 | element.replaceWith(createBlockedImagePlaceholder(doc, element.getAttribute("alt"))); |
| 64 | continue; |
| 65 | } |
| 66 | |
| 67 | if (!isRemoteResourceUrl(src)) { |
| 68 | element.removeAttribute("src"); |
| 69 | } |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | blockedRemoteContent = sanitizeElementAttributes(element, allowRemoteContent) || blockedRemoteContent; |
| 74 | |
| 75 | if (element.hasAttribute("href") || element.hasAttribute("xlink:href")) { |
| 76 | element.setAttribute("target", "_blank"); |
| 77 | element.setAttribute("rel", "noopener noreferrer nofollow"); |
| 78 | } |
| 79 | } |
| 80 | |
| 81 | return { |
| 82 | headHtml: headStyles.join(""), |
| 83 | html: doc.body.innerHTML, |
| 84 | bodyAttributes: serializeBodyAttributes(doc.body), |
| 85 | blockedRemoteContent, |
| 86 | }; |
| 87 | } |