import { createElement } from 'Common/Globals'; import { forEachObjectEntry, pInt } from 'Common/Utils'; import { SettingsUserStore } from 'Stores/User/Settings'; const tpl = createElement('template'), htmlre = /[&<>"']/g, httpre = /^(https?:)?\/\//i, htmlmap = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }, blockquoteSwitcher = () => { SettingsUserStore.collapseBlockquotes() && // tpl.content.querySelectorAll('blockquote').forEach(node => { [...tpl.content.querySelectorAll('blockquote')].reverse().forEach(node => { const el = createElement('details', {class:'sm-bq-switcher'}); el.innerHTML = '•••'; node.replaceWith(el); el.append(node); }); }, replaceWithChildren = node => node.replaceWith(...[...node.childNodes]), url = /(^|\s|\n|\/?>)(https?:\/\/[-A-Z0-9+&#/%?=()~_|!:,.;]*[-A-Z0-9+&#/%=~()_|])/gi, // eslint-disable-next-line max-len email = /(^|\s|\n|\/?>)((?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x21\x23-\x5b\x5d-\x7f]|\\[\x21\x23-\x5b\x5d-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\x21-\x5a\x53-\x7f]|\\[\x21\x23-\x5b\x5d-\x7f])+)\]))/gi, // rfc3966 tel = /(^|\s|\n|\/?>)(tel:(\+[0-9().-]+|[0-9*#().-]+(;phone-context=\+[0-9+().-]+)?))/g, // Strip tracking /** TODO: implement other url strippers like from * https://www.bleepingcomputer.com/news/security/new-firefox-privacy-feature-strips-urls-of-tracking-parameters/ * https://github.com/newhouse/url-tracking-stripper * https://github.com/svenjacobs/leon * https://maxchadwick.xyz/tracking-query-params-registry/ * https://github.com/M66B/FairEmail/blob/master/app/src/main/java/eu/faircode/email/UriHelper.java */ // eslint-disable-next-line max-len stripParams = /^(utm_|ec_|fbclid|mc_eid|mkt_tok|_hsenc|vero_id|oly_enc_id|oly_anon_id|__s|Referrer|mailing|elq|bch|trc|ref|correlation_id|pd_|pf_|email_hash)/i, urlGetParam = (url, name) => new URL(url).searchParams.get(name) || url, base64Url = data => atob(data.replace(/_/g,'/').replace(/-/g,'+')), decode = decodeURIComponent, stripTracking = url => { try { let nurl = url // Copernica .replace(/^.+\/(https%253A[^/?&]+).*$/i, (...m) => decode(decode(m[1]))) .replace(/tracking\.(printabout\.nl[^?]+)\?.*/i, (...m) => m[1]) .replace(/(zalando\.nl[^?]+)\?.*/i, (...m) => m[1]) .replace(/^.+(awstrack\.me|redditmail\.com)\/.+(https:%2F%2F[^/]+).*/i, (...m) => decode(m[2])) .replace(/^.+(www\.google|safelinks\.protection\.outlook\.com|mailchimp\.com).+url=.+$/i, () => urlGetParam(url, 'url')) .replace(/^.+click\.godaddy\.com.+$/i, () => urlGetParam(url, 'redir')) .replace(/^.+delivery-status\.com.+$/i, () => urlGetParam(url, 'fb')) .replace(/^.+go\.dhlparcel\.nl.+\/([A-Za-z0-9_-]+)$/i, (...m) => base64Url(m[1])) .replace(/^(.+mopinion\.com.+)\?.*$/i, (...m) => m[1]) .replace(/^.+sellercentral\.amazon\.com\/nms\/redirect.+$/i, () => base64Url(urlGetParam(url, 'u'))) .replace(/^.+amazon\.com\/gp\/r\.html.+$/i, () => urlGetParam(url, 'U')) // Mandrill .replace(/^.+\/track\/click\/.+\?p=.+$/i, () => { let d = urlGetParam(url, 'p'); try { d = JSON.parse(base64Url(d)); if (d?.p) { d = JSON.parse(d.p); } } catch (e) { console.error(e); } return d?.url || url; }) // Remove invalid URL characters .replace(/[\s<>]+/gi, ''); nurl = new URL(nurl); let s = nurl.searchParams; [...s.keys()].forEach(key => stripParams.test(key) && s.delete(key)); return nurl.toString(); } catch (e) { console.dir({ error:e, url:url }); } return url; }, /* Parses given css string, and returns css object keys as selectors and values are css rules eliminates all css comments before parsing @param source css string to be parsed @return object css */ parseCSS = source => { const css = []; css.toString = () => css.reduce( (ret, tmp) => ret + tmp.selector + ' {\n' + (tmp.type === 'media' ? tmp.subStyles.toString() : tmp.rules) + '}\n' , '' ); /** * Given css array, parses it and then for every selector, * prepends namespace to prevent css collision issues */ css.applyNamespace = namespace => css.forEach(obj => { if (obj.type === 'media') { obj.subStyles.applyNamespace(namespace); } else { obj.selector = obj.selector.split(',').map(selector => (namespace + ' .mail-body ' + selector.replace(/\./g, '.msg-')) .replace(/\sbody/gi, '') ).join(','); } }); if (source) { source = source // strip comments .replace(/\/\*[\s\S]*?\*\/|/gi, '') // strip import statements .replace(/@import .*?;/gi , '') // strip keyframe statements .replace(/((@.*?keyframes [\s\S]*?){([\s\S]*?}\s*?)})/gi, ''); // unified regex to match css & media queries together let unified = /((\s*?(?:\/\*[\s\S]*?\*\/)?\s*?@media[\s\S]*?){([\s\S]*?)}\s*?})|(([\s\S]*?){([\s\S]*?)})/gi, arr; while (true) { arr = unified.exec(source); if (arr === null) { break; } let selector = arr[arr[2] === undefined ? 5 : 2].split('\r\n').join('\n').trim() // Never have more than a single line break in a row .replace(/\n+/, "\n") // Remove :root and html .split(/\s+/g).map(item => item.replace(/^(:root|html)$/, '')).join(' ').trim(); // determine the type if (selector.includes('@media')) { // we have a media query css.push({ selector: selector, type: 'media', subStyles: parseCSS(arr[3] + '\n}') //recursively parse media query inner css }); } else if (selector && !selector.includes('@')) { // we have standard css css.push({ selector: selector, rules: arr[6] }); } } } return css; }; export const /** * @param {string} text * @returns {string} */ encodeHtml = text => (text?.toString?.() || '' + text).replace(htmlre, m => htmlmap[m]), /** * Clears the Message Html for viewing * @param {string} text * @returns {string} */ cleanHtml = (html, oAttachments, msgId) => { let aColor; const debug = false, // Config()->Get('debug', 'enable', false); detectHiddenImages = true, // !!SettingsGet('try_to_detect_hidden_images'), bqLevel = parseInt(SettingsUserStore.maxBlockquotesLevel()), result = { hasExternals: false }, findAttachmentByCid = cId => oAttachments.findByCid(cId), findLocationByCid = cId => { const attachment = findAttachmentByCid(cId); return attachment?.contentLocation ? attachment : 0; }, // convert body attributes to CSS tasks = { link: value => aColor = value, text: (value, node) => node.style.color = value, topmargin: (value, node) => node.style.marginTop = pInt(value) + 'px', leftmargin: (value, node) => node.style.marginLeft = pInt(value) + 'px', bottommargin: (value, node) => node.style.marginBottom = pInt(value) + 'px', rightmargin: (value, node) => node.style.marginRight = pInt(value) + 'px' }, allowedAttributes = [ // defaults 'name', 'dir', 'lang', 'style', 'title', 'background', 'bgcolor', 'alt', 'height', 'width', 'src', 'href', 'border', 'bordercolor', 'charset', 'direction', // a 'download', 'hreflang', // body 'alink', 'bottommargin', 'leftmargin', 'link', 'rightmargin', 'text', 'topmargin', 'vlink', // col 'align', 'valign', // font 'color', 'face', 'size', // hr 'noshade', // img 'hspace', 'sizes', 'srcset', 'vspace', // meter 'low', 'high', 'optimum', 'value', // ol 'reversed', 'start', // table 'cols', 'rows', 'frame', 'rules', 'summary', 'cellpadding', 'cellspacing', // th 'abbr', 'scope', // td 'colspan', 'rowspan', 'headers' ], disallowedTags = [ 'SVG','SCRIPT','TITLE','LINK','BASE','META', 'INPUT','OUTPUT','SELECT','BUTTON','TEXTAREA', 'BGSOUND','KEYGEN','SOURCE','OBJECT','EMBED','APPLET','IFRAME','FRAME','FRAMESET','VIDEO','AUDIO','AREA','MAP' // Not supported by