diff --git a/package-lock.json b/package-lock.json index 3d57398..4fa9eb6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2381,6 +2381,7 @@ "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", "license": "MIT", + "peer": true, "dependencies": { "cluster-key-slot": "1.1.2", "generic-pool": "3.9.0", @@ -3419,6 +3420,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.112.tgz", "integrity": "sha512-i+Vukt9POdS/MBI7YrrkkI5fMfwFtOjphSmt4WXYLfwqsfr6z/HdCx7LqT9M7JktGob8WNgj8nFB4TbGNE4Cog==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~5.26.4" } @@ -3564,6 +3566,7 @@ "integrity": "sha512-4O3idHxhyzjClSMJ0a29AcoK0+YwnEqzI6oz3vlRf3xw0zbzt15MzXwItOlnr5nIth6zlY2RENLsOPvhyrKAQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.34.1", "@typescript-eslint/types": "8.34.1", @@ -4049,6 +4052,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5842,6 +5846,7 @@ "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -5988,6 +5993,7 @@ "integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.8", @@ -8416,6 +8422,7 @@ "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-7.8.7.tgz", "integrity": "sha512-5Bo4CrUxrPITrhMKsqUTOkXXo2CoRC5tXxVQhnddCzqDMwRXfyStrxj1oY865g8gaekSBhxAeNkYyUSJvGm9Hw==", "license": "MIT", + "peer": true, "dependencies": { "bson": "^5.5.0", "kareem": "2.5.1", @@ -10654,6 +10661,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/src/assets/emails/confirmationTemplate.html b/src/assets/emails/confirmationTemplate.html deleted file mode 100644 index 0a94625..0000000 --- a/src/assets/emails/confirmationTemplate.html +++ /dev/null @@ -1,203 +0,0 @@ - - - - - - - - -
Hello {{username}}! Your Pretendo Network ID activation is almost complete. Please click the link in this email to confirm your e-mail address and complete the activation process.
- - - - -
- - - - -
- - - - - - -
  - - - - - - - -
 
- - - - - - - - - - - - - - - - - - - -
- - - -
 
- - - - - - -
  - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
 
- Hello {{username}}! -
 
- Your Pretendo Network ID activation is almost complete. Please click the link below to confirm your e-mail address and complete the activation process. -
 
 
- You may also enter the following 6-digit code on your console: -
 
- {{confirmation-code}} -
 
- We hope you have fun using our services! -
 
- 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. -
 
-
-
 
-
-
- - \ No newline at end of file diff --git a/src/assets/emails/genericTemplate.html b/src/assets/emails/genericTemplate.html index ce875fe..734a3b1 100644 --- a/src/assets/emails/genericTemplate.html +++ b/src/assets/emails/genericTemplate.html @@ -15,30 +15,44 @@ body.email-body, table.centerer, table.wrapper { - background-color: #FFFFFF !important; - color: #FFFFFF !important; + background-color: #673DB6 !important; + color: #673DB6 !important; } table.card { - background-color: #673DB6 !important; + background-color: #fff !important; } span.shoutout { - color: #D9C6FA !important; + color: #9D6FF3 !important; } - td.confirm-link { + td { + color: #673DB6 !important; + } + td a { + color: #673DB6 !important; + font-weight: 700 !important; + text-decoration: underline !important; + } + td.primary, td.primary a { background-color: #9D6FF3 !important; + color: #fff !important; } - td.confirm-code { + td.secondary, td.secondary a { background-color: #D9C6FA !important; color: #45297A !important; } + td.primary a, td.secondary a { + text-decoration: none !important; + } td.notice { - color: #9D6FF3 !important; + color: #c5adf2 !important; } td.notice a { - color: #673DB6 !important; + color: #fff !important; + font-weight: 700 !important; } - img.logo { - content: url("https://assets.pretendo.cc/images/pretendo-wordmark-singlecolor-purple.png") !important; + strong { + font-weight: 700 !important; + color: #9D6FF3 !important; } } @media (prefers-color-scheme: dark) { @@ -46,7 +60,7 @@ table.centerer, table.wrapper { background-color: #1B1F3B !important; - color: #FFFFFF !important; + color: #A1A8D9 !important; } table.card { background-color: #23274A !important; @@ -54,28 +68,52 @@ span.shoutout { color: #CAB1FB !important; } - td.confirm-link { + td { + color: #A1A8D9 !important; + } + td a { + color: #fff !important; + font-weight: 700 !important; + text-decoration: underline !important; + } + td.header { + color: #fff !important; + } + td.primary { background-color: #673DB6 !important; } - td.confirm-code { + td.secondary { background-color: #373C65 !important; - color: #ffffff !important; + color: #fff !important; + } + td.primary a, td.secondary a { + text-decoration: none !important; + } + td.signature { + color: #A1A8D9 !important; } td.notice { color: #8990C1 !important; } td.notice a { - color: #CAC1F5 !important; + color: #fff !important; + } + strong { + font-weight: 700 !important; + color: #fff !important; + } + img.logo { + content: url("https://assets.pretendo.cc/images/pretendo-wordmark-multicolor-purple+white.png") !important; } } -
{{preview}}
+
- +
@@ -92,7 +130,7 @@ @@ -101,42 +139,30 @@ @@ -145,8 +171,8 @@ - diff --git a/src/mailer.ts b/src/mailer.ts index 0744e14..178acbf 100644 --- a/src/mailer.ts +++ b/src/mailer.ts @@ -6,7 +6,6 @@ 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 +27,174 @@ if (!disabledFeatures.email) { }); } +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)[] = []; + + /** + * utility function which returns a table row of the specified height to use as padding. + */ + 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(component, this.addPadding(24)); + + return this; + } + + /** + * 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(16)); + + 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(this.addPadding(4), component, this.addPadding(32)); + + return this; + } + + // parses pnid name and links. set the plaintext bool (false by default) to use no html + private parseReplacements(c: emailComponent, plainText: boolean = false): void { + // 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]) => { + if (key === 'pnid') { + if (plainText) { + c.text = c.text.replace(/{{pnid}}/g, value); + } else { + c.text = c.text.replace(/{{pnid}}/g, `${value}`); + } + } + }); + } + + // replace [links](https://example.com) with html anchor tags or a plaintext representation + const linkRegex = /\[(?.*?)\]\((?.*?)\)/g; + + if (linkRegex.test(c.text)) { + if (plainText) { + c.text = c.text.replace(linkRegex, `$ ($)`); + } else { + c.text = c.text.replace(linkRegex, `$`); + } + } + } + + // generates the html version of the email + public toHTML(): string { + let innerHTML = ''; + + this.componentArray.forEach((c) => { + switch (c.type) { + case 'padding': + innerHTML += `\n`; + break; + case 'header': + this.parseReplacements(c); + innerHTML += `\n`; + break; + case 'paragraph': + this.parseReplacements(c); + innerHTML += `\n`; + break; + case 'button': + innerHTML += `\n`; + break; + } + }); + + const generatedHTML = genericEmailTemplate.replace('', innerHTML); + + 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) => { + switch (c.type) { + case 'padding': + break; + case 'header': + this.parseReplacements(c, true); + plainText += `\n${c.text}`; + break; + case 'paragraph': + this.parseReplacements(c, true); + plainText += `\n${c.text}`; + 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'; + + plainText = plainText.replace(/(<([^>]+)>)/gi, ''); + + return plainText; + } +} + export async function sendMail(options: MailerOptions): Promise { if (!disabledFeatures.email) { - const { to, subject, username, paragraph, preview, text, link, confirmation } = options; - - let html = confirmation ? confirmationEmailTemplate : genericEmailTemplate; - - 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 || ''); - - if (link) { - const { href, text } = link; - - const button = ``; - html = html.replace(//g, button); - } + 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/types/common/mailer-options.ts b/src/types/common/mailer-options.ts index d3999f7..f8a31ef 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 f364d0a..68343de 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,33 @@ 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.') + .addParagraph('If you do not receive this cancellation email, or your subscription is still being charged, please contact @jonbarrow on our Discord server.') + .addButton('Join the Discord', '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);
- +
- +
- + - +
   - + - - - + + + + + - - - - - - - - - - - - +
 
- Dear {{username}}, -
 
 
- {{paragraph}} -
 
+ 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 email message was auto-generated, please do not respond. For further assistance, please join our Discord server or make a post on our Forum.
 
${c.text}
${c.text}
${c.text}