nodemailer-direct-transport
Version:
Direct transport for Nodemailer
499 lines (431 loc) • 17.3 kB
JavaScript
'use strict';
var createQueue = require('./message-queue');
var SMTPConnection = require('smtp-connection');
var dns = require('dns');
var net = require('net');
var os = require('os');
var util = require('util');
var packageData = require('../package.json');
var EventEmitter = require('events').EventEmitter;
var shared = require('nodemailer-shared');
// Expose to the world
module.exports = function (options) {
return new DirectMailer(options);
};
/**
* Creates a new DirectMailer instance. Provides method 'send' to queue
* outgoing e-mails. The queue is processed in the background.
*
* @constructor
* @param {Object} [options] Optional options object
*/
function DirectMailer(options) {
EventEmitter.call(this);
this.options = options || {};
this._queue = createQueue();
this._started = false;
this._lastId = 0;
if (options && typeof options.getSocket === 'function') {
this.getSocket = options.getSocket;
}
this.logger = shared.getLogger(this.options);
// temporary object
var connection = new SMTPConnection({});
this.name = 'SMTP (direct)';
this.version = packageData.version + '[client:' + connection.version + ']';
}
util.inherits(DirectMailer, EventEmitter);
// Adds a dynamic property 'length'
Object.defineProperty(DirectMailer.prototype, 'length', {
get: function () {
return this._queue._instantQueue.length + this._queue._sortedQueue.length;
}
});
/**
* Placeholder function for creating proxy sockets. This method immediatelly returns
* without a socket
*
* @param {Object} options Connection options
* @param {Function} callback Callback function to run with the socket keys
*/
DirectMailer.prototype.getSocket = function (options, callback) {
// return immediatelly
return callback(null, false);
};
/**
* Adds an outgoing message to the queue. Recipient addresses are sorted
* by the receiving domain and for every domain, a copy of the message is queued.
*
* If input is deemed invalid, an error is thrown, so be ready to catch these
* when calling directmail.send(...)
*
* @param {Object} mail Mail object
* @param {Function} callback Callback function
*/
DirectMailer.prototype.send = function (mail, callback) {
var envelope = mail.message.getEnvelope();
var domainEnvelopes = {};
if (!envelope.from) {
return callback(new Error('"From" address missing'));
}
envelope.to = [].concat(envelope.to || []);
if (!envelope.to.length) {
return callback('"Recipients" addresses missing');
}
// We cant't run existing streams more than once so we need to change these
// to buffers. Filenames, URLs etc are not affected – for every
// message copy a new file stream will be created
this._clearStreams(mail, function (err) {
if (err) {
return callback(err);
}
this._formatMessage(mail.message);
envelope.to.forEach(function (recipient) {
recipient = (recipient || '').toString();
var domain = (recipient.split('@').pop() || '').toLowerCase().trim();
if (!domainEnvelopes[domain]) {
domainEnvelopes[domain] = {
from: envelope.from,
to: [recipient]
};
} else if (domainEnvelopes[domain].to.indexOf(recipient) < 0) {
domainEnvelopes[domain].to.push(recipient);
}
});
var returned = 0;
var domains = Object.keys(domainEnvelopes);
var combinedInfo = {
accepted: [],
rejected: [],
pending: [],
errors: [],
envelope: mail.message.getEnvelope()
};
domains.forEach((function (domain) {
var called = false;
var id = ++this._lastId;
var item = {
envelope: domainEnvelopes[domain],
data: mail.data,
message: mail.message,
domain: domain,
id: id,
callback: function (err, info) {
if (called) {
this.logger.info('Callback for #%s already called. Updated values: %s', id, JSON.stringify(err || info));
return;
}
called = true;
returned++;
if (err) {
combinedInfo.errors.push(err);
if (err.recipients) {
combinedInfo.rejected = combinedInfo.rejected.concat(err.recipients || []);
}
} else if (info) {
combinedInfo.accepted = combinedInfo.accepted.concat(info.accepted || []);
combinedInfo.rejected = combinedInfo.rejected.concat(info.rejected || []);
combinedInfo.pending = combinedInfo.pending.concat(info.pending || []);
combinedInfo.messageId = info.messageId;
}
if (returned >= domains.length) {
if (combinedInfo.errors.length === domains.length) {
var error = new Error('Sending failed');
error.errors = combinedInfo.errors;
return callback(error);
} else {
return callback(null, combinedInfo);
}
}
}.bind(this)
};
this._queue.insert(item);
}).bind(this));
// start send loop if needed
if (!this._started) {
this._started = true;
// do not start the loop before current execution context is finished
setImmediate(this._loop.bind(this));
}
}.bind(this));
};
/**
* Looping function to fetch a message from the queue and send it.
*/
DirectMailer.prototype._loop = function () {
// callback is fired when a message is added to the queue
this._queue.get((function (data) {
this.logger.info('Retrieved message #%s from the queue, resolving %s', data.id, data.domain);
// Resolve destination MX server
this._resolveMx(data.domain, (function (err, list) {
if (err) {
this.logger.info('Resolving %s for #%s failed', data.domain, data.id);
this.logger.info(err);
} else if (!list || !list.length) {
this.logger.info('Could not resolve any MX servers for %s', data.domain);
}
if (err || !list || !list.length) {
data.callback(err || new Error('Could not resolve MX for ' + data.domain));
return setImmediate(this._loop.bind(this));
}
// Sort MX list by priority field
list.sort(function (a, b) {
return (a && a.priority || 0) - (b && b.priority || 0);
});
// Use the first server on the list
var exchanges = list.map(function (item) {
return item.exchange;
});
// Try to send the message
this._process([].concat(exchanges), data, (function (err, response) {
if (err) {
this.logger.info('Failed processing message #%s', data.id);
} else {
this.logger.info('Server responded for #%s: %s', data.id, JSON.stringify(response));
}
if (err) {
if (err.responseCode && err.responseCode >= 500) {
err.domain = data.domain;
err.exchange = exchanges[0];
err.recipients = data.envelope.to;
data.callback(err);
} else {
data.replies = (data.replies || 0) + 1;
if (data.replies <= 5) {
this._queue.insert(data, this.options.retryDelay || data.replies * 15 * 60 * 1000);
this.logger.info('Message #%s requeued', data.id);
data.callback(null, {
pending: {
domain: data.domain,
exchange: exchanges[0],
recipients: data.envelope.to,
response: err.response
}
});
} else {
err.domain = data.domain;
err.exchange = exchanges[0];
err.recipients = data.envelope.to;
data.callback(err);
}
}
} else {
data.callback(null, response);
}
setImmediate(this._loop.bind(this));
}).bind(this));
}).bind(this));
}).bind(this));
};
/**
* Sends a message to provided MX server
*
* @param {Array} exchanges Priority list of MX servers
* @param {Object} data Message object
* @param {Function} callback Callback to run once the message is either sent or sending fails
*/
DirectMailer.prototype._process = function (exchanges, data, callback) {
var exchange = exchanges[0];
this.logger.info('%s resolved to %s for #%s', data.domain, exchange, data.id);
this.logger.info('Connecting to %s:%s for message #%s %s STARTTLS', exchange, this.options.port || 25, data.id, data.ignoreTLS ? 'without' : 'with');
var options = {
host: exchange,
port: this.options.port || 25,
requireTLS: !data.ignoreTLS,
ignoreTLS: data.ignoreTLS,
tls: {
rejectUnauthorized: false
}
};
// Add options from DirectMailer options to simplesmtp client
Object.keys(this.options).forEach((function (key) {
options[key] = this.options[key];
}).bind(this));
this.getSocket(options, function (err, socketOptions) {
if (err) {
// try next host
exchanges.shift();
if (!exchanges.length) {
// no more hosts to try
return callback(err);
}
this.logger.info('Failed to connect to %s, trying next MX', exchange);
return this._process(exchanges, data, callback);
}
if (socketOptions && socketOptions.connection) {
this.logger.info('Using proxied socket from %s:%s to %s:%s', socketOptions.connection.remoteAddress, socketOptions.connection.remotePort, options.host || '', options.port || '');
Object.keys(socketOptions).forEach(function (key) {
options[key] = socketOptions[key];
});
}
var connection = new SMTPConnection(options);
var returned = false;
var connected = false;
connection.once('error', function (err) {
if (returned) {
return;
}
returned = true;
if (err.code === 'ETLS') {
// STARTTLS failed, try again, this time without encryption
data.ignoreTLS = true;
return this._process(exchanges, data, callback);
}
if (!connected) {
// try next host
exchanges.shift();
if (!exchanges.length) {
// no more hosts to try
return callback(err);
}
this.logger.info('Failed to connect to %s, trying next MX', exchange);
return this._process(exchanges, data, callback);
}
return callback(err);
}.bind(this));
var sendMessage = function () {
var messageId = (data.message.getHeader('message-id') || '').replace(/[<>\s]/g, '');
var recipients = [].concat(data.envelope.to || []);
if (recipients.length > 3) {
recipients.push('...and ' + recipients.splice(2).length + ' more');
}
this.logger.info('Sending message <%s> to <%s>', messageId, recipients.join(', '));
connection.send(data.envelope, data.message.createReadStream(), function (err, info) {
if (returned) {
return;
}
returned = true;
connection.close();
if (err) {
return callback(err);
}
info.messageId = (data.message.getHeader('message-id') || '').replace(/[<>\s]/g, '');
return callback(null, info);
});
}.bind(this);
connection.connect(function () {
connected = true;
if (returned) {
return;
}
sendMessage();
}.bind(this));
}.bind(this));
};
/**
* Adds additional headers to the outgoing message
*
* @param {Object} message BuildMail message object
*/
DirectMailer.prototype._formatMessage = function (message) {
var hostname = this._resolveHostname(this.options.name);
// set the first header as 'Received:'
message._headers.unshift({
key: 'Received',
value: 'from localhost (127.0.0.1) by ' + hostname + ' with SMTP; ' + Date()
});
};
/**
* Detects stream objects and resolves these to buffers before sending. File paths,
* urls etc. are not affected.
*
* @param {Object} message BuildMail message object
* @param {Function} callback Callback to run
*/
DirectMailer.prototype._clearStreams = function (mail, callback) {
var streamNodes = [];
function walkNode(node) {
if (node.content && typeof node.content.pipe === 'function') {
streamNodes.push(node);
}
if (node.childNodes && node.childNodes.length) {
node.childNodes.forEach(walkNode);
}
}
walkNode(mail.message);
function resolveNodes() {
if (!streamNodes.length) {
return callback();
}
var node = streamNodes.shift();
mail.resolveContent(node, 'content', function (err) {
if (err) {
return callback(err);
}
setImmediate(resolveNodes);
});
}
resolveNodes();
};
/**
* Resolves MX server for a domain. This solution is somewhat incomplete as
* it only considers the hostname with lowest priority and ignores all the rest
*
* @param {String} domain Domain to resolve the MX to
* @param {Function} callback Callback function to run
*/
DirectMailer.prototype._resolveMx = function (domain, callback) {
domain = domain.replace(/^\[(ipv6:)?|\]$/gi, '');
// Do not try to resolve the domain name if it is an IP address
if (net.isIP(domain)) {
return callback(null, [{
priority: 0,
exchange: domain
}]);
}
dns.resolveMx(domain, function (err, list) {
if (err) {
if (err.code === 'ENODATA' || err.code === 'ENOTFOUND') {
// fallback to A
dns.resolve4(domain, function (err, list) {
if (err) {
if (err.code === 'ENODATA' || err.code === 'ENOTFOUND') {
// fallback to AAAA
dns.resolve6(domain, function (err, list) {
if (err) {
return callback(err);
}
// return the first resolved Ipv6 with priority 0
return callback(null, [].concat(list || []).map(function (entry) {
return {
priority: 0,
exchange: entry
};
}).slice(0, 1));
});
} else {
return callback(err);
}
return;
}
// return the first resolved Ipv4 with priority 0
return callback(null, [].concat(list || []).map(function (entry) {
return {
priority: 0,
exchange: entry
};
}).slice(0, 1));
});
} else {
return callback(err);
}
return;
}
callback(null, list);
});
};
/**
* Resolves current hostname. If resolved name is an IP address, uses 'localhost'.
*
* @param {String} [name] Preferred hostname
* @return {String} Resolved hostname
*/
DirectMailer.prototype._resolveHostname = function (name) {
if (!name || net.isIP(name.replace(/[\[\]]/g, '').trim())) {
name = (os.hostname && os.hostname()) || '';
}
if (!name || net.isIP(name.replace(/[\[\]]/g, '').trim())) {
name = 'localhost';
}
return name.toLowerCase();
};