feat(auth): email service with stub fallback + html templates

This commit is contained in:
2026-05-20 22:50:22 +02:00
parent 4ef3eaae52
commit 1ba2cab2e8
4 changed files with 143 additions and 0 deletions

21
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -0,0 +1,47 @@
import nodemailer, { type Transporter } from 'nodemailer';
export interface Mailer {
send(to: string, msg: { subject: string; html: string; text: string }): Promise<void>;
}
class SmtpMailer implements Mailer {
constructor(private transporter: Transporter, private from: string) {}
async send(to: string, msg: { subject: string; html: string; text: string }): Promise<void> {
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<void> {
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 <noreply@example.com>';
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;
}

View File

@@ -0,0 +1,73 @@
function layout(title: string, body: string): { html: string; text: string } {
const html = `<!doctype html>
<html><body style="font-family: -apple-system, Segoe UI, sans-serif; background:#FAF5FF; padding:24px;">
<div style="max-width:520px; margin:0 auto; background:white; border-radius:24px; padding:32px; box-shadow:0 8px 32px rgba(124,58,237,0.12);">
<h1 style="margin:0 0 16px; font-size:22px; color:#6D28D9;">${title}</h1>
${body}
<hr style="border:none; border-top:1px solid #EFE7FC; margin:24px 0;" />
<p style="font-size:12px; color:#94A3B8;">Flashcard — leer slimmer met spaced repetition</p>
</div>
</body></html>`;
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 👋', `
<p>Hi ${escapeHtml(displayName)},</p>
<p>Klik op de knop hieronder om je e-mailadres te bevestigen. De link is 24 uur geldig.</p>
<p style="margin:24px 0;"><a href="${link}" style="background:#7C3AED; color:white; padding:12px 20px; border-radius:14px; text-decoration:none; font-weight:600;">Bevestig e-mail</a></p>
<p style="font-size:13px; color:#64748B;">Werkt de knop niet? Kopieer deze link in je browser:<br/><span style="word-break:break-all;">${link}</span></p>
`),
};
}
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', `
<p>Hi ${escapeHtml(displayName)},</p>
<p>Iemand vroeg een wachtwoord-reset aan voor je account. Was jij dat niet? Negeer dan deze e-mail.</p>
<p>De link is 1 uur geldig.</p>
<p style="margin:24px 0;"><a href="${link}" style="background:#7C3AED; color:white; padding:12px 20px; border-radius:14px; text-decoration:none; font-weight:600;">Reset wachtwoord</a></p>
<p style="font-size:13px; color:#64748B; word-break:break-all;">${link}</p>
`),
};
}
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 ✨', `
<p>${escapeHtml(inviterName)} heeft je uitgenodigd voor Flashcard.</p>
<p>Maak je account aan via onderstaande knop. De link is 24 uur geldig.</p>
<p style="margin:24px 0;"><a href="${link}" style="background:#7C3AED; color:white; padding:12px 20px; border-radius:14px; text-decoration:none; font-weight:600;">Account aanmaken</a></p>
<p style="font-size:13px; color:#64748B; word-break:break-all;">${link}</p>
`),
};
}
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', `
<p>Hi ${escapeHtml(displayName)},</p>
<p>Bevestig <strong>${escapeHtml(newEmail)}</strong> als je nieuwe e-mailadres.</p>
<p style="margin:24px 0;"><a href="${link}" style="background:#7C3AED; color:white; padding:12px 20px; border-radius:14px; text-decoration:none; font-weight:600;">Bevestig adres</a></p>
<p style="font-size:13px; color:#64748B; word-break:break-all;">${link}</p>
`),
};
}
function escapeHtml(s: string): string {
return s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]!));
}