@ -14,25 +14,25 @@ const require = createRequire(import.meta.url); // jshint ignore:line
import nodemailer from "nodemailer" ;
import Mail from "nodemailer/lib/mailer" ;
import mongoose from 'mongoose' ;
import Bull from 'bull' ;
import pug from 'pug' ;
import mongoose from "mongoose" ;
import Bull from "bull" ;
import pug from "pug" ;
import dayjs from 'dayjs' ;
import emailValidator from 'email-validator' ;
const emailDomainCheck = require ( 'email-domain-check' ) ;
import numeral from 'numeral' ;
import dayjs from "dayjs" ;
import emailValidator from "email-validator" ;
const emailDomainCheck = require ( "email-domain-check" ) ;
import numeral from "numeral" ;
import { IEmailBlacklist } from '../../app/models/email-blacklist.js' ;
const EmailBlacklist = mongoose . model < IEmailBlacklist > ( 'EmailBlacklist' ) ;
import { IEmailBlacklist } from "../../app/models/email-blacklist.js" ;
const EmailBlacklist = mongoose . model < IEmailBlacklist > ( "EmailBlacklist" ) ;
import { IEmailVerify } from '../../app/models/email-verify.js' ;
const EmailVerify = mongoose . model < IEmailVerify > ( 'EmailVerify' ) ;
import { IEmailVerify } from "../../app/models/email-verify.js" ;
const EmailVerify = mongoose . model < IEmailVerify > ( "EmailVerify" ) ;
import { IEmailLog } from '../../app/models/email-log.js' ;
const EmailLog = mongoose . model < IEmailLog > ( 'EmailLog' ) ;
import { IEmailLog } from "../../app/models/email-log.js" ;
const EmailLog = mongoose . model < IEmailLog > ( "EmailLog" ) ;
import { IUser } from '../../app/models/user.js' ;
import { IUser } from "../../app/models/user.js" ;
import {
DtpTools ,
@ -40,11 +40,11 @@ import {
DtpService ,
WebError ,
WebViewModel ,
} from '../../lib/dtplib.js' ;
} from "../../lib/dtplib.js" ;
import { UserService } from './user.js' ;
import { JobQueueService } from './job-queue.js' ;
import { CacheService } from './cache.js' ;
import { UserService } from "./user.js" ;
import { JobQueueService } from "./job-queue.js" ;
import { CacheService } from "./cache.js" ;
export type EmailAddress = string | Mail . Address ;
@ -58,48 +58,56 @@ export type EmailMessage = {
} ;
export class EmailService extends DtpService {
static get name ( ) { return 'EmailService' ; }
static get slug ( ) { return 'email' ; }
static get name() {
return "EmailService" ;
}
static get slug() {
return "email" ;
}
transport? : nodemailer.Transporter ;
emailQueue? : Bull.Queue ;
disposableEmailDomains? : Array < string > ;
templates : Record < string , Record < string , pug.compileTemplate > > = { } ;
templates : Record < string , Record < string , pug.compileTemplate > > = { } ;
populateEmailVerify : Array < mongoose.PopulateOptions > = [ ] ;
populateEmailVerify : Array < mongoose.PopulateOptions > = [ ] ;
constructor ( platform : DtpPlatform ) {
constructor ( platform : DtpPlatform ) {
super ( platform , EmailService ) ;
}
async start ( ) {
async start() {
await super . start ( ) ;
const domainFile = path . resolve (
env . root ,
'node_modules' ,
'disposable-email-provider-domains' ,
'data' ,
'domains.json' ,
"node_modules" ,
"disposable-email-provider-domains" ,
"data" ,
"domains.json"
) ;
this . disposableEmailDomains = await DtpTools . readJsonFile ( domainFile ) as Array < string > ;
this . disposableEmailDomains = ( await DtpTools . readJsonFile (
domainFile
) ) as Array < string > ;
const userService = this . platform . components . getService < UserService > ( 'user' ) ;
const userService =
this . platform . components . getService < UserService > ( "user" ) ;
this . populateEmailVerify = [
{
path : 'user' ,
path : "user" ,
select : userService.USER_SELECT ,
} ,
] ;
if ( ! env . email . enabled ) {
this . log . info ( "MT_EMAIL_SERVICE is disabled, the system can't send email and will not try." ) ;
this . log . info (
"MT_EMAIL_SERVICE is disabled, the system can't send email and will not try."
) ;
return ;
}
this . log . info ( 'creating SMTP transport' , {
this . log . info ( "creating SMTP transport" , {
host : env.email.smtp.host ,
port : env.email.smtp.port ,
} ) ;
@ -119,22 +127,23 @@ export class EmailService extends DtpService {
this . templates = {
html : {
welcome : this.loadAppTemplate ( 'html' , 'welcome.pug' ) ,
passwordReset : this.loadAppTemplate ( 'html' , 'password-reset.pug' ) ,
marketingBlast : this.loadAppTemplate ( 'html' , 'newsletter.pug' ) ,
userEmail : this.loadAppTemplate ( 'html' , 'user-email.pug' ) ,
welcome : this.loadAppTemplate ( "html" , "welcome.pug" ) ,
passwordReset : this.loadAppTemplate ( "html" , "password-reset.pug" ) ,
marketingBlast : this.loadAppTemplate ( "html" , "newsletter.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' , 'newsletter.pug' ) ,
userEmail : this.loadAppTemplate ( 'text' , 'user-email.pug' ) ,
welcome : this.loadAppTemplate ( "text" , "welcome.pug" ) ,
passwordReset : this.loadAppTemplate ( "text" , "password-reset.pug" ) ,
marketingBlast : this.loadAppTemplate ( "text" , "newsletter.pug" ) ,
userEmail : this.loadAppTemplate ( "text" , "user-email.pug" ) ,
} ,
} ;
const jobQueueService = this . platform . components . getService < JobQueueService > ( 'jobQueue' ) ;
this . emailQueue = jobQueueService . getJobQueue ( 'email' , env . jobQueues . email ) ;
const jobQueueService =
this . platform . components . getService < JobQueueService > ( "jobQueue" ) ;
this . emailQueue = jobQueueService . getJobQueue ( "email" , env . jobQueues . email ) ;
}
/ * *
@ -148,11 +157,11 @@ export class EmailService extends DtpService {
* @returns Promise < string > A promise that resolves the rendered HTML or text
* content .
* /
async renderTemplate (
async renderTemplate (
templateId : string ,
templateType : string ,
templateModel : WebViewModel ,
) : Promise < string > {
templateModel : WebViewModel
) : Promise < string > {
const templateMap = this . templates [ templateType ] ;
if ( ! templateMap ) {
throw new Error ( ` Unknown email template type: ${ templateType } ` ) ;
@ -163,12 +172,16 @@ export class EmailService extends DtpService {
throw new WebError ( 500 , ` Unknown email template: ${ templateId } ` ) ;
}
const cacheService = this . platform . components . getService < CacheService > ( 'cache' ) ;
const cacheService =
this . platform . components . getService < CacheService > ( "cache" ) ;
const settingsKey = ` settings: ${ env . site . domainKey } :site ` ;
const adminSettings = await cacheService . getObject ( settingsKey ) as Record < string , unknown > ;
const adminSettings = ( await cacheService . getObject ( settingsKey ) ) as Record <
string ,
unknown
> ;
const siteSettings = Object . assign ( { } , env . site ) ; // defaults and .env
const siteSettings = Object . assign ( { } , env . site ) ; // defaults and .env
templateModel . site = Object . assign ( siteSettings , adminSettings ) ; // admin overrides
return template ( templateModel ) ;
@ -179,21 +192,23 @@ export class EmailService extends DtpService {
* The message specifies to , from , subject , html , and text content .
* @param message NodeMailerMessage The message to be sent .
* /
async send ( message : EmailMessage ) : Promise < IEmailLog > {
async send ( message : EmailMessage ) : Promise < IEmailLog > {
if ( ! this . transport ) {
throw new WebError ( 500 , 'Email transport required' ) ;
throw new WebError ( 500 , "Email transport required" ) ;
}
const NOW = new Date ( ) ;
this . log . info ( 'sending email' , { to : message.to } ) ;
this . log . info ( "sending email" , { to : message.to } ) ;
const response = await this . transport . sendMail ( message ) ;
this . log . debug ( 'email sent' , { to : message.to , response } ) ;
this . log . debug ( "email sent" , { to : message.to , response } ) ;
const from = ( typeof message . from === 'object' ) ? message.from.address : message.from ;
const to = ( typeof message . to === 'object' ) ? message.to.address : message.to ;
const from =
typeof message . from === "object" ? message.from.address : message.from ;
const to = typeof message . to === "object" ? message.to.address : message.to ;
const log = await EmailLog . create ( {
created : NOW ,
from , to ,
from ,
to ,
to_lc : to.toLowerCase ( ) ,
subject : message.subject ,
messageId : response.messageId ,
@ -202,7 +217,7 @@ export class EmailService extends DtpService {
return log . toObject ( ) ;
}
async sendWelcomeEmail ( user : IUser ) : Promise < void > {
async sendWelcomeEmail ( user : IUser ) : Promise < void > {
await this . removeVerificationTokensForUser ( user ) ;
const verifyToken = await this . createVerificationToken ( user ) ;
const templateModel : WebViewModel = {
@ -211,7 +226,7 @@ export class EmailService extends DtpService {
emailVerifyToken : verifyToken.token ,
} ;
const emailName = user . email . split ( '@' ) [ 0 ] ;
const emailName = user . email . split ( "@" ) [ 0 ] ;
assert ( emailName , "recipient email address is malformed" ) ;
const message : EmailMessage = {
@ -221,18 +236,21 @@ export class EmailService extends DtpService {
address : user.email ,
} ,
subject : ` Welcome to ${ env . site . name } ! ` ,
html : await this . renderTemplate ( 'welcome' , 'html' , templateModel ) ,
text : await this . renderTemplate ( 'welcome' , 'text' , templateModel ) ,
html : await this . renderTemplate ( "welcome" , "html" , templateModel ) ,
text : await this . renderTemplate ( "welcome" , "text" , templateModel ) ,
} ;
await this . send ( message ) ;
}
async sendNewsletter ( message : EmailMessage ) : Promise < void > {
async sendNewsletter ( message : EmailMessage ) : Promise < void > {
if ( ! this . emailQueue ) {
throw new WebError ( 500 , 'Email job queue is required for newsletter services' ) ;
throw new WebError (
500 ,
"Email job queue is required for newsletter services"
) ;
}
this . log . info ( 'mass mail definition' , { message } ) ;
await this . emailQueue . add ( 'email-newsletter' , { message } ) ;
this . log . info ( "mass mail definition" , { message } ) ;
await this . emailQueue . add ( "email-newsletter" , { message } ) ;
}
/ * *
@ -241,18 +259,18 @@ export class EmailService extends DtpService {
* the address is rejected by the system .
* @param emailAddress string The email address to be checked .
* /
async checkEmailAddress ( emailAddress : string ) : Promise < string > {
this . log . debug ( 'validating email address' , { emailAddress } ) ;
async checkEmailAddress ( emailAddress : string ) : Promise < string > {
this . log . debug ( "validating email address" , { emailAddress } ) ;
emailAddress = emailAddress . trim ( ) ;
if ( ! emailValidator . validate ( emailAddress ) ) {
throw new Error ( 'Email address is invalid' ) ;
throw new Error ( "Email address is invalid" ) ;
}
const domainCheck = await emailDomainCheck ( emailAddress ) ;
this . log . debug ( 'email domain check' , { domainCheck } ) ;
this . log . debug ( "email domain check" , { domainCheck } ) ;
if ( ! domainCheck ) {
throw new Error ( 'Email address is invalid' ) ;
throw new Error ( "Email address is invalid" ) ;
}
await this . isEmailBlacklisted ( emailAddress ) ;
@ -266,26 +284,34 @@ export class EmailService extends DtpService {
* @param emailAddress string The email address to be checked
* @returns boolean true if the address is blacklisted ; false otherwise .
* /
async isEmailBlacklisted ( emailAddress : string ) : Promise < boolean > {
async isEmailBlacklisted ( emailAddress : string ) : Promise < boolean > {
emailAddress = emailAddress . toLowerCase ( ) . trim ( ) ;
const parts = emailAddress . split ( '@' ) ;
const parts = emailAddress . split ( "@" ) ;
if ( ! parts [ 0 ] ) {
throw new WebError ( 400 , 'Invalid email address' ) ;
throw new WebError ( 400 , "Invalid email address" ) ;
}
const domain = parts [ 1 ] ? parts [ 1 ] : parts [ 0 ] ;
if ( this . disposableEmailDomains ) {
if ( this . disposableEmailDomains . includes ( domain ) ) {
this . log . alert ( 'blacklisted email domain blocked' , { emailAddress , domain } ) ;
throw new WebError ( 400 , 'Invalid email address' ) ;
this . log . alert ( "blacklisted email domain blocked" , {
emailAddress ,
domain ,
} ) ;
throw new WebError ( 400 , "Invalid email address" ) ;
}
}
this . log . debug ( 'checking email domain for blacklist' , { domain } ) ;
const blacklistRecord = await EmailBlacklist . findOne ( { email : emailAddress } ) ;
this . log . debug ( "checking email domain for blacklist" , { domain } ) ;
const blacklistRecord = await EmailBlacklist . findOne ( {
email : emailAddress ,
} ) ;
if ( blacklistRecord ) {
throw new WebError ( 401 , 'Email address has requested to not receive emails' ) ;
throw new WebError (
401 ,
"Email address has requested to not receive emails"
) ;
}
return false ;
@ -298,7 +324,7 @@ export class EmailService extends DtpService {
* be created .
* @returns new email verification token ( for use in creating a link to it )
* /
async createVerificationToken ( user : IUser ) : Promise < IEmailVerify > {
async createVerificationToken ( user : IUser ) : Promise < IEmailVerify > {
const NOW = new Date ( ) ;
const verify = new EmailVerify ( ) ;
@ -308,7 +334,7 @@ export class EmailService extends DtpService {
await verify . save ( ) ;
this . log . info ( 'created email verification token for user' , {
this . log . info ( "created email verification token for user" , {
user : {
_id : user._id ,
} ,
@ -317,9 +343,8 @@ export class EmailService extends DtpService {
return verify . toObject ( ) ;
}
async getVerificationToken ( token : string ) : Promise < IEmailVerify | null > {
const emailVerify = await EmailVerify
. findOne ( { token } )
async getVerificationToken ( token : string ) : Promise < IEmailVerify | null > {
const emailVerify = await EmailVerify . findOne ( { token } )
. populate ( this . populateEmailVerify )
. lean ( ) ;
return emailVerify ;
@ -331,32 +356,37 @@ export class EmailService extends DtpService {
* @param token string The token received from the email verification
* request .
* /
async verifyToken ( token : string ) : Promise < void > {
async verifyToken ( token : string ) : Promise < void > {
const NOW = new Date ( ) ;
const userService = this . platform . components . getService < UserService > ( 'user' ) ;
const userService =
this . platform . components . getService < UserService > ( "user" ) ;
// fetch the token from the db
const emailVerify = await EmailVerify
. findOne ( { token : token } )
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 WebError ( 403 , 'Email verification token is invalid' ) ;
this . log . error ( "email verify token not found" , { token } ) ;
throw new WebError ( 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 WebError ( 403 , 'Email verification token is invalid' ) ;
this . log . error ( "email verify token already claimed" , { token } ) ;
throw new WebError ( 403 , "Email verification token is invalid" ) ;
}
this . log . info ( 'marking user email verified' , { userId : emailVerify.user._id } ) ;
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 } } ) ;
await EmailVerify . updateOne (
{ _id : emailVerify._id } ,
{ $set : { verified : NOW } }
) ;
}
/ * *
@ -367,12 +397,15 @@ export class EmailService extends DtpService {
* @param user IUser the User for whom all pending verification tokens are
* to be removed .
* /
async removeVerificationTokensForUser ( user : IUser ) : Promise < void > {
this . log . info ( 'removing all pending email address verification tokens for user' , { user : user._id } ) ;
async removeVerificationTokensForUser ( user : IUser ) : Promise < void > {
this . log . info (
"removing all pending email address verification tokens for user" ,
{ user : user._id }
) ;
await EmailVerify . deleteMany ( { user : user._id } ) ;
}
createMessageModel ( viewModel : WebViewModel ) : WebViewModel {
createMessageModel ( viewModel : WebViewModel ) : WebViewModel {
const messageModel : WebViewModel = {
config : env ,
site : env.site ,