1
0
mirror of https://github.com/MailHops/mailhops-plugin.git synced 2025-05-16 14:20:10 -07:00
mailhops-plugin/js/mailhops.js
Mihai Moldovan 5cf488aab9 js/mailhops{,_details}.js: rework Unsubscribe header parsing.
Instead of dropping additional links in the presentation layer, iterate
over all links in the parsing layer and add each link individually.

Some MTAs wrap the links in angle brackets (<>), so make sure to remove
those, since they wouldn't parse as valid XHTML (and also aren't valid
link targets).
2024-04-17 12:32:38 +02:00

499 lines
18 KiB
JavaScript

/*
* @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.4.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 });
}
sanitizeString(str) {
return str.replace (/\t/g, ' ').replace (/\s+/g, ' ').replace (/</g, '&lt;').replace (/>/g, '&gt;').trim ();
}
auth(header_xmailer, header_useragent, header_xmimeole, header_auth, header_spf, header_unsubscribe) {
let auth = [];
//SPF
if (header_spf) {
// Compact whitespace, make sure addresses enclosed in <> parse as valid
// XHTMl later on.
header_spf = this.sanitizeString (header_spf);
// Split value on whitespace. We'll extract data from this.
var headerSPFArr = header_spf.split(' ');
// First element should always indicate the state.
var spfState = headerSPFArr.shift ();
// Additionally, we might have a reason description, enclosed in parenthesis.
// Example: spfState = "Pass", reason description: "(mailfrom)"
var spfStateReason = '';
if (-1 != headerSPFArr[0].search (/^\(.*\)$/)) {
spfStateReason = ' ' + headerSPFArr.shift ();
}
// Put it all together, with extra information if requested.
var copy = spfState + spfStateReason;
if (this.options.extrainfo) {
copy += '\n<br />' + headerSPFArr.join (' ') + '\n<br />';
}
copy += '\n<br />' + MailHopsUtils.spf(spfState.toLowerCase ()).trim ();
this.LOG ("SPF state and data: " + copy);
auth.push({
type: 'SPF',
color: 'green',
icon: '/images/auth/' + spfState.toLowerCase () + '.png',
copy: copy
});
}
//Authentication-Results
//http://tools.ietf.org/html/rfc5451
if (header_auth) {
var headerAuthArr = header_auth.split(';');
var dkim_result;
var spf_result;
for (var h = 0; h < headerAuthArr.length; h++) {
if (headerAuthArr[h].indexOf('dkim=') != -1) {
dkim_result = headerAuthArr[h];
if (header_spf)
break;
}
if (!header_spf && headerAuthArr[h].indexOf('spf=') != -1) {
spf_result = headerAuthArr[h];
if (dkim_result)
break;
}
}
if (dkim_result) {
dkim_result = dkim_result.replace(/^\s+/, "");
var dkimArr = dkim_result.split(' ');
auth.push({
type: 'DKIM',
color: 'green',
icon: '/images/auth/' + dkimArr[0].replace('dkim=', '') + '.png',
copy: dkim_result + '\n' + MailHopsUtils.dkim(dkimArr[0].replace('dkim=', '')).trim()
});
}
if (spf_result) {
spf_result = spf_result.replace(/^\s+/, "");
var spfArr = spf_result.split(' ');
auth.push({
type: 'SPF',
color: 'green',
icon: '/images/auth/' + spfArr[0].replace('spf=', '') + '.png',
copy: spf_result + '\n' + MailHopsUtils.spf(spfArr[0].replace('spf=', '')).trim()
});
}
}
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(/</, '').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);
}
}
}