UNPKG

@notracking/node-mailin

Version:

Artisanal inbound emails for every web app using nodejs

563 lines (489 loc) 18.1 kB
"use strict"; const LanguageDetect = require("languagedetect"); const simpleParser = require("mailparser").simpleParser; const _ = require("lodash"); const htmlToText = require("html-to-text"); const events = require("events"); const Promise = require("bluebird"); const fs = Promise.promisifyAll(require("fs")); const path = require("path"); const shell = require("shelljs"); const util = require("util"); const SMTPServer = require("smtp-server").SMTPServer; const uuid = require("uuid"); const dns = require("dns"); const extend = require("extend"); const logger = require("./logger"); const mailUtilities = Promise.promisifyAll(require("./mailUtilities")); const NodeMailin = function() { events.EventEmitter.call(this); /* Set up the default options. */ this.configuration = { host: "0.0.0.0", port: 2500, tmp: ".tmp", logFile: null, disableDkim: false, disableSpf: false, disableSpamScore: true, verbose: false, debug: false, logLevel: "debug", profile: false, disableDNSValidation: true, smtpOptions: { banner: "NodeMailin Smtp Server", logger: false, disabledCommands: ["AUTH"] } }; /* The simplesmtp server instance, 'exposed' as an undocuumented, private * member. It is not meant for normal usage, but is can be uuseful for * NodeMailin hacking. * The instance will be initialized only after that NodeMailin.start() has been called. */ this._smtp = null; }; util.inherits(NodeMailin, events.EventEmitter); NodeMailin.prototype.start = function(options, callback) { const _this = this; options = options || {}; if (_.isFunction(options)) { callback = options; options = {}; } const configuration = this.configuration; extend(true, configuration, options); configuration.smtpOptions.secure = Boolean(configuration.smtpOptions.secure); callback = callback || function() {}; /* Create tmp dir if necessary. */ if (!fs.existsSync(configuration.tmp)) { shell.mkdir("-p", configuration.tmp); } /* Log to a file if necessary. */ if (configuration.logFile) { logger.setLogFile(configuration.logFile); } /* Set log level if necessary. */ if (configuration.logLevel) { logger.setLevel(configuration.logLevel); } if (configuration.verbose) { logger.setLevel("verbose"); logger.info("Log level set to verbose."); } if (configuration.debug) { logger.info("Debug option activated."); logger.setLevel("debug"); /* Enable debug for the simplesmtp server as well. */ configuration.smtpOptions.debug = true; } /* Basic memory profiling. */ if (configuration.profile) { logger.info("Enable memory profiling"); setInterval(function() { const memoryUsage = process.memoryUsage(); const ram = memoryUsage.rss + memoryUsage.heapUsed; const million = 1000000; logger.info( "Ram Usage: " + ram / million + "mb | rss: " + memoryUsage.rss / million + "mb | heapTotal: " + memoryUsage.heapTotal / million + "mb | heapUsed: " + memoryUsage.heapUsed / million ); }, 5000); } function validateAddress(addressType, email, session) { return new Promise(function(resolve, reject) { if (configuration.disableDnsLookup) { return resolve(); } try { let validateEvent, validationFailedEvent, dnsErrorMessage, localErrorMessage; if (addressType === "sender") { validateEvent = "validateSender"; validationFailedEvent = "senderValidationFailed"; dnsErrorMessage = "450 4.1.8 <" + email + ">: Sender address rejected: Domain not found"; localErrorMessage = "550 5.1.1 <" + email + ">: Sender address rejected: User unknown in local sender table"; } else if (addressType === "recipient") { validateEvent = "validateRecipient"; validationFailedEvent = "recipientValidationFailed"; dnsErrorMessage = "450 4.1.8 <" + email + ">: Recipient address rejected: Domain not found"; localErrorMessage = "550 5.1.1 <" + email + ">: Recipient address rejected: User unknown in local recipient table"; } else { // How are internal errors handled? return reject(new Error("Address type not supported")); } if (!email) { return reject(new Error(localErrorMessage)); } let domain = /@(.*)/.exec(email)[1]; let validateViaLocal = function() { if (_this.listeners(validateEvent).length) { _this.emit(validateEvent, session, email, function(err) { if (err) { _this.emit(validationFailedEvent, email); return reject(err); } else { return resolve(); } }); } else { return resolve(); } }; let validateViaDNS = function() { try { dns.resolveMx(domain, function(err, addresses) { if (err || !addresses || !addresses.length) { _this.emit(validationFailedEvent, email); return reject(new Error(dnsErrorMessage)); } validateViaLocal(); }); } catch (e) { return reject(e); } }; if (configuration.disableDNSValidation) { validateViaLocal(); } else { validateViaDNS(); } } catch (e) { reject(e); } }); } function dataReady(connection) { logger.info( connection.id + " Processing message from " + connection.envelope.mailFrom.address ); return retrieveRawEmail(connection) .then(function(rawEmail) { return Promise.all([ rawEmail, validateDkim(connection, rawEmail), validateSpf(connection), computeSpamScore(connection, rawEmail), parseEmail(connection) ]); }) .spread(function( rawEmail, isDkimValid, isSpfValid, spamScore, parsedEmail ) { return Promise.all([ connection, rawEmail, isDkimValid, isSpfValid, spamScore, parsedEmail, detectLanguage(connection, parsedEmail.text) ]); }) .spread(finalizeMessage) .then(unlinkFile.bind(null, connection)) .catch(function(error) { logger.error( connection.id + " Unable to finish processing message!!", error ); logger.error(error); throw error; }); } function retrieveRawEmail(connection) { return fs.readFileAsync(connection.mailPath).then(function(rawEmail) { return rawEmail; //returns buffer }); } function validateDkim(connection, rawEmail) { if (configuration.disableDkim) { return Promise.resolve(false); } logger.verbose(connection.id + " Validating dkim."); return mailUtilities.validateDkimAsync(rawEmail).catch(function(err) { logger.error( connection.id + " Unable to validate dkim. Consider dkim as failed." ); logger.error(err); return false; }); } function validateSpf(connection) { if (configuration.disableSpf) { return Promise.resolve(false); } logger.verbose(connection.id + " Validating spf."); /* Get ip and host. */ return mailUtilities .validateSpfAsync( connection.remoteAddress, connection.clientHostname, connection.envelope.mailFrom.address //from email address ) .catch(function(err) { logger.error( connection.id + " Unable to validate spf. Consider spf as failed." ); logger.error(err); return false; }); } function computeSpamScore(connection, rawEmail) { if (configuration.disableSpamScore) { return Promise.resolve(0.0); } return mailUtilities.computeSpamScoreAsync(rawEmail).catch(function(err) { logger.error( connection.id + " Unable to compute spam score. Set spam score to 0." ); logger.error(err); return 0.0; }); } function parseEmail(connection) { return new Promise(async function(resolve, reject) { try { logger.verbose(connection.id + " Parsing email."); /* Stream the written email to the parser. */ let src = fs.createReadStream(connection.mailPath); const mail = await simpleParser(src); // logger.verbose(util.inspect(mail, { // depth: 5 // })); /* Make sure that both text and html versions of the * body are available. */ if (!mail.text && !mail.html) { mail.text = ""; mail.html = "<div></div>"; } else if (!mail.html) { mail.html = _this._convertTextToHtml(mail.text); } else if (!mail.text) { mail.text = _this._convertHtmlToText(mail.html); } // console.log(mail); return resolve(mail); } catch (err) { console.log(`parseEmail err: `, err); reject(err); } }); } function detectLanguage(connection, text) { logger.verbose(connection.id + " Detecting language."); let language = ""; let languageDetector = new LanguageDetect(); let potentialLanguages = languageDetector.detect(text, 2); if (potentialLanguages.length !== 0) { logger.verbose( "Potential languages: " + util.inspect(potentialLanguages, { depth: 5 }) ); /* Use the first detected language. * potentialLanguages = [['english', 0.5969], ['hungarian', 0.40563]] */ language = potentialLanguages[0][0]; } else { logger.info( connection.id + " Unable to detect language for the current message." ); } return language; } function finalizeMessage( connection, rawEmail, isDkimValid, isSpfValid, spamScore, parsedEmail, language ) { /* Finalize the parsed email object. */ parsedEmail.dkim = isDkimValid ? "pass" : "failed"; parsedEmail.spf = isSpfValid ? "pass" : "failed"; parsedEmail.spamScore = spamScore; parsedEmail.language = language; /* Make fields exist, even if empty. That will make * json easier to use on the webhook receiver side. */ parsedEmail.cc = parsedEmail.cc || []; parsedEmail.from = parsedEmail.from || []; parsedEmail.to = parsedEmail.to || []; parsedEmail.attachments = parsedEmail.attachments || []; /* Add the connection authentication to the parsedEmail. */ parsedEmail.connection = connection; /* Add envelope data to the parsedEmail. */ parsedEmail.envelopeFrom = connection.envelope.mailFrom; parsedEmail.envelopeTo = connection.envelope.rcptTo; _this.emit("message", connection, parsedEmail, rawEmail); return parsedEmail; } function unlinkFile(connection) { /* Don't forget to unlink the tmp file. */ return fs.unlinkAsync(connection.mailPath).then(function() { logger.info( connection.id + " End processing message, deleted " + connection.mailPath ); return; }); } let _session; function onData(stream, session, callback) { _session = session; let connection = _.cloneDeep(session); connection.id = uuid.v4(); let mailPath = path.join(configuration.tmp, connection.id); connection.mailPath = mailPath; _this.emit("startData", connection); logger.verbose("Connection id " + connection.id); logger.info( connection.id + " Receiving message from " + connection.envelope.mailFrom.address ); _this.emit("startMessage", connection); stream.pipe(fs.createWriteStream(mailPath)); stream.on("data", function(chunk) { _this.emit("data", connection, chunk); }); stream.on("end", function() { dataReady(connection); callback(); }); stream.on("close", function() { _this.emit("close", connection); }); stream.on("error", function(error) { _this.emit("error", connection, error); }); } function onAuth(auth, session, streamCallback) { const callback = function (error, authorized) { if (typeof authorized === 'boolean' ){ streamCallback(error, { user: authorized }); } else { streamCallback(error, authorized); } } const isAnyListener = _this.emit( "authorizeUser", session, auth.username, auth.password, callback ); if ( !isAnyListener ) { streamCallback(new Error("Unauthorized user")); } } function onMailFrom(address, session, streamCallback) { let ack = function(error) { streamCallback(error); }; validateAddress("sender", address.address, session) .then(ack) .catch(ack); } function onRcptTo(address, session, streamCallback) { let ack = function(error) { streamCallback(error); }; validateAddress("recipient", address.address, session) .then(ack) .catch(ack); } const smtpOptions = _.extend({}, configuration.smtpOptions || {}, { onData: onData, onAuth: onAuth, onMailFrom: onMailFrom, onRcptTo: onRcptTo }); const server = new SMTPServer(smtpOptions); this._smtp = server; server.listen(configuration.port, configuration.host, function(err) { if (!err) { logger.info( "NodeMailin Smtp server listening on port " + configuration.port ); } else { callback(err); logger.error( "Could not start server on port " + configuration.port + "." ); if (configuration.port < 1000) { logger.error("Ports under 1000 require root privileges."); } if (configuration.logFile) { logger.error( "Do you have write access to log file " + configuration.logFile + "?" ); } logger.error(err.message); } }); server.on("close", function() { logger.info("Closing smtp server"); _this.emit("close", _session); }); server.on("error", function(error) { if (error.code == "ETIMEDOUT") { logger.warn(error); } else if (error.code == "ECONNRESET") { logger.warn(error); } else { logger.error(error); _this.emit("error", _session, error); } }); callback(); }; NodeMailin.prototype.stop = function(callback) { callback = callback || function() {}; logger.info("Stopping NodeMailin."); /* FIXME A bug in the RAI module prevents the callback to be called, so * call end and call the callback directly. */ this._smtp.close(callback); callback(); }; NodeMailin.prototype._convertTextToHtml = function(text) { /* Replace newlines by <br>. */ text = text.replace(/(\n\r)|(\n)/g, "<br>"); /* Remove <br> at the begining. */ text = text.replace(/^\s*(<br>)*\s*/, ""); /* Remove <br> at the end. */ text = text.replace(/\s*(<br>)*\s*$/, ""); return text; }; NodeMailin.prototype._convertHtmlToText = function(html) { return htmlToText.htmlToText(html); }; module.exports = new NodeMailin();