Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for sending ad hoc batch emails #234

Merged
merged 6 commits into from
Aug 28, 2018
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions client/admin.html
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ <h1 class="center">Admin Panel</h1>
<a href="#users" class="btn">Users</a>
<a href="#applicants" class="btn">Applicants</a>
<a href="#settings" class="btn">Settings</a>
<a href="#emails" class="btn">Emails</a>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please move this to after Applicants and before Settings

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

</nav>

<section id="statistics">
Expand Down Expand Up @@ -412,6 +413,42 @@ <h4><code>config.json</code> options</h4>
</div>
</form>
</section>
<section id="emails">
<h2>Batch Emails</h2>
<select id="email-branch-filter">
<option value="*">All accounts</option>
<option value="na">Not Applied</option>
<optgroup label="Applicant Type">
{{#each settings.branches.application}}
<option value="application-{{this.name}}">{{this.name}}</option>
{{/each}}
{{#each settings.branches.confirmation}}
<option value="confirmation-{{this.name}}">{{this.name}}</option>
{{/each}}
</optgroup>
</select>
<select id="email-status-filter">
</select>

<input type="text" id="batch-email-subject" placeholder="Email Subject"/>
<textarea id="batch-email-content"></textarea>
<h5>Rendered HTML and text:</h5>
<section id="batch-email-rendered"></section>

<h5>List of variables:</h5>
<ul>
<li><strong>\{{eventName}}</strong>: The name of the event, configured by the <code>eventName</code> key-value pair in the <code>config.json</code> and displayed at the top of the page.</li>
<li><strong>\{{email}}</strong>: The user's email as reported by them or a 3rd party OAuth provider (i.e. Google, GitHub, Facebook).</li>
<li><strong>\{{name}}</strong>: The user's name as reported by them or a 3rd party OAuth provider (i.e. Google, GitHub, Facebook).</li>
<li><strong>\{{teamName}}</strong>: The user's team name if teams are enabled and the user has joined a team. Otherwise, will output <code>Teams not enabled</code> or <code>No team created or joined</code> respectively.</li>
<li><strong>\{{applicationBranch}}</strong>: The question branch name that the user applied / was accepted to.</li>
<li><strong>\{{confirmationBranch}}</strong>: The question branch name that the user RSVPed to.</li>
<li><strong>\{{application.<code>question-name</code>}}</strong>: Prints the user's response to the application question with the specified name from <code>questions.json</code>. Note that the question name is different from the question label. <a href="https://github.com/HackGT/registration/blob/master/server/config/questions.json" target="_blank">See the GitHub project</a> for details. Will print <code>N/A</code> if not yet answered.</li>
<li><strong>\{{confirmation.<code>question-name</code>}}</strong>: Prints the user's response to the confirmation question with the specified name from <code>questions.json</code>.</li>
<li><strong>\{{reimbursementAmount}}</strong>: A string representing how much a user should be reimbursed for.</li>
</ul>
<button id="sendEmail">Send Email</button>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be centered by wrapping in <div class="row center"></div> like the structure in the other tabs

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

</section>
{{/sidebar}}
</body>
</html>
113 changes: 112 additions & 1 deletion client/js/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,61 @@ class State {
this.sectionElement.style.display = "block";
}
}
const states: State[] = ["statistics", "users", "applicants", "settings"].map(id => new State(id));
const states: State[] = ["statistics", "users", "applicants", "settings", "emails"].map(id => new State(id));

function generateFilter(branchFilter: HTMLInputElement, statusFilter: HTMLInputElement) {
let filter: any = {};
if (branchFilter.value !== "*" && branchFilter.value !== "na") {
let [, type, branchName] = branchFilter.value.match(/^(application|confirmation)-(.*)$/)!;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good use of destructuring 👍

if (type === "application") {
filter.applicationBranch = branchName;
}
else if (type === "confirmation") {
filter.confirmationBranch = branchName;
}
switch (statusFilter.value) {
case "no-submission":
if (type === "confirmation") {
filter.confirmed = false;
}
break;
case "submitted":
if (type === "confirmation") {
filter.confirmed = true;
} else {
filter.applied = true;
}
break;
}
} else if (branchFilter.value === "na") {
filter.applied = false;
}
return filter;
}
const batchEmailBranchFilterSelect = document.getElementById("email-branch-filter") as HTMLSelectElement;
const batchEmailStatusFilterSelect = document.getElementById("email-status-filter") as HTMLSelectElement;
async function batchEmailTypeChange(): Promise<void> {
if (batchEmailBranchFilterSelect.value === "*" || batchEmailBranchFilterSelect.value === "na") {
batchEmailStatusFilterSelect.style.display = "none";
} else {
for (let i = 0; i < batchEmailBranchFilterSelect.options.length; i++) {
batchEmailStatusFilterSelect.options.remove(0);
}
batchEmailStatusFilterSelect.style.display = "block";
let [, type ] = batchEmailBranchFilterSelect.value.match(/^(application|confirmation)-(.*)$/)!;
// Only confirmation branches have no-submission option since confirmation is manually assigned
if (type === "confirmation") {
let noSubmission = new Option("Have not submitted (Confirmation)", "no-submission");
batchEmailStatusFilterSelect.add(noSubmission);
}
let submitted = new Option(`Submitted (${type.charAt(0).toUpperCase() + type.slice(1)})`, "submitted");
batchEmailStatusFilterSelect.add(submitted);
}
}
batchEmailBranchFilterSelect.addEventListener("change", batchEmailTypeChange);
batchEmailTypeChange().catch(err => {
console.error(err);
});

class UserEntries {
private static readonly NODE_COUNT = 20;
Expand Down Expand Up @@ -833,3 +887,60 @@ for (let i = 0; i < data.length; i++) {
}
});
}

let emailBranchFilter = document.getElementById("email-branch-filter") as HTMLInputElement;
let emailStatusFilter = document.getElementById("email-status-filter") as HTMLInputElement;
let sendEmailButton = document.getElementById("sendEmail") as HTMLButtonElement;
let batchEmailSubject = document.getElementById("batch-email-subject") as HTMLInputElement;
let batchEmailEditor = new SimpleMDE({ element: document.getElementById("batch-email-content")! });
let batchEmailRenderedArea: HTMLElement | ShadowRoot = document.getElementById("batch-email-rendered") as HTMLElement;
if (document.head.attachShadow) {
// Browser supports Shadow DOM
batchEmailRenderedArea = batchEmailRenderedArea.attachShadow({ mode: "open" });
}
batchEmailEditor.codemirror.on("change", async () => {
try {
let content = new FormData();
content.append("content", batchEmailEditor.value());
let { html, text }: { html: string; text: string } = (
await fetch(`/api/settings/email_content/batch_email/rendered`, {
credentials: "same-origin",
method: "POST",
body: content
}).then(checkStatus).then(parseJSON)
);
batchEmailRenderedArea.innerHTML = html;
let hr = document.createElement("hr");
hr.style.border = "1px solid #737373";
batchEmailRenderedArea.appendChild(hr);
let textContainer = document.createElement("pre");
textContainer.textContent = text;
batchEmailRenderedArea.appendChild(textContainer);
}
catch {
batchEmailRenderedArea.textContent = "Couldn't retrieve email content";
}
});
sendEmailButton.addEventListener("click", () => {
let subject = batchEmailSubject.value;
let markdownContent = batchEmailEditor.value();
if (subject === "") {
return sweetAlert("Oh no!", "You need an email subject", "error");
} else if (markdownContent === "") {
return sweetAlert("Oh no!", "Your email body is empty.", "error");
}
let filter = generateFilter(emailBranchFilter, emailStatusFilter);
let content = new FormData();
content.append("filter", JSON.stringify(filter));
content.append("subject", subject);
content.append("markdownContent", markdownContent);
sendEmailButton.disabled = true;
return fetch(`/api/settings/send_batch_email`, {
credentials: "same-origin",
method: "POST",
body: content
}).then(checkStatus).then(parseJSON).then((result: {success: boolean; count: number} ) => {
sendEmailButton.disabled = false;
sweetAlert("Success!", `Successfully sent ${result.count} email(s)!`, "success");
});
});
22 changes: 11 additions & 11 deletions server/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,18 +389,22 @@ export const defaultEmailSubjects = {
preConfirm: `[${config.eventName}] - Application Update`,
attend: `[${config.eventName}] - Thank you for RSVPing!`
};
interface IMailObject {
export interface IMailObject {
to: string;
from: string;
subject: string;
html: string;
text: string;
}
export async function sendMailAsync(mail: IMailObject): Promise<void> {
await sendgrid.send(mail);
// Union types don't work well with overloaded method resolution in Typescript so we split into two methods
export async function sendMailAsync(mail: IMailObject) {
return sendgrid.send(mail);
}
export function sanitize(input: string): string {
if (typeof input !== "string") {
export async function sendBatchMailAsync(mail: IMailObject[]) {
return sendgrid.send(mail);
}
export function sanitize(input?: string): string {
if (!input || typeof input !== "string") {
return "";
}
return input.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
Expand Down Expand Up @@ -458,16 +462,12 @@ export async function renderEmailHTML(markdown: string, user: IUser): Promise<st

// Interpolate and sanitize variables
markdown = markdown.replace(/{{eventName}}/g, sanitize(config.eventName));
if (user.reimbursementAmount) {
markdown = markdown.replace(/{{reimbursementAmount}}/g, sanitize(user.reimbursementAmount));
} else {
markdown = markdown.replace(/{{reimbursementAmount}}/g, "");
}
markdown = markdown.replace(/{{reimbursementAmount}}/g, sanitize(user.reimbursementAmount));
markdown = markdown.replace(/{{email}}/g, sanitize(user.email));
markdown = markdown.replace(/{{name}}/g, sanitize(user.name));
markdown = markdown.replace(/{{teamName}}/g, sanitize(teamName));
markdown = markdown.replace(/{{applicationBranch}}/g, sanitize(user.applicationBranch));
markdown = markdown.replace(/{{confirmationBranch}}/g, sanitize(user.confirmationBranch || ""));
markdown = markdown.replace(/{{confirmationBranch}}/g, sanitize(user.confirmationBranch));
markdown = markdown.replace(/{{application\.([a-zA-Z0-9\- ]+)}}/g, (match, name: string) => {
let question = user.applicationData.find(data => data.name === name);
return formatFormItem(question);
Expand Down
50 changes: 49 additions & 1 deletion server/routes/api/settings.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as express from "express";

import {
getSetting, updateSetting, renderEmailHTML, renderEmailText, defaultEmailSubjects
getSetting, updateSetting, renderEmailHTML, renderEmailText, defaultEmailSubjects, sendBatchMailAsync, config, IMailObject
} from "../../common";
import {
isAdmin, uploadHandler
Expand Down Expand Up @@ -279,3 +279,51 @@ settingsRoutes.route("/email_content/:type/rendered")
});
}
});

settingsRoutes.route("/send_batch_email")
.post(isAdmin, uploadHandler.any(), async (request, response) => {
let filter = JSON.parse(request.body.filter);
let subject = request.body.subject;
let markdownContent = request.body.markdownContent;
if (typeof filter !== "object") {
return response.status(400).json({
"error": `Your query '${filter}' is not a valid MongoDB query`
});
} else if (subject === "" || subject === undefined) {
return response.status(400).json({
"error": "Can't have an empty subject!"
});
} else if (markdownContent === "" || markdownContent === undefined) {
return response.status(400).json({
"error": "Can't have an empty email body!"
});
}

let users = await User.find(filter);
let emails: IMailObject[] = [];
for (let user of users) {
let html: string = await renderEmailHTML(markdownContent, user);
let text: string = await renderEmailText(html, user, true);

emails.push({
from: config.email.from,
to: user.email,
subject,
html,
text
});
}
try {
await sendBatchMailAsync(emails);
} catch (e) {
console.error(e);
return response.status(500).json({
"error": "Error sending email!"
});
}
console.log(`Sent ${emails.length} batch emails requested by ${(request.user as IUser).email}`);
return response.json({
"success": true,
"count": emails.length
});
});