|
|
|
'use strict';
|
|
|
|
|
|
|
|
var _ = require('lodash');
|
|
|
|
var $ = require('preconditions').singleton();
|
|
|
|
var async = require('async');
|
|
|
|
var Mustache = require('mustache');
|
|
|
|
var log = require('npmlog');
|
|
|
|
log.debug = log.verbose;
|
|
|
|
var fs = require('fs');
|
|
|
|
var path = require('path');
|
|
|
|
var nodemailer = require('nodemailer');
|
|
|
|
|
|
|
|
var Utils = require('./common/utils');
|
|
|
|
var Defaults = require('./common/defaults');
|
|
|
|
var Storage = require('./storage');
|
|
|
|
var MessageBroker = require('./messagebroker');
|
|
|
|
var Lock = require('./lock');
|
|
|
|
|
|
|
|
var Model = require('./model');
|
|
|
|
|
|
|
|
var EMAIL_TYPES = {
|
|
|
|
'NewCopayer': {
|
|
|
|
filename: 'new_copayer',
|
|
|
|
notifyDoer: false,
|
|
|
|
notifyOthers: true,
|
|
|
|
},
|
|
|
|
'WalletComplete': {
|
|
|
|
filename: 'wallet_complete',
|
|
|
|
notifyDoer: true,
|
|
|
|
notifyOthers: true,
|
|
|
|
},
|
|
|
|
'NewTxProposal': {
|
|
|
|
filename: 'new_tx_proposal',
|
|
|
|
notifyDoer: false,
|
|
|
|
notifyOthers: true,
|
|
|
|
},
|
|
|
|
'NewOutgoingTx': {
|
|
|
|
filename: 'new_outgoing_tx',
|
|
|
|
notifyDoer: true,
|
|
|
|
notifyOthers: true,
|
|
|
|
},
|
|
|
|
'NewIncomingTx': {
|
|
|
|
filename: 'new_incoming_tx',
|
|
|
|
notifyDoer: true,
|
|
|
|
notifyOthers: true,
|
|
|
|
},
|
|
|
|
'TxProposalFinallyRejected': {
|
|
|
|
filename: 'txp_finally_rejected',
|
|
|
|
notifyDoer: false,
|
|
|
|
notifyOthers: true,
|
|
|
|
},
|
|
|
|
'TxConfirmation': {
|
|
|
|
filename: 'tx_confirmation',
|
|
|
|
notifyDoer: true,
|
|
|
|
notifyOthers: false,
|
|
|
|
},
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
function EmailService() {};
|
|
|
|
|
|
|
|
EmailService.prototype.start = function(opts, cb) {
|
|
|
|
opts = opts || {};
|
|
|
|
|
|
|
|
function _readDirectories(basePath, cb) {
|
|
|
|
fs.readdir(basePath, function(err, files) {
|
|
|
|
if (err) return cb(err);
|
|
|
|
async.filter(files, function(file, next) {
|
|
|
|
fs.stat(path.join(basePath, file), function(err, stats) {
|
|
|
|
return next(!err && stats.isDirectory());
|
|
|
|
});
|
|
|
|
}, function(dirs) {
|
|
|
|
return cb(null, dirs);
|
|
|
|
});
|
|
|
|
});
|
|
|
|
};
|
|
|
|
|
|
|
|
var self = this;
|
|
|
|
|
|
|
|
self.defaultLanguage = opts.emailOpts.defaultLanguage || 'en';
|
|
|
|
self.defaultUnit = opts.emailOpts.defaultUnit || 'btc';
|
|
|
|
self.templatePath = path.normalize((opts.emailOpts.templatePath || (__dirname + '/templates')) + '/');
|
|
|
|
self.publicTxUrlTemplate = opts.emailOpts.publicTxUrlTemplate || {};
|
|
|
|
self.subjectPrefix = opts.emailOpts.subjectPrefix || '[Wallet service]';
|
|
|
|
self.from = opts.emailOpts.from;
|
|
|
|
|
|
|
|
async.parallel([
|
|
|
|
|
|
|
|
function(done) {
|
|
|
|
_readDirectories(self.templatePath, function(err, res) {
|
|
|
|
self.availableLanguages = res;
|
|
|
|
done(err);
|
|
|
|
});
|
|
|
|
},
|
|
|
|
function(done) {
|
|
|
|
if (opts.storage) {
|
|
|
|
self.storage = opts.storage;
|
|
|
|
done();
|
|
|
|
} else {
|
|
|
|
self.storage = new Storage();
|
|
|
|
self.storage.connect(opts.storageOpts, done);
|
|
|
|
}
|
|
|
|
},
|
|
|
|
function(done) {
|
|
|
|
self.messageBroker = opts.messageBroker || new MessageBroker(opts.messageBrokerOpts);
|
|
|
|
self.messageBroker.onMessage(_.bind(self.sendEmail, self));
|
|
|
|
done();
|
|
|
|
},
|
|
|
|
function(done) {
|
|
|
|
self.lock = opts.lock || new Lock(opts.lockOpts);
|
|
|
|
done();
|
|
|
|
},
|
|
|
|
function(done) {
|
|
|
|
self.mailer = opts.mailer || nodemailer.createTransport(opts.emailOpts);
|
|
|
|
done();
|
|
|
|
},
|
|
|
|
], function(err) {
|
|
|
|
if (err) {
|
|
|
|
log.error(err);
|
|
|
|
}
|
|
|
|
return cb(err);
|
|
|
|
});
|
|
|
|
};
|
|
|
|
|
|
|
|
EmailService.prototype._compileTemplate = function(template, extension) {
|
|
|
|
var lines = template.split('\n');
|
|
|
|
if (extension == '.html') {
|
|
|
|
lines.unshift('');
|
|
|
|
}
|
|
|
|
return {
|
|
|
|
subject: lines[0],
|
|
|
|
body: _.rest(lines).join('\n'),
|
|
|
|
};
|
|
|
|
};
|
|
|
|
|
|
|
|
EmailService.prototype._readTemplateFile = function(language, filename, cb) {
|
|
|
|
var self = this;
|
|
|
|
|
|
|
|
var fullFilename = path.join(self.templatePath, language, filename);
|
|
|
|
fs.readFile(fullFilename, 'utf8', function(err, template) {
|
|
|
|
if (err) {
|
|
|
|
return cb(new Error('Could not read template file ' + fullFilename, err));
|
|
|
|
}
|
|
|
|
return cb(null, template);
|
|
|
|
});
|
|
|
|
};
|
|
|
|
|
|
|
|
// TODO: cache for X minutes
|
|
|
|
EmailService.prototype._loadTemplate = function(emailType, recipient, extension, cb) {
|
|
|
|
var self = this;
|
|
|
|
|
|
|
|
self._readTemplateFile(recipient.language, emailType.filename + extension, function(err, template) {
|
|
|
|
if (err) return cb(err);
|
|
|
|
return cb(null, self._compileTemplate(template, extension));
|
|
|
|
});
|
|
|
|
};
|
|
|
|
|
|
|
|
EmailService.prototype._applyTemplate = function(template, data, cb) {
|
|
|
|
if (!data) return cb(new Error('Could not apply template to empty data'));
|
|
|
|
|
|
|
|
var error;
|
|
|
|
var result = _.mapValues(template, function(t) {
|
|
|
|
try {
|
|
|
|
return Mustache.render(t, data);
|
|
|
|
} catch (e) {
|
|
|
|
log.error('Could not apply data to template', e);
|
|
|
|
error = e;
|
|
|
|
}
|
|
|
|
});
|
|
|
|
if (error) return cb(error);
|
|
|
|
return cb(null, result);
|
|
|
|
};
|
|
|
|
|
|
|
|
EmailService.prototype._getRecipientsList = function(notification, emailType, cb) {
|
|
|
|
var self = this;
|
|
|
|
|
|
|
|
self.storage.fetchWallet(notification.walletId, function(err, wallet) {
|
|
|
|
if (err) return cb(err);
|
|
|
|
|
|
|
|
self.storage.fetchPreferences(notification.walletId, null, function(err, preferences) {
|
|
|
|
if (err) return cb(err);
|
|
|
|
if (_.isEmpty(preferences)) return cb(null, []);
|
|
|
|
|
|
|
|
var usedEmails = {};
|
|
|
|
var recipients = _.compact(_.map(preferences, function(p) {
|
|
|
|
if (!p.email || usedEmails[p.email]) return;
|
|
|
|
|
|
|
|
usedEmails[p.email] = true;
|
|
|
|
if (notification.creatorId == p.copayerId && !emailType.notifyDoer) return;
|
|
|
|
if (notification.creatorId != p.copayerId && !emailType.notifyOthers) return;
|
|
|
|
if (!_.contains(self.availableLanguages, p.language)) {
|
|
|
|
if (p.language) {
|
|
|
|
log.warn('Language for email "' + p.language + '" not available.');
|
|
|
|
}
|
|
|
|
p.language = self.defaultLanguage;
|
|
|
|
}
|
|
|
|
|
|
|
|
var unit;
|
|
|
|
if (wallet.coin != Defaults.COIN) {
|
|
|
|
unit = wallet.coin;
|
|
|
|
} else {
|
|
|
|
unit = p.unit || self.defaultUnit;
|
|
|
|
}
|
|
|
|
|
|
|
|
return {
|
|
|
|
copayerId: p.copayerId,
|
|
|
|
emailAddress: p.email,
|
|
|
|
language: p.language,
|
|
|
|
unit: unit,
|
|
|
|
};
|
|
|
|
}));
|
|
|
|
|
|
|
|
return cb(null, recipients);
|
|
|
|
});
|
|
|
|
});
|
|
|
|
};
|
|
|
|
|
|
|
|
EmailService.prototype._getDataForTemplate = function(notification, recipient, cb) {
|
|
|
|
var self = this;
|
|
|
|
|
|
|
|
// TODO: Declare these in BWU
|
|
|
|
var UNIT_LABELS = {
|
|
|
|
btc: 'BTC',
|
|
|
|
bit: 'bits',
|
|
|
|
bch: 'BCH',
|
|
|
|
};
|
|
|
|
|
|
|
|
var data = _.cloneDeep(notification.data);
|
|
|
|
data.subjectPrefix = _.trim(self.subjectPrefix) + ' ';
|
|
|
|
if (data.amount) {
|
|
|
|
try {
|
|
|
|
var unit = recipient.unit.toLowerCase();
|
|
|
|
data.amount = Utils.formatAmount(+data.amount, unit) + ' ' + UNIT_LABELS[unit];
|
|
|
|
} catch (ex) {
|
|
|
|
return cb(new Error('Could not format amount', ex));
|
|
|
|
}
|
|
|
|
}
|
|
|
|
self.storage.fetchWallet(notification.walletId, function(err, wallet) {
|
|
|
|
if (err) return cb(err);
|
|
|
|
data.walletId = wallet.id;
|
|
|
|
data.walletName = wallet.name;
|
|
|
|
data.walletM = wallet.m;
|
|
|
|
data.walletN = wallet.n;
|
|
|
|
var copayer = _.find(wallet.copayers, {
|
|
|
|
id: notification.creatorId
|
|
|
|
});
|
|
|
|
if (copayer) {
|
|
|
|
data.copayerId = copayer.id;
|
|
|
|
data.copayerName = copayer.name;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (notification.type == 'TxProposalFinallyRejected' && data.rejectedBy) {
|
|
|
|
var rejectors = _.map(data.rejectedBy, function(copayerId) {
|
|
|
|
return _.find(wallet.copayers, {
|
|
|
|
id: copayerId
|
|
|
|
}).name
|
|
|
|
});
|
|
|
|
data.rejectorsNames = rejectors.join(', ');
|
|
|
|
}
|
|
|
|
|
|
|
|
if (_.contains(['NewIncomingTx', 'NewOutgoingTx'], notification.type) && data.txid) {
|
|
|
|
var urlTemplate = self.publicTxUrlTemplate[wallet.network];
|
|
|
|
if (urlTemplate) {
|
|
|
|
try {
|
|
|
|
data.urlForTx = Mustache.render(urlTemplate, data);
|
|
|
|
} catch (ex) {
|
|
|
|
log.warn('Could not render public url for tx', ex);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return cb(null, data);
|
|
|
|
});
|
|
|
|
};
|
|
|
|
|
|
|
|
EmailService.prototype._send = function(email, cb) {
|
|
|
|
var self = this;
|
|
|
|
|
|
|
|
var mailOptions = {
|
|
|
|
from: email.from,
|
|
|
|
to: email.to,
|
|
|
|
subject: email.subject,
|
|
|
|
text: email.bodyPlain,
|
|
|
|
};
|
|
|
|
if (email.bodyHtml) {
|
|
|
|
mailOptions.html = email.bodyHtml;
|
|
|
|
}
|
|
|
|
self.mailer.sendMail(mailOptions, function(err, result) {
|
|
|
|
if (err) {
|
|
|
|
log.error('An error occurred when trying to send email to ' + email.to, err);
|
|
|
|
return cb(err);
|
|
|
|
}
|
|
|
|
log.debug('Message sent: ', result || '');
|
|
|
|
return cb(err, result);
|
|
|
|
});
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
EmailService.prototype._readAndApplyTemplates = function(notification, emailType, recipientsList, cb) {
|
|
|
|
var self = this;
|
|
|
|
|
|
|
|
async.map(recipientsList, function(recipient, next) {
|
|
|
|
async.waterfall([
|
|
|
|
|
|
|
|
function(next) {
|
|
|
|
self._getDataForTemplate(notification, recipient, next);
|
|
|
|
},
|
|
|
|
function(data, next) {
|
|
|
|
async.map(['plain', 'html'], function(type, next) {
|
|
|
|
self._loadTemplate(emailType, recipient, '.' + type, function(err, template) {
|
|
|
|
if (err && type == 'html') return next();
|
|
|
|
if (err) return next(err);
|
|
|
|
self._applyTemplate(template, data, function(err, res) {
|
|
|
|
return next(err, [type, res]);
|
|
|
|
});
|
|
|
|
});
|
|
|
|
}, function(err, res) {
|
|
|
|
return next(err, _.zipObject(res));
|
|
|
|
});
|
|
|
|
},
|
|
|
|
function(result, next) {
|
|
|
|
next(null, result);
|
|
|
|
},
|
|
|
|
], function(err, res) {
|
|
|
|
next(err, [recipient.language, res]);
|
|
|
|
});
|
|
|
|
}, function(err, res) {
|
|
|
|
return cb(err, _.zipObject(res));
|
|
|
|
});
|
|
|
|
};
|
|
|
|
|
|
|
|
EmailService.prototype._checkShouldSendEmail = function(notification, cb) {
|
|
|
|
var self = this;
|
|
|
|
|
|
|
|
if (notification.type != 'NewTxProposal') return cb(null, true);
|
|
|
|
self.storage.fetchWallet(notification.walletId, function(err, wallet) {
|
|
|
|
return cb(err, wallet.m > 1);
|
|
|
|
});
|
|
|
|
};
|
|
|
|
|
|
|
|
EmailService.prototype.sendEmail = function(notification, cb) {
|
|
|
|
var self = this;
|
|
|
|
|
|
|
|
cb = cb || function() {};
|
|
|
|
|
|
|
|
var emailType = EMAIL_TYPES[notification.type];
|
|
|
|
if (!emailType) return cb();
|
|
|
|
|
|
|
|
self._checkShouldSendEmail(notification, function(err, should) {
|
|
|
|
if (err) return cb(err);
|
|
|
|
if (!should) return cb();
|
|
|
|
|
|
|
|
self._getRecipientsList(notification, emailType, function(err, recipientsList) {
|
|
|
|
if (_.isEmpty(recipientsList)) return cb();
|
|
|
|
|
|
|
|
// TODO: Optimize so one process does not have to wait until all others are done
|
|
|
|
// Instead set a flag somewhere in the db to indicate that this process is free
|
|
|
|
// to serve another request.
|
|
|
|
self.lock.runLocked('email-' + notification.id, cb, function(cb) {
|
|
|
|
self.storage.fetchEmailByNotification(notification.id, function(err, email) {
|
|
|
|
if (err) return cb(err);
|
|
|
|
if (email) return cb();
|
|
|
|
|
|
|
|
async.waterfall([
|
|
|
|
|
|
|
|
function(next) {
|
|
|
|
self._readAndApplyTemplates(notification, emailType, recipientsList, next);
|
|
|
|
},
|
|
|
|
function(contents, next) {
|
|
|
|
async.map(recipientsList, function(recipient, next) {
|
|
|
|
var content = contents[recipient.language];
|
|
|
|
var email = Model.Email.create({
|
|
|
|
walletId: notification.walletId,
|
|
|
|
copayerId: recipient.copayerId,
|
|
|
|
from: self.from,
|
|
|
|
to: recipient.emailAddress,
|
|
|
|
subject: content.plain.subject,
|
|
|
|
bodyPlain: content.plain.body,
|
|
|
|
bodyHtml: content.html ? content.html.body : null,
|
|
|
|
notificationId: notification.id,
|
|
|
|
});
|
|
|
|
self.storage.storeEmail(email, function(err) {
|
|
|
|
return next(err, email);
|
|
|
|
});
|
|
|
|
}, next);
|
|
|
|
},
|
|
|
|
function(emails, next) {
|
|
|
|
async.each(emails, function(email, next) {
|
|
|
|
self._send(email, function(err) {
|
|
|
|
if (err) {
|
|
|
|
email.setFail();
|
|
|
|
} else {
|
|
|
|
email.setSent();
|
|
|
|
}
|
|
|
|
self.storage.storeEmail(email, next);
|
|
|
|
});
|
|
|
|
}, function(err) {
|
|
|
|
return next();
|
|
|
|
});
|
|
|
|
},
|
|
|
|
], function(err) {
|
|
|
|
if (err) {
|
|
|
|
log.error('An error ocurred generating email notification', err);
|
|
|
|
}
|
|
|
|
return cb(err);
|
|
|
|
});
|
|
|
|
});
|
|
|
|
});
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
module.exports = EmailService;
|