You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
315 lines
10 KiB
315 lines
10 KiB
// 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);
|
|
}
|
|
}
|