diff --git a/app/controllers/email.js b/app/controllers/email.js new file mode 100644 index 0000000..e152299 --- /dev/null +++ b/app/controllers/email.js @@ -0,0 +1,78 @@ +// email.js +// Copyright (C) 2021 Digital Telepresence, LLC +// License: Apache-2.0 + +'use strict'; + +const DTP_COMPONENT_NAME = 'email'; + +const express = require('express'); + +const { SiteController/*, SiteError*/ } = require('../../lib/site-lib'); + +class EmailController extends SiteController { + + constructor (dtp) { + super(dtp, DTP_COMPONENT_NAME); + } + + async start ( ) { + const { jobQueue: jobQueueService, limiter: limiterService } = this.dtp.services; + + this.emailJobQueue = jobQueueService.getJobQueue('email', { + attempts: 3 + }); + + const router = express.Router(); + this.dtp.app.use('/email', router); + + router.get( + '/verify', + limiterService.create(limiterService.config.email.getEmailVerify), + this.getEmailVerify.bind(this), + ); + + router.get( + '/opt-out', + limiterService.create(limiterService.config.email.getEmailOptOut), + this.getEmailOptOut.bind(this), + ); + + return router; + } + + async getEmailOptOut (req, res, next) { + const { user: userService } = this.dtp.services; + try { + await userService.emailOptOut(req.query.u, req.query.c); + res.render('email/opt-out-success'); + } catch (error) { + this.log.error('failed to opt-out from email', { + userId: req.query.t, + category: req.query.c, + error, + }); + return next(error); + } + } + + async getEmailVerify (req, res, next) { + const { email: emailService } = this.dtp.services; + try { + await emailService.verifyToken(req.query.t); + res.render('email/verify-success'); + } catch (error) { + this.log.error('failed to verify email', { error }); + return next(error); + } + } +} + +module.exports = { + slug: 'email', + name: 'email', + create: async (dtp) => { + let controller = new EmailController(dtp); + return controller; + }, +}; \ No newline at end of file diff --git a/app/models/email-log.js b/app/models/email-log.js new file mode 100644 index 0000000..2131e25 --- /dev/null +++ b/app/models/email-log.js @@ -0,0 +1,19 @@ +// email-log.js +// Copyright (C) 2022 DTP Technologies, LLC +// All Rights Reserved + +'use strict'; + +const mongoose = require('mongoose'); +const Schema = mongoose.Schema; + +const EmailLogSchema = new Schema({ + created: { type: Date, default: Date.now, required: true, index: -1 }, + from: { type: String, required: true, }, + to: { type: String, required: true }, + to_lc: { type: String, required: true, lowercase: true, index: 1 }, + subject: { type: String }, + messageId: { type: String }, +}); + +module.exports = mongoose.model('EmailLog', EmailLogSchema); \ No newline at end of file diff --git a/app/models/email-verify.js b/app/models/email-verify.js new file mode 100644 index 0000000..9b86900 --- /dev/null +++ b/app/models/email-verify.js @@ -0,0 +1,18 @@ +// email-verify.js +// Copyright (C) 2021 Digital Telepresence, LLC +// License: Apache-2.0 + +'use strict'; + +const mongoose = require('mongoose'); + +const Schema = mongoose.Schema; + +const EmailVerifySchema = new Schema({ + created: { type: Date, default: Date.now, required: true, index: -1, expires: '30d' }, + verified: { type: Date }, + user: { type: Schema.ObjectId, required: true, index: 1, ref: 'User' }, + token: { type: String, required: true }, +}); + +module.exports = mongoose.model('EmailVerify', EmailVerifySchema); \ No newline at end of file diff --git a/app/models/user.js b/app/models/user.js index 7b604ad..bed0dca 100644 --- a/app/models/user.js +++ b/app/models/user.js @@ -23,8 +23,8 @@ const UserPermissionsSchema = new Schema({ }); const UserOptInSchema = new Schema({ - system: { type: Boolean, default: true, requred: true }, - marketing: { type: Boolean, default: true, requred: true }, + system: { type: Boolean, default: true, required: true }, + marketing: { type: Boolean, default: true, required: true }, }); const UserSchema = new Schema({ diff --git a/app/services/email.js b/app/services/email.js index fef6d2b..3d88c5d 100644 --- a/app/services/email.js +++ b/app/services/email.js @@ -4,19 +4,20 @@ 'use strict'; -const path = require('path'); -const pug = require('pug'); - const nodemailer = require('nodemailer'); +const uuidv4 = require('uuid').v4; const mongoose = require('mongoose'); + const EmailBlacklist = mongoose.model('EmailBlacklist'); +const EmailVerify = mongoose.model('EmailVerify'); +const EmailLog = mongoose.model('EmailLog'); const disposableEmailDomains = require('disposable-email-provider-domains'); const emailValidator = require('email-validator'); const emailDomainCheck = require('email-domain-check'); -const { SiteService } = require('../../lib/site-lib'); +const { SiteService, SiteError } = require('../../lib/site-lib'); class EmailService extends SiteService { @@ -27,7 +28,8 @@ class EmailService extends SiteService { async start ( ) { await super.start(); - if (process.env.DTP_EMAIL_ENABLED !== 'enabled') { + 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; } @@ -51,17 +53,35 @@ class EmailService extends SiteService { this.templates = { html: { - welcome: pug.compileFile(path.join(this.dtp.config.root, 'app', 'templates', 'html', 'welcome.pug')), + userEmail: this.loadAppTemplate('html', 'user-email.pug'), + welcome: this.loadAppTemplate('html', 'welcome.pug'), }, text: { - welcome: pug.compileFile(path.join(this.dtp.config.root, 'app', 'templates', 'text', 'welcome.pug')), + userEmail: this.loadAppTemplate('text', 'user-email.pug'), + welcome: this.loadAppTemplate('text', 'welcome.pug'), }, }; } + async renderTemplate (templateId, templateType, templateModel) { + this.log.debug('rendering email template', { templateId, templateType }); + return this.templates[templateType][templateId](templateModel); + } + async send (message) { + const NOW = new Date(); + this.log.info('sending email', { to: message.to, subject: message.subject }); - await this.transport.sendMail(message); + const response = await this.transport.sendMail(message); + + await EmailLog.create({ + created: NOW, + from: message.from, + to: message.to, + to_lc: message.to.toLowerCase(), + subject: message.subject, + messageId: response.messageId, + }); } async checkEmailAddress (emailAddress) { @@ -97,8 +117,52 @@ class EmailService extends SiteService { return false; } - async renderTemplate (templateId, templateType, message) { - return this.templates[templateType][templateId](message); + 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 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 } }); + } + + async removeVerificationTokensForUser (user) { + this.log.info('removing all pending email address verification tokens for user', { user: user._id }); + await EmailVerify.deleteMany({ user: user._id }); } } diff --git a/app/services/user.js b/app/services/user.js index 1a48f98..e8e030a 100644 --- a/app/services/user.js +++ b/app/services/user.js @@ -107,7 +107,7 @@ class UserService { user.optIn = { system: true, - newsletter: false, + marketing: false, }; this.log.info('creating new user account', { email: userDefinition.email }); @@ -280,7 +280,7 @@ class UserService { .select('+passwordSalt +password +flags') .lean(); if (!user) { - throw new SiteError(404, 'Member account not found'); + throw new SiteError(404, 'Member credentials are invalid'); } const maskedPassword = crypto.maskPassword( @@ -288,7 +288,7 @@ class UserService { account.password, ); if (maskedPassword !== user.password) { - throw new SiteError(403, 'Account credentials do not match'); + throw new SiteError(403, 'Member credentials are invalid'); } // remove these critical fields from the user object diff --git a/app/templates/common/html/footer.pug b/app/templates/common/html/footer.pug index 8f74b15..2bf23cd 100644 --- a/app/templates/common/html/footer.pug +++ b/app/templates/common/html/footer.pug @@ -1,10 +1,8 @@ .common-footer - - p This email was sent to #{user.email} because you selected to receive emails from LocalRedAction.com. You can #[a(href=`https://localredaction.com/opt-out/${voter._id}/email`) opt out] at any time to stop receiving these emails. - - p You can request to stop receiving these emails in writing at: - address - div Local Red Action - div P.O. Box ######## - div McKees Rocks, PA 15136 - div USA \ No newline at end of file + p This email was sent to #{recipient.email} because you selected to receive emails from #{site.name}. You can #[a(href=`https://${site.domain}/email/opt-out?u=${recipient._id}&c=marketing`) opt out] at any time to stop receiving these emails. + //- p You can request to stop receiving these emails in writing at: + //- address + //- div Digital Telepresence, LLC + //- div P.O. Box ######## + //- div McKees Rocks, PA 15136 + //- div USA \ No newline at end of file diff --git a/app/templates/common/html/header.pug b/app/templates/common/html/header.pug index abd3ff7..0b1cf55 100644 --- a/app/templates/common/html/header.pug +++ b/app/templates/common/html/header.pug @@ -1 +1,2 @@ -.greeting Dear #{voter.name}, \ No newline at end of file +.common-title= emailTitle || `Greetings from ${site.name}!` +.common-slogan= site.description \ No newline at end of file diff --git a/app/templates/common/text/footer.pug b/app/templates/common/text/footer.pug index a9e38f6..cddfef2 100644 --- a/app/templates/common/text/footer.pug +++ b/app/templates/common/text/footer.pug @@ -1,9 +1,10 @@ +| | - - - -| This email was sent to #{user.email} because you selected to receive emails from LocalRedAction.com. Visit #{`https://localredaction.com/opt-out/${voter._id}/email`} to opt out and stop receiving these emails. +| This email was sent to #{recipient.email} because you selected to receive emails from #{site.name}. Visit #{`https://${site.domain}/email/opt-out?u=${recipient._id}&c=marketing`} to opt out and stop receiving these emails. | -| You can request to stop receiving these emails in writing at: -| -| Local Red Action -| P.O. Box ######## -| McKees Rocks, PA 15136 -| USA \ No newline at end of file +//- | You can request to stop receiving these emails in writing at: +//- | +//- | #{site.company} +//- | P.O. Box ######## +//- | McKees Rocks, PA 15136 +//- | USA \ No newline at end of file diff --git a/app/templates/common/text/header.pug b/app/templates/common/text/header.pug index e0c2dc5..621d167 100644 --- a/app/templates/common/text/header.pug +++ b/app/templates/common/text/header.pug @@ -1 +1,2 @@ -| Dear #{voter.name}, \ No newline at end of file +| Dear #{recipient.displayName || recipient.username}, +| \ No newline at end of file diff --git a/app/templates/html/user-email.pug b/app/templates/html/user-email.pug new file mode 100644 index 0000000..ca41792 --- /dev/null +++ b/app/templates/html/user-email.pug @@ -0,0 +1,3 @@ +extends ../layouts/html/system-message +block message-body + .content-message!= htmlMessage \ No newline at end of file diff --git a/app/templates/html/welcome.pug b/app/templates/html/welcome.pug index ff8449d..eceb7e6 100644 --- a/app/templates/html/welcome.pug +++ b/app/templates/html/welcome.pug @@ -1,27 +1,4 @@ -doctype html -html(lang='en') - head - meta(charset='UTF-8') - meta(name='viewport', content='width=device-width, initial-scale=1.0') - meta(name='description', content= pageDescription || siteDescription) - - title= pageTitle ? `${pageTitle} | ${site.name}` : site.name - - style(type="text/css"). - html, body { - margin: 0; - padding: 0; - } - .greeting { font-size: 1.5em; margin-bottom: 16px; } - .message {} - - body - - include ../common/html/header - - .message - p Welcome to #{service.name}! Please visit #[a(href=`https://localredaction.com/verify?t=${emailVerifyToken}`)= `https://localredaction.com/verify?t=${emailVerifyToken}`] to verify your email address. - - p Thank you for supporting your local Republican committee and candidates! - - include ../common/html/footer \ No newline at end of file +extends ../layouts/html/system-message +block content + p Welcome to #{site.name}! Please visit #[a(href=`https://${site.domain}/email/verify?t=${emailVerifyToken}`)= `https://${site.domain}/email/verify?t=${emailVerifyToken}`] to verify your email address and enable all features on your new account. + p If you did not sign up for a new account at #{site.name}, please disregard this message. \ No newline at end of file diff --git a/app/templates/layouts/html/system-message.pug b/app/templates/layouts/html/system-message.pug new file mode 100644 index 0000000..1e8b599 --- /dev/null +++ b/app/templates/layouts/html/system-message.pug @@ -0,0 +1,106 @@ +doctype html +html(lang='en') + head + meta(charset='UTF-8') + meta(name='viewport', content='width=device-width, initial-scale=1.0') + meta(name='description', content= pageDescription || siteDescription) + + title= pageTitle ? `${pageTitle} | ${site.name}` : site.name + + style(type="text/css"). + html, body { + padding: 0; + margin: 0; + background-color: #ffffff; + color: #1a1a1a; + } + + section { + padding: 20px 0; + background-color: #ffffff; + color: #1a1a1a; + } + + section.section-muted { + background-color: #f8f8f8; + color: #2a2a2a; + } + + .common-title { + max-width: 640px; + margin: 0 auto 8px auto; + font-size: 1.5em; + } + + .common-greeting { + max-width: 640px; + margin: 0 auto 20px auto; + font-size: 1.1em; + } + + .common-slogan { + max-width: 640px; + margin: 0 auto; + font-size: 1.1em; + font-style: italic; + } + + .content-message { + max-width: 640px; + margin: 0 auto; + background: white; + color: black; + font-size: 14px; + } + + .content-signature { + max-width: 640px; + margin: 0 auto; + background: white; + color: black; + font-size: 14px; + } + + .common-footer { + max-width: 640px; + margin: 0 auto; + font-size: 10px; + } + + .channel-icon { + border-radius: 8px; + } + + .action-button { + padding: 6px 20px; + margin: 24px 0; + border: none; + border-radius: 20px; + outline: none; + background-color: #1093de; + color: #ffffff; + font-size: 16px; + font-weight: bold; + } + + body + + include ../library + + section.section-muted + include ../../common/html/header + + section + .common-greeting + div Dear #{recipient.displayName || recipient.username}, + + block message-body + .content-message + block content + + .content-signature + p Thank you for your continued support! + p The #{site.name} team. + + section.section-muted + include ../../common/html/footer \ No newline at end of file diff --git a/app/templates/layouts/library.pug b/app/templates/layouts/library.pug new file mode 100644 index 0000000..89c69a2 --- /dev/null +++ b/app/templates/layouts/library.pug @@ -0,0 +1,4 @@ +- + function formatCount (count) { + return numeral(count).format((count > 1000) ? '0,0.0a' : '0,0'); + } \ No newline at end of file diff --git a/app/templates/layouts/text/system-message.pug b/app/templates/layouts/text/system-message.pug new file mode 100644 index 0000000..9777eb7 --- /dev/null +++ b/app/templates/layouts/text/system-message.pug @@ -0,0 +1,10 @@ +include ../library +include ../../common/text/header +| +block content +| +| Thank you for your continued support! +| +| The #{site.name} team. +| +include ../../common/text/footer \ No newline at end of file diff --git a/app/templates/text/user-email.pug b/app/templates/text/user-email.pug new file mode 100644 index 0000000..ec95e3e --- /dev/null +++ b/app/templates/text/user-email.pug @@ -0,0 +1,5 @@ +extends ../layouts/text/system-message +block content + | + | #{textMessage} + | \ No newline at end of file diff --git a/app/templates/text/welcome.pug b/app/templates/text/welcome.pug index 678390a..1127acf 100644 --- a/app/templates/text/welcome.pug +++ b/app/templates/text/welcome.pug @@ -1,7 +1,7 @@ -include ../common/text/header -| -| Welcome to #{service.name}! Please visit #[a(href=`https://localredaction.com/verify?t=${emailVerifyToken}`)= `https://localredaction.com/verify?t=${emailVerifyToken}`] to verify your email address. -| -| Thank you for supporting your local Republican committee and candidates! -| -include ../common/text/footer \ No newline at end of file +extends ../layouts/text/system-message +block content + | + | Welcome to #{site.name}! Please visit #{`https://${site.domain}/email/verify?t=${emailVerifyToken}`} to verify your email address and enable all features on your new account. + | + | If you did not sign up for a new account at #{site.name}, please disregard this message. + | \ No newline at end of file diff --git a/app/views/email/verify-success.pug b/app/views/email/verify-success.pug new file mode 100644 index 0000000..f6e9778 --- /dev/null +++ b/app/views/email/verify-success.pug @@ -0,0 +1,12 @@ +extends ../layouts/main +block content + + section.uk-section.uk-section-default.uk-section-small + .uk-container + h1 Email Verification + p You have successfully verified your email address, and have unlocked additional features of the site. + + a(href="/").uk-button.dtp-button-primary + span + i.fas.fa-home + span.uk-margin-small-left Home \ No newline at end of file diff --git a/app/views/welcome/login.pug b/app/views/welcome/login.pug index bec2a15..aab8ff1 100644 --- a/app/views/welcome/login.pug +++ b/app/views/welcome/login.pug @@ -12,7 +12,7 @@ block content .uk-margin-small div(uk-grid) .uk-width-1-3 - img(src=`/img/icon/${site.domainKey}.png`).responsive + img(src=`/img/icon/${site.domainKey}/icon-256x256.png`).responsive.uk-border-rounded .uk-width-expand if loginResult div(uk-alert).uk-alert.uk-alert-danger= loginResult diff --git a/app/views/welcome/signup.pug b/app/views/welcome/signup.pug index 77e3789..1f33b81 100644 --- a/app/views/welcome/signup.pug +++ b/app/views/welcome/signup.pug @@ -35,11 +35,6 @@ block content input(id="passwordv", name="passwordv", type="password", placeholder="Verify password").uk-input .uk-text-small.uk-text-muted.uk-margin-small-top(class="uk-visible@m") Please enter your password again to prove you're not an idiot. - .uk-margin - label - input(type="checkbox", checked).uk-checkbox - | Join #{site.name}'s Newsletter - section.uk-section.uk-section-secondary.uk-section .uk-container.uk-container-small .uk-margin-large diff --git a/config/limiter.js b/config/limiter.js index d72ad60..e45d925 100644 --- a/config/limiter.js +++ b/config/limiter.js @@ -6,7 +6,7 @@ const ONE_SECOND = 1000; const ONE_MINUTE = ONE_SECOND * 60; -// const ONE_HOUR = ONE_MINUTE * 60; +const ONE_HOUR = ONE_MINUTE * 60; module.exports = { @@ -70,6 +70,22 @@ module.exports = { }, }, + /* + * EmailController + */ + email: { + getEmailOptOut: { + total: 10, + expire: ONE_HOUR, + message: "You really don't need to do that this much.", + }, + getEmailVerify: { + total: 10, + expire: ONE_HOUR, + message: "You really don't need to do that this much and can stop.", + }, + }, + /* * HomeController */ diff --git a/lib/site-common.js b/lib/site-common.js index 4b5e822..6886b8d 100644 --- a/lib/site-common.js +++ b/lib/site-common.js @@ -4,12 +4,16 @@ 'use strict'; +const path = require('path'); +const pug = require('pug'); + const Events = require('events'); class SiteCommon extends Events { constructor (dtp) { super(); this.dtp = dtp; + this.appTemplateRoot = path.join(this.dtp.config.root, 'app', 'templates'); } saveSession (req) { @@ -22,6 +26,19 @@ class SiteCommon extends Events { }); }); } + + isValidString (text) { + return text && (typeof text === 'string') && (text.length > 0); + } + + loadAppTemplate (type, name) { + return pug.compileFile(path.join(this.appTemplateRoot, type, name)); + } + + loadViewTemplate (filename) { + const scriptFile = path.join(this.dtp.config.root, 'app', 'views', filename); + return pug.compileFile(scriptFile); + } } module.exports.SiteCommon = SiteCommon; \ No newline at end of file