From 1ba2cab2e8a2207e6171e22ff91f7e2c083348f7 Mon Sep 17 00:00:00 2001 From: Bert Hausmans Date: Wed, 20 May 2026 22:50:22 +0200 Subject: [PATCH] feat(auth): email service with stub fallback + html templates --- package-lock.json | 21 ++++++ packages/backend/package.json | 2 + packages/backend/src/services/auth/email.ts | 47 ++++++++++++ .../backend/src/services/auth/templates.ts | 73 +++++++++++++++++++ 4 files changed, 143 insertions(+) create mode 100644 packages/backend/src/services/auth/email.ts create mode 100644 packages/backend/src/services/auth/templates.ts diff --git a/package-lock.json b/package-lock.json index 908255a..515883b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2131,6 +2131,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/nodemailer": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.0.tgz", + "integrity": "sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -4764,6 +4774,15 @@ "dev": true, "license": "MIT" }, + "node_modules/nodemailer": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz", + "integrity": "sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -7859,6 +7878,7 @@ "drizzle-orm": "^0.33.0", "express": "^4.19.0", "multer": "^1.4.5-lts.1", + "nodemailer": "^8.0.7", "xlsx": "^0.18.5", "zod": "^3.23.0" }, @@ -7868,6 +7888,7 @@ "@types/express": "^4.17.0", "@types/multer": "^1.4.0", "@types/node": "^20.0.0", + "@types/nodemailer": "^8.0.0", "@types/supertest": "^6.0.0", "drizzle-kit": "^0.24.0", "supertest": "^7.0.0", diff --git a/packages/backend/package.json b/packages/backend/package.json index 4736b46..87d10f9 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -22,6 +22,7 @@ "drizzle-orm": "^0.33.0", "express": "^4.19.0", "multer": "^1.4.5-lts.1", + "nodemailer": "^8.0.7", "xlsx": "^0.18.5", "zod": "^3.23.0" }, @@ -31,6 +32,7 @@ "@types/express": "^4.17.0", "@types/multer": "^1.4.0", "@types/node": "^20.0.0", + "@types/nodemailer": "^8.0.0", "@types/supertest": "^6.0.0", "drizzle-kit": "^0.24.0", "supertest": "^7.0.0", diff --git a/packages/backend/src/services/auth/email.ts b/packages/backend/src/services/auth/email.ts new file mode 100644 index 0000000..23d5933 --- /dev/null +++ b/packages/backend/src/services/auth/email.ts @@ -0,0 +1,47 @@ +import nodemailer, { type Transporter } from 'nodemailer'; + +export interface Mailer { + send(to: string, msg: { subject: string; html: string; text: string }): Promise; +} + +class SmtpMailer implements Mailer { + constructor(private transporter: Transporter, private from: string) {} + async send(to: string, msg: { subject: string; html: string; text: string }): Promise { + await this.transporter.sendMail({ from: this.from, to, ...msg }); + } +} + +class StubMailer implements Mailer { + async send(to: string, msg: { subject: string; html: string; text: string }): Promise { + console.log('\n=== EMAIL (stub) ==='); + console.log(`TO: ${to}`); + console.log(`SUBJECT: ${msg.subject}`); + console.log('---'); + console.log(msg.text); + console.log('====================\n'); + } +} + +let cached: Mailer | null = null; + +export function getMailer(): Mailer { + if (cached) return cached; + const host = process.env.SMTP_HOST; + const from = process.env.SMTP_FROM ?? 'Flashcard '; + if (!host) { + cached = new StubMailer(); + return cached; + } + const transporter = nodemailer.createTransport({ + host, + port: Number(process.env.SMTP_PORT ?? 587), + secure: process.env.SMTP_SECURE === 'true', + auth: process.env.SMTP_USER ? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS ?? '' } : undefined, + }); + cached = new SmtpMailer(transporter, from); + return cached; +} + +export function setMailerForTests(m: Mailer | null): void { + cached = m; +} diff --git a/packages/backend/src/services/auth/templates.ts b/packages/backend/src/services/auth/templates.ts new file mode 100644 index 0000000..246f265 --- /dev/null +++ b/packages/backend/src/services/auth/templates.ts @@ -0,0 +1,73 @@ +function layout(title: string, body: string): { html: string; text: string } { + const html = ` + +
+

${title}

+ ${body} +
+

Flashcard — leer slimmer met spaced repetition

+
+`; + return { html, text: stripHtml(body) }; +} + +function stripHtml(s: string): string { + return s.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); +} + +export function verifyEmailTemplate(appUrl: string, token: string, displayName: string) { + const link = `${appUrl}/verify-email?token=${encodeURIComponent(token)}`; + return { + subject: 'Bevestig je e-mailadres', + ...layout('Welkom bij Flashcard 👋', ` +

Hi ${escapeHtml(displayName)},

+

Klik op de knop hieronder om je e-mailadres te bevestigen. De link is 24 uur geldig.

+

Bevestig e-mail

+

Werkt de knop niet? Kopieer deze link in je browser:
${link}

+ `), + }; +} + +export function passwordResetTemplate(appUrl: string, token: string, displayName: string) { + const link = `${appUrl}/reset-password?token=${encodeURIComponent(token)}`; + return { + subject: 'Reset je wachtwoord', + ...layout('Wachtwoord resetten', ` +

Hi ${escapeHtml(displayName)},

+

Iemand vroeg een wachtwoord-reset aan voor je account. Was jij dat niet? Negeer dan deze e-mail.

+

De link is 1 uur geldig.

+

Reset wachtwoord

+

${link}

+ `), + }; +} + +export function inviteTemplate(appUrl: string, token: string, inviterName: string) { + const link = `${appUrl}/accept-invite?token=${encodeURIComponent(token)}`; + return { + subject: 'Je bent uitgenodigd voor Flashcard', + ...layout('Je bent uitgenodigd ✨', ` +

${escapeHtml(inviterName)} heeft je uitgenodigd voor Flashcard.

+

Maak je account aan via onderstaande knop. De link is 24 uur geldig.

+

Account aanmaken

+

${link}

+ `), + }; +} + +export function changeEmailTemplate(appUrl: string, token: string, displayName: string, newEmail: string) { + const link = `${appUrl}/verify-email?token=${encodeURIComponent(token)}`; + return { + subject: 'Bevestig je nieuwe e-mailadres', + ...layout('Nieuw e-mailadres bevestigen', ` +

Hi ${escapeHtml(displayName)},

+

Bevestig ${escapeHtml(newEmail)} als je nieuwe e-mailadres.

+

Bevestig adres

+

${link}

+ `), + }; +} + +function escapeHtml(s: string): string { + return s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]!)); +}