Browse Source

Merge pull request #261 from isocolsky/ref/emails

Ref/emails
activeAddress
Matias Alejo Garcia 10 years ago
parent
commit
1ab1871313
  1. 3
      config.js
  2. 135
      lib/emailservice.js
  3. 14
      lib/model/email.js
  4. 4
      lib/model/preferences.js
  5. 36
      lib/server.js
  6. 0
      lib/templates/en/new_copayer.plain
  7. 0
      lib/templates/en/new_incoming_tx.plain
  8. 0
      lib/templates/en/new_outgoing_tx.plain
  9. 0
      lib/templates/en/new_tx_proposal.plain
  10. 0
      lib/templates/en/txp_finally_rejected.plain
  11. 0
      lib/templates/en/wallet_complete.plain
  12. 2
      lib/templates/es/new_copayer.plain
  13. 2
      lib/templates/es/new_incoming_tx.plain
  14. 2
      lib/templates/es/new_outgoing_tx.plain
  15. 2
      lib/templates/es/new_tx_proposal.plain
  16. 2
      lib/templates/es/txp_finally_rejected.plain
  17. 2
      lib/templates/es/wallet_complete.plain
  18. 2
      lib/templates/fr/new_copayer.plain
  19. 2
      lib/templates/fr/new_incoming_tx.plain
  20. 2
      lib/templates/fr/new_outgoing_tx.plain
  21. 2
      lib/templates/fr/new_tx_proposal.plain
  22. 2
      lib/templates/fr/txp_finally_rejected.plain
  23. 2
      lib/templates/fr/wallet_complete.plain
  24. 2
      lib/templates/ja/new_copayer.plain
  25. 2
      lib/templates/ja/new_incoming_tx.plain
  26. 2
      lib/templates/ja/new_outgoing_tx.plain
  27. 2
      lib/templates/ja/new_tx_proposal.plain
  28. 2
      lib/templates/ja/txp_finally_rejected.plain
  29. 2
      lib/templates/ja/wallet_complete.plain
  30. 104
      test/integration/server.js

3
config.js

@ -52,6 +52,9 @@ var config = {
ignoreTLS: true,
subjectPrefix: '[Wallet Service]',
from: 'wallet-service@bitcore.io',
templatePath: './lib/templates',
defaultLanguage: 'en',
defaultUnit: 'btc',
},
};
module.exports = config;

135
lib/emailservice.js

@ -7,6 +7,7 @@ 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 WalletUtils = require('bitcore-wallet-utils');
@ -49,10 +50,33 @@ 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.defaultLanguage || 'en';
self.defaultUnit = opts.defaultUnit || 'btc';
self.templatePath = path.normalize((opts.templatePath || (__dirname + '/templates')) + '/');
async.parallel([
function(done) {
_readDirectories(self.templatePath, function(err, res) {
self.availableLanguages = res;
done(err);
});
},
function(done) {
if (opts.storage) {
self.storage = opts.storage;
@ -85,19 +109,33 @@ EmailService.prototype.start = function(opts, cb) {
});
};
EmailService.prototype._compileTemplate = function(template) {
var lines = template.split('\n');
return {
subject: lines[0],
body: _.rest(lines).join('\n'),
};
};
// TODO: cache for X minutes
EmailService.prototype._readTemplate = function(filename, cb) {
fs.readFile(__dirname + '/templates/' + filename + '.plain', 'utf8', function(err, template) {
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) {
log.error('Could not read template file ' + filename, err);
return cb(err);
return cb(new Error('Could not read template file ' + fullFilename, err));
}
var lines = template.split('\n');
return cb(null, {
subject: lines[0],
body: _.rest(lines).join('\n'),
});
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));
});
};
@ -126,9 +164,18 @@ EmailService.prototype._getRecipientsList = function(notification, emailType, cb
usedEmails[p.email] = true;
if (notification.creatorId == p.copayerId && !emailType.notifyDoer) return;
if (!_.contains(self.availableLanguages, p.language)) {
if (p.language) {
log.warn('Language for email "' + p.language + '" not available.');
}
p.language = self.defaultLanguage;
}
return {
copayerId: p.copayerId,
emailAddress: p.email
emailAddress: p.email,
language: p.language,
unit: p.unit || self.defaultUnit,
};
}));
@ -136,13 +183,13 @@ EmailService.prototype._getRecipientsList = function(notification, emailType, cb
});
};
EmailService.prototype._getDataForTemplate = function(notification, cb) {
EmailService.prototype._getDataForTemplate = function(notification, recipient, cb) {
var self = this;
var data = _.cloneDeep(notification.data);
data.subjectPrefix = _.trim(self.subjectPrefix) + ' ';
if (data.amount) {
data.amount = WalletUtils.formatAmount(+data.amount, 'bit') + ' bits';
data.amount = WalletUtils.formatAmount(+data.amount, recipient.unit) + ' ' + recipient.unit;
}
self.storage.fetchWallet(notification.walletId, function(err, wallet) {
if (err) return cb(err);
@ -177,8 +224,11 @@ EmailService.prototype._send = function(email, cb) {
from: email.from,
to: email.to,
subject: email.subject,
text: email.body,
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);
@ -189,6 +239,40 @@ EmailService.prototype._send = function(email, cb) {
});
};
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.sendEmail = function(notification, cb) {
var self = this;
@ -211,30 +295,19 @@ EmailService.prototype.sendEmail = function(notification, cb) {
async.waterfall([
function(next) {
async.parallel([
function(next) {
self._readTemplate(emailType.filename, next);
},
function(next) {
self._getDataForTemplate(notification, next);
},
], function(err, res) {
next(err, res[0], res[1]);
});
},
function(template, data, next) {
self._applyTemplate(template, data, next);
self._readAndApplyTemplates(notification, emailType, recipientsList, next);
},
function(content, 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.subject,
body: content.body,
subject: content.plain.subject,
bodyPlain: content.plain.body,
bodyHtml: content.html ? content.html.body : null,
notificationId: notification.id,
});
self.storage.storeEmail(email, function(err) {

14
lib/model/email.js

@ -4,7 +4,7 @@ var _ = require('lodash');
var Uuid = require('uuid');
function Email() {
this.version = '1.0.0';
this.version = '1.0.1';
};
Email.create = function(opts) {
@ -20,11 +20,13 @@ Email.create = function(opts) {
x.from = opts.from;
x.to = opts.to;
x.subject = opts.subject;
x.body = opts.body;
x.bodyPlain = opts.bodyPlain;
x.bodyHtml = opts.bodyHtml;
x.status = 'pending';
x.attempts = 0;
x.lastAttemptOn = null;
x.notificationId = opts.notificationId;
x.language = opts.language || 'en';
return x;
};
@ -38,11 +40,17 @@ Email.fromObj = function(obj) {
x.from = obj.from;
x.to = obj.to;
x.subject = obj.subject;
x.body = obj.body;
if (obj.version == '1.0.0') {
x.bodyPlain = obj.body;
} else {
x.bodyPlain = obj.bodyPlain;
}
x.bodyHtml = obj.bodyHtml;
x.status = obj.status;
x.attempts = obj.attempts;
x.lastAttemptOn = obj.lastAttemptOn;
x.notificationId = obj.notificationId;
x.language = obj.language;
return x;
};

4
lib/model/preferences.js

@ -13,6 +13,8 @@ Preferences.create = function(opts) {
x.walletId = opts.walletId;
x.copayerId = opts.copayerId;
x.email = opts.email;
x.language = opts.language;
x.unit = opts.unit;
return x;
};
@ -23,6 +25,8 @@ Preferences.fromObj = function(obj) {
x.walletId = obj.walletId;
x.copayerId = obj.copayerId;
x.email = obj.email;
x.language = obj.language;
x.unit = obj.unit;
return x;
};

36
lib/server.js

@ -454,16 +454,42 @@ WalletService.prototype.joinWallet = function(opts, cb) {
* Save copayer preferences for the current wallet/copayer pair.
* @param {Object} opts
* @param {string} opts.email - Email address for notifications.
* @param {string} opts.language - Language used for notifications.
* @param {string} opts.unit - Bitcoin unit used to format amounts in notifications.
*/
WalletService.prototype.savePreferences = function(opts, cb) {
var self = this;
opts = opts || {};
if (opts.email) {
if (!EmailValidator.validate(opts.email)) {
return cb(new ClientError('Invalid email address'));
}
var preferences = [{
name: 'email',
isValid: function(value) {
return EmailValidator.validate(value);
},
}, {
name: 'language',
isValid: function(value) {
return _.isString(value) && value.length == 2;
},
}, {
name: 'unit',
isValid: function(value) {
return _.isString(value) && _.contains(['btc', 'bit'], value.toLowerCase());
},
}];
try {
_.each(preferences, function(preference) {
var value = opts[preference.name];
if (!value) return;
if (!preference.isValid(value)) {
throw 'Invalid ' + preference.name;
return false;
}
});
} catch (ex) {
return cb(new ClientError(ex));
}
self._runLocked(cb, function(cb) {
@ -471,6 +497,8 @@ WalletService.prototype.savePreferences = function(opts, cb) {
walletId: self.walletId,
copayerId: self.copayerId,
email: opts.email,
language: opts.language,
unit: opts.unit,
});
self.storage.storePreferences(preferences, function(err) {
return cb(err);

0
lib/templates/new_copayer.plain → lib/templates/en/new_copayer.plain

0
lib/templates/new_incoming_tx.plain → lib/templates/en/new_incoming_tx.plain

0
lib/templates/new_outgoing_tx.plain → lib/templates/en/new_outgoing_tx.plain

0
lib/templates/new_tx_proposal.plain → lib/templates/en/new_tx_proposal.plain

0
lib/templates/txp_finally_rejected.plain → lib/templates/en/txp_finally_rejected.plain

0
lib/templates/wallet_complete.plain → lib/templates/en/wallet_complete.plain

2
lib/templates/es/new_copayer.plain

@ -0,0 +1,2 @@
{{subjectPrefix}}Nuevo copayer
Un nuevo copayer ha ingresado a su monedero {{walletName}}.

2
lib/templates/es/new_incoming_tx.plain

@ -0,0 +1,2 @@
{{subjectPrefix}}Nuevo pago recibido
Un pago de {{amount}} fue recibido en su monedero {{walletName}}.

2
lib/templates/es/new_outgoing_tx.plain

@ -0,0 +1,2 @@
{{subjectPrefix}}Pago enviado
Un pago de {{amount}} ha sido enviado de su monedero {{walletName}}.

2
lib/templates/es/new_tx_proposal.plain

@ -0,0 +1,2 @@
{{subjectPrefix}}Nueva propuesta de pago
Una nueva propuesta de pago ha sido creada en su monedero {{walletName}} por {{copayerName}}.

2
lib/templates/es/txp_finally_rejected.plain

@ -0,0 +1,2 @@
{{subjectPrefix}}Propuesta de pago rechazada
Una propuesta de pago en su monedero {{walletName}} ha sido rechazada por {{rejectorsNames}}.

2
lib/templates/es/wallet_complete.plain

@ -0,0 +1,2 @@
{{subjectPrefix}}Monedero completo
Su monedero {{walletName}} está completo.

2
lib/templates/fr/new_copayer.plain

@ -0,0 +1,2 @@
{{subjectPrefix}}Nouveau copayer
Un nouveau copayer vient de rejoindre votre portefeuille {{walletName}}.

2
lib/templates/fr/new_incoming_tx.plain

@ -0,0 +1,2 @@
{{subjectPrefix}}Nouveau paiement reçu
Un paiement de {{amount}} a été reçu dans votre portefeuille {{walletName}}.

2
lib/templates/fr/new_outgoing_tx.plain

@ -0,0 +1,2 @@
{{subjectPrefix}}Paiement envoyé
Un paiement de {{amount}} a été envoyé de votre portefeuille {{walletName}}.

2
lib/templates/fr/new_tx_proposal.plain

@ -0,0 +1,2 @@
{{subjectPrefix}}Nouvelle proposition de paiement
Une nouvelle proposition de paiement a été créée dans votre portefeuille {{walletName}} par {{copayerName}}.

2
lib/templates/fr/txp_finally_rejected.plain

@ -0,0 +1,2 @@
{{subjectPrefix}}Proposition de paiement rejetée
Une proposition de paiement dans votre portefeuille {{walletName}} a été rejetée par {{rejectorsNames}}.

2
lib/templates/fr/wallet_complete.plain

@ -0,0 +1,2 @@
{{subjectPrefix}}Portefeuille terminé
Votre portefeuille {{walletName}} est terminé.

2
lib/templates/ja/new_copayer.plain

@ -0,0 +1,2 @@
{{subjectPrefix}}ウォレットメンバー参加の知らせ
「{{walletName}}」のウォレットに新しいメンバーが加わりました。

2
lib/templates/ja/new_incoming_tx.plain

@ -0,0 +1,2 @@
{{subjectPrefix}}着金確認の知らせ
{{amount}} のビットコインがウォレット「{{walletName}}」に着金しました。

2
lib/templates/ja/new_outgoing_tx.plain

@ -0,0 +1,2 @@
{{subjectPrefix}}送金のお知らせ
{{amount}}のビットコインがウォレット「{{walletName}}」から送金されました。

2
lib/templates/ja/new_tx_proposal.plain

@ -0,0 +1,2 @@
{{subjectPrefix}}送金の新規提案のお知らせ
「{{walletName}}」のウォレットにおいて {{copayerName}} さんが送金の提案をしました。

2
lib/templates/ja/txp_finally_rejected.plain

@ -0,0 +1,2 @@
{{subjectPrefix}}送金提案の却下のお知らせ
「{{walletName}}」のウォレットにおいて {{rejectorsNames}} さんが送金の提案を却下しました。

2
lib/templates/ja/wallet_complete.plain

@ -0,0 +1,2 @@
{{subjectPrefix}}ウォレット作成完了
あなたの新しいウォレット「{{walletName}}」が完成されました。

104
test/integration/server.js

@ -321,6 +321,7 @@ describe('Wallet service', function() {
helpers.getAuthServer(copayer.id, function(server) {
server.savePreferences({
email: 'copayer' + (++i) + '@domain.com',
unit: 'bit',
}, next);
});
}, function(err) {
@ -349,6 +350,14 @@ describe('Wallet service', function() {
});
it('should notify copayers a new tx proposal has been created', function(done) {
var _readTemplateFile_old = emailService._readTemplateFile;
emailService._readTemplateFile = function(language, filename, cb) {
if (_.endsWith(filename, '.html')) {
return cb(null, 'Subject\n<html><body>{{walletName}}</body></html>');
} else {
_readTemplateFile_old.call(emailService, language, filename, cb);
}
};
helpers.stubUtxos(server, wallet, [1, 1], function() {
var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 0.8, 'some message', TestData.copayers[0].privKey_1H_0);
server.createTx(txOpts, function(err, tx) {
@ -365,9 +374,13 @@ describe('Wallet service', function() {
one.subject.should.contain('New payment proposal');
one.text.should.contain(wallet.name);
one.text.should.contain(wallet.copayers[0].name);
should.exist(one.html);
one.html.should.contain('<body>');
one.html.should.contain(wallet.name);
server.storage.fetchUnsentEmails(function(err, unsent) {
should.not.exist(err);
unsent.should.be.empty;
emailService._readTemplateFile = _readTemplateFile_old;
done();
});
}, 100);
@ -543,6 +556,49 @@ describe('Wallet service', function() {
});
});
it('should build each email using preferences of the copayers', function(done) {
// Set same email address for copayer1 and copayer2
server.savePreferences({
email: 'copayer1@domain.com',
language: 'es',
unit: 'btc',
}, function(err) {
server.createAddress({}, function(err, address) {
should.not.exist(err);
// Simulate incoming tx notification
server._notify('NewIncomingTx', {
txid: '999',
address: address,
amount: 12300000,
}, function(err) {
setTimeout(function() {
var calls = mailerStub.sendMail.getCalls();
calls.length.should.equal(3);
var emails = _.map(calls, function(c) {
return c.args[0];
});
var spanish = _.find(emails, {
to: 'copayer1@domain.com'
});
spanish.from.should.equal('bws@dummy.net');
spanish.subject.should.contain('Nuevo pago recibido');
spanish.text.should.contain(wallet.name);
spanish.text.should.contain('0.123 btc');
var english = _.find(emails, {
to: 'copayer2@domain.com'
});
english.from.should.equal('bws@dummy.net');
english.subject.should.contain('New payment received');
english.text.should.contain(wallet.name);
english.text.should.contain('123,000 bit');
done();
}, 100);
});
});
});
});
it('should support multiple emailservice instances running concurrently', function(done) {
var emailService2 = new EmailService();
emailService2.start({
@ -1099,13 +1155,17 @@ describe('Wallet service', function() {
it('should save & retrieve preferences', function(done) {
server.savePreferences({
email: 'dummy@dummy.com'
email: 'dummy@dummy.com',
language: 'es',
unit: 'bit',
}, function(err) {
should.not.exist(err);
server.getPreferences({}, function(err, preferences) {
should.not.exist(err);
should.exist(preferences);
preferences.email.should.equal('dummy@dummy.com');
preferences.language.should.equal('es');
preferences.unit.should.equal('bit');
done();
});
});
@ -1125,20 +1185,40 @@ describe('Wallet service', function() {
});
});
it.skip('should save preferences only for requesting wallet', function(done) {});
it('should validate email address', function(done) {
server.savePreferences({
email: ' '
}, function(err) {
should.exist(err);
err.message.should.contain('email');
server.savePreferences({
it('should validate entries', function(done) {
var invalid = [{
preferences: {
email: ' ',
},
expected: 'email'
}, {
preferences: {
email: 'dummy@' + _.repeat('domain', 50),
}, function(err) {
},
expected: 'email'
}, {
preferences: {
language: 'xxxxx',
},
expected: 'language'
}, {
preferences: {
language: 123,
},
expected: 'language'
}, {
preferences: {
unit: 'xxxxx',
},
expected: 'unit'
}, ];
async.each(invalid, function(item, next) {
server.savePreferences(item.preferences, function(err) {
should.exist(err);
err.message.should.contain('email');
done();
err.message.should.contain(item.expected);
next();
});
});
}, done);
});
});

Loading…
Cancel
Save