1// static/js/emailLibrary/utils.js 2// 3// Pure helpers extracted from emailLibrary.js. No DOM state, no fetch, 4// no shared mutable references â safe to import anywhere. 5 6// ââ Talon-inspired multilingual quote-detection regexes âââââââââââ 7// Borrowed (loosely) from Mailgun's `talon` library. These are partial 8// regex source strings â combined with surrounding patterns by callers. 9// Multilingual on purpose: a typed "wrote:" line is locale-bound, and 10// people forward / reply across language settings all the time. 11 12export const _TALON_WROTE = '(?:wrote|écrit|escribió|scrisse|schrieb|skrev|schreef|napisaÅ|напиÑал|napsal|напиÑа|ÎγÏαÏε|katselivat|napisao|напиÑав|napisaÅa|napisali|hat geschrieben|kirjoitti|напиÑала|escreveu|napisao|напиÑа|напиÑала)'; 13 14export const _TALON_FROM = '(?:From|FrÃ¥n|Von|De|Da|ÐÑ|Od|Van|å·®åºäºº|å件人|å¯ä»¶äºº|Ut|Frá|Lähettäjä|Avsender|PoÅ¡iljatelj|Ðд|ÐÑд|Posiljatelj|FrÃ¥)'; 15export const _TALON_SENT = '(?:Sent|Skickat|Gesendet|Envoy[ée]|Inviato|Enviado|Verzonden|ÐÑпÑавлено|WysÅane|Date|éä¿¡æ¥æ|åéæ¶é´|å¯ä»¶æ¥æ|Sendt|Lähetetty|Tarih|Datum|Data|Datum)'; 16export const _TALON_SUBJ = '(?:Subject|Ãmne|Betreff|Objet|Oggetto|Asunto|Onderwerp|Тема|Temat|ä»¶å|主é¢|主æ¨|Emne|Aihe|Onderwerp|Konu)'; 17export const _TALON_TO = '(?:To|Till|An|Ã|A|Voor|Para|Naar|ÐомÑ|Do|å®å |æ¶ä»¶äºº|Emri|Komu)'; 18export const _TALON_ORIG_RE = /(?:^|\n)[\s>]*[-_=]{3,}\s*(?:Original\s+Message|Forwarded\s+message|Ursprüngliche\s+Nachricht|Mensaje\s+original|Messaggio\s+originale|Message\s+d['â]origine|Oorspronkelijk\s+bericht|Original\s+meddelande|Vor[ ]asal[a]\s+meddelande|åæ|åå§é®ä»¶|転é)\s*[-_=]{3,}/i; 19 20// Minimum plain-text length of a "signature" before we bother folding it. 21// Short closings ("Cheers, John") stay inline â folding them would add 22// a click for two bytes of saving. 23export const _SIG_BLOAT_MIN_CHARS = 200; 24 25// HTML-escape a string by round-tripping through a detached div. Cheap 26// and correct (handles all the entities that matter for innerHTML). 27export function _esc(text) { 28 const div = document.createElement('div'); 29 div.textContent = text || ''; 30 return div.innerHTML; 31} 32 33function _attrEsc(text) { 34 return String(text ?? '') 35 .replace(/"/g, '"') 36 .replace(/'/g, ''') 37 .replace(/</g, '<') 38 .replace(/>/g, '>') 39 .replace(/`/g, '`'); 40} 41 42function _compactUrlSchemeValue(value) { 43 return String(value || '').replace(/[\u0000-\u0020\u007f-\u009f]+/g, '').toLowerCase(); 44} 45 46function _isDangerousUrl(value) { 47 const compact = _compactUrlSchemeValue(value); 48 return compact.startsWith('javascript:') || compact.startsWith('vbscript:') || compact.startsWith('data:'); 49} 50 51function _isDangerousSrcset(value) { 52 return String(value || '').split(',').some(candidate => _isDangerousUrl(candidate)); 53} 54 55// Escape + linkify URLs and email addresses. Returns innerHTML-safe markup. 56export function _escLinkify(text) { 57 const escaped = _esc(text); 58 // URLs: http(s)://... or www.... 59 const urlRe = /\b((?:https?:\/\/|www\.)[^\s<>"']+[^\s<>"'.,;:!?)\]])/g; 60 const mailRe = /\b([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})\b/g; 61 return escaped 62 .replace(urlRe, (m) => { 63 const href = m.startsWith('www.') ? `https://${m}` : m; 64 return `<a href="${_attrEsc(href)}" target="_blank" rel="noopener noreferrer">${m}</a>`; 65 }) 66 .replace(mailRe, (m) => `<a href="${_attrEsc(`mailto:${m}`)}">${m}</a>`); 67} 68 69// Pull display name out of "Name <email@x>"; fallback to local-part of 70// the email; final fallback to the input string. 71export function _extractName(addr) { 72 const m = addr.match(/^"?([^"<]+?)"?\s*<([^>]+)>\s*$/); 73 if (m) return m[1].trim(); 74 const localPart = addr.split('@')[0]; 75 return localPart || addr; 76} 77 78// Parse the "Author <email> · Date" metadata string emitted by the 79// server-side thread parser. 80export function _parseTurnMeta(meta) { 81 if (!meta) return { author: '', email: '', date: '' }; 82 const m = String(meta); 83 const eMatch = m.match(/<([^<>\s]+@[^<>\s]+)>/) || 84 m.match(/\b([\w.+-]+@[\w.-]+\.[A-Za-z]{2,})\b/); 85 const email = eMatch ? eMatch[1].toLowerCase().trim() : ''; 86 const parts = m.split(/\s+[·â¢]\s+/); 87 let author = '', date = ''; 88 if (parts.length >= 2) { 89 author = parts[0].replace(/<[^>]+>/g, '').trim(); 90 date = parts.slice(1).join(' · ').trim(); 91 } else { 92 author = m.replace(/<[^>]+>/g, '').trim(); 93 } 94 return { author, email, date }; 95} 96 97// Short, locale-aware display string for a chat-bubble timestamp. 98// Returns '' for invalid / empty input. 99export function _formatBubbleDate(iso) { 100 if (!iso) return ''; 101 const d = new Date(iso); 102 if (!d || isNaN(d.getTime())) return ''; 103 try {
104 return d.toLocaleString(undefined, { 105 month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', 106 }); 107 } catch (_) { return ''; } 108} 109 110// Format a raw "to" address string ("Foo <[email protected]>, [email protected]") into a 111// short, readable list â display names when present, just the local part 112// of the email otherwise, and ", +N" once there are more than 2 recipients. 113export function _formatRecipients(raw) { 114 if (!raw) return ''; 115 const addrs = String(raw).split(',').map(s => s.trim()).filter(Boolean); 116 if (!addrs.length) return ''; 117 const friendly = addrs.map(a => { 118 const m = a.match(/^\s*"?([^"<]+?)"?\s*<[^>]+>\s*$/); 119 if (m && m[1].trim()) return m[1].trim(); 120 const em = a.replace(/[<>]/g, '').trim(); 121 return em.split('@')[0] || em; 122 }); 123 if (friendly.length === 1) return friendly[0]; 124 if (friendly.length === 2) return friendly.join(', '); 125 return friendly.slice(0, 2).join(', ') + ' +' + (friendly.length - 2); 126} 127 128// Deterministic per-sender colour. Same hashing as 129// emailInbox.js#_senderColor so a sender's avatar / name colour matches 130// across the list view and the bubble reader. 131export function _senderColor(name) { 132 if (!name) return 'hsl(220, 55%, 65%)'; 133 const key = String(name).toLowerCase(); 134 let hash = 0; 135 for (let i = 0; i < key.length; i++) { 136 hash = ((hash << 5) - hash + key.charCodeAt(i)) | 0; 137 } 138 const hue = ((hash % 360) + 360) % 360; 139 return `hsl(${hue}, 55%, 65%)`; 140} 141 142// 1- or 2-letter initials for an avatar bubble. Unicode-friendly. 143export function _initials(s) { 144 if (!s) return '?'; 145 const clean = String(s).replace(/<[^>]+>/g, '').replace(/[^\p{L}\s]/gu, ' ').trim(); 146 const parts = clean.split(/\s+/).filter(Boolean); 147 if (!parts.length) return '?'; 148 const first = parts[0][0] || ''; 149 const last = parts.length > 1 ? parts[parts.length - 1][0] : ''; 150 return (first + last).toUpperCase(); 151} 152 153// HTML sanitizer for rendering remote email bodies. Strips script/iframe/ 154// form/style/etc., kills `on*` handlers, blocks `javascript:`/`vbscript:`/ 155// `data:` URLs on every known URL attribute, scrubs inline colour/font/ 156// position styles so the theme can take over, and wraps highlight-bearing 157// inline tags in <mark> so they render legibly across themes. 158function _sanitizeHtmlOnce(html) { 159 const doc = new DOMParser().parseFromString(html, 'text/html'); 160 doc.querySelectorAll( 161 'script, iframe, object, embed, form, style, link, ' + 162 'svg, math, base, meta, noscript, frame, frameset, applet, portal' 163 ).forEach(el => el.remove()); 164 165 const URL_ATTRS = ['href', 'src', 'xlink:href', 'srcset', 'action', 'formaction', 'background', 'poster', 'data']; 166 167 const STRIP_CSS_PROPS = ['color', 'background', 'background-color', 168 'font-family', 'font', '-webkit-text-fill-color', 169 'position', 'z-index']; 170 const HIGHLIGHT_INLINE_TAGS = new Set(['SPAN', 'FONT', 'EM', 'B', 'I', 171 'STRONG', 'SMALL', 'U']); 172 const HAS_BG_COLOR = /background(?:-color)?\s*:\s*(?!\s*(?:transparent|none|inherit|initial)\b)[^;]+/i; 173 const _markedForHighlight = []; 174 175 doc.querySelectorAll('*').forEach(el => { 176 for (const attr of [...el.attributes]) { 177 const name = attr.name.toLowerCase(); 178 if (name.startsWith('on')) { el.removeAttribute(attr.name); continue; } 179 if (name === 'srcdoc') { el.removeAttribute(attr.name); continue; } 180 if (URL_ATTRS.includes(name) && (name === 'srcset' ? _isDangerousSrcset(attr.value) : _isDangerousUrl(attr.value))) { 181 el.removeAttribute(attr.name); 182 continue; 183 } 184 } 185 el.removeAttribute('color'); 186 const bgcolor = el.getAttribute('bgcolor'); 187 el.removeAttribute('bgcolor'); 188 el.removeAttribute('face'); 189 const style = el.getAttribute('style'); 190 const hadHighlight = 191 HIGHLIGHT_INLINE_TAGS.has(el.tagName) && 192 ((style && HAS_BG_COLOR.test(style)) || (bgcolor && bgcolor !== 'transparent')); 193 if (hadHighlight) _markedForHighlight.push(el); 194 if (style) { 195 const kept = style.split(';').map(s => s.trim()).filter(decl => { 196 if (!decl) return false;
197 const lower = _compactUrlSchemeValue(decl); 198 if (lower.includes('javascript:') || lower.includes('vbscript:') || lower.includes('data:') || lower.includes('expression(')) return false; 199 const prop = decl.split(':', 1)[0].trim().toLowerCase(); 200 return !STRIP_CSS_PROPS.includes(prop); 201 }); 202 if (kept.length) el.setAttribute('style', kept.join('; ')); 203 else el.removeAttribute('style'); 204 } 205 if (el.tagName === 'A') { 206 el.setAttribute('target', '_blank'); 207 el.setAttribute('rel', 'noopener noreferrer'); 208 } 209 }); 210 211 _markedForHighlight.forEach(el => { 212 if (el.tagName === 'MARK' || !el.firstChild) return; 213 const mark = doc.createElement('mark'); 214 while (el.firstChild) mark.appendChild(el.firstChild); 215 el.appendChild(mark); 216 }); 217 218 return doc.body.innerHTML; 219} 220 221export function _sanitizeHtml(html) { 222 let out = String(html ?? ''); 223 for (let i = 0; i < 4; i++) { 224 const next = _sanitizeHtmlOnce(out); 225 if (next === out) break; 226 out = next; 227 } 228 return out; 229}
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.