UNPKG

bitcore-wallet-service

Version:
694 lines (642 loc) 22.8 kB
import * as async from 'async'; import juice from 'juice'; import 'source-map-support/register'; import sgMail from '@sendgrid/mail'; import { Constants as ConstantsCWC } from 'crypto-wallet-core'; import fs from 'fs'; import { IncomingMessage } from 'http'; import Mustache from 'mustache'; import * as nodemailer from 'nodemailer'; import path from 'path'; import request from 'request'; import config from '../config'; import { Common } from './common'; import { getIconHtml } from './iconsconfig'; import { Lock } from './lock'; import logger from './logger'; import { MessageBroker } from './messagebroker'; import { Email, Notification, Preferences } from './model'; import { Storage } from './storage'; export interface Recipient { copayerId: string; emailAddress: string; language: string; unit: string; } interface EmailType { filename: string; notifyDoer: boolean; notifyOthers: boolean; }; const Utils = Common.Utils; const Defaults = Common.Defaults; const Constants = Common.Constants; const EMAIL_TYPES: { [key: string]: EmailType } = { 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 } }; export class EmailService { defaultLanguage: string; defaultUnit: string; templatePath: string; masterTemplatePath: string; masterTemplate: string; publicTxUrlTemplate: string; subjectPrefix: string; from: string; availableLanguages: string[]; storage: Storage; messageBroker: MessageBroker; lock: Lock; mailer: nodemailer.Transporter | typeof sgMail; request: request.RequestAPI<any, any, any>; sendMail: (opts) => any; start(opts, cb) { opts = opts || {}; this.request = opts.request || request; opts.emailOpts = opts.emailOpts || {}; this.defaultLanguage = opts.emailOpts.defaultLanguage || 'en'; this.defaultUnit = opts.emailOpts.defaultUnit || 'btc'; this.templatePath = path.normalize(opts.emailOpts.templatePath || path.join(__dirname, '../../templates')); this.masterTemplatePath = path.join(this.templatePath, 'master-template.html'); try { this.masterTemplate = fs.readFileSync(this.masterTemplatePath, 'utf8'); logger.debug('Master template loaded successfully from: %s', this.masterTemplatePath); } catch (err) { logger.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(); this.storage.connect(opts.storageOpts, done); } }, done => { this.messageBroker = opts.messageBroker || new MessageBroker(opts.messageBrokerOpts); this.messageBroker.onMessage(this.sendEmail.bind(this)); done(); }, done => { this.lock = opts.lock || new 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') { sgMail.setApiKey(opts.emailOpts.sendGridApiKey); this.mailer = sgMail; 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.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.error('%o', err); } return cb(err); } ); } _getTemplateLanguages(templatePath) { const contents = fs.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: string, filename: string, 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: EmailType, recipient: Recipient, extension: string, cb) { this._readTemplateFile(recipient.language, emailType.filename + extension, (err, template) => { if (err) { logger.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.render(templateStr, data); } catch (e) { logger.error('Could not apply data to template: %o', e); error = e; } } if (error) return cb(error); return cb(null, result); } _getRecipientsList(notification: Notification, emailType: EmailType, cb) { this.storage.fetchWallet(notification.walletId, (err, wallet) => { if (err) return cb(err); this.storage.fetchPreferences<Preferences[]>(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.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'; // backwards compatibility 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); // filter out falsy values (e.g. undefined) return cb(null, recipients); }); }); } async _getDataForTemplate(notification: Notification, recipient: 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)) as Notification['data']; data.subjectPrefix = this.subjectPrefix.trim() + ' '; // Helper function to properly title case text const toTitleCase = (text: string) => { 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<string>((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 = getIconHtml(templateName, true); if (icon) { data.icon = icon; } if (data.amount) { try { let unit = recipient.unit.toLowerCase(); let label = UNIT_LABELS[unit]; let opts = {} as any; 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.render(urlTemplate, data); } catch (ex) { logger.warn('Could not render public url for tx: %o', ex); } } else { logger.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 = juice(email.bodyHtml, { removeStyleTags: false, preserveImportant: true, preserveMediaQueries: true, preserveFontFaces: true, applyStyleTags: true }); } this.sendMail(mailOptions) .then(result => { result = result[0] instanceof IncomingMessage ? result[0] : result; logger.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.warn('An error occurred when trying to send email to %o %o', email.to, (errStr || err)); return cb(err); }); } _readAndApplyTemplates(notification: Notification, emailType: 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, Utils.fromPairs(res.filter(Boolean))); } ); }, (result, next) => { next(null, result); } ], (err, res) => { next(err, [recipient.language, res]); } ); }, (err, res: any) => { return cb(err, Utils.fromPairs(res.filter(Boolean))); } ); } _checkShouldSendEmail(notification: 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: 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 (!recipientsList?.length) 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(); } catch (e) { } logger.warn('An error ocurred generating email notification: %o', errStr || err); } return cb(err); } ); }); }); }); }); } private oneInchGetCredentials() { if (!config.oneInch) throw new Error('1Inch missing credentials'); const credentials = { API: config.oneInch.api, API_KEY: config.oneInch.apiKey, referrerAddress: config.oneInch.referrerAddress, referrerFee: config.oneInch.referrerFee }; return credentials; } public getTokenData(chain: string) { return new Promise((resolve, reject) => { try { const credentials = this.oneInchGetCredentials(); // Get mainnet chainId const chainId = ConstantsCWC.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) { // oneinch rate limit return reject(); } return resolve(data?.body?.tokens); } ); } catch (err) { return reject(err); } }); } }