bitcore-wallet-service
Version:
A service for Mutisig HD Bitcoin Wallets
668 lines • 28.2 kB
JavaScript
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.EmailService = void 0;
const async = __importStar(require("async"));
const juice_1 = __importDefault(require("juice"));
require("source-map-support/register");
const mail_1 = __importDefault(require("@sendgrid/mail"));
const crypto_wallet_core_1 = require("crypto-wallet-core");
const fs_1 = __importDefault(require("fs"));
const http_1 = require("http");
const mustache_1 = __importDefault(require("mustache"));
const nodemailer = __importStar(require("nodemailer"));
const path_1 = __importDefault(require("path"));
const request_1 = __importDefault(require("request"));
const config_1 = __importDefault(require("../config"));
const common_1 = require("./common");
const iconsconfig_1 = require("./iconsconfig");
const lock_1 = require("./lock");
const logger_1 = __importDefault(require("./logger"));
const messagebroker_1 = require("./messagebroker");
const model_1 = require("./model");
const storage_1 = require("./storage");
;
const Utils = common_1.Common.Utils;
const Defaults = common_1.Common.Defaults;
const Constants = common_1.Common.Constants;
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
},
NewIncomingTxTestnet: {
filename: 'new_incoming_tx_testnet',
notifyDoer: true,
notifyOthers: true
},
NewZeroOutgoingTx: {
filename: 'new_zero_outgoing_tx',
notifyDoer: true,
notifyOthers: true
},
TxProposalFinallyRejected: {
filename: 'txp_finally_rejected',
notifyDoer: false,
notifyOthers: true
},
TxConfirmation: {
filename: 'tx_confirmation',
notifyDoer: true,
notifyOthers: false
},
TxConfirmationReceiver: {
filename: 'tx_confirmation_receiver',
notifyDoer: true,
notifyOthers: false
},
TxConfirmationSender: {
filename: 'tx_confirmation_sender',
notifyDoer: true,
notifyOthers: false
}
};
class EmailService {
start(opts, cb) {
opts = opts || {};
this.request = opts.request || request_1.default;
opts.emailOpts = opts.emailOpts || {};
this.defaultLanguage = opts.emailOpts.defaultLanguage || 'en';
this.defaultUnit = opts.emailOpts.defaultUnit || 'btc';
this.templatePath = path_1.default.normalize(opts.emailOpts.templatePath || path_1.default.join(__dirname, '../../templates'));
this.masterTemplatePath = path_1.default.join(this.templatePath, 'master-template.html');
try {
this.masterTemplate = fs_1.default.readFileSync(this.masterTemplatePath, 'utf8');
logger_1.default.debug('Master template loaded successfully from: %s', this.masterTemplatePath);
}
catch (err) {
logger_1.default.error('Could not load master template from %s: %o', this.masterTemplatePath, err);
return cb(new Error('Could not load master template'));
}
this.publicTxUrlTemplate = opts.emailOpts.publicTxUrlTemplate || {};
this.subjectPrefix = opts.emailOpts.subjectPrefix || '[Wallet service]';
this.from = opts.emailOpts.from;
async.parallel([
done => {
try {
const langs = this._getTemplateLanguages(this.templatePath);
this.availableLanguages = langs;
done();
}
catch (err) {
done(err);
}
},
done => {
if (opts.storage) {
this.storage = opts.storage;
done();
}
else {
this.storage = new storage_1.Storage();
this.storage.connect(opts.storageOpts, done);
}
},
done => {
this.messageBroker = opts.messageBroker || new messagebroker_1.MessageBroker(opts.messageBrokerOpts);
this.messageBroker.onMessage(this.sendEmail.bind(this));
done();
},
done => {
this.lock = opts.lock || new lock_1.Lock(this.storage);
done();
},
done => {
try {
if (opts.emailOpts.mailer === 'nodemailer') {
this.mailer = nodemailer.createTransport(opts.emailOpts);
this.sendMail = this.mailer.sendMail.bind(this.mailer);
}
else if (opts.emailOpts.mailer === 'sendgrid') {
mail_1.default.setApiKey(opts.emailOpts.sendGridApiKey);
this.mailer = mail_1.default;
this.sendMail = this.mailer.send.bind(this.mailer);
}
else if (opts.emailOpts.mailer === 'mailersend') {
this.sendMail = async function (opts) {
return new Promise((resolve, reject) => {
this.request({
url: 'https://api.mailersend.com/v1/email',
method: 'POST',
json: true,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
Authorization: 'Bearer ' + config_1.default.emailOpts.mailerSendApiKey,
},
body: {
from: { email: opts.from },
to: [{ email: opts.to }],
subject: opts.subject,
text: opts.text,
html: opts.html
}
}, (err, data) => {
if (err)
return reject(err);
data = data.toJSON();
if (data?.statusCode >= 200 && data?.statusCode < 300) {
return resolve(data?.body || data);
}
return reject(data?.body || data);
});
});
};
}
else {
throw new Error('Unknown emailOpts.mailer: ' + opts.emailOpts.mailer);
}
}
catch (err) {
return done(err);
}
done();
}
], err => {
if (err) {
logger_1.default.error('%o', err);
}
return cb(err);
});
}
_getTemplateLanguages(templatePath) {
const contents = fs_1.default.readdirSync(templatePath, { withFileTypes: true });
const langs = contents.filter(item => item.isDirectory()).map(item => item.name);
return langs;
}
_compileTemplate(template, extension) {
const lines = template.split('\n');
if (extension == '.html') {
lines.unshift('');
}
return {
subject: lines[0],
body: lines.slice(1).join('\n')
};
}
_readTemplateFile(language, filename, cb) {
const fullFilename = path_1.default.join(this.templatePath, language, filename);
fs_1.default.readFile(fullFilename, 'utf8', (err, template) => {
if (err) {
return cb(new Error('Could not read template file ' + fullFilename + err));
}
return cb(null, template);
});
}
_loadTemplate(emailType, recipient, extension, cb) {
this._readTemplateFile(recipient.language, emailType.filename + extension, (err, template) => {
if (err) {
logger_1.default.error('Could not read template file for language %s: %o', recipient.language, err.message);
return cb(err);
}
const compiled = this._compileTemplate(template, extension);
if (extension === '.html') {
compiled.body = this.masterTemplate.replace('{{> htmlContent}}', compiled.body);
}
return cb(null, compiled);
});
}
_applyTemplate(template, data, cb) {
if (!data)
return cb(new Error('Could not apply template to empty data'));
let error;
const result = {};
for (const [key, templateStr] of Object.entries(template)) {
try {
result[key] = mustache_1.default.render(templateStr, data);
}
catch (e) {
logger_1.default.error('Could not apply data to template: %o', 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 (!preferences?.length)
return cb(null, []);
const usedEmails = {};
const recipients = preferences.map(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 (!this.availableLanguages.includes(p.language)) {
if (p.language) {
logger_1.default.warn('Language for email "' + p.language + '" not available.');
}
p.language = this.defaultLanguage;
}
let unit;
if (wallet.coin != Defaults.COIN) {
switch (wallet.coin) {
case 'pax':
unit = 'usdp';
break;
default:
unit = wallet.coin;
}
}
else {
unit = p.unit || this.defaultUnit;
}
return {
copayerId: p.copayerId,
emailAddress: p.email,
language: p.language,
unit
};
})
.filter(p => !!p);
return cb(null, recipients);
});
});
}
async _getDataForTemplate(notification, recipient, cb) {
const UNIT_LABELS = {
btc: 'BTC',
bit: 'bits',
bch: 'BCH',
eth: 'ETH',
matic: 'MATIC',
xrp: 'XRP',
doge: 'DOGE',
ltc: 'LTC',
usdc: 'USDC',
pyusd: 'PYUSD',
usdp: 'USDP',
gusd: 'GUSD',
busd: 'BUSD',
wbtc: 'WBTC',
dai: 'DAI',
shib: 'SHIB',
ape: 'APE',
euroc: 'EUROC',
usdt: 'USDT',
weth: 'WETH',
'usdc.e': 'USDC.e',
};
const data = JSON.parse(JSON.stringify(notification.data));
data.subjectPrefix = this.subjectPrefix.trim() + ' ';
const toTitleCase = (text) => {
const minorWords = ['a', 'an', 'and', 'as', 'at', 'but', 'by', 'for', 'in', 'of', 'on', 'or', 'the', 'to', 'via'];
return text.split(' ').map((word, index) => {
if (index === 0 || !minorWords.includes(word.toLowerCase())) {
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
}
return word.toLowerCase();
}).join(' ');
};
try {
const plainTemplate = await new Promise((resolve, reject) => {
this._readTemplateFile(recipient.language, `${EMAIL_TYPES[notification.type].filename}.plain`, (err, template) => {
if (err)
reject(err);
else
resolve(template);
});
});
const firstLine = plainTemplate.split('\n')[0];
if (firstLine && firstLine.startsWith('{{subjectPrefix}}')) {
data.title = toTitleCase(firstLine.replace('{{subjectPrefix}}', ''));
}
}
catch (err) {
const templateName = EMAIL_TYPES[notification.type].filename;
data.title = toTitleCase(templateName.split('_').join(' '));
}
const templateName = EMAIL_TYPES[notification.type]?.filename;
const icon = (0, iconsconfig_1.getIconHtml)(templateName, true);
if (icon) {
data.icon = icon;
}
if (data.amount) {
try {
let unit = recipient.unit.toLowerCase();
let label = UNIT_LABELS[unit];
let opts = {};
if (data.tokenAddress) {
const tokenAddress = data.tokenAddress.toLowerCase();
if (Constants.ETH_TOKEN_OPTS[tokenAddress]) {
unit = Constants.ETH_TOKEN_OPTS[tokenAddress].symbol.toLowerCase();
label = UNIT_LABELS[unit];
}
else if (Constants.MATIC_TOKEN_OPTS[tokenAddress]) {
unit = Constants.MATIC_TOKEN_OPTS[tokenAddress].symbol.toLowerCase();
label = UNIT_LABELS[unit];
}
else if (Constants.ARB_TOKEN_OPTS[tokenAddress]) {
unit = Constants.ARB_TOKEN_OPTS[tokenAddress].symbol.toLowerCase();
label = UNIT_LABELS[unit];
}
else if (Constants.OP_TOKEN_OPTS[tokenAddress]) {
unit = Constants.OP_TOKEN_OPTS[tokenAddress].symbol.toLowerCase();
label = UNIT_LABELS[unit];
}
else if (Constants.BASE_TOKEN_OPTS[tokenAddress]) {
unit = Constants.BASE_TOKEN_OPTS[tokenAddress].symbol.toLowerCase();
label = UNIT_LABELS[unit];
}
else {
let customTokensData;
try {
customTokensData = await this.getTokenData(data.address.coin);
}
catch (error) {
return cb(new Error('Could not get custom tokens data'));
}
if (customTokensData && customTokensData[tokenAddress]) {
unit = customTokensData[tokenAddress].symbol.toLowerCase();
label = unit.toUpperCase();
opts.toSatoshis = 10 ** customTokensData[tokenAddress].decimals;
opts.decimals = {
maxDecimals: 6,
minDecimals: 2
};
}
else {
return cb(new Error(`Email Notifications for unsupported tokens are not allowed: ${tokenAddress}`));
}
}
}
data.amount = Utils.formatAmount(+data.amount, unit, opts) + ' ' + label;
}
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 = data.rejectedBy.map(copayerId => {
const copayer = wallet.copayers.find(c => c.id == copayerId);
return copayer.name;
});
data.rejectorsNames = rejectors.join(', ');
}
if (['NewIncomingTx', 'NewOutgoingTx'].includes(notification.type) && data.txid) {
const urlTemplate = this.publicTxUrlTemplate[wallet.chain]?.[wallet.network];
if (urlTemplate) {
try {
data.urlForTx = mustache_1.default.render(urlTemplate, data);
}
catch (ex) {
logger_1.default.warn('Could not render public url for tx: %o', ex);
}
}
else {
logger_1.default.warn(`Could not find template for chain "${wallet.chain}" on network "${wallet.network}"`);
}
}
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 = (0, juice_1.default)(email.bodyHtml, {
removeStyleTags: false,
preserveImportant: true,
preserveMediaQueries: true,
preserveFontFaces: true,
applyStyleTags: true
});
}
this.sendMail(mailOptions)
.then(result => {
result = result[0] instanceof http_1.IncomingMessage ? result[0] : result;
logger_1.default.debug('Message sent: %o %o', mailOptions, result?.toJSON?.() || result || '(no result)');
return cb(null, result);
})
.catch(err => {
let errStr;
try {
errStr = err.toString();
}
catch (e) { }
logger_1.default.warn('An error occurred when trying to send email to %o %o', email.to, (errStr || err));
return cb(err);
});
}
_readAndApplyTemplates(notification, emailType, recipientsList, 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) => {
return next(err, Utils.fromPairs(res.filter(Boolean)));
});
},
(result, next) => {
next(null, result);
}
], (err, res) => {
next(err, [recipient.language, res]);
});
}, (err, res) => {
return cb(err, Utils.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) => {
if (!recipientsList?.length)
return cb();
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 = model_1.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, 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();
}
catch (e) { }
logger_1.default.warn('An error ocurred generating email notification: %o', errStr || err);
}
return cb(err);
});
});
});
});
});
}
oneInchGetCredentials() {
if (!config_1.default.oneInch)
throw new Error('1Inch missing credentials');
const credentials = {
API: config_1.default.oneInch.api,
API_KEY: config_1.default.oneInch.apiKey,
referrerAddress: config_1.default.oneInch.referrerAddress,
referrerFee: config_1.default.oneInch.referrerFee
};
return credentials;
}
getTokenData(chain) {
return new Promise((resolve, reject) => {
try {
const credentials = this.oneInchGetCredentials();
const chainId = crypto_wallet_core_1.Constants.EVM_CHAIN_NETWORK_TO_CHAIN_ID[`${chain.toUpperCase()}_mainnet`];
this.request({
url: `${credentials.API}/v5.2/${chainId}/tokens`,
method: 'GET',
json: true,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
Authorization: 'Bearer ' + credentials.API_KEY,
}
}, (err, data) => {
if (err)
return reject(err);
if (data?.statusCode === 429) {
return reject();
}
return resolve(data?.body?.tokens);
});
}
catch (err) {
return reject(err);
}
});
}
}
exports.EmailService = EmailService;
//# sourceMappingURL=emailservice.js.map