From 42dfb427682e21ae45148909829c27f076b62058 Mon Sep 17 00:00:00 2001 From: Shaun Warman Date: Sun, 7 Apr 2024 15:55:07 -0500 Subject: [PATCH] feat: enhanced support --- .gitignore | 1 + app/controllers/web/admin/index.js | 4 +- app/controllers/web/admin/inquiries.js | 211 +++++++++++++++++++++++++ app/controllers/web/help.js | 11 +- app/models/inquiries.js | 13 ++ app/views/_nav.pug | 4 + app/views/admin/inquiries/_table.pug | 102 ++++++++++++ app/views/admin/inquiries/index.pug | 9 ++ app/views/admin/inquiries/retrieve.pug | 27 ++++ assets/js/core.js | 68 ++++++++ docker-compose-dev.yml | 25 +++ emails/inquiry-response/html.pug | 16 ++ emails/inquiry-response/subject.pug | 4 + package-scripts.js | 18 +-- routes/web/admin.js | 7 + 15 files changed, 509 insertions(+), 11 deletions(-) create mode 100644 app/controllers/web/admin/inquiries.js create mode 100644 app/views/admin/inquiries/_table.pug create mode 100644 app/views/admin/inquiries/index.pug create mode 100644 app/views/admin/inquiries/retrieve.pug create mode 100644 docker-compose-dev.yml create mode 100644 emails/inquiry-response/html.pug create mode 100644 emails/inquiry-response/subject.pug diff --git a/.gitignore b/.gitignore index bd93b782d1..82cfca62a2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ *.awspublish* *.env +.env* !.gulpfile.env !test/.env *.lcov diff --git a/app/controllers/web/admin/index.js b/app/controllers/web/admin/index.js index dc721637eb..3faeec7b5c 100644 --- a/app/controllers/web/admin/index.js +++ b/app/controllers/web/admin/index.js @@ -10,6 +10,7 @@ const logs = require('./logs'); const allowlist = require('./allowlist'); const denylist = require('./denylist'); const emails = require('./emails'); +const inquiries = require('./inquiries'); module.exports = { dashboard, @@ -18,5 +19,6 @@ module.exports = { logs, allowlist, denylist, - emails + emails, + inquiries }; diff --git a/app/controllers/web/admin/inquiries.js b/app/controllers/web/admin/inquiries.js new file mode 100644 index 0000000000..3c9b3c8159 --- /dev/null +++ b/app/controllers/web/admin/inquiries.js @@ -0,0 +1,211 @@ +/** + * Copyright (c) Forward Email LLC + * SPDX-License-Identifier: BUSL-1.1 + */ + +const Boom = require('@hapi/boom'); +const paginate = require('koa-ctx-paginate'); + +const { Inquiries, Users } = require('#models'); +const config = require('#config'); +const email = require('#helpers/email'); + +async function list(ctx) { + let $sort = { created_at: -1 }; + if (ctx.query.sort) { + const order = ctx.query.sort.startsWith('-') ? -1 : 1; + $sort = { + [order === -1 ? ctx.query.sort.slice(1) : ctx.query.sort]: order + }; + } + + const [inquiries, itemCount] = await Promise.all([ + Inquiries.aggregate([ + { + $match: { + $or: [{ is_resolved: { $exists: false } }, { is_resolved: false }] + } + }, + { + $lookup: { + from: 'users', + localField: 'user', + foreignField: '_id', + as: 'user' + } + }, + { + $unwind: '$user' + }, + { + $project: { + id: 1, + message: 1, + created_at: 1, + updated_at: 1, + email: '$user.email', + plan: '$user.plan' + } + }, + { + $sort + }, + { + $skip: ctx.paginate.skip + }, + { + $limit: ctx.query.limit + } + ]).exec(), + Inquiries.countDocuments() + ]); + + const pageCount = Math.ceil(itemCount / ctx.query.limit); + + if (ctx.accepts('html')) + return ctx.render('admin/inquiries', { + inquiries, + pageCount, + itemCount, + pages: paginate.getArrayPages(ctx)(6, pageCount, ctx.query.page) + }); + + const table = await ctx.render('admin/inquiries/_table', { + inquiries, + pageCount, + itemCount, + pages: paginate.getArrayPages(ctx)(6, pageCount, ctx.query.page) + }); + + ctx.body = { table }; +} + +async function retrieve(ctx) { + ctx.state.result = await Inquiries.findById(ctx.params.id); + if (!ctx.state.result) + throw Boom.notFound(ctx.translateError('INVALID_INQUIRY')); + return ctx.render('admin/inquiries/retrieve'); +} + +async function remove(ctx) { + const inquiry = await Inquiries.findById(ctx.params.id); + if (!inquiry) throw Boom.notFound(ctx.translateError('INVALID_INQUIRY')); + + await Inquiries.deleteOne({ _id: inquiry.id }); + + ctx.flash('custom', { + title: ctx.request.t('Success'), + text: ctx.translate('REQUEST_OK'), + type: 'success', + toast: true, + showConfirmButton: false, + timer: 3000, + position: 'top' + }); + + if (ctx.accepts('html')) ctx.redirect('back'); + else ctx.body = { reloadPage: true }; +} + +async function reply(ctx) { + const inquiry = await Inquiries.findById(ctx.params.id); + if (!inquiry) throw Boom.notFound(ctx.translateError('INVALID_INQUIRY')); + + const user = await Users.findById(inquiry.user); + if (!user) throw Boom.notFound(ctx.translateError('INVALID_USER')); + + const { message } = ctx.request.body; + + await email({ + template: 'inquiry-response', + message: { + to: user[config.userFields.fullEmail], + cc: config.email.message.from, + inReplyTo: inquiry.references[0], + references: inquiry.references, + subject: inquiry.subject + }, + locals: { + user: user.toObject(), + inquiry, + response: { message } + } + }); + + await Inquiries.findOneAndUpdate( + { id: inquiry.id }, + { + $set: { is_resolved: true } + } + ); + + ctx.flash('custom', { + title: ctx.request.t('Success'), + text: ctx.translate('REQUEST_OK'), + type: 'success', + toast: true, + showConfirmButton: false, + timer: 3000, + position: 'top' + }); + + if (ctx.accepts('html')) ctx.redirect('/admin/inquiries'); + else ctx.body = { redirectTo: '/admin/inquiries' }; +} + +async function bulkReply(ctx) { + const { ids, message } = ctx.request.body; + + const repliedTo = new Set(); + + try { + for (const id of ids) { + // eslint-disable-next-line no-await-in-loop + const inquiry = await Inquiries.findById(id); + if (!inquiry) throw Boom.notFound(ctx.translateError('INVALID_INQUIRY')); + + // eslint-disable-next-line no-await-in-loop + const user = await Users.findById(inquiry.user); + if (!user) throw Boom.notFound(ctx.translateError('INVALID_USER')); + + // if the user has multiple inquiries and we've just responded + // in bulk to a previous message then let's skip the email + if (!repliedTo.has(user)) { + // eslint-disable-next-line no-await-in-loop + await email({ + template: 'inquiry-response', + message: { + to: user[config.userFields.fullEmail], + cc: config.email.message.from, + inReplyTo: inquiry.references[0], + references: inquiry.references, + subject: inquiry.subject + }, + locals: { + user: user.toObject(), + inquiry, + response: { message } + } + }); + } + + // eslint-disable-next-line no-await-in-loop + await Inquiries.findOneAndUpdate( + { id: inquiry.id }, + { + $set: { is_resolved: true } + } + ); + + repliedTo.add(user); + } + } catch (err) { + ctx.flash('error', `Error replying: ${err.message}`); + return; + } + + if (ctx.accepts('html')) ctx.redirect('/admin/inquiries'); + else ctx.body = { redirectTo: '/admin/inquiries' }; +} + +module.exports = { list, retrieve, remove, reply, bulkReply }; diff --git a/app/controllers/web/help.js b/app/controllers/web/help.js index e553659c78..3b5d47d522 100644 --- a/app/controllers/web/help.js +++ b/app/controllers/web/help.js @@ -43,7 +43,7 @@ async function help(ctx) { ctx.logger.debug('created inquiry', { inquiry }); - await email({ + const emaild = await email({ template: 'inquiry', message: { to: ctx.state.user[config.userFields.fullEmail], @@ -56,6 +56,15 @@ async function help(ctx) { } }); + const { subject } = JSON.parse(emaild.message); + + await Inquiries.findOneAndUpdate( + { id: inquiry.id }, + { + $set: { references: [emaild.messageId], subject } + } + ); + const message = ctx.translate('SUPPORT_REQUEST_SENT'); if (ctx.accepts('html')) { ctx.flash('success', message); diff --git a/app/models/inquiries.js b/app/models/inquiries.js index fb4b235e24..68f85dd414 100644 --- a/app/models/inquiries.js +++ b/app/models/inquiries.js @@ -26,6 +26,19 @@ const Inquiries = new mongoose.Schema({ is_denylist: { type: Boolean, default: false + }, + is_resolved: { + type: Boolean, + required: false, + default: false + }, + references: { + type: Array, + required: false + }, + subject: { + type: String, + required: false } }); diff --git a/app/views/_nav.pug b/app/views/_nav.pug index 878f4ee2d6..7b3da3e8d6 100644 --- a/app/views/_nav.pug +++ b/app/views/_nav.pug @@ -594,6 +594,10 @@ nav.navbar(class=navbarClasses.join(" ")) class=ctx.pathWithoutLocale.startsWith("/admin/domains") ? "active" : "", href=l("/admin/domains") )= t("Domains") + a.dropdown-item( + class=ctx.pathWithoutLocale.startsWith("/admin/inquiries") ? "active" : "", + href=l("/admin/inquiries") + )= t("Inquiries") a.dropdown-item( class=ctx.pathWithoutLocale.startsWith("/admin/emails") ? "active" : "", href=l("/admin/emails") diff --git a/app/views/admin/inquiries/_table.pug b/app/views/admin/inquiries/_table.pug new file mode 100644 index 0000000000..d775b0990a --- /dev/null +++ b/app/views/admin/inquiries/_table.pug @@ -0,0 +1,102 @@ +include ../../_sort-header +include ../../_pagination + +.table-responsive + table.table.table-hover.table-bordered.table-sm + thead.thead-dark + tr + th.text-center.align-middle(scope="col")= t("Selected") + th(scope="col") + +sortHeader('email', null, '#table-inquiries') + th.text-center.align-middle(scope="col")= t("Message") + th(scope="col") + +sortHeader('plan', null, '#table-inquiries') + th(scope="col") + +sortHeader('created_at', 'Created', '#table-inquiries') + th(scope="col") + +sortHeader('updated_at', 'Updated', '#table-inquiries') + if passport && passport.otp + th(scope="col") + +sortHeader(config.passport.fields.otpEnabled, 'OTP Enabled', '#table-inquiries') + th.text-center.align-middle(scope="col")= t("Actions") + tbody + if inquiries.length === 0 + td.alert.alert-info= t("No inquiries exist for that search.") + else + each inquiry in inquiries + tr + td.align-middle.text-center + .form-group.form-check.form-check-inline.mb-0 + input#is-inquiry-selected.form-check-input( + type="checkbox", + name="is_inquiry_selected", + value=inquiry.id + ) + td.align-middle + a( + href=`mailto:${inquiry.email}`, + target="_blank", + rel="noopener noreferrer" + )= inquiry.email + td.align-middle + =inquiry.message + td.align-middle + =inquiry.plan + td.align-middle.dayjs( + data-time=new Date(inquiry.created_at).getTime() + )= dayjs(inquiry.created_at).format("M/D/YY h:mm A z") + td.align-middle.dayjs( + data-time=new Date(inquiry.updated_at).getTime() + )= dayjs(inquiry.updated_at).format("M/D/YY h:mm A z") + td.align-middle + .btn-group(role="group", aria-label=t("Actions")) + a.btn.btn-secondary( + href=l(`/admin/inquiries/${inquiry.id}`), + data-toggle="tooltip", + data-title=t("Edit") + ): i.fa.fa-fw.fa-edit + form.ajax-form.confirm-prompt.btn-group( + action=l(`/admin/inquiries/${inquiry.id}`), + method="POST", + autocomplete="off" + ) + input(type="hidden", name="_method", value="DELETE") + button.btn.btn-danger( + type="submit", + data-toggle="tooltip", + data-title=t("Remove") + ): i.fa.fa-fw.fa-remove + button#bulk-reply-button.btn.btn-secondary.float-right.mb-3 Bulk Reply + #bulk-reply-modal.modal.fade( + tabindex="-1", + role="dialog", + aria-labelledby="modal-bulk-reply-title", + aria-hidden="true" + ) + .modal-dialog(role="document") + .modal-content + .modal-header.text-center.d-block + h4#modal-bulk-reply-title.d-inline-block.ml-4= t("Bulk Reply") + button.close( + type="button", + data-dismiss="modal", + aria-label="Close" + ) + span(aria-hidden="true") × + .modal-body + .text-center + form.form-group + label(for="bulk-reply-message") + h5= t("Message") + = " " + textarea#textarea-bulk-reply-message.form-control( + name="bulk-reply-message", + maxlength=300, + rows=8 + ) + p.form-text.small.text-black.text-themed-50= t("Message has a max of 300 characters.") + button.btn.btn-lg.btn-block.btn-primary(type='button' id='submit-bulk-reply') + i.fa.fa-edit + = " " + = t("Submit Bulk Reply") ++paginate('#table-inquiries') diff --git a/app/views/admin/inquiries/index.pug b/app/views/admin/inquiries/index.pug new file mode 100644 index 0000000000..4b59815a22 --- /dev/null +++ b/app/views/admin/inquiries/index.pug @@ -0,0 +1,9 @@ +extends ../../layout + +block body + .container-fluid.py-3 + .row.mt-1 + .col + include ../../_breadcrumbs + #table-inquiries + include ./_table diff --git a/app/views/admin/inquiries/retrieve.pug b/app/views/admin/inquiries/retrieve.pug new file mode 100644 index 0000000000..80a42f6053 --- /dev/null +++ b/app/views/admin/inquiries/retrieve.pug @@ -0,0 +1,27 @@ +extends ../../layout + +block body + .container.py-3 + .row.mt-1 + .col + include ../../_breadcrumbs + form.ajax-form.confirm-prompt(action=ctx.path, method="POST") + .card.border-themed + .card-body + .form-group.floating-label + label.read-only-message.text-muted= result.message + .form-group.floating-label + textarea#input-message.form-control( + rows="8", + required, + maxlength=config.supportRequestMaxLength, + name="message", + placeholder=t("Write your response") + ) + label(for="input-message")= t("Reply") + .card-footer + button.btn.btn-block.btn-primary.btn-lg( + type="submit", + data-toggle="tooltip", + data-placement="bottom" + )= t("Send reply") diff --git a/assets/js/core.js b/assets/js/core.js index c9ce2c0f96..dca1b69c32 100644 --- a/assets/js/core.js +++ b/assets/js/core.js @@ -830,3 +830,71 @@ window.addEventListener( 'resize', setViewportProperty(document.documentElement) ); + +function handleBulkReply() { + const checkboxes = $('#table-inquiries input[type="checkbox"]:checked'); + const ids = checkboxes + .map(function () { + return $(this).val(); + }) + .get(); + + if (ids.length === 0) { + Swal.fire(window._types.error, 'No inquiries selected.', 'error'); + return; + } + + if (ids.length === 1) { + const { origin, pathname } = window.location; + const redirectUrl = `${origin}${pathname}/${ids[0]}`; + window.location.href = redirectUrl; + return; + } + + $('#bulk-reply-modal').modal('show'); +} + +async function handleSubmitBulkReply() { + const checkboxes = $('#table-inquiries input[type="checkbox"]:checked'); + const ids = checkboxes + .map(function () { + return $(this).val(); + }) + .get(); + + const message = $('#textarea-bulk-reply-message').val(); + + try { + spinner.show(); + + const url = `${window.location.pathname}/bulk`; + const response = await sendRequest({ ids, message }, url); + + if (response.err) throw response.err; + + if ( + typeof response.body !== 'object' || + response.body === null || + typeof response.body.challenge !== 'string' + ) + throw new Error( + response.statusText || + response.text || + 'Invalid response, please try again' + ); + + spinner.hide(); + Swal.fire( + window._types.error, + `Successfully replied to ${ids.length} inquiries!`, + 'success' + ); + } catch (err) { + console.error(err); + spinner.hide(); + Swal.fire(window._types.error, err.message, 'error'); + } +} + +$('#table-inquiries').on('click', '#bulk-reply-button', handleBulkReply); +$('#table-inquiries').on('click', '#submit-bulk-reply', handleSubmitBulkReply); diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml new file mode 100644 index 0000000000..2ea0e946a1 --- /dev/null +++ b/docker-compose-dev.yml @@ -0,0 +1,25 @@ +version: '3.8' + +services: + + mongodb: + image: mongo:latest + ports: + - "27017:27017" + volumes: + - mongodb-data:/data/db + env_file: + - .env + + redis: + image: redis:latest + ports: + - "6379:6379" + volumes: + - redis-data:/data + env_file: + - .env + +volumes: + mongodb-data: + redis-data: diff --git a/emails/inquiry-response/html.pug b/emails/inquiry-response/html.pug new file mode 100644 index 0000000000..9f0dde2877 --- /dev/null +++ b/emails/inquiry-response/html.pug @@ -0,0 +1,16 @@ +extends ../layout + +block content + .container.mt-3 + .row + .col-12 + .card.border-dark.d-block + h1.h5.card-header.text-center= t("Your Help Request") + .card-body.p-0 + //- replace line breaks with
+ .p-3 + .card-text.text-monospace.small!= splitLines(response.message).join("
") + .card-footer.text-center.small.text-muted + strong= t("Have attachments?") + = " " + = t("Reply to this email with them.") diff --git a/emails/inquiry-response/subject.pug b/emails/inquiry-response/subject.pug new file mode 100644 index 0000000000..4d6f1ce8c0 --- /dev/null +++ b/emails/inquiry-response/subject.pug @@ -0,0 +1,4 @@ +- if (inquiry.subject) + = inquiry.subject +- else + = `${emoji(user.plan === "free" ? "mega" : "star")} ${user.plan === "free" ? "" : "Premium Support: "}${t("Your Help Request")} #${new Date(inquiry.created_at).getTime()}` diff --git a/package-scripts.js b/package-scripts.js index 91dcdbca12..20d563efc0 100644 --- a/package-scripts.js +++ b/package-scripts.js @@ -13,15 +13,15 @@ module.exports = { webAndWatch: series.nps('build', 'web', 'watch'), - bree: 'ttab -G nodemon bree.js', - api: 'ttab -G nodemon api.js', - web: 'ttab -G nodemon web.js', - smtp: 'ttab -G SMTP_ALLOW_INSECURE_AUTH=true SMTP_PORT=2432 nodemon smtp.js', - imap: 'ttab -G IMAP_PORT=2113 nodemon imap.js', - pop3: 'ttab -G POP3_PORT=2115 nodemon pop3.js', - sqlite: 'ttab -G nodemon sqlite.js', - - watch: 'ttab -G gulp watch', + bree: 'nodemon bree.js', + api: 'nodemon api.js', + web: 'nodemon web.js', + smtp: 'SMTP_ALLOW_INSECURE_AUTH=true SMTP_PORT=2432 nodemon smtp.js', + imap: 'IMAP_PORT=2113 nodemon imap.js', + pop3: 'POP3_PORT=2115 nodemon pop3.js', + sqlite: 'nodemon sqlite.js', + + watch: 'gulp watch', clean: 'gulp clean', build: 'gulp build', buildTest: 'NODE_ENV=test gulp build', diff --git a/routes/web/admin.js b/routes/web/admin.js index 3253c0f6bc..fa399cd823 100644 --- a/routes/web/admin.js +++ b/routes/web/admin.js @@ -52,6 +52,13 @@ router .post('/users/:id/login', web.admin.users.login) .delete('/users/:id', web.admin.users.remove) + // inquiries + .get('/inquiries', paginate.middleware(10, 50), web.admin.inquiries.list) + .get('/inquiries/:id', web.admin.inquiries.retrieve) + .post('/inquiries/bulk', web.admin.inquiries.bulkReply) + .post('/inquiries/:id', web.admin.inquiries.reply) + .delete('/inquiries/:id', web.admin.inquiries.remove) + // domains .get('/domains', paginate.middleware(10, 50), web.admin.domains.list) .put('/domains/:id', web.admin.domains.update)