@abcpros/bitcore-wallet-service
Version:
A service for Mutisig HD Bitcoin Wallets
463 lines (421 loc) • 13.8 kB
text/typescript
import * as async from 'async';
import * as _ from 'lodash';
import 'source-map-support/register';
// This has been changed in favor of @sendgrid. To use nodemail, change the
// sending function from `.send` to `.sendMail`.
// import * as nodemailer from nodemailer';
import { Lock } from './lock';
import logger from './logger';
import { MessageBroker } from './messagebroker';
import { Email } from './model';
import { Storage } from './storage';
export interface Recipient {
copayerId: string;
emailAddress: string;
language: string;
unit: string;
}
const Mustache = require('mustache');
const fs = require('fs');
const path = require('path');
const Utils = require('./common/utils');
const Defaults = require('./common/defaults');
const 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
}
};
export class EmailService {
defaultLanguage: string;
defaultUnit: string;
templatePath: string;
publicTxUrlTemplate: string;
subjectPrefix: string;
from: string;
availableLanguages: string[];
storage: Storage;
messageBroker: MessageBroker;
lock: Lock;
mailer: any;
// mailer: nodemailer.Transporter;
start(opts, cb) {
opts = opts || {};
const _readDirectories = (basePath, cb) => {
fs.readdir(basePath, (err, files) => {
if (err) return cb(err);
async.filter(
files,
(file, next) => {
fs.stat(path.join(basePath, file), (err, stats) => {
return next(!err && stats.isDirectory());
});
},
dirs => {
return cb(null, dirs);
}
);
});
};
opts.emailOpts = opts.emailOpts || {};
this.defaultLanguage = opts.emailOpts.defaultLanguage || 'en';
this.defaultUnit = opts.emailOpts.defaultUnit || 'btc';
logger.info('Email templates at:' + (opts.emailOpts.templatePath || __dirname + '/../../templates') + '/');
this.templatePath = path.normalize((opts.emailOpts.templatePath || __dirname + '/../../templates') + '/');
this.publicTxUrlTemplate = opts.emailOpts.publicTxUrlTemplate || {};
this.subjectPrefix = opts.emailOpts.subjectPrefix || '[Wallet service]';
this.from = opts.emailOpts.from;
async.parallel(
[
done => {
_readDirectories(this.templatePath, (err, res) => {
this.availableLanguages = res;
done(err);
});
},
done => {
if (opts.storage) {
this.storage = opts.storage;
done();
} else {
this.storage = new Storage();
this.storage.connect(opts.storageOpts, done);
}
},
done => {
this.messageBroker = opts.messageBroker || new MessageBroker(opts.messageBrokerOpts);
this.messageBroker.onMessage(_.bind(this.sendEmail, this));
done();
},
done => {
this.lock = opts.lock || new Lock(this.storage);
done();
},
done => {
this.mailer = opts.mailer; // || nodemailer.createTransport(opts.emailOpts);
done();
}
],
err => {
if (err) {
logger.error(err);
}
return cb(err);
}
);
}
_compileTemplate(template, extension) {
const lines = template.split('\n');
if (extension == '.html') {
lines.unshift('');
}
return {
subject: lines[0],
body: _.tail(lines).join('\n')
};
}
_readTemplateFile(language, filename, cb) {
const fullFilename = path.join(this.templatePath, language, filename);
fs.readFile(fullFilename, 'utf8', (err, template) => {
if (err) {
return cb(new Error('Could not read template file ' + fullFilename + err));
}
return cb(null, template);
});
}
// TODO: cache for X minutes
_loadTemplate(emailType, recipient, extension, cb) {
this._readTemplateFile(recipient.language, emailType.filename + extension, (err, template) => {
if (err) return cb(err);
return cb(null, this._compileTemplate(template, extension));
});
}
_applyTemplate(template, data, cb) {
if (!data) return cb(new Error('Could not apply template to empty data'));
let error;
const result = _.mapValues(template, t => {
try {
return Mustache.render(t, data);
} catch (e) {
logger.error('Could not apply data to template', e);
error = e;
}
});
if (error) return cb(error);
return cb(null, result);
}
_getRecipientsList(notification, emailType, cb) {
this.storage.fetchWallet(notification.walletId, (err, wallet) => {
if (err) return cb(err);
this.storage.fetchPreferences(notification.walletId, null, (err, preferences) => {
if (err) return cb(err);
if (_.isEmpty(preferences)) return cb(null, []);
const usedEmails = {};
const recipients = _.compact(
_.map(preferences, 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 (!_.includes(this.availableLanguages, p.language)) {
if (p.language) {
logger.warn('Language for email "' + p.language + '" not available.');
}
p.language = this.defaultLanguage;
}
let unit;
if (wallet.coin != Defaults.COIN) {
unit = wallet.coin;
} else {
unit = p.unit || this.defaultUnit;
}
return {
copayerId: p.copayerId,
emailAddress: p.email,
language: p.language,
unit
};
})
);
return cb(null, recipients);
});
});
}
_getDataForTemplate(notification, recipient, cb) {
// TODO: Declare these in BWU
const UNIT_LABELS = {
btc: 'BTC',
bit: 'bits',
bch: 'BCH',
xec: 'XEC',
eth: 'ETH',
xrp: 'XRP',
doge: 'DOGE',
xpi: 'XPI',
ltc: 'LTC'
};
const data = _.cloneDeep(notification.data);
data.subjectPrefix = _.trim(this.subjectPrefix) + ' ';
if (data.amount) {
try {
const 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));
}
}
this.storage.fetchWallet(notification.walletId, (err, wallet) => {
if (err) return cb(err);
if (!wallet) return cb('no wallet');
data.walletId = wallet.id;
data.walletName = wallet.name;
data.walletM = wallet.m;
data.walletN = wallet.n;
const copayer = wallet.copayers.find(c => c.id == notification.creatorId);
if (copayer) {
data.copayerId = copayer.id;
data.copayerName = copayer.name;
}
if (notification.type == 'TxProposalFinallyRejected' && data.rejectedBy) {
const rejectors = _.map(data.rejectedBy, copayerId => {
const copayer = wallet.copayers.find(c => c.id == copayerId);
return copayer.name;
});
data.rejectorsNames = rejectors.join(', ');
}
if (_.includes(['NewIncomingTx', 'NewOutgoingTx'], notification.type) && data.txid) {
const urlTemplate = this.publicTxUrlTemplate[wallet.coin][wallet.network];
if (urlTemplate) {
try {
data.urlForTx = Mustache.render(urlTemplate, data);
} catch (ex) {
logger.warn('Could not render public url for tx', ex);
}
}
}
return cb(null, data);
});
}
_send(email, cb) {
const mailOptions = {
from: email.from,
to: email.to,
subject: email.subject,
text: email.bodyPlain,
html: undefined
};
if (email.bodyHtml) {
mailOptions.html = email.bodyHtml;
}
this.mailer
.send(mailOptions)
.then(result => {
logger.debug('Message sent: ', result || '');
return cb(null, result);
})
.catch(err => {
let errStr;
try {
errStr = err.toString().substr(0, 100);
} catch (e) {}
logger.warn('An error occurred when trying to send email to ' + email.to, errStr || err);
return cb(err);
});
}
_readAndApplyTemplates(notification, emailType, recipientsList: Recipient[], cb) {
async.map(
recipientsList,
(recipient, next) => {
async.waterfall(
[
next => {
this._getDataForTemplate(notification, recipient, next);
},
(data, next) => {
async.map(
['plain', 'html'],
(type, next) => {
this._loadTemplate(emailType, recipient, '.' + type, (err, template) => {
if (err && type == 'html') return next();
if (err) return next(err);
this._applyTemplate(template, data, (err, res) => {
return next(err, [type, res]);
});
});
},
(err, res: any) => {
return next(err, _.fromPairs(res.filter(Boolean)));
}
);
},
(result, next) => {
next(null, result);
}
],
(err, res) => {
next(err, [recipient.language, res]);
}
);
},
(err, res: any) => {
return cb(err, _.fromPairs(res.filter(Boolean)));
}
);
}
_checkShouldSendEmail(notification, cb) {
if (notification.type != 'NewTxProposal') return cb(null, true);
this.storage.fetchWallet(notification.walletId, (err, wallet) => {
return cb(err, wallet.m > 1);
});
}
sendEmail(notification, cb) {
cb = cb || function() {};
const emailType = EMAIL_TYPES[notification.type];
if (!emailType) return cb();
this._checkShouldSendEmail(notification, (err, should) => {
if (err) return cb(err);
if (!should) return cb();
this._getRecipientsList(notification, emailType, (err, recipientsList: Recipient[]) => {
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.
this.lock.runLocked('email-' + notification.id, {}, cb, cb => {
this.storage.fetchEmailByNotification(notification.id, (err, email) => {
if (err) return cb(err);
if (email) return cb();
async.waterfall(
[
next => {
this._readAndApplyTemplates(notification, emailType, recipientsList, next);
},
(contents, next) => {
async.map(
recipientsList,
(recipient, next) => {
const content = contents[recipient.language];
const email = Email.create({
walletId: notification.walletId,
copayerId: recipient.copayerId,
from: this.from,
to: recipient.emailAddress,
subject: content.plain.subject,
bodyPlain: content.plain.body,
bodyHtml: content.html ? content.html.body : null,
notificationId: notification.id
});
this.storage.storeEmail(email, err => {
return next(err, email);
});
},
next
);
},
(emails, next) => {
async.each(
emails,
(email: any, next) => {
this._send(email, err => {
if (err) {
email.setFail();
} else {
email.setSent();
}
this.storage.storeEmail(email, next);
});
},
err => {
return next();
}
);
}
],
err => {
if (err) {
let errStr;
try {
errStr = err.toString().substr(0, 100);
} catch (e) {}
logger.warn('An error ocurred generating email notification', errStr || err);
}
return cb(err);
}
);
});
});
});
});
}
}
module.exports = EmailService;