feat(auth): email service with stub fallback + html templates
This commit is contained in:
21
package-lock.json
generated
21
package-lock.json
generated
@@ -2131,6 +2131,16 @@
|
|||||||
"undici-types": "~6.21.0"
|
"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": {
|
"node_modules/@types/prop-types": {
|
||||||
"version": "15.7.15",
|
"version": "15.7.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||||
@@ -4764,6 +4774,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/normalize-path": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||||
@@ -7859,6 +7878,7 @@
|
|||||||
"drizzle-orm": "^0.33.0",
|
"drizzle-orm": "^0.33.0",
|
||||||
"express": "^4.19.0",
|
"express": "^4.19.0",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"nodemailer": "^8.0.7",
|
||||||
"xlsx": "^0.18.5",
|
"xlsx": "^0.18.5",
|
||||||
"zod": "^3.23.0"
|
"zod": "^3.23.0"
|
||||||
},
|
},
|
||||||
@@ -7868,6 +7888,7 @@
|
|||||||
"@types/express": "^4.17.0",
|
"@types/express": "^4.17.0",
|
||||||
"@types/multer": "^1.4.0",
|
"@types/multer": "^1.4.0",
|
||||||
"@types/node": "^20.0.0",
|
"@types/node": "^20.0.0",
|
||||||
|
"@types/nodemailer": "^8.0.0",
|
||||||
"@types/supertest": "^6.0.0",
|
"@types/supertest": "^6.0.0",
|
||||||
"drizzle-kit": "^0.24.0",
|
"drizzle-kit": "^0.24.0",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.0.0",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
"drizzle-orm": "^0.33.0",
|
"drizzle-orm": "^0.33.0",
|
||||||
"express": "^4.19.0",
|
"express": "^4.19.0",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"nodemailer": "^8.0.7",
|
||||||
"xlsx": "^0.18.5",
|
"xlsx": "^0.18.5",
|
||||||
"zod": "^3.23.0"
|
"zod": "^3.23.0"
|
||||||
},
|
},
|
||||||
@@ -31,6 +32,7 @@
|
|||||||
"@types/express": "^4.17.0",
|
"@types/express": "^4.17.0",
|
||||||
"@types/multer": "^1.4.0",
|
"@types/multer": "^1.4.0",
|
||||||
"@types/node": "^20.0.0",
|
"@types/node": "^20.0.0",
|
||||||
|
"@types/nodemailer": "^8.0.0",
|
||||||
"@types/supertest": "^6.0.0",
|
"@types/supertest": "^6.0.0",
|
||||||
"drizzle-kit": "^0.24.0",
|
"drizzle-kit": "^0.24.0",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.0.0",
|
||||||
|
|||||||
47
packages/backend/src/services/auth/email.ts
Normal file
47
packages/backend/src/services/auth/email.ts
Normal 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;
|
||||||
|
}
|
||||||
73
packages/backend/src/services/auth/templates.ts
Normal file
73
packages/backend/src/services/auth/templates.ts
Normal 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]!));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user