1/** 2 * HTML sanitization + rendering-quality utilities for email and messaging content. 3 * 4 * Three strategies (don't conflate them): 5 * - sanitizeEmailHtml: SECURITY â strip dangerous tags / attrs / URIs 6 * - cleanupEmailRenderingHtml: QUALITY â fix nested tables, cid: images, fixed 7 * widths, oversized images. Ported from legacy 8 * messaging-helpers.js#fixReplyBodyHTML. 9 * - sanitizeMessageHtml: Allowlist for chat (Teams) â stricter than email 10 * 11 * For an inbound email body the pipeline is sanitizeEmail â cleanupEmail â render. 12 * One does security, the other does presentation. Don't merge them â they have 13 * different reasons to change and different fail modes. 14 */ 15 16// Browsers discard ASCII whitespace/control characters when recognizing a URL 17// scheme. Check that normalized candidate without rewriting legitimate URLs. 18function isUnsafeHtmlUri(value) { 19 const candidate = String(value ?? '').replace(/[\u0000-\u0020\u007f-\u009f]/g, ''); 20 return /^(javascript|vbscript|data):/i.test(candidate); 21} 22 23/** 24 * Sanitize HTML email body â strip dangerous elements and attributes. 25 * Used for Gmail and Outlook mail content before rendering. 26 * @param {string} html - raw HTML from email provider 27 * @returns {string} sanitized HTML safe for innerHTML 28 */ 29export function sanitizeEmailHtml(html) { 30 if (!html) return ''; 31 32 // Extract <body> content if full HTML document 33 const bodyMatch = html.match(/<body[^>]*>([\s\S]*)<\/body/i); 34 let content = bodyMatch ? bodyMatch[1] : html; 35 36 const temp = document.createElement('div'); 37 temp.innerHTML = content; 38 39 // Remove dangerous elements 40 temp.querySelectorAll('script, style, link, iframe, object, embed, form, base, meta, svg, math, template') 41 .forEach(el => el.remove()); 42 43 // Remove event handlers and dangerous URIs 44 temp.querySelectorAll('*').forEach(el => { 45 Array.from(el.attributes).forEach(attr => { 46 if (attr.name.startsWith('on') || attr.name === 'srcdoc') { 47 el.removeAttribute(attr.name); 48 } 49 if (['href', 'src', 'xlink:href', 'action', 'formaction', 'background', 'poster'].includes(attr.name) && 50 isUnsafeHtmlUri(attr.value)) { 51 el.removeAttribute(attr.name); 52 } 53 }); 54 }); 55 56 return temp.innerHTML; 57} 58 59/** 60 * Strip HTML from a snippet of text â for safe preview/snippet rendering with 61 * collapsed whitespace. Ported verbatim from legacy messaging-helpers.js so 62 * truncated bodies (BodyShort, snippet, bodyPreview) can't smuggle broken or 63 * malicious HTML into the list cards. 64 * 65 * Handles three categories that escapeHtml misses: 66 * - Complete tags (removed) 67 * - Orphaned opening tag at end of truncated string (removed) 68 * - Orphaned closing > at start (removed when not paired) 69 * 70 * Then decodes entities via a textarea and collapses whitespace. 71 * 72 * @param {string|null|undefined} text 73 * @returns {string} plain-text snippet, whitespace-collapsed 74 */ 75export function stripHtml(text) { 76 if (text === null || text === undefined) return ''; 77 let s = String(text); 78 s = s.replace(/<!--[\s\S]*?-->/g, ''); // comments 79 s = s.replace(/<[^>]*>/g, ''); // complete tags 80 s = s.replace(/<\/?[a-zA-Z][^<]*$/g, ''); // orphaned opening tag at end 81 s = s.replace(/^[^>]*>/, (m) => (m.indexOf('<') === -1 ? '' : m)); 82 const ta = document.createElement('textarea'); 83 ta.innerHTML = s; 84 return ta.value.replace(/\s+/g, ' ').trim(); 85} 86 87/** 88 * RENDERING-QUALITY cleanup for an HTML email body. Distinct from 89 * sanitizeEmailHtml (security) â this fixes the rendering problems that come 90 * from wild-west outbound email HTML: nested tbodies, fixed-pixel widths, 91 * orphaned cid: image references, oversized inline images, non-wrapping <pre>. 92 * 93 * Run AFTER sanitizeEmailHtml â once it's safe to insert, we make it look 94 * decent. Ported from legacy messaging-helpers.js#fixReplyBodyHTML. 95 * 96 * Idempotent â re-running it on already-cleaned HTML is a no-op. 97 * 98 * @param {string} html - sanitized HTML body 99 * @returns {string} cleaned HTML, ready to insert 100 */ 101export function cleanupEmailRenderingHtml(html) { 102 if (!html) return ''; 103 104 try { 105 const parser = new DOMParser(); 106 const doc = parser.parseFromString(html, 'text/html'); 107 108 // 1. Merge multiple <tbody> children inside the same table â invalid HTML 109 // that Outlook etc. produce; browsers render the rows but layout breaks. 110 doc.querySelectorAll('table').forEach((table) => { 111 const tbodies = Array.from(table.children).filter((c) => c.tagName === 'TBODY'); 112 if (tbodies.length > 1) { 113 const primary = tbodies[0]; 114 for (let i = 1; i < tbodies.length; i++) { 115 const extra = tbodies[i]; 116 Array.from(extra.querySelectorAll('tr')).forEac
116h((row) => primary.appendChild(row)); 117 extra.remove(); 118 } 119 } 120 }); 121 122 // 2. Force tables to fit the column they render in. 123 doc.querySelectorAll('table').forEach((table) => { 124 table.style.width = '100%'; 125 table.style.maxWidth = '100%'; 126 table.style.tableLayout = 'fixed'; 127 }); 128 129 // 3. Strip cid: image refs â these point at MIME parts we can't resolve 130 // client-side. Better to show an empty box than a broken-image icon. 131 doc.querySelectorAll('img[src^="cid:"]').forEach((img) => { 132 img.removeAttribute('src'); 133 }); 134 135 // 4. Make <pre> wrap so signature monospace blocks don't blow the layout. 136 doc.querySelectorAll('pre').forEach((pre) => { 137 pre.style.whiteSpace = 'pre-wrap'; 138 pre.style.wordBreak = 'break-all'; 139 }); 140 141 // 5. Image sizing â convert width/height attrs to inline styles, intelligently: 142 // - Preserve specific pixel HEIGHT (logos sized intentionally). 143 // - Specific pixel WIDTH â cap with max-width:100% so it scales down. 144 // - No specific width â just max-width:100%, leave height auto. 145 doc.querySelectorAll('img').forEach((img) => { 146 const widthAttr = img.getAttribute('width'); 147 const heightAttr = img.getAttribute('height'); 148 149 if (heightAttr && !heightAttr.includes('%')) { 150 const height = parseInt(heightAttr, 10); 151 if (!isNaN(height) && height > 0) { 152 img.style.height = `${height}px`; 153 img.removeAttribute('height'); 154 } 155 } 156 157 if (widthAttr && !widthAttr.includes('%')) { 158 const width = parseInt(widthAttr, 10); 159 if (!isNaN(width) && width > 0) { 160 img.style.width = `${width}px`; 161 img.style.maxWidth = '100%'; 162 if (!heightAttr) img.style.height = 'auto'; 163 img.removeAttribute('width'); 164 img.removeAttribute('height'); 165 } 166 } else { 167 img.style.maxWidth = '100%'; 168 if (!heightAttr) img.style.height = 'auto'; 169 img.style.width = ''; 170 } 171 }); 172
173 // 6. Cap fixed-pixel widths on non-image block elements. These cause 174 // horizontal scroll on phones when an email was authored for desktop. 175 doc.querySelectorAll('table, td, div, p').forEach((el) => { 176 const widthAttr = el.getAttribute('width'); 177 if (widthAttr && !widthAttr.includes('%')) { 178 el.style.maxWidth = '100%'; 179 el.style.width = 'auto'; 180 el.removeAttribute('width'); 181 } 182 if (el.style.maxWidth && el.style.maxWidth.endsWith('px')) { 183 el.style.maxWidth = '100%'; 184 } 185 }); 186 187 return doc.body.innerHTML; 188 } catch (err) { 189 console.error('cleanupEmailRenderingHtml failed:', err); 190 return html; 191 } 192} 193 194/** 195 * Render stored email HTML through security sanitization before presentation cleanup. 196 * The returned markup is suitable for the intentional JsRender HTML body slot. 197 */ 198export function renderEmailHtml(html) { 199 return cleanupEmailRenderingHtml(sanitizeEmailHtml(html)); 200} 201 202/** 203 * Sanitize HTML from messaging platforms (Teams, etc.) â allowlist approach. 204 * Only permits a safe subset of tags; everything else becomes text. 205 * @param {string} html - raw HTML from messaging provider 206 * @returns {string} sanitized HTML safe for innerHTML 207 */ 208export function sanitizeMessageHtml(html) { 209 if (!html) return ''; 210 211 // Inert document: no browsing context, so a disallowed <img onerror> never loads or fires while 212 // it is being sanitized (a live-document div did both) â CA-660. 213 const doc = document.implementation.createHTMLDocument(''); 214 const temp = doc.body; 215 temp.innerHTML = html; 216 217 const ALLOWED = new Set([ 218 'B', 'I', 'U', 'A', 'BR', 'P', 'UL', 'OL', 'LI', 219 'H1', 'H2', 'H3', 'PRE', 'CODE', 'BLOCKQUOTE', 220 'STRONG', 'EM', 'SPAN', 'DIV' 221 ]); 222 223 function clean(node) { 224 const children = [...node.childNodes]; 225 for (const child of children) { 226 if (child.nodeType === Node.TEXT_NODE) continue; 227 if (child.nodeType === Node.ELEMENT_NODE) { 228 if (!ALLOWED.has(child.tagName)) { 229 const text = doc.createTextNode(child.textContent); 230 node.replaceChild(text, child); 231 } else { 232 // Strip dangerous attributes 233 for (const attr of [...child.attributes]) { 234 if (attr.name.startsWith('on') || attr.name === 'style') { 235 child.removeAttribute(attr.name); 236 } 237 } 238 // Keep ordinary links under the same URI rule as email. 239 if (child.tagName === 'A') { 240 const href = child.getAttribute('href') || ''; 241 if (isUnsafeHtmlUri(href)) { 242 child.removeAttribute('href'); 243 } 244 child.setAttribute('target', '_blank'); 245 child.setAttribute('rel', 'noopener noreferrer'); 246 } 247 clean(child); 248 } 249 } else { 250 node.removeChild(child); 251 } 252 } 253 } 254 255 clean(temp); 256 return temp.innerHTML; 257} 258 259/** 260 * Legacy-era stored HTML (the RFI Suggestions suffix `<br/><br/><i>â¦</i>`) through the allowlist above, 261 * never raw â utils/rfi-suggestions.js feeds it only that suffix (CA-660). 262 */ 263export function renderLegacyHtml(value) { 264 return sanitizeMessageHtml(value == null ? '' : String(value)); 265}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.