// email.js // Copyright (C) 2021 Digital Telepresence, LLC // License: Apache-2.0 'use strict'; import nodemailer from 'nodemailer'; import { v4 as uuidv4 } from 'uuid'; import mongoose from 'mongoose'; const EmailBlacklist = mongoose.model('EmailBlacklist'); const EmailVerify = mongoose.model('EmailVerify'); const EmailLog = mongoose.model('EmailLog'); import disposableEmailDomains from 'disposable-email-provider-domains'; import emailValidator from 'email-validator'; import emailDomainCheck from 'email-domain-check'; import dayjs from 'dayjs'; import numeral from 'numeral'; import { SiteService, SiteError } from '../../lib/site-lib.js'; export default class EmailService extends SiteService { static get slug () { return 'email'; } static get name ( ) { return 'EmailService'; } constructor (dtp) { super(dtp, EmailService); this.populateEmailVerify = [ { path: 'user', select: '_id email username username_lc displayName picture', }, ]; } async start ( ) { await super.start(); const { jobQueue: jobQueueService } = this.dtp.services; if (process.env.DTP_EMAIL_SERVICE !== 'enabled') { this.log.info("DTP_EMAIL_SERVICE is disabled, the system can't send email and will not try."); return; } const SMTP_PORT = parseInt(process.env.DTP_EMAIL_SMTP_PORT || '587', 10); this.log.info('creating SMTP transport', { host: process.env.DTP_EMAIL_SMTP_HOST, port: SMTP_PORT, }); this.transport = nodemailer.createTransport({ host: process.env.DTP_EMAIL_SMTP_HOST, port: SMTP_PORT, secure: process.env.DTP_EMAIL_SMTP_SECURE === 'enabled', auth: { user: process.env.DTP_EMAIL_SMTP_USER, pass: process.env.DTP_EMAIL_SMTP_PASS, }, pool: true, maxConnections: parseInt(process.env.DTP_EMAIL_SMTP_POOL_MAX_CONN || '5', 10), maxMessages: parseInt(process.env.DTP_EMAIL_SMTP_POOL_MAX_MSGS || '5', 10), }); this.templates = { html: { welcome: this.loadAppTemplate('html', 'welcome.pug'), passwordReset: this.loadAppTemplate('html', 'password-reset.pug'), marketingBlast: this.loadAppTemplate('html', 'marketing-blast.pug'), userEmail: this.loadAppTemplate('html', 'user-email.pug'), }, text: { welcome: this.loadAppTemplate('text', 'welcome.pug'), passwordReset: this.loadAppTemplate('text', 'password-reset.pug'), marketingBlast: this.loadAppTemplate('text', 'marketing-blast.pug'), userEmail: this.loadAppTemplate('text', 'user-email.pug'), }, }; this.emailQueue = jobQueueService.getJobQueue( 'email', this.dtp.config.jobQueues.email, ); } /** * Renders a configured email template using the specified template view model * to produce the requested output content type (HTML or text). * * @param {String} templateId the identifier of the template to use * @param {*} templateType Either 'html' or 'text' to indicate the type of * output desired. * @param {*} templateModel The template's view model from which template * data will be inserted into the template being rendered. * @returns the resulting rendered content (HTML or text as requested). */ async renderTemplate (templateId, templateType, templateModel) { const { cache: cacheService } = this.dtp.services; const { config } = this.dtp; const settingsKey = `settings:${config.site.domainKey}:site`; const adminSettings = await cacheService.getObject(settingsKey); templateModel.site = Object.assign({ }, config.site); // defaults and .env templateModel.site = Object.assign(templateModel.site, adminSettings); // admin overrides // this.log.debug('rendering email template', { templateId, templateType }); return this.templates[templateType][templateId](templateModel); } /** * Sends the email message using the configured NodeMailer transport. * The message specifies to, from, subject, html, and text content. * @param {NodeMailerMessage} message the message to be sent. */ async send (message) { const NOW = new Date(); this.log.info('sending email', { to: message.to, subject: message.subject }); const response = await this.transport.sendMail(message); const log = await EmailLog.create({ created: NOW, from: message.from.address || message.from, to: message.to.address || message.to, to_lc: (message.to.address || message.to).toLowerCase(), subject: message.subject, messageId: response.messageId, }); return log.toObject(); } async sendWelcomeEmail (user) { /* * Remove all pending EmailVerify tokens for the User. */ await this.removeVerificationTokensForUser(user); /* * Create the new/only EmailVerify token for the user. This will be the only * token accepted. Previous emails sent (if they were received) are invalid * after this. */ const verifyToken = await this.createVerificationToken(user); /* * Send the welcome email using the new EmailVerify token so it can * construct a new, valid link to use for verifying the email address. */ const templateModel = { site: this.dtp.config.site, recipient: user, emailVerifyToken: verifyToken.token, }; const message = { from: process.env.DTP_EMAIL_SMTP_FROM, to: user.email, subject: `Welcome to ${this.dtp.config.site.name}!`, html: await this.renderTemplate('welcome', 'html', templateModel), text: await this.renderTemplate('welcome', 'text', templateModel), }; await this.send(message); } async sendMassMail (definition) { this.log.info('mass mail definition', { definition }); await this.emailQueue.add('marketing-email', { subject: definition.subject, textMessage: definition.textMessage, htmlMessage: definition.htmlMessage, }); return 0; } /** * Checks an email address for validity and to not be blocked or blacklisted * by the service. Does not return a Boolean. Will instead throw an error if * the address is rejected by the system. * @param {String} emailAddress The email address to be checked. */ async checkEmailAddress (emailAddress) { this.log.debug('validating email address', { emailAddress }); if (!emailValidator.validate(emailAddress)) { throw new Error('Email address is invalid'); } const domainCheck = await emailDomainCheck(emailAddress); this.log.debug('email domain check', { domainCheck }); if (!domainCheck) { throw new Error('Email address is invalid'); } await this.isEmailBlacklisted(emailAddress); } /** * Check if the provided email address is blacklisted either by our database * of known-malicious/spammy emails or by this system itself. * @param {String} emailAddress The email address to be checked * @returns true if the address is blacklisted; false otherwise. */ async isEmailBlacklisted (emailAddress) { emailAddress = emailAddress.toLowerCase().trim(); const domain = emailAddress.split('@')[1]; this.log.debug('checking email domain for blacklist', { domain }); if (disposableEmailDomains.domains.includes(domain)) { this.log.alert('blacklisted email domain blocked', { emailAddress, domain }); throw new Error('Invalid email address'); } const blacklistRecord = await EmailBlacklist.findOne({ email: emailAddress }); if (blacklistRecord) { throw new Error('Email address has requested to not receive emails', { blacklistRecord }); } return false; } /** * Creates an email verification token that can be used to verify the email * address of a new site member. * @param {User} user the user for which an email verification token should * be created. * @returns new email verification token (for use in creating a link to it) */ async createVerificationToken (user) { const NOW = new Date(); const verify = new EmailVerify(); verify.created = NOW; verify.user = user._id; verify.token = uuidv4(); await verify.save(); this.log.info('created email verification token for user', { user: user._id }); return verify.toObject(); } async getVerificationToken (token) { const emailVerify = await EmailVerify .findOne({ token }) .populate(this.populateEmailVerify) .lean(); return emailVerify; } /** * Checks an email verification token for validity, and updates the associated * User's isEmailVerified flag on success. * @param {String} token The token received from the email verification * request. */ async verifyToken (token) { const NOW = new Date(); const { user: userService } = this.dtp.services; // fetch the token from the db const emailVerify = await EmailVerify .findOne({ token: token }) .populate(this.populateEmailVerify) .lean(); // verify that the token is at least valid (it exists) if (!emailVerify) { this.log.error('email verify token not found', { token }); throw new SiteError(403, 'Email verification token is invalid'); } // verify that it hasn't already been verified (user clicked link more than once) if (emailVerify.verified) { this.log.error('email verify token already claimed', { token }); throw new SiteError(403, 'Email verification token is invalid'); } this.log.info('marking user email verified', { userId: emailVerify.user._id }); await userService.setEmailVerification(emailVerify.user, true); await EmailVerify.updateOne({ _id: emailVerify._id }, { $set: { verified: NOW } }); } /** * Removes all pending EmailVerify tokens for the specified user. This will * happen when the User changes their email address or if triggered * administratively on the User account. * * @param {User} user the User for whom all pending verification tokens are * to be removed. */ async removeVerificationTokensForUser (user) { this.log.info('removing all pending email address verification tokens for user', { user: user._id }); await EmailVerify.deleteMany({ user: user._id }); } createMessageModel (viewModel) { const messageModel = { process: { env: process.env, }, config: this.dtp.config, site: this.dtp.config.site, dayjs, numeral, }; return Object.assign(messageModel, viewModel); } }