Browse Source

Newsletter wip

master
Rob Colbert 2 years ago
parent
commit
a799781972
  1. 1
      .gitignore
  2. 3
      app/controllers/admin.js
  3. 67
      app/controllers/admin/newsletter.js
  4. 45
      app/controllers/admin/page.js
  5. 45
      app/controllers/admin/post.js
  6. 18
      app/controllers/image.js
  7. 100
      app/controllers/newsletter.js
  8. 15
      app/models/email-body.js
  9. 20
      app/models/email.js
  10. 21
      app/models/newsletter-recipient.js
  11. 31
      app/models/newsletter.js
  12. 38
      app/services/email.js
  13. 87
      app/services/image.js
  14. 37
      app/services/markdown.js
  15. 6
      app/services/minio.js
  16. 114
      app/services/newsletter.js
  17. 18
      app/views/admin/components/menu.pug
  18. 2
      app/views/admin/layouts/main.pug
  19. 59
      app/views/admin/newsletter/editor.pug
  20. 21
      app/views/admin/newsletter/index.pug
  21. 2
      app/views/index.pug
  22. 15
      app/views/newsletter/index.pug
  23. 113
      app/workers/newsletter.js
  24. 14
      client/js/site-app.js
  25. 16
      config/limiter.js
  26. 40
      docs/services.md
  27. 2
      package.json
  28. 303
      yarn.lock

1
.gitignore

@ -2,3 +2,4 @@
node_modules
dist
data/minio

3
app/controllers/admin.js

@ -46,6 +46,9 @@ class AdminController extends SiteController {
router.use('/host',await this.loadChild(path.join(__dirname, 'admin', 'host')));
router.use('/job-queue',await this.loadChild(path.join(__dirname, 'admin', 'job-queue')));
router.use('/newsletter',await this.loadChild(path.join(__dirname, 'admin', 'newsletter')));
router.use('/page',await this.loadChild(path.join(__dirname, 'admin', 'page')));
router.use('/post',await this.loadChild(path.join(__dirname, 'admin', 'post')));
router.use('/user', await this.loadChild(path.join(__dirname, 'admin', 'user')));
router.get('/', this.getHomeView.bind(this));

67
app/controllers/admin/newsletter.js

@ -0,0 +1,67 @@
// admin/newsletter.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const DTP_COMPONENT_NAME = 'admin:newsletter';
const express = require('express');
const { SiteController } = require('../../../lib/site-lib');
class NewsletterController extends SiteController {
constructor (dtp) {
super(dtp, DTP_COMPONENT_NAME);
}
async start ( ) {
const router = express.Router();
router.use(async (req, res, next) => {
res.locals.currentView = 'admin';
res.locals.adminView = 'newsletter';
return next();
});
router.param('newsletterId', this.populateNewsletterId.bind(this));
router.get('/compose', this.getComposer.bind(this));
router.get('/:newsletterId', this.getComposer.bind(this));
router.get('/', this.getIndex.bind(this));
return router;
}
async populateNewsletterId (req, res, next, newsletterId) {
const { newsletter: newsletterService } = this.dtp.services;
try {
res.locals.newsletter = await newsletterService.getById(newsletterId);
return next();
} catch (error) {
this.log.error('failed to populate newsletterId', { newsletterId, error });
return next(error);
}
}
async getComposer (req, res) {
res.render('admin/newsletter/editor');
}
async getIndex (req, res, next) {
const { newsletter: newsletterService } = this.dtp.services;
try {
res.locals.pagination = this.getPaginationParameters(req, 20);
res.locals.newsletters = await newsletterService.getNewsletters(res.locals.pagination);
res.render('admin/newsletter/index');
} catch (error) {
return next(error);
}
}
}
module.exports = async (dtp) => {
let controller = new NewsletterController(dtp);
return controller;
};

45
app/controllers/admin/page.js

@ -0,0 +1,45 @@
// admin/page.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const DTP_COMPONENT_NAME = 'admin:page';
const express = require('express');
const { SiteController } = require('../../../lib/site-lib');
class PageController extends SiteController {
constructor (dtp) {
super(dtp, DTP_COMPONENT_NAME);
}
async start ( ) {
const router = express.Router();
router.use(async (req, res, next) => {
res.locals.currentView = 'admin';
res.locals.adminView = 'page';
return next();
});
router.param('pageId', this.populatePageId.bind(this));
router.get('/', this.getIndex.bind(this));
return router;
}
async populatePageId (req, res, next/*, pageId*/) {
return next();
}
async getIndex (req, res) {
res.render('admin/page/index');
}
}
module.exports = async (dtp) => {
let controller = new PageController(dtp);
return controller;
};

45
app/controllers/admin/post.js

@ -0,0 +1,45 @@
// admin/post.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const DTP_COMPONENT_NAME = 'admin:post';
const express = require('express');
const { SiteController } = require('../../../lib/site-lib');
class PostController extends SiteController {
constructor (dtp) {
super(dtp, DTP_COMPONENT_NAME);
}
async start ( ) {
const router = express.Router();
router.use(async (req, res, next) => {
res.locals.currentView = 'admin';
res.locals.adminView = 'post';
return next();
});
router.param('postId', this.populatePostId.bind(this));
router.get('/', this.getIndex.bind(this));
return router;
}
async populatePostId (req, res, next/*, postId*/) {
return next();
}
async getIndex (req, res) {
res.render('admin/post/index');
}
}
module.exports = async (dtp) => {
let controller = new PostController(dtp);
return controller;
};

18
app/controllers/image.js

@ -31,7 +31,7 @@ class ImageController extends SiteController {
const imageUpload = multer({
dest: '/tmp/dtp-sites/upload/image',
limits: {
fileSize: 1024 * 1000 * 5,
fileSize: 1024 * 1000 * 12,
},
});
@ -42,6 +42,11 @@ class ImageController extends SiteController {
router.param('imageId', this.populateImage.bind(this));
router.post('/tinymce',
imageUpload.single('file'),
this.postTinyMceImage.bind(this),
);
router.post('/',
limiterService.create(limiterService.config.image.postCreateImage),
imageUpload.single('file'),
@ -66,6 +71,17 @@ class ImageController extends SiteController {
}
}
async postTinyMceImage (req, res, next) {
const { image: imageService } = this.dtp.services;
try {
res.locals.image = await imageService.create(req.user, req.body, req.file);
res.status(200).json({ location: `/image/${res.locals.image._id}` });
} catch (error) {
this.log.error('failed to create image', { error });
return next(error);
}
}
async postCreateImage (req, res, next) {
const { image: imageService } = this.dtp.services;
try {

100
app/controllers/newsletter.js

@ -0,0 +1,100 @@
// newsletter.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const DTP_COMPONENT_NAME = 'newsletter';
const express = require('express');
const multer = require('multer');
const { SiteController } = require('../../lib/site-lib');
class NewsletterController extends SiteController {
constructor (dtp) {
super(dtp, DTP_COMPONENT_NAME);
}
async start ( ) {
const { dtp } = this;
const { limiter: limiterService } = dtp.services;
const upload = multer({ dest: '/tmp' });
const router = express.Router();
dtp.app.use('/newsletter', router);
router.use(async (req, res, next) => {
res.locals.currentView = DTP_COMPONENT_NAME;
return next();
});
router.param('newsletterId', this.populateNewsletterId.bind(this));
router.post('/', upload.none(), this.postAddRecipient.bind(this));
router.get('/:newsletterId',
limiterService.create(limiterService.config.newsletter.getView),
this.getView.bind(this),
);
router.get('/',
limiterService.create(limiterService.config.newsletter.getIndex),
this.getIndex.bind(this),
);
}
async populateNewsletterId (req, res, next, newsletterId) {
const { newsletter: newsletterService } = this.dtp.services;
try {
res.locals.newsletter = await newsletterService.getById(newsletterId);
return next();
} catch (error) {
this.log.error('failed to populate newsletterId', { newsletterId, error });
return next(error);
}
}
async postAddRecipient (req, res) {
const { newsletter: newsletterService, displayEngine: displayEngineService } = this.dtp.services;
try {
const displayList = displayEngineService.createDisplayList('add-recipient');
await newsletterService.addRecipient(req.body.email);
displayList.showNotification(
'You have been added to the newsletter. Please check your email and verify your email address.',
'success',
'bottom-center',
10000,
);
res.status(200).json({ success: true, displayList });
} catch (error) {
this.log.error('failed to update account settings', { error });
return res.status(error.statusCode || 500).json({
success: false,
message: error.message,
});
}
}
async getView (req, res) {
res.render('newsletter/view');
}
async getIndex (req, res, next) {
const { newsletter: newsletterService } = this.dtp.services;
try {
res.locals.pagination = this.getPaginationParameters(req, 20);
res.locals.newsletters = await newsletterService.getNewsletters(res.locals.pagination);
res.render('newsletter/index');
} catch (error) {
return next(error);
}
}
}
module.exports = async (dtp) => {
let controller = new NewsletterController(dtp);
return controller;
};

15
app/models/email-body.js

@ -0,0 +1,15 @@
// email-body.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
const EmailBodySchema = new Schema({
subject: { type: String, required: true },
body: { type: String, required: true },
});
module.exports = mongoose.model('EmailBody', EmailBodySchema);

20
app/models/email.js

@ -0,0 +1,20 @@
// email.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
const EmailSchema = new Schema({
created: { type: Date, default: Date.now, required: true, index: -1 },
token: { type: String, required: true, index: 1 },
from: { type: String, required: true, },
to: { type: String, required: true },
to_lc: { type: String, required: true, lowercase: true, index: 1 },
contentType: { type: String, enum: ['Newsletter'], required: true },
content: { type: Schema.ObjectId, required: true, index: true, refPath: 'contentType' },
});
module.exports = mongoose.model('Email', EmailSchema);

21
app/models/newsletter-recipient.js

@ -0,0 +1,21 @@
// newsletter-recipient.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
const NewsletterRecipientSchema = new Schema({
created: { type: Date, default: Date.now, required: true, index: 1 },
address: { type: String, required: true },
address_lc: { type: String, required: true, lowercase: true, unique: true, index: 1 },
flags: {
isVerified: { type: Boolean, default: false, required: true, index: 1 },
isOptIn: { type: Boolean, default: false, required: true, index: 1 },
isRejected: { type: Boolean, default: false, required: true, index: 1 },
},
});
module.exports = mongoose.model('NewsletterRecipient', NewsletterRecipientSchema);

31
app/models/newsletter.js

@ -0,0 +1,31 @@
// newsletter.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
const NEWSLETTER_STATUS_LIST = ['draft', 'published', 'archived'];
const NewsletterSchema = new Schema({
created: { type: Date, default: Date.now, required: true, index: -1 },
author: { type: Schema.ObjectId, required: true, index: 1, ref: 'User' },
title: { type: String, required: true },
summary: { type: String },
content: {
html: { type: String, required: true, select: false, },
text: { type: String, required: true, select: false, },
},
status: {
type: String,
enum: NEWSLETTER_STATUS_LIST,
default: 'draft',
required: true,
index: true,
},
});
module.exports = mongoose.model('Newsletter', NewsletterSchema);

38
app/services/email.js

@ -7,7 +7,7 @@
const path = require('path');
const pug = require('pug');
const mailgun = require('mailgun-js');
const nodemailer = require('nodemailer');
const mongoose = require('mongoose');
const EmailBlacklist = mongoose.model('EmailBlacklist');
@ -27,13 +27,28 @@ class EmailService extends SiteService {
async start ( ) {
await super.start();
if (process.env.DTP_MAILGUN_ENABLED === 'enabled') {
this.mg = mailgun({
apiKey: process.env.MAILGUN_API_KEY,
domain: process.env.MAILGUN_DOMAIN,
});
if (process.env.DTP_EMAIL_ENABLED !== 'enabled') {
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: pug.compileFile(path.join(this.dtp.config.root, 'app', 'templates', 'html', 'welcome.pug')),
@ -45,15 +60,8 @@ class EmailService extends SiteService {
}
async send (message) {
return new Promise((resolve, reject) => {
this.log.info('sending email', { to: message.to, subject: message.subject });
this.mg.messages().send(message, async (error, body) => {
if (error) {
return reject(error);
}
resolve(body);
});
});
this.log.info('sending email', { to: message.to, subject: message.subject });
await this.transport.sendMail(message);
}
async checkEmailAddress (emailAddress) {

87
app/services/image.js

@ -34,46 +34,53 @@ class ImageService extends SiteService {
async create (owner, imageDefinition, file) {
const NOW = new Date();
const { minio: minioService } = this.dtp.services;
this.log.debug('processing uploaded image', { imageDefinition, file });
const sharpImage = await sharp(file.path);
const metadata = await sharpImage.metadata();
// create an Image model instance, but leave it here in application memory.
// we don't persist it to the db until MinIO accepts the binary data.
const image = new SiteImage();
image.created = NOW;
image.owner = owner._id;
image.type = file.mimetype;
image.size = file.size;
image.file.bucket = process.env.MINIO_IMAGE_BUCKET;
image.metadata = this.makeImageMetadata(metadata);
const imageId = image._id.toString();
const ownerId = owner._id.toString();
const fileKey = `/${ownerId.slice(0, 3)}/${ownerId}/${imageId.slice(0, 3)}/${imageId}`;
image.file.key = fileKey;
// upload the image file to MinIO
const response = await minioService.uploadFile({
bucket: image.file.bucket,
key: image.file.key,
filePath: file.path,
metadata: {
'Content-Type': file.mimetype,
'Content-Length': file.size,
},
});
// store the eTag from MinIO in the Image model
image.file.etag = response.etag;
// save the Image model to the db
await image.save();
this.log.info('processed uploaded image', { ownerId, imageId, fileKey });
return image.toObject();
try {
this.log.debug('processing uploaded image', { imageDefinition, file });
const sharpImage = await sharp(file.path);
const metadata = await sharpImage.metadata();
// create an Image model instance, but leave it here in application memory.
// we don't persist it to the db until MinIO accepts the binary data.
const image = new SiteImage();
image.created = NOW;
image.owner = owner._id;
image.type = file.mimetype;
image.size = file.size;
image.file.bucket = process.env.MINIO_IMAGE_BUCKET;
image.metadata = this.makeImageMetadata(metadata);
const imageId = image._id.toString();
const ownerId = owner._id.toString();
const fileKey = `/${ownerId.slice(0, 3)}/${ownerId}/${imageId.slice(0, 3)}/${imageId}`;
image.file.key = fileKey;
// upload the image file to MinIO
const response = await minioService.uploadFile({
bucket: image.file.bucket,
key: image.file.key,
filePath: file.path,
metadata: {
'Content-Type': file.mimetype,
'Content-Length': file.size,
},
});
// store the eTag from MinIO in the Image model
image.file.etag = response.etag;
// save the Image model to the db
await image.save();
this.log.info('processed uploaded image', { ownerId, imageId, fileKey });
return image.toObject();
} catch (error) {
this.log.error('failed to process image', { error });
throw error;
} finally {
this.log.info('removing uploaded image from local file system', { file: file.path });
await fs.promises.rm(file.path);
}
}
async getImageById (imageId) {

37
app/services/markdown.js

@ -0,0 +1,37 @@
// markdown.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const fs = require('fs');
const { SiteService } = require('../../lib/site-lib');
const marked = require('marked');
class MarkdownService extends SiteService {
constructor (dtp) {
super(dtp, module.exports);
}
async start ( ) {
this.markedRenderer = new marked.Renderer();
}
async renderMarkdownFile (documentFile) {
const markdown = await fs.promises.readFile(documentFile, 'utf8');
return this.renderMarkdown(markdown);
}
async renderMarkdown (markdown) {
return marked(markdown, { renderer: this.markedRenderer });
}
}
module.exports = {
slug: 'markdown',
name: 'markdown',
create: (dtp) => { return new MarkdownService(dtp); },
};

6
app/services/minio.js

@ -17,13 +17,15 @@ class MinioService extends SiteService {
async start ( ) {
await super.start();
this.minio = new Minio.Client({
const minioConfig = {
endPoint: process.env.MINIO_ENDPOINT,
port: parseInt(process.env.MINIO_PORT, 10),
useSSL: (process.env.MINIO_USE_SSL === 'enabled'),
accessKey: process.env.MINIO_ACCESS_KEY,
secretKey: process.env.MINIO_SECRET_KEY,
});
};
this.log.debug('MinIO config', { minioConfig });
this.minio = new Minio.Client(minioConfig);
}
async stop ( ) {

114
app/services/newsletter.js

@ -0,0 +1,114 @@
// newsletter.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const striptags = require('striptags');
const { SiteService } = require('../../lib/site-lib');
const mongoose = require('mongoose');
const Newsletter = mongoose.model('Newsletter');
const NewsletterRecipient = mongoose.model('NewsletterRecipient');
class NewsletterService extends SiteService {
constructor (dtp) {
super(dtp, module.exports);
this.populateNewsletter = [
{
path: 'author',
select: '_id username username_lc displayName picture',
},
];
}
async create (author, newsletterDefinition) {
const NOW = new Date();
const newsletter = new Newsletter();
newsletter.created = NOW;
newsletter.author = author._id;
newsletter.title = striptags(newsletterDefinition.title.trim());
newsletter.summary = striptags(newsletterDefinition.summary.trim());
newsletter.content = newsletterDefinition.content.trim();
newsletter.status = striptags(newsletterDefinition.status.trim().toLowerCase());
await newsletter.save();
return newsletter.toObject();
}
async update (newsletter, newsletterDefinition) {
const updateOp = { $set: { } };
if (newsletterDefinition.title) {
updateOp.$set.title = striptags(newsletterDefinition.title.trim());
}
if (newsletterDefinition.summary) {
updateOp.$set.summary = striptags(newsletterDefinition.summary.trim());
}
if (newsletterDefinition.content) {
updateOp.$set.content = newsletterDefinition.title.trim();
}
if (newsletterDefinition.status) {
updateOp.$set.status = striptags(newsletterDefinition.status.trim());
}
if (Object.keys(updateOp.$set).length === 0) {
return; // no update to perform
}
await Newsletter.updateOne(
{ _id: newsletter._id },
updateOp,
{ upsert: true },
);
}
async getNewsletters (pagination, status = ['published']) {
if (!Array.isArray(status)) {
status = [status];
}
const newsletters = await Newsletter
.find({ status: { $in: status } })
.sort({ created: -1 })
.skip(pagination.skip)
.limit(pagination.cpp)
.lean();
return newsletters;
}
async getById (newsletterId) {
const newsletter = await Newsletter
.findById(newsletterId)
.select('+content')
.populate(this.populateNewsletter)
.lean();
return newsletter;
}
async addRecipient (emailAddress) {
const { email: emailService } = this.dtp.services;
const NOW = new Date();
await emailService.checkEmailAddress(emailAddress);
const recipient = new NewsletterRecipient();
recipient.created = NOW;
recipient.address = striptags(emailAddress.trim());
recipient.address_lc = recipient.address.toLowerCase();
await recipient.save();
return recipient.toObject();
}
}
module.exports = {
slug: 'newsletter',
name: 'newsletter',
create: (dtp) => { return new NewsletterService(dtp); },
};

18
app/views/admin/components/menu.pug

@ -9,6 +9,24 @@ ul.uk-nav.uk-nav-default
li.uk-nav-divider
li(class={ 'uk-active': (adminView === 'post') })
a(href="/admin/post")
span.nav-item-icon
i.fas.fa-pen
span.uk-margin-small-left Posts
li(class={ 'uk-active': (adminView === 'page') })
a(href="/admin/page")
span.nav-item-icon
i.fas.fa-file
span.uk-margin-small-left Pages
li(class={ 'uk-active': (adminView === 'newsletter') })
a(href="/admin/newsletter")
span.nav-item-icon
i.fas.fa-newspaper
span.uk-margin-small-left Newsletter
li.uk-nav-divider
li(class={ 'uk-active': (adminView === 'host') })
a(href="/admin/host")
span.nav-item-icon

2
app/views/admin/layouts/main.pug

@ -4,7 +4,7 @@ block content-container
block page-header
section.uk-section.uk-section-header.uk-section-xsmall
.uk-container
h1.uk-text-center DTP Sites Engine
h1.uk-text-center #{site.name} Admin
block admin-layout

59
app/views/admin/newsletter/editor.pug

@ -0,0 +1,59 @@
extends ../layouts/main
block content
- var actionUrl = newsletter ? `/admin/newsletter/${newsletter._id}` : `/admin/newsletter`;
form(method="POST", action= actionUrl).uk-form
.uk-margin
label(for="title").uk-form-label.sr-only Newsletter title
input(id="title", name="title", type="text", placeholder= "Enter newsletter title", value= newsletter ? newsletter.title : undefined).uk-input
.uk-margin
label(for="content-html").uk-form-label.sr-only Newsletter HTML body
textarea(id="content-html", name="content.html", rows="4").uk-textarea= newsletter ? newsletter.content.html : undefined
.uk-margin
button(type="button", onclick="return dtp.app.copyHtmlToText(event, 'content-text');").uk-button.dtp-button-default Copy HTML to Text
.uk-margin
label(for="content-text").uk-form-label.sr-only Newsletter text body
textarea(id="content-text", name="content.text", rows="4", placeholder= "Enter text-only version of newsletter.").uk-textarea= newsletter ? newsletter.content.text : undefined
.uk-margin
label(for="summary").uk-form-label.sr-only Newsletter summary
textarea(id="summary", name="summary", rows="4", placeholder= "Enter newsletter summary (text only, no HTML)").uk-textarea= newsletter ? newsletter.summary : undefined
button(type="submit").uk-button.dtp-button-primary= newsletter ? 'Update newsletter' : 'Save newsletter'
block viewjs
script(src="/tinymce/tinymce.min.js")
script.
window.addEventListener('dtp-load', async ( ) => {
const toolbarItems = [
'undo redo',
'formatselect',
'bold italic backcolor',
'alignleft aligncenter alignright alignjustify',
'bullist numlist outdent indent removeformat',
'link image',
'help'
];
const pluginItems = [
'advlist', 'autolink', 'lists', 'link', 'image', 'charmap', 'print',
'preview', 'anchor', 'searchreplace', 'visualblocks', 'code',
'fullscreen', 'insertdatetime', 'media', 'table', 'paste', 'code',
'help', 'wordcount',
]
const editors = await tinymce.init({
selector: 'textarea#content-html',
height: 500,
menubar: false,
plugins: pluginItems.join(' '),
toolbar: toolbarItems.join('|'),
branding: false,
images_upload_url: '/image/tinymce',
});
window.dtp.app.editor = editors[0];
});

21
app/views/admin/newsletter/index.pug

@ -0,0 +1,21 @@
extends ../layouts/main
block content
.uk-margin
div(uk-grid)
.uk-width-expand
h1 Newsletters
.uk-width-auto
a(href="/admin/newsletter/compose").uk-button.dtp-button-primary
span
i.fas.fa-plus
span.uk-margin-small-left New Newsletter
.uk-margin
if (Array.isArray(newsletters) && (newsletters.length > 0))
ul.uk-list
each newsletter in newsletters
li
a(href=`/admin/newsletter/${newsletter._id}`)= newsletter.title
else
div There are no newsletters at this time.

2
app/views/index.pug

@ -62,7 +62,7 @@ block content
.dtp-border-bottom.uk-margin
h3.uk-heading-bullet Mailing List
form(method="post", action="/newsletter").uk-form
form(method="post", action="/newsletter", onsubmit="return dtp.app.submitForm(event, 'Subscribe to newsletter');").uk-form
.uk-card.uk-card-secondary.uk-card-small
.uk-card-body

15
app/views/newsletter/index.pug

@ -0,0 +1,15 @@
extends ../layouts/main
block content
section.uk-section.uk-section-default
.uk-container
h1 #{site.name} Newsletters
if Array.isArray(newsletters) && (newsletters.length > 0)
ul.uk-list
each newsletter of newsletters
li
a(href=`/newsletter/${newsletter._id}`).uk-link-reset= newsletter.title
else
div There are no newsletters at this time. Please check back later.

113
app/workers/newsletter.js

@ -0,0 +1,113 @@
// newsletter.js
// Copyright (C) 2021 Digital Telepresence, LLC
// All Rights Reserved
'use strict';
const DTP_COMPONENT_NAME = 'newsletter';
const path = require('path');
require('dotenv').config({ path: path.resolve(__dirname, '..', '..', '.env') });
const mongoose = require('mongoose');
const { SitePlatform, SiteLog } = require(path.join(__dirname, '..', '..', 'lib', 'site-lib'));
module.pkg = require(path.resolve(__dirname, '..', '..', 'package.json'));
module.config = {
componentName: DTP_COMPONENT_NAME,
root: path.resolve(__dirname, '..', '..'),
};
module.log = new SiteLog(module, module.config.componentName);
module.sendNewsletter = async (job) => {
module.log.info('newsletter email job received', { data: job.data });
const NewsletterRecipient = mongoose.model('NewsletterRecipient');
try {
/*
* Create one Bull Queue job per email to be delivered.
*/
await NewsletterRecipient
.find({ 'flags.isVerified': true, 'flags.isOptIn': true, 'flags.isRejected': false })
.lean()
.cursor()
.eachAsync(async (recipient) => {
try {
const jobData = {
newsletterId: job.data.newsletterId,
recipient: recipient.address,
};
const jobOptions = {
attempts: 3,
};
await module.jobQueue.add('email-send', jobData, jobOptions);
} catch (error) {
module.log.error('failed to create newsletter email job');
// but continue
}
}, { parallel: 4 });
} catch (error) {
module.log('failed to send newsletter', { newsletterId: job.data.newsletterId, error });
throw error; // throw error to Bull so it can report in job reports
}
};
module.sendNewsletterEmail = async (job) => {
const { newsletter: newsletterService, email: emailService } = module.services;
const { newsletterId, recipient } = job.data;
try {
let newsletter = module.newsletters[newsletterId];
if (!newsletter) {
newsletter = await newsletterService.getById(newsletterId);
module.newsletters[newsletterId] = newsletter; //TODO: clean up memory leak of newsletter (remove when all emails are sent)
}
if (!newsletter) {
throw new Error('newsletter not found');
}
const response = await emailService.send({
from: '[email protected]',
to: recipient,
subject: newsletter.title,
html: newsletter.content.html,
text: newsletter.content.text,
});
job.log(`newsletter email sent: ${response}`);
} catch (error) {
module.error('failed to send newsletter email', { newsletterId, recipient, error });
throw error; // throw error to Bull so it can report in job reports
}
};
(async ( ) => {
try {
/*
* Platform startup
*/
await SitePlatform.startPlatform(module);
const { jobQueue: jobQueueService } = module.services;
module.jobQueue = await jobQueueService.getJobQueue('newsletter', {
attempts: 3,
});
module.jobQueue.process('email', module.sendNewsletter);
module.jobQueue.process('email-send', module.sendNewsletterEmail);
/*
* Worker startup
*/
module.log.info(`${module.pkg.name} v${module.pkg.version} Newsletter worker started`);
} catch (error) {
module.log.error('failed to start Newsletter worker', { error });
process.exit(-1);
}
})();

14
client/js/site-app.js

@ -152,7 +152,13 @@ export default class DtpSiteApp extends DtpApp {
});
if (!response.ok) {
throw new Error('Server error');
let json;
try {
json = await response.json();
} catch (error) {
throw new Error('Server error');
}
throw new Error(json.message || 'Server error');
}
await this.processResponse(response);
@ -163,6 +169,12 @@ export default class DtpSiteApp extends DtpApp {
return;
}
async copyHtmlToText (event, textContentId) {
const content = this.editor.getContent({ format: 'text' });
const text = document.getElementById(textContentId);
text.value = content;
}
async selectImageFile (event) {
event.preventDefault();

16
config/limiter.js

@ -119,6 +119,22 @@ module.exports = {
}
},
/*
* NewsletterController
*/
newsletter: {
getView: {
total: 5,
expire: ONE_MINUTE,
message: 'You are reading newsletters too quickly',
},
getIndex: {
total: 60,
expire: ONE_MINUTE,
message: 'You are fetching newsletters too quickly',
},
},
/*
* UserController
*/

40
docs/services.md

@ -0,0 +1,40 @@
# DTP Sites: Services
Services are common logic implemented in a centralized location made accessible to the rest of the application to perform tasks in a common way. They live in [app/services](app/services), and are scripts that export a specific structure that identifies the service and provides a way to create, start, and stop them.
Services can't reference each other in their constructors, but they can reference each other in their `start` method with the caveat that the service you are referencing may not have had it's `start` method called, yet. DTP loads services in one loop, then starts them in a separate loop after they are loaded.
All other service methods implemented can reference all other services with the assumption that they are started and ready to provide full service.
```js
// myservice.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const { SiteService } = require('../../lib/site-lib');
class MyService extends SiteService {
constructor (dtp) {
super(dtp, module.exports);
}
async start ( ) {
/*
* perform service initialization here
*/
}
/*
* Implement service methods here
*/
}
module.exports = {
slug: 'my-service',
name: 'myService',
create: (dtp) => { return new MyService(dtp); },
};
```

2
package.json

@ -41,7 +41,6 @@
"ioredis": "^4.28.2",
"jsdom": "^19.0.0",
"libphonenumber-js": "^1.9.44",
"mailgun-js": "^0.22.0",
"marked": "^4.0.6",
"method-override": "^3.0.0",
"minio": "^7.0.23",
@ -50,6 +49,7 @@
"morgan": "^1.10.0",
"multer": "^1.4.3",
"node-fetch": "2",
"nodemailer": "^6.7.2",
"numeral": "^2.0.6",
"otplib": "^12.0.1",
"passport": "^0.5.0",

303
yarn.lock

@ -1265,13 +1265,6 @@ [email protected]:
resolved "https://registry.yarnpkg.com/after/-/after-0.8.2.tgz#fedb394f9f0e02aa9768e702bda23b505fae7e1f"
integrity sha1-/ts5T58OAqqXaOcCvaI7UF+ufh8=
agent-base@4, agent-base@^4.2.0, agent-base@^4.3.0:
version "4.3.0"
resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-4.3.0.tgz#8165f01c436009bccad0b1d122f05ed770efc6ee"
integrity sha512-salcGninV0nPrwpGNn4VTXBb1SOuXQBiqbrNXoeizJsHrsL6ERFM2Ne3JUSBWRE6aeNJI2ROP/WEEIDUiDe3cg==
dependencies:
es6-promisify "^5.0.0"
agent-base@6:
version "6.0.2"
resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77"
@ -1279,13 +1272,6 @@ agent-base@6:
dependencies:
debug "4"
agent-base@~4.2.1:
version "4.2.1"
resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-4.2.1.tgz#d89e5999f797875674c07d87f260fc41e83e8ca9"
integrity sha512-JVwXMr9nHYTUXsBFKUqhJwvlcYU/blreOEUkhNR2eXZIvwd+c+o5V4MgDPKWnMS/56awN3TRzIP+KoPn+roQtg==
dependencies:
es6-promisify "^5.0.0"
ajv-keywords@^3.5.2:
version "3.5.2"
resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.5.2.tgz#31f29da5ab6e00d1c2d329acf7b5929614d5014d"
@ -1547,13 +1533,6 @@ assign-symbols@^1.0.0:
resolved "https://registry.yarnpkg.com/assign-symbols/-/assign-symbols-1.0.0.tgz#59667f41fadd4f20ccbc2bb96b8d4f7f78ec0367"
integrity sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c=
[email protected]:
version "0.14.2"
resolved "https://registry.yarnpkg.com/ast-types/-/ast-types-0.14.2.tgz#600b882df8583e3cd4f2df5fa20fa83759d4bdfd"
integrity sha512-O0yuUDnZeQDL+ncNGlJ78BiO4jnYI3bvMsD5prT0/nsgijG/LpNBIr63gTjVTNsiGkgQhiyCShTgxt8oXOrklA==
dependencies:
tslib "^2.0.1"
async-done@^1.2.0, async-done@^1.2.2:
version "1.3.2"
resolved "https://registry.yarnpkg.com/async-done/-/async-done-1.3.2.tgz#5e15aa729962a4b07414f528a88cdf18e0b290a2"
@ -1591,7 +1570,7 @@ [email protected]:
resolved "https://registry.yarnpkg.com/async/-/async-1.5.2.tgz#ec6a61ae56480c0c3cb241c95618e20892f9672a"
integrity sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=
async@^2.1.1, async@^2.6.1:
async@^2.1.1:
version "2.6.3"
resolved "https://registry.yarnpkg.com/async/-/async-2.6.3.tgz#d72625e2344a3656e3a3ad4fa749fa83299d82ff"
integrity sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg==
@ -2301,7 +2280,7 @@ colors@^1.1.2, colors@^1.2.1:
resolved "https://registry.yarnpkg.com/colors/-/colors-1.4.0.tgz#c50491479d4c1bdaed2c9ced32cf7c7dc2360f78"
integrity sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==
combined-stream@^1.0.6, combined-stream@^1.0.8:
combined-stream@^1.0.8:
version "1.0.8"
resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f"
integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==
@ -2574,11 +2553,6 @@ d@1, d@^1.0.1:
es5-ext "^0.10.50"
type "^1.0.1"
data-uri-to-buffer@1:
version "1.2.0"
resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-1.2.0.tgz#77163ea9c20d8641b4707e8f18abdf9a78f34835"
integrity sha512-vKQ9DTQPN1FLYiiEEOQ6IBGFqvjCa5rSK3cWMy/Nespm5d/x3dGFT9UBZnkLxCwua/IXBi2TYnwTEpsOvhC4UQ==
data-urls@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-3.0.1.tgz#597fc2ae30f8bc4dbcf731fcd1b1954353afc6f8"
@ -2593,7 +2567,7 @@ date-now@^0.1.4:
resolved "https://registry.yarnpkg.com/date-now/-/date-now-0.1.4.tgz#eaf439fd4d4848ad74e5cc7dbef200672b9e345b"
integrity sha1-6vQ5/U1ISK105cx9vvIAZyueNFs=
debug@2, [email protected], debug@^2.2.0, debug@^2.3.3, debug@~2.6.4:
[email protected], debug@^2.2.0, debug@^2.3.3, debug@~2.6.4:
version "2.6.9"
resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==
@ -2621,7 +2595,7 @@ [email protected]:
dependencies:
ms "2.1.2"
debug@^3.1.0, debug@^3.2.6, debug@^3.2.7:
debug@^3.2.6, debug@^3.2.7:
version "3.2.7"
resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a"
integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==
@ -2730,15 +2704,6 @@ define-property@^2.0.2:
is-descriptor "^1.0.2"
isobject "^3.0.1"
degenerator@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/degenerator/-/degenerator-1.0.4.tgz#fcf490a37ece266464d9cc431ab98c5819ced095"
integrity sha1-/PSQo37OJmRk2cxDGrmMWBnO0JU=
dependencies:
ast-types "0.x.x"
escodegen "1.x.x"
esprima "3.x.x"
delayed-stream@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
@ -3146,18 +3111,6 @@ es6-iterator@^2.0.1, es6-iterator@^2.0.3, es6-iterator@~2.0.3:
es5-ext "^0.10.35"
es6-symbol "^3.1.1"
es6-promise@^4.0.3:
version "4.2.8"
resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-4.2.8.tgz#4eb21594c972bc40553d276e510539143db53e0a"
integrity sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==
es6-promisify@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/es6-promisify/-/es6-promisify-5.0.0.tgz#5109d62f3e56ea967c4b63505aef08291c8a5203"
integrity sha1-UQnWLz5W6pZ8S2NQWu8IKRyKUgM=
dependencies:
es6-promise "^4.0.3"
es6-symbol@^3.1.1, es6-symbol@~3.1.3:
version "3.1.3"
resolved "https://registry.yarnpkg.com/es6-symbol/-/es6-symbol-3.1.3.tgz#bad5d3c1bcdac28269f4cb331e431c78ac705d18"
@ -3196,18 +3149,6 @@ escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5:
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=
[email protected]:
version "1.14.3"
resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-1.14.3.tgz#4e7b81fba61581dc97582ed78cab7f0e8d63f503"
integrity sha512-qFcX0XJkdg+PB3xjZZG/wKSuT1PnQWx57+TVSjIMmILd2yC/6ByYElPwJnslDsuWuSAp4AwJGumarAAmJch5Kw==
dependencies:
esprima "^4.0.1"
estraverse "^4.2.0"
esutils "^2.0.2"
optionator "^0.8.1"
optionalDependencies:
source-map "~0.6.1"
escodegen@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-2.0.0.tgz#5e32b12833e8aa8fa35e1bf0befa89380484c7dd"
@ -3228,11 +3169,6 @@ [email protected]:
esrecurse "^4.3.0"
estraverse "^4.1.1"
[email protected]:
version "3.1.3"
resolved "https://registry.yarnpkg.com/esprima/-/esprima-3.1.3.tgz#fdca51cee6133895e3c88d535ce49dbff62a4633"
integrity sha1-/cpRzuYTOJXjyI1TXOSdv/YqRjM=
esprima@^4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71"
@ -3245,7 +3181,7 @@ esrecurse@^4.3.0:
dependencies:
estraverse "^5.2.0"
estraverse@^4.1.1, estraverse@^4.2.0:
estraverse@^4.1.1:
version "4.3.0"
resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d"
integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==
@ -3409,7 +3345,7 @@ extend-shallow@^3.0.0, extend-shallow@^3.0.2:
assign-symbols "^1.0.0"
is-extendable "^1.0.1"
extend@^3.0.0, extend@~3.0.2:
extend@^3.0.0:
version "3.0.2"
resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa"
integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==
@ -3479,7 +3415,7 @@ feed@^4.2.2:
dependencies:
xml-js "^1.6.11"
file-uri-to-path@1, [email protected]:
[email protected]:
version "1.0.0"
resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd"
integrity sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==
@ -3616,15 +3552,6 @@ foreach@^2.0.5:
resolved "https://registry.yarnpkg.com/foreach/-/foreach-2.0.5.tgz#0bee005018aeb260d0a3af3ae658dd0136ec1b99"
integrity sha1-C+4AUBiusmDQo6865ljdATbsG5k=
form-data@^2.3.3:
version "2.5.1"
resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.5.1.tgz#f2cbec57b5e59e23716e128fe44d4e5dd23895f4"
integrity sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA==
dependencies:
asynckit "^0.4.0"
combined-stream "^1.0.6"
mime-types "^2.1.12"
form-data@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452"
@ -3701,14 +3628,6 @@ fsevents@~2.3.2:
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a"
integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==
ftp@~0.3.10:
version "0.3.10"
resolved "https://registry.yarnpkg.com/ftp/-/ftp-0.3.10.tgz#9197d861ad8142f3e63d5a83bfe4c59f7330885d"
integrity sha1-kZfYYa2BQvPmPVqDv+TFn3MwiF0=
dependencies:
readable-stream "1.1.x"
xregexp "2.0.0"
function-bind@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
@ -3802,18 +3721,6 @@ get-symbol-description@^1.0.0:
call-bind "^1.0.2"
get-intrinsic "^1.1.1"
get-uri@^2.0.0:
version "2.0.4"
resolved "https://registry.yarnpkg.com/get-uri/-/get-uri-2.0.4.tgz#d4937ab819e218d4cb5ae18e4f5962bef169cc6a"
integrity sha512-v7LT/s8kVjs+Tx0ykk1I+H/rbpzkHvuIq87LmeXptcf5sNWm9uQiwjNAt94SJPA1zOlCntmnOlJvVWKmzsxG8Q==
dependencies:
data-uri-to-buffer "1"
debug "2"
extend "~3.0.2"
file-uri-to-path "1"
ftp "~0.3.10"
readable-stream "2"
get-value@^2.0.3, get-value@^2.0.6:
version "2.0.6"
resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28"
@ -4236,14 +4143,6 @@ http-errors@~1.7.2:
statuses ">= 1.5.0 < 2"
toidentifier "1.0.0"
http-proxy-agent@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-2.1.0.tgz#e4821beef5b2142a2026bd73926fe537631c5405"
integrity sha512-qwHbBLV7WviBl0rQsOzH6o5lwyOIvwp/BdFnvVxXORldu5TmjFfjzBcWUWS5kWAZhmv+JtiDhSuQCp4sBfbIgg==
dependencies:
agent-base "4"
debug "3.1.0"
http-proxy-agent@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz#5129800203520d434f142bc78ff3c170800f2b43"
@ -4262,14 +4161,6 @@ http-proxy@^1.18.1:
follow-redirects "^1.0.0"
requires-port "^1.0.0"
https-proxy-agent@^3.0.0:
version "3.0.1"
resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-3.0.1.tgz#b8c286433e87602311b01c8ea34413d856a4af81"
integrity sha512-+ML2Rbh6DAuee7d07tYGEKOEi2voWPUGan+ExdPbPW6Z3svq+JCqr0v8WmKPOkz1vOVykPCBSuobe7G8GJUtVg==
dependencies:
agent-base "^4.3.0"
debug "^3.1.0"
https-proxy-agent@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz#e2a90542abb68a762e0a0850f6c9edadfd8506b2"
@ -4337,16 +4228,6 @@ [email protected]:
resolved "https://registry.yarnpkg.com/indexof/-/indexof-0.0.1.tgz#82dc336d232b9062179d05ab3293a66059fd435d"
integrity sha1-gtwzbSMrkGIXnQWrMpOmYFn9Q10=
inflection@~1.12.0:
version "1.12.0"
resolved "https://registry.yarnpkg.com/inflection/-/inflection-1.12.0.tgz#a200935656d6f5f6bc4dc7502e1aecb703228416"
integrity sha1-ogCTVlbW9fa8TcdQLhrstwMihBY=
inflection@~1.3.0:
version "1.3.8"
resolved "https://registry.yarnpkg.com/inflection/-/inflection-1.3.8.tgz#cbd160da9f75b14c3cc63578d4f396784bf3014e"
integrity sha1-y9Fg2p91sUw8xjV41POWeEvzAU4=
inflight@^1.0.4:
version "1.0.6"
resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9"
@ -4420,11 +4301,6 @@ ip-address@^5.8.9:
lodash "^4.17.15"
sprintf-js "1.1.2"
[email protected], ip@^1.1.5:
version "1.1.5"
resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.5.tgz#bdded70114290828c0a039e72ef25f5aaec4354a"
integrity sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo=
[email protected]:
version "1.9.1"
resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3"
@ -4754,11 +4630,6 @@ is-shared-array-buffer@^1.0.1:
resolved "https://registry.yarnpkg.com/is-shared-array-buffer/-/is-shared-array-buffer-1.0.1.tgz#97b0c85fbdacb59c9c446fe653b82cf2b5b7cfe6"
integrity sha512-IU0NmyknYZN0rChcKhRO1X8LYz5Isj/Fsqh8NJOSf+N/hCOTwy29F32Ik7a+QszE63IdvmwdTPDd6cZ5pg4cwA==
is-stream@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44"
integrity sha1-EtSj3U5o4Lec6428hBc66A2RykQ=
is-stream@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077"
@ -5295,13 +5166,6 @@ lowercase-keys@^2.0.0:
resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-2.0.0.tgz#2603e78b7b4b0006cbca2fbcc8a3202558ac9479"
integrity sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==
lru-cache@^5.1.1:
version "5.1.1"
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920"
integrity sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==
dependencies:
yallist "^3.0.2"
lru-cache@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94"
@ -5316,21 +5180,6 @@ magic-string@^0.25.0, magic-string@^0.25.7:
dependencies:
sourcemap-codec "^1.4.4"
mailgun-js@^0.22.0:
version "0.22.0"
resolved "https://registry.yarnpkg.com/mailgun-js/-/mailgun-js-0.22.0.tgz#128942b5e47a364a470791608852bf68c96b3a05"
integrity sha512-a2alg5nuTZA9Psa1pSEIEsbxr1Zrmqx4VkgGCQ30xVh0kIH7Bu57AYILo+0v8QLSdXtCyLaS+KVmdCrQo0uWFA==
dependencies:
async "^2.6.1"
debug "^4.1.0"
form-data "^2.3.3"
inflection "~1.12.0"
is-stream "^1.1.0"
path-proxy "~1.0.0"
promisify-call "^2.0.2"
proxy-agent "^3.0.3"
tsscmp "^1.0.6"
make-dir@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-2.1.0.tgz#5f0310e18b8be898cc07009295a30ae41e91e6f5"
@ -5700,11 +5549,6 @@ neo-async@^2.6.2:
resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f"
integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==
netmask@^1.0.6:
version "1.0.6"
resolved "https://registry.yarnpkg.com/netmask/-/netmask-1.0.6.tgz#20297e89d86f6f6400f250d9f4f6b4c1945fcd35"
integrity sha1-ICl+idhvb2QA8lDZ9Pa0wZRfzTU=
next-tick@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.0.0.tgz#ca86d1fe8828169b0120208e3dc8424b9db8342c"
@ -5734,6 +5578,11 @@ node-releases@^2.0.1:
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.1.tgz#3d1d395f204f1f2f29a54358b9fb678765ad2fc5"
integrity sha512-CqyzN6z7Q6aMeF/ktcMVTzhAHCEpf8SOarwpzpf8pNBY2k5/oM34UHldUwp8VKI7uxct2HxSRdJjBaZeESzcxA==
nodemailer@^6.7.2:
version "6.7.2"
resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-6.7.2.tgz#44b2ad5f7ed71b7067f7a21c4fedabaec62b85e0"
integrity sha512-Dz7zVwlef4k5R71fdmxwR8Q39fiboGbu3xgswkzGwczUfjp873rVxt1O46+Fh0j1ORnAC6L9+heI8uUpO6DT7Q==
nodemon@^2.0.2:
version "2.0.15"
resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-2.0.15.tgz#504516ce3b43d9dc9a955ccd9ec57550a31a8d4e"
@ -6026,31 +5875,6 @@ p-try@^2.0.0:
resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6"
integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==
pac-proxy-agent@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/pac-proxy-agent/-/pac-proxy-agent-3.0.1.tgz#115b1e58f92576cac2eba718593ca7b0e37de2ad"
integrity sha512-44DUg21G/liUZ48dJpUSjZnFfZro/0K5JTyFYLBcmh9+T6Ooi4/i4efwUiEy0+4oQusCBqWdhv16XohIj1GqnQ==
dependencies:
agent-base "^4.2.0"
debug "^4.1.1"
get-uri "^2.0.0"
http-proxy-agent "^2.1.0"
https-proxy-agent "^3.0.0"
pac-resolver "^3.0.0"
raw-body "^2.2.0"
socks-proxy-agent "^4.0.1"
pac-resolver@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/pac-resolver/-/pac-resolver-3.0.0.tgz#6aea30787db0a891704deb7800a722a7615a6f26"
integrity sha512-tcc38bsjuE3XZ5+4vP96OfhOugrX+JcnpUbhfuc4LuXBLQhoTthOstZeoQJBDnQUDYzYmdImKsbz0xSl1/9qeA==
dependencies:
co "^4.6.0"
degenerator "^1.0.4"
ip "^1.1.5"
netmask "^1.0.6"
thunkify "^2.1.2"
package-json@^6.3.0:
version "6.5.0"
resolved "https://registry.yarnpkg.com/package-json/-/package-json-6.5.0.tgz#6feedaca35e75725876d0b0e64974697fed145b0"
@ -6169,13 +5993,6 @@ path-parse@^1.0.6:
resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735"
integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==
path-proxy@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/path-proxy/-/path-proxy-1.0.0.tgz#18e8a36859fc9d2f1a53b48dee138543c020de5e"
integrity sha1-GOijaFn8nS8aU7SN7hOFQ8Ag3l4=
dependencies:
inflection "~1.3.0"
path-root-regex@^0.1.0:
version "0.1.2"
resolved "https://registry.yarnpkg.com/path-root-regex/-/path-root-regex-0.1.2.tgz#bfccdc8df5b12dc52c8b43ec38d18d72c04ba96d"
@ -6334,13 +6151,6 @@ promise@^7.0.1:
dependencies:
asap "~2.0.3"
promisify-call@^2.0.2:
version "2.0.4"
resolved "https://registry.yarnpkg.com/promisify-call/-/promisify-call-2.0.4.tgz#d48c2d45652ccccd52801ddecbd533a6d4bd5fba"
integrity sha1-1IwtRWUszM1SgB3ey9UzptS9X7o=
dependencies:
with-callback "^1.0.2"
proxy-addr@~2.0.5:
version "2.0.7"
resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025"
@ -6349,25 +6159,6 @@ proxy-addr@~2.0.5:
forwarded "0.2.0"
ipaddr.js "1.9.1"
proxy-agent@^3.0.3:
version "3.1.1"
resolved "https://registry.yarnpkg.com/proxy-agent/-/proxy-agent-3.1.1.tgz#7e04e06bf36afa624a1540be247b47c970bd3014"
integrity sha512-WudaR0eTsDx33O3EJE16PjBRZWcX8GqCEeERw1W3hZJgH/F2a46g7jty6UGty6NeJ4CKQy8ds2CJPMiyeqaTvw==
dependencies:
agent-base "^4.2.0"
debug "4"
http-proxy-agent "^2.1.0"
https-proxy-agent "^3.0.0"
lru-cache "^5.1.1"
pac-proxy-agent "^3.0.1"
proxy-from-env "^1.0.0"
socks-proxy-agent "^4.0.1"
proxy-from-env@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2"
integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==
prr@~1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/prr/-/prr-1.0.1.tgz#d3fc114ba06995a45ec6893f484ceb1d78f5f476"
@ -6580,7 +6371,7 @@ [email protected]:
iconv-lite "0.4.24"
unpipe "1.0.0"
raw-body@^2.2.0, raw-body@^2.3.2:
raw-body@^2.3.2:
version "2.4.2"
resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.4.2.tgz#baf3e9c21eebced59dd6533ac872b71f7b61cb32"
integrity sha512-RPMAFUJP19WIet/99ngh6Iv8fzAbqum4Li7AD6DtGaW2RpMB/11xDoalPiJMTbu6I3hkbMVkATvZrqb9EEqeeQ==
@ -6654,7 +6445,16 @@ [email protected]:
isarray "0.0.1"
string_decoder "~0.10.x"
readable-stream@2, readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.5, readable-stream@^2.0.6, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.3.3, readable-stream@^2.3.5, readable-stream@^2.3.6, readable-stream@~2.3.6:
"readable-stream@2 || 3", readable-stream@3, readable-stream@^3.1.1, readable-stream@^3.4.0:
version "3.6.0"
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198"
integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==
dependencies:
inherits "^2.0.3"
string_decoder "^1.1.1"
util-deprecate "^1.0.1"
readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.5, readable-stream@^2.0.6, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.3.3, readable-stream@^2.3.5, readable-stream@^2.3.6, readable-stream@~2.3.6:
version "2.3.7"
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57"
integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==
@ -6667,15 +6467,6 @@ readable-stream@2, readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stre
string_decoder "~1.1.1"
util-deprecate "~1.0.1"
"readable-stream@2 || 3", readable-stream@3, readable-stream@^3.1.1, readable-stream@^3.4.0:
version "3.6.0"
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198"
integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==
dependencies:
inherits "^2.0.3"
string_decoder "^1.1.1"
util-deprecate "^1.0.1"
readdirp@^2.2.1:
version "2.2.1"
resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-2.2.1.tgz#0e87622a3325aa33e892285caf8b4e846529a525"
@ -7281,11 +7072,6 @@ slug@^5.1.0:
resolved "https://registry.yarnpkg.com/slug/-/slug-5.1.0.tgz#8a7e30ca1c3a6dc40cf74e269750913a865edb0b"
integrity sha512-IS39jKR6m+puU8zWgH6ruwx1sfzFNJ6Ai5PKIlUqd0X8C3ca7PB49Cvm0uayqgEt1jgaojO2wWEsQJngnh7fDA==
smart-buffer@^4.1.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.2.0.tgz#6e1d71fa4f18c05f7d0ff216dd16a481d0e8d9ae"
integrity sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==
snapdragon-node@^2.0.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/snapdragon-node/-/snapdragon-node-2.1.1.tgz#6c175f86ff14bdb0724563e8f3c1b021a286853b"
@ -7414,22 +7200,6 @@ socket.io@^4.4.0:
socket.io-adapter "~2.3.3"
socket.io-parser "~4.0.4"
socks-proxy-agent@^4.0.1:
version "4.0.2"
resolved "https://registry.yarnpkg.com/socks-proxy-agent/-/socks-proxy-agent-4.0.2.tgz#3c8991f3145b2799e70e11bd5fbc8b1963116386"
integrity sha512-NT6syHhI9LmuEMSK6Kd2V7gNv5KFZoLE7V5udWmn0de+3Mkj3UMA/AJPLyeNUVmElCurSHtUdM3ETpR3z770Wg==
dependencies:
agent-base "~4.2.1"
socks "~2.3.2"
socks@~2.3.2:
version "2.3.3"
resolved "https://registry.yarnpkg.com/socks/-/socks-2.3.3.tgz#01129f0a5d534d2b897712ed8aceab7ee65d78e3"
integrity sha512-o5t52PCNtVdiOvzMry7wU4aOqYWL0PeCXRWBEiJow4/i/wr+wpsJQ9awEu1EonLIqsfGd5qSgDdxEOvCdmBEpA==
dependencies:
ip "1.1.5"
smart-buffer "^4.1.0"
source-list-map@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-2.0.1.tgz#3993bd873bfc48479cca9ea3a547835c7c154b34"
@ -7878,11 +7648,6 @@ through@^2.3.8:
resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5"
integrity sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=
thunkify@^2.1.2:
version "2.1.2"
resolved "https://registry.yarnpkg.com/thunkify/-/thunkify-2.1.2.tgz#faa0e9d230c51acc95ca13a361ac05ca7e04553d"
integrity sha1-+qDp0jDFGsyVyhOjYawFyn4EVT0=
time-stamp@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/time-stamp/-/time-stamp-1.1.0.tgz#764a5a11af50561921b133f3b44e618687e0f5c3"
@ -8005,16 +7770,11 @@ tr46@~0.0.3:
resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a"
integrity sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=
tslib@^2.0.1, tslib@^2.3.0:
tslib@^2.3.0:
version "2.3.1"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01"
integrity sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==
tsscmp@^1.0.6:
version "1.0.6"
resolved "https://registry.yarnpkg.com/tsscmp/-/tsscmp-1.0.6.tgz#85b99583ac3589ec4bfef825b5000aa911d605eb"
integrity sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==
tunnel-agent@^0.6.0:
version "0.6.0"
resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd"
@ -8578,11 +8338,6 @@ widest-line@^3.1.0:
dependencies:
string-width "^4.0.0"
with-callback@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/with-callback/-/with-callback-1.0.2.tgz#a09629b9a920028d721404fb435bdcff5c91bc21"
integrity sha1-oJYpuakgAo1yFAT7Q1vc/1yRvCE=
with@^7.0.0:
version "7.0.2"
resolved "https://registry.yarnpkg.com/with/-/with-7.0.2.tgz#ccee3ad542d25538a7a7a80aad212b9828495bac"
@ -8870,11 +8625,6 @@ xmlhttprequest-ssl@~1.6.2:
resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.6.3.tgz#03b713873b01659dfa2c1c5d056065b27ddc2de6"
integrity sha512-3XfeQE/wNkvrIktn2Kf0869fC0BN6UpydVasGIeSm2B1Llihf7/0UfZM+eCkOw3P7bP4+qPgqhm7ZoxuJtFU0Q==
[email protected]:
version "2.0.0"
resolved "https://registry.yarnpkg.com/xregexp/-/xregexp-2.0.0.tgz#52a63e56ca0b84a7f3a5f3d61872f126ad7a5943"
integrity sha1-UqY+VsoLhKfzpfPWGHLxJq16WUM=
xtend@^4.0.0, xtend@~4.0.0, xtend@~4.0.1:
version "4.0.2"
resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"
@ -8895,11 +8645,6 @@ y18n@^5.0.5:
resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55"
integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==
yallist@^3.0.2:
version "3.1.1"
resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd"
integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==
yallist@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72"

Loading…
Cancel
Save