From 42a15a3aaf1fec495b81b8974a9bccd360915911 Mon Sep 17 00:00:00 2001 From: Jonathan Barrow Date: Sun, 12 Oct 2025 15:02:30 -0400 Subject: [PATCH 01/16] chore: move certificate device ID check close to other certificate checks --- src/middleware/console-status-verification.ts | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/src/middleware/console-status-verification.ts b/src/middleware/console-status-verification.ts index c67c86e..d5fcacc 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: { From 2e9ac5c49c0b932b162d5ffd0fdb32ff0e4dcbe1 Mon Sep 17 00:00:00 2001 From: limes Date: Mon, 10 Nov 2025 02:14:13 +0100 Subject: [PATCH 02/16] feat: clean up email generation --- package-lock.json | 8 + src/assets/emails/confirmationTemplate.html | 203 -------------------- src/assets/emails/genericTemplate.html | 112 ++++++----- src/mailer.ts | 180 +++++++++++++++-- src/types/common/mailer-options.ts | 15 +- src/util.ts | 66 ++++--- 6 files changed, 279 insertions(+), 305 deletions(-) delete mode 100644 src/assets/emails/confirmationTemplate.html 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); From ec7e80f38da66b6ad88b43725a3f85f4e9b656de Mon Sep 17 00:00:00 2001 From: limes Date: Mon, 10 Nov 2025 04:29:53 +0100 Subject: [PATCH 03/16] fix(mailer): wrong asset url --- src/assets/emails/genericTemplate.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/assets/emails/genericTemplate.html b/src/assets/emails/genericTemplate.html index 734a3b1..4c7ebc8 100644 --- a/src/assets/emails/genericTemplate.html +++ b/src/assets/emails/genericTemplate.html @@ -130,7 +130,7 @@ From bfbebed3c8e0180e5786f8a7b50257305d7756be Mon Sep 17 00:00:00 2001 From: limes Date: Mon, 10 Nov 2025 04:34:58 +0100 Subject: [PATCH 04/16] style(mailer): fix wrong font-size on buttons --- src/mailer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mailer.ts b/src/mailer.ts index 178acbf..21622f5 100644 --- a/src/mailer.ts +++ b/src/mailer.ts @@ -140,7 +140,7 @@ export class CreateEmail { innerHTML += `\n`; break; case 'button': - innerHTML += `\n`; + innerHTML += `\n`; break; } }); From bb37ea54112487c1407439e7d8cca6fe044bc0a7 Mon Sep 17 00:00:00 2001 From: limes Date: Mon, 10 Nov 2025 13:55:12 +0100 Subject: [PATCH 05/16] docs(mailer): fix error in comments, minor changes --- src/mailer.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/mailer.ts b/src/mailer.ts index 21622f5..66881bd 100644 --- a/src/mailer.ts +++ b/src/mailer.ts @@ -42,7 +42,6 @@ interface buttonComponent { link?: string; primary?: boolean; } - interface emailTextReplacements { [key: string]: string; } @@ -52,8 +51,8 @@ export class CreateEmail { private readonly componentArray: (emailComponent | paddingComponent | buttonComponent)[] = []; /** - * utility function which returns a table row of the specified height to use as padding. - */ + * adds padding of the specified height in px + */ private addPadding(size: number): paddingComponent { return { type: 'padding', @@ -62,7 +61,7 @@ export class CreateEmail { } /** - * adds a header. for greetings, do addHeader("Hi {{pnid}}!", { pnid: "theUsername" }). + * 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 }; @@ -72,7 +71,7 @@ export class CreateEmail { } /** - * adds a paragraph. for links, do addParagraph("this is a [named link](https://example.org)."). for greetings, do addParagraph("Hi {pnid}!", { pnid: "theUsername" }). + * 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 }; @@ -82,7 +81,7 @@ export class CreateEmail { } /** - * adds a button. + * adds a button * * @param {String} text the button text * @param {String} [link] the link From 3c6393de1e5aeaaf753c8772e169d7cfd4e5c296 Mon Sep 17 00:00:00 2001 From: limes Date: Mon, 10 Nov 2025 14:19:41 +0100 Subject: [PATCH 06/16] feat(mailer): add required asset for light theme email --- .../pretendo-wordmark-singlecolor-white.png | Bin 0 -> 4402 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/assets/images/pretendo-wordmark-singlecolor-white.png diff --git a/src/assets/images/pretendo-wordmark-singlecolor-white.png b/src/assets/images/pretendo-wordmark-singlecolor-white.png new file mode 100644 index 0000000000000000000000000000000000000000..f2fe07fe3d0e93fd341dd0580bfe067a44931e5a GIT binary patch literal 4402 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 literal 0 HcmV?d00001 From 660adbd4bfb0034931f7f5192b865afa5e33cde4 Mon Sep 17 00:00:00 2001 From: limes Date: Mon, 10 Nov 2025 15:22:49 +0100 Subject: [PATCH 07/16] feat(mailer): add padding before all headers --- src/assets/emails/genericTemplate.html | 3 --- src/mailer.ts | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/src/assets/emails/genericTemplate.html b/src/assets/emails/genericTemplate.html index 4c7ebc8..3a92c03 100644 --- a/src/assets/emails/genericTemplate.html +++ b/src/assets/emails/genericTemplate.html @@ -144,9 +144,6 @@
- +
- +
- + - +
   - + - - - + + + + + - - - - - - - - - - - - +
 
- 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}
 
- +
${c.text}
${c.text}
${c.text}
  - - - diff --git a/src/mailer.ts b/src/mailer.ts index 66881bd..e041242 100644 --- a/src/mailer.ts +++ b/src/mailer.ts @@ -65,7 +65,7 @@ export class CreateEmail { */ public addHeader(text: string, replacements?: emailTextReplacements): this { const component: emailComponent = { type: 'header', text, replacements }; - this.componentArray.push(component, this.addPadding(24)); + this.componentArray.push(this.addPadding(36), component, this.addPadding(24)); return this; } From e848cc5f352e4ce351f4ba3f29af159418d6b41b Mon Sep 17 00:00:00 2001 From: limes Date: Mon, 10 Nov 2025 17:49:52 +0100 Subject: [PATCH 08/16] feat: improve compatibility with iCloud web and legacy clients --- src/assets/emails/genericTemplate.html | 15 +++++++++++---- src/assets/images/wordmark-purple-white.png | Bin 0 -> 5299 bytes ...-singlecolor-white.png => wordmark-white.png} | Bin src/mailer.ts | 4 ++-- src/util.ts | 2 +- 5 files changed, 14 insertions(+), 7 deletions(-) create mode 100644 src/assets/images/wordmark-purple-white.png rename src/assets/images/{pretendo-wordmark-singlecolor-white.png => wordmark-white.png} (100%) diff --git a/src/assets/emails/genericTemplate.html b/src/assets/emails/genericTemplate.html index 3a92c03..059468e 100644 --- a/src/assets/emails/genericTemplate.html +++ b/src/assets/emails/genericTemplate.html @@ -50,11 +50,15 @@ color: #fff !important; font-weight: 700 !important; } - strong { + strong, b { font-weight: 700 !important; color: #9D6FF3 !important; } + img.logo { + content: url("https://assets.pretendo.cc/images/wordmark-white.png") !important; + } } + @media (prefers-color-scheme: dark) { body.email-body, table.centerer, @@ -98,14 +102,17 @@ td.notice a { color: #fff !important; } - strong { + strong, b { font-weight: 700 !important; color: #fff !important; } + /* isn't this redundant? no. icloud web doesn't display the logo correctly without this. */ img.logo { - content: url("https://assets.pretendo.cc/images/pretendo-wordmark-multicolor-purple+white.png") !important; + content: url("https://assets.pretendo.cc/images/wordmark-purple-white.png") !important; } } + + @@ -130,7 +137,7 @@ diff --git a/src/assets/images/wordmark-purple-white.png b/src/assets/images/wordmark-purple-white.png new file mode 100644 index 0000000000000000000000000000000000000000..66c89d53644bb1e7276065b57d3de48f26084364 GIT binary patch literal 5299 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 K0jrQT3;rKUTjt3C literal 0 HcmV?d00001 diff --git a/src/assets/images/pretendo-wordmark-singlecolor-white.png b/src/assets/images/wordmark-white.png similarity index 100% rename from src/assets/images/pretendo-wordmark-singlecolor-white.png rename to src/assets/images/wordmark-white.png diff --git a/src/mailer.ts b/src/mailer.ts index e041242..6734011 100644 --- a/src/mailer.ts +++ b/src/mailer.ts @@ -116,7 +116,7 @@ export class CreateEmail { if (plainText) { c.text = c.text.replace(linkRegex, `$ ($)`); } else { - c.text = c.text.replace(linkRegex, `$`); + c.text = c.text.replace(linkRegex, `$`); } } } @@ -139,7 +139,7 @@ export class CreateEmail { innerHTML += `\n`; break; case 'button': - innerHTML += `\n`; + innerHTML += `\n`; break; } }); diff --git a/src/util.ts b/src/util.ts index 68343de..8f05d2d 100644 --- a/src/util.ts +++ b/src/util.ts @@ -203,7 +203,7 @@ 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.') + .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) From 11bc868da3ced02bf28af778254c36f8a95185a2 Mon Sep 17 00:00:00 2001 From: limes Date: Mon, 10 Nov 2025 21:38:16 +0100 Subject: [PATCH 09/16] feat(mailer): fix theme on gmail --- src/assets/emails/genericTemplate.html | 127 +++++++++++++++++++++---- src/mailer.ts | 14 ++- 2 files changed, 119 insertions(+), 22 deletions(-) diff --git a/src/assets/emails/genericTemplate.html b/src/assets/emails/genericTemplate.html index 059468e..c0539ea 100644 --- a/src/assets/emails/genericTemplate.html +++ b/src/assets/emails/genericTemplate.html @@ -1,5 +1,7 @@ - + + @@ -8,7 +10,7 @@ :root { color-scheme: light dark; - supported-color-schemes:light dark; + supported-color-schemes: light dark; } @media (prefers-color-scheme: light) { @@ -32,15 +34,18 @@ font-weight: 700 !important; text-decoration: underline !important; } - td.primary, td.primary a { + td.primary, + td.primary a { background-color: #9D6FF3 !important; color: #fff !important; } - td.secondary, td.secondary a { + td.secondary, + td.secondary a { background-color: #D9C6FA !important; color: #45297A !important; } - td.primary a, td.secondary a { + td.primary a, + td.secondary a { text-decoration: none !important; } td.notice { @@ -50,7 +55,8 @@ color: #fff !important; font-weight: 700 !important; } - strong, b { + strong, + b { font-weight: 700 !important; color: #9D6FF3 !important; } @@ -90,7 +96,8 @@ background-color: #373C65 !important; color: #fff !important; } - td.primary a, td.secondary a { + td.primary a, + td.secondary a { text-decoration: none !important; } td.signature { @@ -102,7 +109,8 @@ td.notice a { color: #fff !important; } - strong, b { + strong, + b { font-weight: 700 !important; color: #fff !important; } @@ -111,16 +119,79 @@ content: url("https://assets.pretendo.cc/images/wordmark-purple-white.png") !important; } } - - + + /* the following specifically targets gmail, because it doesn't support media queries and automatically inverts colors */ + u+.email-body, + u+.email-body table.centerer, + u+.email-body table.wrapper { + background-color: #1B1F3B !important; + background-image: linear-gradient(#1B1F3B, #1B1F3B) !important; + color: #fff !important; + } + u+.email-body table.card { + background-color: #23274A !important; + background-image: linear-gradient(#23274A, #23274A) !important; + } + u+.email-body span.shoutout { + color: #fff !important; + } + u+.email-body td { + color: #fff !important; + } + u+.email-body td a { + color: #673DB6 !important; + font-weight: 700 !important; + text-decoration: underline !important; + } + u+.email-body td.primary, + u+.email-body td.primary a { + background-color: #9D6FF3 !important; + background-image: linear-gradient(#9D6FF3, #9D6FF3) !important; + color: #fff !important; + } + u+.email-body td.secondary, + u+.email-body td.secondary a { + background-color: #373C65 !important; + background-image: linear-gradient(#373C65, #373C65) !important; + color: #fff !important; + } + u+.email-body td.primary a, + u+.email-body td.secondary a { + text-decoration: none !important; + } + u+.email-body td.notice { + color: #fff !important; + } + u+.email-body td.notice a { + color: #fff !important; + font-weight: 700 !important; + } + u+.email-body strong, + u+.email-body b { + font-weight: 700 !important; + color: #fff !important; + } + u+.email-body .gmail-s { + background: #000 !important; + mix-blend-mode: screen !important; + } + u+.email-body .gmail-d { + background: #000 !important; + mix-blend-mode: difference !important; + } - + +
-
 
- +
${c.text}
${c.text}
${c.text}
+ +
- +
@@ -137,8 +208,9 @@ @@ -146,7 +218,8 @@ `; break; case 'header': this.parseReplacements(c); - innerHTML += `\n`; + el = this.addGmailDarkModeFix(c.text); + innerHTML += `\n`; break; case 'paragraph': this.parseReplacements(c); - innerHTML += `\n`; + el = this.addGmailDarkModeFix(c.text); + innerHTML += `\n`; break; case 'button': - innerHTML += `\n`; + el = this.addGmailDarkModeFix(c.text); + innerHTML += `\n`; break; } }); From a0bde9538a4aa36ef1374f1142003e3ba56ff445 Mon Sep 17 00:00:00 2001 From: limes Date: Tue, 11 Nov 2025 01:25:55 +0100 Subject: [PATCH 10/16] feat(mailer): improve support for gmail on mobile, minor fixes --- src/assets/emails/genericTemplate.html | 54 +++++++++++++++----------- src/mailer.ts | 31 ++++++++------- 2 files changed, 50 insertions(+), 35 deletions(-) diff --git a/src/assets/emails/genericTemplate.html b/src/assets/emails/genericTemplate.html index c0539ea..4c28493 100644 --- a/src/assets/emails/genericTemplate.html +++ b/src/assets/emails/genericTemplate.html @@ -35,17 +35,21 @@ text-decoration: underline !important; } td.primary, - td.primary a { + td.primary a, + td.primary span { background-color: #9D6FF3 !important; color: #fff !important; } td.secondary, - td.secondary a { + td.secondary a, + td.secondary span { background-color: #D9C6FA !important; color: #45297A !important; } td.primary a, - td.secondary a { + td.secondary a, + td.primary span, + td.secondary span { text-decoration: none !important; } td.notice { @@ -97,7 +101,9 @@ color: #fff !important; } td.primary a, - td.secondary a { + td.secondary a, + td.primary span, + td.secondary span { text-decoration: none !important; } td.signature { @@ -109,8 +115,8 @@ td.notice a { color: #fff !important; } - strong, - b { + td strong, + td b { font-weight: 700 !important; color: #fff !important; } @@ -144,19 +150,23 @@ text-decoration: underline !important; } u+.email-body td.primary, - u+.email-body td.primary a { + u+.email-body td.primary a, + u+.email-body td.primary span { background-color: #9D6FF3 !important; background-image: linear-gradient(#9D6FF3, #9D6FF3) !important; color: #fff !important; } u+.email-body td.secondary, - u+.email-body td.secondary a { + u+.email-body td.secondary a, + u+.email-body td.secondary span { background-color: #373C65 !important; background-image: linear-gradient(#373C65, #373C65) !important; color: #fff !important; } u+.email-body td.primary a, - u+.email-body td.secondary a { + u+.email-body td.secondary a, + u+.email-body td.primary span, + u+.email-body td.secondary span { text-decoration: none !important; } u+.email-body td.notice { @@ -183,14 +193,14 @@ + style="margin-left:0;margin-right:0;margin-top:0;margin-bottom:0;padding-left:0;padding-right:0;padding-top:0;padding-bottom:0;font-family:Poppins, Arial, Helvetica, sans-serif;">
- - + +
- +
+ + + @@ -176,7 +255,15 @@ @@ -196,5 +283,7 @@
  @@ -154,11 +227,17 @@ -
 
 
- 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 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/mailer.ts b/src/mailer.ts index 6734011..6f46b7b 100644 --- a/src/mailer.ts +++ b/src/mailer.ts @@ -94,6 +94,10 @@ export class CreateEmail { return this; } + private addGmailDarkModeFix(el: string): string { + return `
${el}
`; + } + // 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. @@ -126,20 +130,24 @@ export class CreateEmail { let innerHTML = ''; this.componentArray.forEach((c) => { + let el = ''; switch (c.type) { case 'padding': innerHTML += `\n
 
${c.text}
${el}
${c.text}
${el}
${c.text}
${el}
+ style="max-width:100%;"> diff --git a/src/mailer.ts b/src/mailer.ts index e28b4d4..0e4ab1a 100644 --- a/src/mailer.ts +++ b/src/mailer.ts @@ -75,7 +75,7 @@ export class CreateEmail { */ public addParagraph(text: string, replacements?: emailTextReplacements): this { const component: emailComponent = { type: 'paragraph', text, replacements }; - this.componentArray.push(component, this.addPadding(0.5)); + this.componentArray.push(component, this.addPadding(1)); return this; } @@ -118,9 +118,9 @@ export class CreateEmail { if (linkRegex.test(c.text)) { if (plainText) { - c.text = c.text.replace(linkRegex, `$ ($)`); + c.text = c.text.replace(linkRegex, '$ ($)'); } else { - c.text = c.text.replace(linkRegex, `$`); + c.text = c.text.replace(linkRegex, '$'); } } } @@ -129,8 +129,15 @@ export class CreateEmail { public toHTML(): string { let innerHTML = ''; - this.componentArray.forEach((c) => { + 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; + } if (c.type !== 'padding') { el = this.addGmailDarkModeFix(c.text); } From 169dbdf729ecdefaff36ca693b0bc5a1bfe32b8d Mon Sep 17 00:00:00 2001 From: limes Date: Wed, 12 Nov 2025 08:34:30 +0100 Subject: [PATCH 12/16] feat(mailer): replace notice in footer to only refer to forum --- src/assets/emails/genericTemplate.html | 8 +++----- src/mailer.ts | 3 +++ 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/assets/emails/genericTemplate.html b/src/assets/emails/genericTemplate.html index d1331f2..9ea9190 100644 --- a/src/assets/emails/genericTemplate.html +++ b/src/assets/emails/genericTemplate.html @@ -260,11 +260,9 @@ diff --git a/src/mailer.ts b/src/mailer.ts index 0e4ab1a..4cdd85c 100644 --- a/src/mailer.ts +++ b/src/mailer.ts @@ -198,6 +198,9 @@ export class CreateEmail { // 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; From 53862037cea26ef0ec446ebaccb4eeead0746128 Mon Sep 17 00:00:00 2001 From: limes Date: Thu, 13 Nov 2025 02:41:14 +0100 Subject: [PATCH 13/16] fix(mailer): and on icloud web and thunderbird --- src/assets/emails/genericTemplate.html | 15 ++++++--------- src/mailer.ts | 19 ++++++++++++------- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/src/assets/emails/genericTemplate.html b/src/assets/emails/genericTemplate.html index 9ea9190..0c5955f 100644 --- a/src/assets/emails/genericTemplate.html +++ b/src/assets/emails/genericTemplate.html @@ -53,7 +53,8 @@ color: #fff !important; font-weight: 700 !important; } - td strong { + td strong, + td b { font-weight: 700 !important; color: #9D6FF3 !important; } @@ -113,14 +114,10 @@ } } - td.primary a, - td.secondary a, - td.primary span, - td.secondary span, - u+.email-body td.primary a, - u+.email-body td.secondary a, - u+.email-body td.primary span, - u+.email-body td.secondary span { + td.button a, + td.button span, + u+.email-body td.button a, + u+.email-body td.button span { text-decoration: none !important; } diff --git a/src/mailer.ts b/src/mailer.ts index 4cdd85c..630c3e4 100644 --- a/src/mailer.ts +++ b/src/mailer.ts @@ -113,15 +113,20 @@ export class CreateEmail { }); } + // wrap and in a element, to fix color on thunderbird and weight on icloud mail web + const bRegex = /.*?<\/b>|.*?<\/strong>/g; + + if (!plainText) { + c.text = c.text.replace(bRegex, el => `${el}`); + } + // 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, '$'); - } + if (plainText) { + c.text = c.text.replace(linkRegex, '$ ($)'); + } else { + c.text = c.text.replace(linkRegex, '$'); } } @@ -159,7 +164,7 @@ export class CreateEmail { } else { el = `${el}`; } - innerHTML += `\n`; + innerHTML += `\n`; break; } }); From 2e400a5715200ea385397d6ad77fb052f984b86c Mon Sep 17 00:00:00 2001 From: limes Date: Sat, 15 Nov 2025 00:42:27 +0100 Subject: [PATCH 14/16] fix(mailer): isolate toText and toHTML, minor changes --- src/mailer.ts | 43 +++++++++++++++++++++++-------------------- src/util.ts | 4 +--- 2 files changed, 24 insertions(+), 23 deletions(-) diff --git a/src/mailer.ts b/src/mailer.ts index 630c3e4..68d1646 100644 --- a/src/mailer.ts +++ b/src/mailer.ts @@ -99,15 +99,17 @@ export class CreateEmail { } // parses pnid name and links. set the plaintext bool (false by default) to use no html - private parseReplacements(c: emailComponent, plainText: boolean = false): void { + private parseReplacements(c: emailComponent, plainText: boolean = false): string { + let tempText = c.text; + // 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); + tempText = tempText.replace(/{{pnid}}/g, value); } else { - c.text = c.text.replace(/{{pnid}}/g, `${value}`); + tempText = tempText.replace(/{{pnid}}/g, `${value}`); } } }); @@ -117,17 +119,19 @@ export class CreateEmail { const bRegex = /.*?<\/b>|.*?<\/strong>/g; if (!plainText) { - c.text = c.text.replace(bRegex, el => `${el}`); + tempText = tempText.replace(bRegex, el => `${el}`); } // replace [links](https://example.com) with html anchor tags or a plaintext representation const linkRegex = /\[(?.*?)\]\((?.*?)\)/g; if (plainText) { - c.text = c.text.replace(linkRegex, '$ ($)'); + tempText = tempText.replace(linkRegex, '$ ($)'); } else { - c.text = c.text.replace(linkRegex, '$'); + tempText = tempText.replace(linkRegex, '$'); } + + return tempText; } // generates the html version of the email @@ -135,7 +139,7 @@ export class CreateEmail { let innerHTML = ''; this.componentArray.map((c, i) => { - let el = ' '; + let el = ''; /* double padding causes issues, and the signature already has padding, so if the last element * is padding we just yeet it @@ -143,20 +147,18 @@ export class CreateEmail { if (i === this.componentArray.length - 1 && c.type === 'padding') { return; } - if (c.type !== 'padding') { - el = this.addGmailDarkModeFix(c.text); - } + switch (c.type) { case 'padding': - innerHTML += `\n`; + innerHTML += `\n`; break; case 'header': - this.parseReplacements(c); - innerHTML += `\n`; + el = this.parseReplacements(c); + innerHTML += `\n`; break; case 'paragraph': - this.parseReplacements(c); - innerHTML += `\n`; + el = this.parseReplacements(c); + innerHTML += `\n`; break; case 'button': if (c.link) { @@ -164,7 +166,7 @@ export class CreateEmail { } else { el = `${el}`; } - innerHTML += `\n`; + innerHTML += `\n`; break; } }); @@ -179,16 +181,17 @@ export class CreateEmail { let plainText = ''; this.componentArray.forEach((c) => { + let el = ''; switch (c.type) { case 'padding': break; case 'header': - this.parseReplacements(c, true); - plainText += `\n${c.text}`; + el = this.parseReplacements(c, true); + plainText += `\n${el}`; break; case 'paragraph': - this.parseReplacements(c, true); - plainText += `\n${c.text}`; + el = this.parseReplacements(c, true); + plainText += `\n${el}`; break; case 'button': if (c.link) { diff --git a/src/util.ts b/src/util.ts index 8f05d2d..41d90ae 100644 --- a/src/util.ts +++ b/src/util.ts @@ -281,9 +281,7 @@ export async function sendPNIDDeletedEmail(emailAddress: string, username: strin 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/'); + .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: emailAddress, From dcf230aa5760545a547c0feae05d5044f0257077 Mon Sep 17 00:00:00 2001 From: limes Date: Sat, 15 Nov 2025 21:19:13 +0100 Subject: [PATCH 15/16] fix(mailer): escape pnid --- package-lock.json | 18 ++++++++++++++++++ package.json | 4 +++- src/mailer.ts | 7 +++++-- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4fa9eb6..3b598d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "fs-extra": "^8.1.0", "got": "^11.8.2", "hcaptcha": "^0.1.0", + "he": "^1.2.0", "image-pixels": "^1.1.1", "ip2location-nodejs": "^9.6.3", "is-valid-hostname": "^1.0.2", @@ -58,6 +59,7 @@ "@types/dicer": "^0.2.2", "@types/express": "^4.17.17", "@types/fs-extra": "^11.0.1", + "@types/he": "^1.2.3", "@types/morgan": "^1.9.4", "@types/ndarray": "^1.0.11", "@types/node": "^18.14.4", @@ -3345,6 +3347,13 @@ "@types/node": "*" } }, + "node_modules/@types/he": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@types/he/-/he-1.2.3.tgz", + "integrity": "sha512-q67/qwlxblDzEDvzHhVkwc1gzVWxaNxeyHUBF4xElrvjL11O+Ytze+1fGpBHlr/H9myiBUaUXNnNPmBHxxfAcA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/http-cache-semantics": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", @@ -7122,6 +7131,15 @@ "integrity": "sha512-iMrDmH2VpIEKOrcKWidVjI89FdDKTEdZ7PfPWkP27sTazIIkob8YfdY2ezaufAnWBiUUcvzsn0qF+dyXtBH2Vw==", "license": "MIT" }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, "node_modules/http-cache-semantics": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", diff --git a/package.json b/package.json index 7d0c32b..8e844b4 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "fs-extra": "^8.1.0", "got": "^11.8.2", "hcaptcha": "^0.1.0", + "he": "^1.2.0", "image-pixels": "^1.1.1", "ip2location-nodejs": "^9.6.3", "is-valid-hostname": "^1.0.2", @@ -74,6 +75,7 @@ "@types/dicer": "^0.2.2", "@types/express": "^4.17.17", "@types/fs-extra": "^11.0.1", + "@types/he": "^1.2.3", "@types/morgan": "^1.9.4", "@types/ndarray": "^1.0.11", "@types/node": "^18.14.4", @@ -88,4 +90,4 @@ "ndarray": "^1.0.19", "typescript": "^4.9.5" } -} \ No newline at end of file +} diff --git a/src/mailer.ts b/src/mailer.ts index 68d1646..61cedc4 100644 --- a/src/mailer.ts +++ b/src/mailer.ts @@ -2,6 +2,7 @@ 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'; @@ -105,11 +106,13 @@ export class CreateEmail { // 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, value); + tempText = tempText.replace(/{{pnid}}/g, safeValue); } else { - tempText = tempText.replace(/{{pnid}}/g, `${value}`); + tempText = tempText.replace(/{{pnid}}/g, `${safeValue}`); } } }); From cd497759f4ec082e39f04a33bb74b7df6072bf32 Mon Sep 17 00:00:00 2001 From: limes Date: Sat, 15 Nov 2025 21:20:07 +0100 Subject: [PATCH 16/16] fix(mailer): fix preview text --- src/mailer.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/mailer.ts b/src/mailer.ts index 61cedc4..eb7b7a0 100644 --- a/src/mailer.ts +++ b/src/mailer.ts @@ -174,7 +174,9 @@ export class CreateEmail { } }); - const generatedHTML = genericEmailTemplate.replace('', innerHTML); + const generatedHTML = genericEmailTemplate + .replace('', innerHTML) + .replace('', this.toPlainText()); return generatedHTML; }
- diff --git a/src/mailer.ts b/src/mailer.ts index 6f46b7b..e28b4d4 100644 --- a/src/mailer.ts +++ b/src/mailer.ts @@ -51,7 +51,7 @@ export class CreateEmail { private readonly componentArray: (emailComponent | paddingComponent | buttonComponent)[] = []; /** - * adds padding of the specified height in px + * adds padding of the specified height in em units */ private addPadding(size: number): paddingComponent { return { @@ -65,7 +65,7 @@ export class CreateEmail { */ public addHeader(text: string, replacements?: emailTextReplacements): this { const component: emailComponent = { type: 'header', text, replacements }; - this.componentArray.push(this.addPadding(36), component, this.addPadding(24)); + this.componentArray.push(this.addPadding(3), component, this.addPadding(2)); return this; } @@ -75,7 +75,7 @@ export class CreateEmail { */ public addParagraph(text: string, replacements?: emailTextReplacements): this { const component: emailComponent = { type: 'paragraph', text, replacements }; - this.componentArray.push(component, this.addPadding(16)); + this.componentArray.push(component, this.addPadding(0.5)); return this; } @@ -89,7 +89,7 @@ export class CreateEmail { */ 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)); + this.componentArray.push(component, this.addPadding(2)); return this; } @@ -107,7 +107,7 @@ export class CreateEmail { if (plainText) { c.text = c.text.replace(/{{pnid}}/g, value); } else { - c.text = c.text.replace(/{{pnid}}/g, `${value}`); + c.text = c.text.replace(/{{pnid}}/g, `${value}`); } } }); @@ -120,7 +120,7 @@ export class CreateEmail { if (plainText) { c.text = c.text.replace(linkRegex, `$ ($)`); } else { - c.text = c.text.replace(linkRegex, `$`); + c.text = c.text.replace(linkRegex, `$`); } } } @@ -130,24 +130,29 @@ export class CreateEmail { let innerHTML = ''; this.componentArray.forEach((c) => { - let el = ''; + let el = ' '; + if (c.type !== 'padding') { + el = this.addGmailDarkModeFix(c.text); + } switch (c.type) { case 'padding': - innerHTML += `\n`; + innerHTML += `\n`; break; case 'header': this.parseReplacements(c); - el = this.addGmailDarkModeFix(c.text); - innerHTML += `\n`; + innerHTML += `\n`; break; case 'paragraph': this.parseReplacements(c); - el = this.addGmailDarkModeFix(c.text); innerHTML += `\n`; break; case 'button': - el = this.addGmailDarkModeFix(c.text); - innerHTML += `\n`; + if (c.link) { + el = `${el}`; + } else { + el = `${el}`; + } + innerHTML += `\n`; break; } }); From 17b294e20f9a30c7c42b3e27eb3363dbd36df711 Mon Sep 17 00:00:00 2001 From: limes Date: Wed, 12 Nov 2025 05:15:14 +0100 Subject: [PATCH 11/16] feat(mailer): improve layout on gmail mobile --- src/assets/emails/genericTemplate.html | 45 +++++++++++--------------- src/mailer.ts | 15 ++++++--- 2 files changed, 30 insertions(+), 30 deletions(-) diff --git a/src/assets/emails/genericTemplate.html b/src/assets/emails/genericTemplate.html index 4c28493..d1331f2 100644 --- a/src/assets/emails/genericTemplate.html +++ b/src/assets/emails/genericTemplate.html @@ -46,12 +46,6 @@ background-color: #D9C6FA !important; color: #45297A !important; } - td.primary a, - td.secondary a, - td.primary span, - td.secondary span { - text-decoration: none !important; - } td.notice { color: #c5adf2 !important; } @@ -59,8 +53,7 @@ color: #fff !important; font-weight: 700 !important; } - strong, - b { + td strong { font-weight: 700 !important; color: #9D6FF3 !important; } @@ -100,12 +93,6 @@ background-color: #373C65 !important; color: #fff !important; } - td.primary a, - td.secondary a, - td.primary span, - td.secondary span { - text-decoration: none !important; - } td.signature { color: #A1A8D9 !important; } @@ -126,6 +113,17 @@ } } + td.primary a, + td.secondary a, + td.primary span, + td.secondary span, + u+.email-body td.primary a, + u+.email-body td.secondary a, + u+.email-body td.primary span, + u+.email-body td.secondary span { + text-decoration: none !important; + } + /* the following specifically targets gmail, because it doesn't support media queries and automatically inverts colors */ u+.email-body, u+.email-body table.centerer, @@ -145,7 +143,7 @@ color: #fff !important; } u+.email-body td a { - color: #673DB6 !important; + color: #fff !important; font-weight: 700 !important; text-decoration: underline !important; } @@ -163,12 +161,6 @@ background-image: linear-gradient(#373C65, #373C65) !important; color: #fff !important; } - u+.email-body td.primary a, - u+.email-body td.secondary a, - u+.email-body td.primary span, - u+.email-body td.secondary span { - text-decoration: none !important; - } u+.email-body td.notice { color: #fff !important; } @@ -192,8 +184,9 @@ - + +
@@ -200,7 +210,7 @@ - + - + @@ -251,23 +261,23 @@ - + - - +
  
@@ -214,11 +224,11 @@
  
- @@ -228,7 +238,7 @@ - + @@ -241,7 +251,7 @@ - +
 
  
  
  
+
Note: this email message was auto-generated, please do not respond. For further assistance, please join our Discord server or make a + style="text-decoration:none;color:#fff;">Discord server or make a post on our Forum. + style="text-decoration:none;color:#fff;">Forum.
  
 
${el}
${el}
${el}
${el}
${el}
${el}
- + - +
   @@ -264,7 +257,7 @@ -
 
+
Note: this email message was auto-generated, please do not respond. For further @@ -284,7 +277,7 @@
  
- 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.
${el}
${el}
${el}
 
${el}
${this.addGmailDarkModeFix(el)}
${el}
${this.addGmailDarkModeFix(el)}
${el}
${this.addGmailDarkModeFix(el)}