-
+
- | |
+ |
- | |
+ |
|
@@ -92,72 +208,71 @@
|
-
-
+
+
|
- | |
+ |
-
+
- | |
+ |
-
- | |
-
-
- |
- Dear {{username}},
- |
-
-
- | |
-
-
- |
- {{paragraph}}
- |
-
-
+
+
+
- | |
+ |
+
- |
- The Pretendo Network team
+ |
+
+
+ The Pretendo Network team
+
+
|
- | |
+ |
|
- |
+ |
- | |
+ |
- |
- Note: this email message was auto-generated, please do not respond. For further assistance, please join our Discord server or make a post on our Forum.
+ |
+
+
+ Note: This is an automatic email; please do not respond. For assistance, please
+ visit forum.pretendo.network.
+
+
|
- | |
+ |
|
|
- |
+ |
|
@@ -166,5 +281,7 @@
+
+
\ No newline at end of file
diff --git a/src/assets/images/wordmark-purple-white.png b/src/assets/images/wordmark-purple-white.png
new file mode 100644
index 00000000..66c89d53
Binary files /dev/null and b/src/assets/images/wordmark-purple-white.png differ
diff --git a/src/assets/images/wordmark-white.png b/src/assets/images/wordmark-white.png
new file mode 100644
index 00000000..f2fe07fe
Binary files /dev/null and b/src/assets/images/wordmark-white.png differ
diff --git a/src/mailer.ts b/src/mailer.ts
index 0744e14b..eb7b7a0a 100644
--- a/src/mailer.ts
+++ b/src/mailer.ts
@@ -2,11 +2,11 @@ import path from 'node:path';
import fs from 'node:fs';
import nodemailer from 'nodemailer';
import * as aws from '@aws-sdk/client-ses';
+import { encode } from 'he';
import { config, disabledFeatures } from '@/config-manager';
import type { MailerOptions } from '@/types/common/mailer-options';
const genericEmailTemplate = fs.readFileSync(path.join(__dirname, './assets/emails/genericTemplate.html'), 'utf8');
-const confirmationEmailTemplate = fs.readFileSync(path.join(__dirname, './assets/emails/confirmationTemplate.html'), 'utf8');
let transporter: nodemailer.Transporter;
@@ -28,31 +28,208 @@ if (!disabledFeatures.email) {
});
}
-export async function sendMail(options: MailerOptions): Promise {
- if (!disabledFeatures.email) {
- const { to, subject, username, paragraph, preview, text, link, confirmation } = options;
+interface emailComponent {
+ type: 'header' | 'paragraph';
+ text: string;
+ replacements?: emailTextReplacements;
+}
+interface paddingComponent {
+ type: 'padding';
+ size: number;
+}
+interface buttonComponent {
+ type: 'button';
+ text: string;
+ link?: string;
+ primary?: boolean;
+}
+interface emailTextReplacements {
+ [key: string]: string;
+}
+
+export class CreateEmail {
+ // an array which stores all components of the email
+ private readonly componentArray: (emailComponent | paddingComponent | buttonComponent)[] = [];
+
+ /**
+ * adds padding of the specified height in em units
+ */
+ private addPadding(size: number): paddingComponent {
+ return {
+ type: 'padding',
+ size
+ };
+ }
+
+ /**
+ * adds a header. for greetings, do addHeader("Hi {{pnid}}!", { pnid: "theUsername" })
+ */
+ public addHeader(text: string, replacements?: emailTextReplacements): this {
+ const component: emailComponent = { type: 'header', text, replacements };
+ this.componentArray.push(this.addPadding(3), component, this.addPadding(2));
+
+ return this;
+ }
- let html = confirmation ? confirmationEmailTemplate : genericEmailTemplate;
+ /**
+ * adds a paragraph. for links, do addParagraph("this is a [named link](https://example.org)."). for greetings, do addParagraph("Hi {{pnid}}!", { pnid: "theUsername" })
+ */
+ public addParagraph(text: string, replacements?: emailTextReplacements): this {
+ const component: emailComponent = { type: 'paragraph', text, replacements };
+ this.componentArray.push(component, this.addPadding(1));
- html = html.replace(/{{username}}/g, username);
- html = html.replace(/{{paragraph}}/g, paragraph || '');
- html = html.replace(/{{preview}}/g, preview || '');
- html = html.replace(/{{confirmation-href}}/g, confirmation?.href || '');
- html = html.replace(/{{confirmation-code}}/g, confirmation?.code || '');
+ return this;
+ }
+
+ /**
+ * adds a button
+ *
+ * @param {String} text the button text
+ * @param {String} [link] the link
+ * @param {boolean} [primary] set to false to use the secondary button styles (true by default)
+ */
+ public addButton(text: string, link?: string, primary: boolean = true): this {
+ const component: buttonComponent = { type: 'button', text, link, primary };
+ this.componentArray.push(component, this.addPadding(2));
+
+ return this;
+ }
+
+ private addGmailDarkModeFix(el: string): string {
+ return ``;
+ }
- if (link) {
- const { href, text } = link;
+ // parses pnid name and links. set the plaintext bool (false by default) to use no html
+ private parseReplacements(c: emailComponent, plainText: boolean = false): string {
+ let tempText = c.text;
- const button = `| | | ${text} | `;
- html = html.replace(//g, button);
+ // for now only replaces the pnid for shoutouts. could easily be expanded to add more.
+ if (c?.replacements) {
+ Object.entries(c.replacements).forEach(([key, value]) => {
+ const safeValue = encode(value);
+
+ if (key === 'pnid') {
+ if (plainText) {
+ tempText = tempText.replace(/{{pnid}}/g, safeValue);
+ } else {
+ tempText = tempText.replace(/{{pnid}}/g, `${safeValue}`);
+ }
+ }
+ });
+ }
+
+ // wrap and in a element, to fix color on thunderbird and weight on icloud mail web
+ const bRegex = /.*?<\/b>|.*?<\/strong>/g;
+
+ if (!plainText) {
+ tempText = tempText.replace(bRegex, el => `${el}`);
}
+ // replace [links](https://example.com) with html anchor tags or a plaintext representation
+ const linkRegex = /\[(?.*?)\]\((?.*?)\)/g;
+
+ if (plainText) {
+ tempText = tempText.replace(linkRegex, '$ ($)');
+ } else {
+ tempText = tempText.replace(linkRegex, '$');
+ }
+
+ return tempText;
+ }
+
+ // generates the html version of the email
+ public toHTML(): string {
+ let innerHTML = '';
+
+ this.componentArray.map((c, i) => {
+ let el = '';
+
+ /* double padding causes issues, and the signature already has padding, so if the last element
+ * is padding we just yeet it
+ */
+ if (i === this.componentArray.length - 1 && c.type === 'padding') {
+ return;
+ }
+
+ switch (c.type) {
+ case 'padding':
+ innerHTML += `\n| | `;
+ break;
+ case 'header':
+ el = this.parseReplacements(c);
+ innerHTML += `\n `;
+ break;
+ case 'paragraph':
+ el = this.parseReplacements(c);
+ innerHTML += `\n| ${this.addGmailDarkModeFix(el)} | `;
+ break;
+ case 'button':
+ if (c.link) {
+ el = `${el}`;
+ } else {
+ el = `${el}`;
+ }
+ innerHTML += `\n| ${this.addGmailDarkModeFix(el)} | `;
+ break;
+ }
+ });
+
+ const generatedHTML = genericEmailTemplate
+ .replace('', innerHTML)
+ .replace('', this.toPlainText());
+
+ return generatedHTML;
+ }
+
+ // generates the plaintext version that shows up on the email preview (and is shown by plaintext clients)
+ public toPlainText(): string {
+ let plainText = '';
+
+ this.componentArray.forEach((c) => {
+ let el = '';
+ switch (c.type) {
+ case 'padding':
+ break;
+ case 'header':
+ el = this.parseReplacements(c, true);
+ plainText += `\n${el}`;
+ break;
+ case 'paragraph':
+ el = this.parseReplacements(c, true);
+ plainText += `\n${el}`;
+ break;
+ case 'button':
+ if (c.link) {
+ plainText += `\n\n${c.text}: ${c.link}\n`;
+ } else {
+ plainText += ` ${c.text}\n`;
+ }
+ break;
+ }
+ });
+
+ // the signature is baked into the template, so it needs to be added manually to the plaintext version
+ plainText += '\n\n- The Pretendo Network team';
+
+ // and so is the notice about the email being auto-generated
+ plainText += '\n\nNote: This is an automatic email; please do not respond. For assistance, please visit https://forum.pretendo.network.';
+
+ plainText = plainText.replace(/(<([^>]+)>)/gi, '');
+
+ return plainText;
+ }
+}
+
+export async function sendMail(options: MailerOptions): Promise {
+ if (!disabledFeatures.email) {
+ const { to, subject, email } = options;
+
await transporter.sendMail({
from: config.email.from,
to,
subject,
- text,
- html
+ text: email.toPlainText(),
+ html: email.toHTML()
});
}
}
diff --git a/src/middleware/console-status-verification.ts b/src/middleware/console-status-verification.ts
index c67c86e9..d5fcacc4 100644
--- a/src/middleware/console-status-verification.ts
+++ b/src/middleware/console-status-verification.ts
@@ -42,6 +42,21 @@ async function consoleStatusVerificationMiddleware(request: express.Request, res
return;
}
+ const certificateDeviceID = parseInt(request.certificate.certificateName.slice(2).split('-')[0], 16);
+
+ if (deviceID !== certificateDeviceID) {
+ // TODO - Change this to a different error
+ response.status(400).send(xmlbuilder.create({
+ error: {
+ cause: 'Bad Request',
+ code: '1600',
+ message: 'Unable to process request'
+ }
+ }).end());
+
+ return;
+ }
+
const serialNumber = getValueFromHeaders(request.headers, 'x-nintendo-serial-number');
// TODO - Verify serial numbers somehow?
@@ -122,21 +137,6 @@ async function consoleStatusVerificationMiddleware(request: express.Request, res
return;
}
- const certificateDeviceID = parseInt(request.certificate.certificateName.slice(2).split('-')[0], 16);
-
- if (deviceID !== certificateDeviceID) {
- // TODO - Change this to a different error
- response.status(400).send(xmlbuilder.create({
- error: {
- cause: 'Bad Request',
- code: '1600',
- message: 'Unable to process request'
- }
- }).end());
-
- return;
- }
-
if (device.access_level < 0) {
response.status(400).send(xmlbuilder.create({
errors: {
diff --git a/src/types/common/mailer-options.ts b/src/types/common/mailer-options.ts
index d3999f71..f8a31efb 100644
--- a/src/types/common/mailer-options.ts
+++ b/src/types/common/mailer-options.ts
@@ -1,16 +1,7 @@
+import type { CreateEmail } from '@/mailer';
+
export interface MailerOptions {
to: string;
subject: string;
- username: string;
- paragraph?: string;
- preview?: string;
- text: string;
- link?: {
- href: string;
- text: string;
- };
- confirmation?: {
- href: string;
- code: string;
- };
+ email: CreateEmail;
}
diff --git a/src/util.ts b/src/util.ts
index f364d0a1..41d90ae4 100644
--- a/src/util.ts
+++ b/src/util.ts
@@ -4,7 +4,7 @@ import { S3 } from '@aws-sdk/client-s3';
import fs from 'fs-extra';
import bufferCrc32 from 'buffer-crc32';
import { crc32 } from 'crc';
-import { sendMail } from '@/mailer';
+import { sendMail, CreateEmail } from '@/mailer';
import { SystemType } from '@/types/common/system-types';
import { TokenType } from '@/types/common/token-types';
import { config, disabledFeatures } from '@/config-manager';
@@ -201,39 +201,47 @@ export function nascError(errorCode: string): URLSearchParams {
}
export async function sendConfirmationEmail(pnid: mongoose.HydratedDocument): Promise {
+ const email = new CreateEmail()
+ .addHeader('Hello {{pnid}}!', { pnid: pnid.username })
+ .addParagraph('Your Pretendo Network ID activation is almost complete. Please click the link below to confirm your e-mail address and complete the activation process.')
+ .addButton('Confirm email address', `https://api.pretendo.cc/v1/email/verify?token=${pnid.identification.email_token}`)
+ .addParagraph('You may also enter the following 6-digit code on your console:')
+ .addButton(pnid.identification.email_code, '', false)
+ .addParagraph('We hope you have fun using our services!');
+
const options = {
to: pnid.email.address,
subject: '[Pretendo Network] Please confirm your email address',
- username: pnid.username,
- confirmation: {
- href: `https://api.pretendo.cc/v1/email/verify?token=${pnid.identification.email_token}`,
- code: pnid.identification.email_code
- },
- text: `Hello ${pnid.username}! \r\n\r\nYour Pretendo Network ID activation is almost complete. Please click the link to confirm your e-mail address and complete the activation process: \r\nhttps://api.pretendo.cc/v1/email/verify?token=${pnid.identification.email_token} \r\n\r\nYou may also enter the following 6-digit code on your console: ${pnid.identification.email_code}`
+ email
};
await sendMail(options);
}
export async function sendEmailConfirmedEmail(pnid: mongoose.HydratedDocument): Promise {
+ const email = new CreateEmail()
+ .addHeader('Dear {{pnid}}!', { pnid: pnid.username })
+ .addParagraph('Your email address has been confirmed.')
+ .addParagraph('We hope you have fun on Pretendo Network!');
+
const options = {
to: pnid.email.address,
subject: '[Pretendo Network] Email address confirmed',
- username: pnid.username,
- paragraph: 'your email address has been confirmed. We hope you have fun on Pretendo Network!',
- text: `Dear ${pnid.username}, \r\n\r\nYour email address has been confirmed. We hope you have fun on Pretendo Network!`
+ email
};
await sendMail(options);
}
export async function sendEmailConfirmedParentalControlsEmail(pnid: mongoose.HydratedDocument): Promise {
+ const email = new CreateEmail()
+ .addHeader('Dear {{pnid}},', { pnid: pnid.username })
+ .addParagraph('your email address has been confirmed for use with Parental Controls.');
+
const options = {
to: pnid.email.address,
subject: '[Pretendo Network] Email address confirmed for Parental Controls',
- username: pnid.username,
- paragraph: 'your email address has been confirmed for use with Parental Controls.',
- text: `Dear ${pnid.username}, \r\n\r\nYour email address has been confirmed for use with Parental Controls.`
+ email
};
await sendMail(options);
@@ -254,31 +262,31 @@ export async function sendForgotPasswordEmail(pnid: mongoose.HydratedDocument {
+export async function sendPNIDDeletedEmail(emailAddress: string, username: string): Promise {
+ const email = new CreateEmail()
+ .addHeader('Dear {{pnid}},', { pnid: username })
+ .addParagraph('your PNID has successfully been deleted.')
+ .addParagraph('If you had a tier subscription, a separate cancellation email will be sent. If you do not receive this cancellation email, or you are still being charged for your subscription, please contact @jonbarrow on our [Discord server](https://discord.pretendo.network/).');
+
const options = {
- to: email,
+ to: emailAddress,
subject: '[Pretendo Network] PNID Deleted',
- username: username,
- link: {
- text: 'Discord Server',
- href: 'https://discord.com/invite/pretendo'
- },
- text: `Your PNID ${username} has successfully been deleted. If you had a tier subscription, a separate cancellation email will be sent. If you do not receive this cancellation email, or your subscription is still being charged, please contact @jon on our Discord server`
+ email
};
await sendMail(options);
|