UNPKG

bitcore-wallet-service

Version:
668 lines 28.2 kB
"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