feat(auth): email service with stub fallback + html templates
This commit is contained in:
@@ -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",
|
||||
|
||||
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