/* * @author: Andrew Van Tassel * @email: andrew@andrewvantassel.com * @website: http://Mailhops.com */ class MailHops { msgURI = null isLoaded = false loading = false tabId = null options = { version: 'MailHops Plugin 4.5.0', api_key: '', owm_key: '', lang: 'en', unit: 'mi', theme: 'light', api_http: 'https://', api_host: 'api.Mailhops.com', travel_time_junk: false, extrainfo: false, debug: false, country_filter: [] } message = { id: null , map_url: '' , time: null , date: new Date().toISOString() , hash: '' , secure: [] , headers: [] , auth: [] , sender: { icon: '/images/refresh.png' , title: 'Loading...' , description: '' }, error: '' } response = {} LOG(msg) { if (!this.options.debug) return; console.log(msg); } async init(tabId, id, headers) { this.tabId = tabId; try { var data = await browser.storage.local.get(); if (data.api_key) { this.options.api_key = data.api_key; } if (data.owm_key) { this.options.owm_key = data.owm_key; } if (data.lang) { this.options.lang = data.lang; } if (data.unit) { this.options.unit = data.unit; } if (data.theme) { this.options.theme = data.theme; } if (data.travel_time_junk && data.travel_time_junk != 'off') { this.options.travel_time_junk = Boolean(data.travel_time_junk); } if (data.extrainfo) { this.options.extrainfo = Boolean(data.extrainfo); } if (data.debug) { this.options.debug = Boolean(data.debug); } if (data.countries) { this.options.country_filter = data.countries.split(','); } this.LOG('load MailHops prefs'); // reset message this.message = { id: id , map_url: '' , time: null , date: new Date().toISOString() , hash: '' , secure: [] , headers: headers , auth: [] , sender: { icon: '/images/refresh.png' , title: 'Loading...' , description: '' }, error: '' }; this.getRoute(); } catch (e) { this.LOG('Error loading MailHops prefs'); } } async getRoute() { if (this.loading) return; this.loading = true; // set loading icon browser.messageDisplayAction.setPopup({ popup: '', tabId: this.tabId }); browser.messageDisplayAction.setIcon({ path: '/images/refresh.png', tabId: this.tabId }); browser.messageDisplayAction.setTitle({ title: 'Loading...', tabId: this.tabId }); //IP regex var regexIp = /(1\d{0,2}|2(?:[0-4]\d{0,1}|[6789]|5[0-5]?)?|[3-9]\d?|0)\.(1\d{0,2}|2(?:[0-4]\d{0,1}|[6789]|5[0-5]?)?|[3-9]\d?|0)\.(1\d{0,2}|2(?:[0-4]\d{0,1}|[6789]|5[0-5]?)?|[3-9]\d?|0)\.(1\d{0,2}|2(?:[0-4]\d{0,1}|[6789]|5[0-5]?)?|[3-9]\d?|0)(\/(?:[012]\d?|3[012]?|[456789])){0,1}$/; var regexAllIp = /(1\d{0,2}|2(?:[0-4]\d{0,1}|[6789]|5[0-5]?)?|[3-9]\d?|0)\.(1\d{0,2}|2(?:[0-4]\d{0,1}|[6789]|5[0-5]?)?|[3-9]\d?|0)\.(1\d{0,2}|2(?:[0-4]\d{0,1}|[6789]|5[0-5]?)?|[3-9]\d?|0)\.(1\d{0,2}|2(?:[0-4]\d{0,1}|[6789]|5[0-5]?)?|[3-9]\d?|0)(\/(?:[012]\d?|3[012]?|[456789])){0,1}/g; var regexIPV6 = /s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*/g; var headReceived = this.message.headers['received'] || []; var headDate = this.message.headers['date'] ? this.message.headers['date'][0] : ''; var headXReceived = this.message.headers['x-received'] ? this.message.headers['x-received'][0] : ''; var headXOrigIP = this.message.headers['x-originating-ip'] ? this.message.headers['x-originating-ip'][0] : ''; // auth box var headXMailer = this.message.headers['x-mailer'] ? this.message.headers['x-mailer'][0] : ''; var headUserAgent = this.message.headers['user-agent'] ? this.message.headers['user-agent'][0] : ''; var headXMimeOLE = this.message.headers['x-mimeole'] ? this.message.headers['x-mimeole'][0] : ''; var headReceivedSPF = this.message.headers['received-spf'] ? this.message.headers['received-spf'][0] : ''; var headAuth = this.message.headers['authentication-results'] ? this.message.headers['authentication-results'][0] : ''; var headListUnsubscribe = this.message.headers['list-unsubscribe'] ? this.message.headers['list-unsubscribe'][0] : ''; var all_ips = new Array(); var rline = ''; var firstDate = headDate; var lastDate; //empty secure and time this.message.secure = []; this.message.time = null; try { this.message.date = new Date(headDate).toISOString(); } catch (error) { headDate = headDate.substring(0, headDate.lastIndexOf(' ')); } try { this.message.date = new Date(headDate).toISOString(); } catch (error) { headDate = new Date(); } this.message.auth = this.auth(headXMailer, headUserAgent, headXMimeOLE, headAuth, headReceivedSPF, headListUnsubscribe); //loop through the received headers and parse for IP addresses if (Boolean(headReceived)) { var received_ips = new Array(); for (var h = 0; h < headReceived.length; h++) { //build the received line by concat until semi-colon ; date/time rline += headReceived[h]; if (headReceived[h].indexOf(';') === -1) continue; // first and last dates are used to calculate time traveled if (rline.indexOf(';') !== -1) { if (!firstDate) firstDate = rline.substring(rline.indexOf(';') + 1).trim(); if (!lastDate) lastDate = rline.substring(rline.indexOf(';') + 1).trim(); } // IPV6 check rline = rline.replace(/\[IPv6\:/g, '['); if (rline.match(regexIPV6)) { all_ips.unshift(rline.match(regexIPV6)[0]); //reset the line rline = ''; continue; } // parse IPs out of Received line received_ips = rline.match(regexAllIp); //continue if no IPs found if (!received_ips) { //reset the line rline = ''; continue; } //get unique IPs for each Received header received_ips = received_ips.filter(function (item, pos) { return received_ips.indexOf(item) === pos; }); for (var r = received_ips.length; r >= 0; r--) { if (regexIp.test(received_ips[r]) && this.testIP(received_ips[r], rline)) { all_ips.unshift(received_ips[r]); } } //reset the line rline = ''; } } // parse dates if (firstDate && firstDate.indexOf('(') !== - 1) firstDate = firstDate.substring(0, firstDate.indexOf('(')).trim(); if (lastDate && lastDate.indexOf('(') !== -1) lastDate = lastDate.substring(0, lastDate.indexOf('(')).trim(); if (firstDate && lastDate) { try { firstDate = new Date(firstDate); lastDate = new Date(lastDate); this.message.time = lastDate - firstDate; } catch (e) { this.LOG('travel dates parse Error: ' + JSON.stringify(e)); this.message.time = null; } } else { this.message.time = null; } //get the originating IP address if (Boolean(headXOrigIP)) { headXOrigIP = headXOrigIP.replace(/\[IPv6\:/g, '['); //IPV6 check if (headXOrigIP.match(regexIPV6)) { var ip = headXOrigIP.match(regexIPV6) if (Boolean(ip) && ip.length && all_ips.indexOf(ip[0]) == -1) all_ips.unshift(ip[0]); } else { var ip = headXOrigIP.match(regexAllIp); if (Boolean(ip) && ip.length && all_ips.indexOf(ip[0]) == -1) all_ips.unshift(ip[0]); } } if (all_ips.length) { // set the message hash this.message.hash = btoa(this.message.date + '' + all_ips.join(',')); const cached = await this.getCacheResponse(); if (cached) { this.displayRoute(cached); this.isLoaded = true; this.loading = false; } else { this.lookupRoute(all_ips.join(',')); } } else { this.clear(); } }; //another ip check, dates will throw off the regex testIP(ip, header) { var validIP = true; try { var firstchar = header.substring(header.indexOf(ip) - 1); firstchar = firstchar.substring(0, 1); var lastchar = header.substring((header.indexOf(ip) + ip.length)); lastchar = lastchar.substring(0, 1); if (firstchar.match(/\.|\d|\-/) || lastchar.match(/\.|\d|\-/) || (firstchar == '?' && lastchar == '?') || (firstchar == ':' || lastchar == ':') || lastchar == ';' || header.toLowerCase().indexOf(' id ' + ip) !== -1 || parseInt(ip.substring(0, ip.indexOf('.'))) >= 240 //IANA-RESERVED ) { //only if there is one instance of this IP if (header.indexOf(ip) == header.lastIndexOf(ip)) validIP = false; } else if (header.indexOf('using SSL') !== -1 || header.indexOf('using TLS') !== -1 || header.indexOf('version=TLSv1/SSLv3') !== -1 ) { //check if this IP was part of a secure transmission this.message.secure.push(ip); } } catch (e) { this.LOG('testIP Error: ' + JSON.stringify(e)); } return validIP; } clear() { this.message.sender = { title: 'Local', countryCode: '', icon: '/images/local.png' }; browser.messageDisplayAction.setIcon({ path: this.message.sender.icon, tabId: this.tabId }); browser.messageDisplayAction.setTitle({ title: this.message.sender.title, tabId: this.tabId }); this.isLoaded = true; this.loading = false; } error(status, data) { this.message.error = (data && data.error && data.error.message) ? data && data.error.message : 'Service Unavailable'; this.message.sender = { title: (data && data.error && data.error.message) ? data && data.error.message : 'Service Unavailable', countryCode: '', icon: '/images/auth/error.png' }; browser.messageDisplayAction.setIcon({ path: this.message.sender.icon, tabId: this.tabId }); browser.messageDisplayAction.setTitle({ title: this.message.sender.title, tabId: this.tabId }); } extractMaybeQuotedValue(line, key) { let ret = [ ]; let new_line = line; console.assert (key.length); // The possible combinations we might be interested in. let dQuote = new RegExp (key + '="(.*?)"'); let sQuote = new RegExp (key + "='(.*?)'"); let nQuote = new RegExp (key + '=(.*?)(?:\\s|$)'); let match = line.match (dQuote); if (null !== match) { ret.push (match[1]); } if (!ret.length) { match = line.match (sQuote); if (null !== match) { ret.push (match[1]); } } if (!ret.length) { match = line.match (nQuote); if (null !== match) { ret.push (match[1]); } } if (ret.length) { // Match length must be non-zero. console.assert (match[0].length); // Drop data leading up to the match. new_line = new_line.substring (match.index); // Drop the match itself. new_line = new_line.substring (match[0].length); // Add to return value. ret.push (new_line); } return ret; } sanitizeString(str) { return str.replace (/\t/g, ' ').replace (/\s+/g, ' ').replace (//g, '>').trim (); } auth(header_xmailer, header_useragent, header_xmimeole, header_auth, header_spf, header_unsubscribe) { let auth = []; var color = 'green'; //SPF if (header_spf) { // Compact whitespace, make sure addresses enclosed in <> parse as valid // XHTMl later on. header_spf = this.sanitizeString (header_spf); } //Authentication-Results //http://tools.ietf.org/html/rfc8601 if (header_auth) { this.LOG ('Authentication-Results header found, parsing...\n'); var headerAuthArr = []; // The header might contain multiple lines, so iterate above them. var headerAuthArr = header_auth.split('\n'); var dkimState = ''; var spfState = ''; var dkimReason = ''; var spfReason = ''; var dkimAux = ''; var spfAux = ''; var gotDkimState = false; var gotSpfState = false; for (var i = 0; i < headerAuthArr.length; ++i) { var curLine = headerAuthArr[i]; this.LOG ('current header line: ' + curLine + '\n'); // This is the actual fun part. // The AH can very funky and naïve field splitting often leads to // wrong results. // We'll heave to tread carefully and parse data in a more // sophisticated way, relying on arcane knowledge. // // It might be temping to try to merge the dkim and spf sections below, // but doing so is not really possible, since the formats are // different. let statePos = 0; let stateWork = curLine; while ((!gotDkimState) && (-1 !== (statePos = stateWork.indexOf ('dkim=')))) { // Fetch "dkim=" value, potentially quoted. // While the RFC doesn't mention anything about quote-enclosing // values, it probably doesn't hurt to support it. let extract = this.extractMaybeQuotedValue (stateWork, 'dkim'); if (extract.length) { dkimState = extract[0]; stateWork = extract[1]; gotDkimState = true; this.LOG ("extracted DKIM state: " + dkimState + "; continuing with new line: " + stateWork); } else { // Nothing found on this line, continue. this.LOG ("nothing found on current line (leftover) '" + truncatedWork + "', continuing..."); continue; } // Look ahead to find the next "dkim=" section. This will mark the // end of our current chunk (if it exists). let stateEnd = -1; let truncatedWork = stateWork; if (-1 !== (stateEnd = stateWork.indexOf ('dkim=', 5))) { truncatedWork = truncatedWork.substring (0, stateEnd); } // Now fetch additional data. // Typically, that should be "reason=". extract = this.extractMaybeQuotedValue (truncatedWork, 'reason'); if (extract.length) { dkimReason = extract[0]; truncatedWork = extract[1]; this.LOG ("extracted DKIM reason: " + dkimReason + "; continuing with new line: " + truncatedWork); } // Same MTA append a string such as "(1024-bit key; unprotected)", // add this to the reason. const key_data = new RegExp ('\\(\\d+-bit key(?:; .+?)?\\)'); let match = truncatedWork.match (key_data); if (null !== match) { if (dkimReason.length) { dkimReason += ' '; } dkimReason += match[0]; truncatedWork = truncatedWork.substring (match.index); truncatedWork = truncatedWork.substring (match[0].length); } // Others enclose the reason in parentheses. const paren_reason = new RegExp ('\\(.+?\\)'); match = truncatedWork.match (paren_reason); if (null !== match) { if (dkimReason.length) { dkimReason += ' '; } dkimReason += match[0]; truncatedWork = truncatedWork.substring (match.index); truncatedWork = truncatedWork.substring (match[0].length); } // Everything else should be additional data, up until the first // semicolon or end of the line. const aux_data = new RegExp ('(.+?)(?:;|$)'); match = truncatedWork.match (aux_data); if (null !== match) { dkimAux = match[1]; } } // Do the same for SPF data. statePos = 0; stateWork = curLine; while ((!gotSpfState) && (-1 !== (statePos = stateWork.indexOf ('spf=')))) { // Refer to the DKIM section above for more information. let extract = this.extractMaybeQuotedValue (stateWork, 'spf'); if (extract.length) { spfState = extract[0]; stateWork = extract[1]; gotSpfState = true; } else { // Nothing found on this line, continue. this.LOG ("nothing found on current line (leftover) '" + stateWork + "', continuing..."); continue; } let stateEnd = -1; let truncatedWork = stateWork; if (-1 !== (stateEnd = stateWork.indexOf ('spf=', 4))) { truncatedWork = truncatedWork.substring (0, stateEnd); } // Now fetch additional data. // Reason is usually enclosed in parentheses. const paren_reason = new RegExp ('\\(.+?\\)'); let match = truncatedWork.match (paren_reason); if (null !== match) { spfReason = match[0]; truncatedWork = truncatedWork.substring (match.index); truncatedWork = truncatedWork.substring (match[0].length); } // Everything else is additional data. const aux_data = new RegExp ('(.+?)[;|$]'); match = truncatedWork.match (aux_data); if (null !== match) { spfAux = match[1]; } } } if (gotDkimState) { // Just sanitize our data. dkimState = this.sanitizeString (dkimState); dkimReason = this.sanitizeString (dkimReason); dkimAux = this.sanitizeString (dkimAux); var copy = dkimState; if (dkimReason.length) { copy += ' - ' + dkimReason; } if ((this.options.extrainfo) && (dkimAux.length)) { copy += '\n
' + dkimAux; } copy += '\n
\n
' + MailHopsUtils.dkim(dkimState).trim(); this.LOG ("DKIM state and data: " + copy); color = 'green'; if(dkimState.toLowerCase().indexOf('soft') != -1 || dkimState.toLowerCase().indexOf('temperror') != -1){ color = 'yellow'; } else if(dkimState.toLowerCase().indexOf('fail') != -1 || dkimState.toLowerCase().indexOf('error') != -1){ color = 'red'; } auth.push({ type: 'DKIM', color: color, icon: '/images/auth/' + dkimState.toLowerCase () + '.png', copy: copy }); } if (gotSpfState) { spfState = this.sanitizeString (spfState); spfReason = this.sanitizeString (spfReason); spfAux = this.sanitizeString (spfAux); var copy = spfState; if (spfReason.length) { copy += ' ' + spfReason; } if ((this.options.extrainfo) && (spfAux.length)) { copy += '\n
' + spfAux; } copy += '\n
\n
' + MailHopsUtils.spf(spfState).trim(); color = 'green'; if(spfState.toLowerCase().indexOf('soft') != -1 || spfState.toLowerCase().indexOf('temperror') != -1){ color = 'yellow'; } else if(spfState.toLowerCase().indexOf('fail') != -1 || spfState.toLowerCase().indexOf('error') != -1){ color = 'red'; } this.LOG ("SPF state and data: " + copy); auth.push({ type: 'SPF', color: color, icon: '/images/auth/' + spfState.toLowerCase () + '.png', copy: copy }); } } if (header_unsubscribe) { var unsubscribeArr = header_unsubscribe.split(','); for (var i = 0; i < unsubscribeArr.length; ++i) { auth.push({ type: 'Unsubscribe', color: 'grey', link: unsubscribeArr[i].replace(//, '').trim() }); } } return auth; } //mailhops lookup lookupRoute(header_route) { let mailHop = this; let lookupURL = '?' + MailHopsUtils.getAPIUrlParams(this.options) + '&r=' + String(header_route) + '&l=' + this.options.lang + '&u=' + this.options.unit; if (this.options.owm_key != '') lookupURL += '&owm_key=' + this.options.owm_key; if (this.message.time != null) lookupURL += '&t=' + this.message.time; if (this.message.date != null) lookupURL += '&d=' + this.message.date; this.message.map_url = MailHopsUtils.getAPIUrl() + '/map/' + lookupURL; //call mailhops api for lookup var xmlhttp = new XMLHttpRequest(); xmlhttp.open("GET", MailHopsUtils.getAPIUrl() + '/lookup/' + lookupURL, true); xmlhttp.onreadystatechange = function () { if (xmlhttp.readyState === 4) { try { let data = JSON.parse(xmlhttp.responseText); if (xmlhttp.status === 200) { mailHop.cacheResponse(data.response); mailHop.displayRoute(data.response); //tag the result mailHop.tagResults(data, data.response.route); } else if (data.error) { mailHop.LOG(JSON.stringify(data.error)); //display the error mailHop.error(xmlhttp.status, data); } } catch (e) { mailHop.LOG(e); mailHop.error(); } } mailHop.isLoaded = true; mailHop.loading = false; }; xmlhttp.send(null); } displayRoute(response) { this.response = response; this.message.sender = MailHopsUtils.getSender(response.route); if (this.message.sender) { browser.messageDisplayAction.setIcon({ path: this.message.sender.icon, tabId: this.tabId }); browser.messageDisplayAction.setTitle({ title: this.message.sender.title, tabId: this.tabId }); } else { browser.messageDisplayAction.setIcon({ path: '/images/local.png', tabId: this.tabId }); browser.messageDisplayAction.setTitle({ title: 'Local', tabId: this.tabId }); } } // keep a daily cache async cacheResponse(response) { let data = await browser.storage.local.get('messages'); let cached_date = new Date(); let messages = { cached: cached_date.getUTCFullYear() + '-' + cached_date.getUTCMonth() + '-' + cached_date.getUTCDate(), list: [] }; if (data.messages && data.messages.list && data.messages.cached === messages.cached) { messages.list = data.messages.list; } messages.list[this.message.hash] = response; await browser.storage.local.set({ messages: messages }); this.LOG('Cached Message ' + this.message.id + ' hash ' + this.message.hash); } // get cached message async getCacheResponse() { let data = await browser.storage.local.get('messages'); if (data.messages && data.messages.list[this.message.hash]) { this.LOG('Found Cached Message ' + this.message.hash); return data.messages.list[this.message.hash]; } return false; }; tagResults(results, route) { if (!results) { return false; } //Add junk tag on messages taking too long to travel try { if (Boolean(this.options.travel_time_junk) && this.message.time != null && this.message.time > 10000) { messenger.messages.update(this.message.id, { 'junk': true }); this.LOG("Junk: Travel time match for " + this.message.time); } if (this.options.country_filter.length && this.message.sender && this.message.sender.countryCode) { if (this.options.country_filter.indexOf(this.message.sender.countryCode.toUpperCase()) != -1) { messenger.messages.update(this.message.id, { 'junk': true }); this.LOG("Junk: Country code match for " + this.message.sender.countryCode); } } } catch (e) { this.LOG("Error tagResults: " + e); } } }