diff --git a/apps/api/package.json b/apps/api/package.json index abceeb5..47460f5 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -22,6 +22,7 @@ "argon2": "^0.44.0", "i18next": "^26.3.4", "nestjs-pino": "^4.3.0", + "nodemailer": "^9.0.3", "pino": "^9.6.0", "pino-http": "^10.4.0", "prisma": "^6.3.0", @@ -33,6 +34,7 @@ "@nestjs/testing": "^11.0.0", "@swc/core": "^1.10.0", "@types/express": "^5.0.0", + "@types/nodemailer": "^8.0.1", "@types/supertest": "^6.0.0", "pino-pretty": "^13.0.0", "supertest": "^7.0.0", diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 7121da6..efefa29 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -6,6 +6,7 @@ import { ApiExceptionFilter } from './common/api-exception.filter'; import { AppConfig } from './config/app-config.service'; import { ConfigModule } from './config/config.module'; import { HealthModule } from './health/health.module'; +import { MailModule } from './mail/mail.module'; import { PrismaModule } from './prisma/prisma.module'; import { RateLimitModule } from './rate-limit/rate-limit.module'; import { UsersModule } from './users/users.module'; @@ -15,6 +16,7 @@ import { UsersModule } from './users/users.module'; ConfigModule, PrismaModule, RateLimitModule, + MailModule, UsersModule, LoggerModule.forRootAsync({ inject: [AppConfig], diff --git a/apps/api/src/i18n/api-i18n.ts b/apps/api/src/i18n/api-i18n.ts index 5065d3a..71550ce 100644 --- a/apps/api/src/i18n/api-i18n.ts +++ b/apps/api/src/i18n/api-i18n.ts @@ -1,5 +1,7 @@ import deErrors from '@dorfteich/shared/i18n/de/errors.json'; +import deMails from '@dorfteich/shared/i18n/de/mails.json'; import enErrors from '@dorfteich/shared/i18n/en/errors.json'; +import enMails from '@dorfteich/shared/i18n/en/mails.json'; import { createInstance, type i18n as I18n } from 'i18next'; /** @@ -11,8 +13,8 @@ export const apiI18n: I18n = createInstance(); void apiI18n.init({ resources: { - en: { errors: enErrors }, - de: { errors: deErrors }, + en: { errors: enErrors, mails: enMails }, + de: { errors: deErrors, mails: deMails }, }, fallbackLng: 'en', supportedLngs: ['de', 'en'], diff --git a/apps/api/src/mail/mail-templates.ts b/apps/api/src/mail/mail-templates.ts new file mode 100644 index 0000000..bb1e0a5 --- /dev/null +++ b/apps/api/src/mail/mail-templates.ts @@ -0,0 +1,53 @@ +import { apiI18n } from '../i18n/api-i18n'; + +export type MailTemplate = 'verifyEmail' | 'resetPassword'; + +export interface RenderedMail { + subject: string; + text: string; + html: string; +} + +/** + * Renders the layout-light transactional mails (text + simple HTML) from + * the `mails` i18n namespace. Every template gets: greeting, body text, + * one action link, expiry note, ignore hint, signoff. + */ +export function renderMail( + template: MailTemplate, + params: { displayName: string; link: string }, + locale: 'de' | 'en', +): RenderedMail { + const t = (key: string, options: Record = {}): string => + apiI18n.t(`mails:${key}`, { lng: locale, ...options }); + + const greeting = t('common.greeting', { displayName: params.displayName }); + const body = t(`${template}.body`); + const action = t(`${template}.action`); + const expiry = t(`${template}.expiry`); + const ignore = t('common.ignoreHint'); + const signoff = t('common.signoff'); + + const text = [greeting, '', body, '', params.link, '', expiry, ignore, '', signoff].join('\n'); + + const html = ` + +

${escapeHtml(greeting)}

+

${escapeHtml(body)}

+

+ ${escapeHtml(action)} +

+

${escapeHtml(expiry)}
${escapeHtml(ignore)}

+

${escapeHtml(signoff)}

+`; + + return { subject: t(`${template}.subject`), text, html }; +} + +function escapeHtml(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); +} diff --git a/apps/api/src/mail/mail-worker.service.ts b/apps/api/src/mail/mail-worker.service.ts new file mode 100644 index 0000000..01363ff --- /dev/null +++ b/apps/api/src/mail/mail-worker.service.ts @@ -0,0 +1,104 @@ +import { Inject, Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { MailOutbox } from '@prisma/client'; +import { PinoLogger } from 'nestjs-pino'; + +import { AppConfig } from '../config/app-config.service'; +import { PrismaService } from '../prisma/prisma.service'; + +/** Minimal transport contract — nodemailer in production, a fake in tests. */ +export interface MailTransport { + sendMail(mail: { + from: string; + to: string; + subject: string; + text: string; + html: string; + }): Promise; +} + +export const MAIL_TRANSPORT = 'MAIL_TRANSPORT'; + +const POLL_INTERVAL_MS = 15_000; +const MAX_ATTEMPTS = 5; +const BATCH_SIZE = 10; + +/** + * Delivers pending outbox mail with exponential backoff. Single-instance + * by design (one api container per stage, ADR 0002); the M2 jobs table + * will absorb this loop once it exists. + */ +@Injectable() +export class MailWorker implements OnModuleInit, OnModuleDestroy { + private timer: NodeJS.Timeout | undefined; + private running = false; + + constructor( + private readonly prisma: PrismaService, + private readonly config: AppConfig, + @Inject(MAIL_TRANSPORT) private readonly transport: MailTransport, + private readonly logger: PinoLogger, + ) { + this.logger.setContext(MailWorker.name); + } + + onModuleInit(): void { + if (this.config.env.NODE_ENV === 'test') return; + this.timer = setInterval(() => void this.deliverDueMail(), POLL_INTERVAL_MS); + this.timer.unref(); + } + + onModuleDestroy(): void { + if (this.timer) clearInterval(this.timer); + } + + /** One delivery pass; public so tests and later admin tooling can drive it. */ + async deliverDueMail(): Promise { + if (this.running) return; // never overlap two passes + this.running = true; + try { + const due = await this.prisma.mailOutbox.findMany({ + where: { status: 'PENDING', nextAttemptAt: { lte: new Date() } }, + orderBy: { createdAt: 'asc' }, + take: BATCH_SIZE, + }); + for (const mail of due) { + await this.deliverOne(mail); + } + } finally { + this.running = false; + } + } + + private async deliverOne(mail: MailOutbox): Promise { + try { + await this.transport.sendMail({ + from: this.config.env.SMTP_FROM, + to: mail.toAddress, + subject: mail.subject, + text: mail.textBody, + html: mail.htmlBody, + }); + await this.prisma.mailOutbox.update({ + where: { id: mail.id }, + data: { status: 'SENT', sentAt: new Date(), attempts: mail.attempts + 1 }, + }); + } catch (error) { + const attempts = mail.attempts + 1; + const failedForGood = attempts >= MAX_ATTEMPTS; + // Backoff: 30s, 2min, 4.5min, 8min — then give up loudly. + const delayMs = 30_000 * attempts * attempts; + await this.prisma.mailOutbox.update({ + where: { id: mail.id }, + data: { + attempts, + status: failedForGood ? 'FAILED' : 'PENDING', + nextAttemptAt: new Date(Date.now() + delayMs), + lastError: error instanceof Error ? error.message.slice(0, 500) : String(error), + }, + }); + if (failedForGood) { + this.logger.warn({ mailId: mail.id, attempts }, 'mail delivery failed permanently'); + } + } + } +} diff --git a/apps/api/src/mail/mail.db.test.ts b/apps/api/src/mail/mail.db.test.ts new file mode 100644 index 0000000..3835264 --- /dev/null +++ b/apps/api/src/mail/mail.db.test.ts @@ -0,0 +1,105 @@ +import { PinoLogger } from 'nestjs-pino'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { AppConfig } from '../config/app-config.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; +import { MailTransport, MailWorker } from './mail-worker.service'; +import { renderMail } from './mail-templates'; +import { MailService } from './mail.service'; + +describe('mail templates', () => { + it('renders both languages with link, action, and greeting', () => { + for (const locale of ['de', 'en'] as const) { + const mail = renderMail( + 'verifyEmail', + { displayName: 'Uma', link: 'https://example.org/verify?token=abc' }, + locale, + ); + expect(mail.subject.length).toBeGreaterThan(5); + expect(mail.text).toContain('https://example.org/verify?token=abc'); + expect(mail.text).toContain('Uma'); + expect(mail.html).toContain('href="https://example.org/verify?token=abc"'); + } + }); + + it('escapes HTML in interpolated values', () => { + const mail = renderMail( + 'resetPassword', + { displayName: '', link: 'https://example.org/r' }, + 'en', + ); + expect(mail.html).not.toContain(''); + }); +}); + +describe.skipIf(!hasTestDb)('MailWorker (database)', () => { + const prisma = hasTestDb ? (createTestPrisma() as unknown as PrismaService) : null!; + const suffix = uniqueSuffix(); + const address = `worker-${suffix}@example.org`; + let config: AppConfig; + + beforeAll(() => { + process.env.DATABASE_URL ??= process.env.TEST_DATABASE_URL; + config = new AppConfig(); + }); + + afterAll(async () => { + if (!hasTestDb) return; + await prisma.mailOutbox.deleteMany({ where: { toAddress: { contains: suffix } } }); + await prisma.$disconnect(); + }); + + function makeWorker(transport: MailTransport): MailWorker { + const logger = { setContext: vi.fn(), warn: vi.fn() } as unknown as PinoLogger; + return new MailWorker(prisma, config, transport, logger); + } + + it('delivers queued mail and marks it sent', async () => { + await new MailService(prisma).enqueue( + address, + 'verifyEmail', + { displayName: 'Uma', link: 'https://example.org/v' }, + 'de', + ); + const sent: string[] = []; + await makeWorker({ + sendMail: async (mail) => { + sent.push(mail.to); + }, + }).deliverDueMail(); + + expect(sent).toContain(address); + const row = await prisma.mailOutbox.findFirst({ where: { toAddress: address } }); + expect(row?.status).toBe('SENT'); + expect(row?.sentAt).not.toBeNull(); + }); + + it('retries with backoff and fails permanently after five attempts', async () => { + const failing = `fail-${suffix}@example.org`; + await new MailService(prisma).enqueue( + failing, + 'resetPassword', + { displayName: 'Uma', link: 'https://example.org/r' }, + 'en', + ); + const worker = makeWorker({ + sendMail: async () => { + throw new Error('smtp down'); + }, + }); + + for (let attempt = 1; attempt <= 5; attempt += 1) { + // Make the row due again regardless of the stored backoff. + await prisma.mailOutbox.updateMany({ + where: { toAddress: failing }, + data: { nextAttemptAt: new Date() }, + }); + await worker.deliverDueMail(); + const row = await prisma.mailOutbox.findFirst({ where: { toAddress: failing } }); + expect(row?.attempts).toBe(attempt); + expect(row?.status).toBe(attempt < 5 ? 'PENDING' : 'FAILED'); + expect(row?.lastError).toContain('smtp down'); + } + }); +}); diff --git a/apps/api/src/mail/mail.module.ts b/apps/api/src/mail/mail.module.ts new file mode 100644 index 0000000..cba06d1 --- /dev/null +++ b/apps/api/src/mail/mail.module.ts @@ -0,0 +1,28 @@ +import { Module } from '@nestjs/common'; +import { createTransport } from 'nodemailer'; + +import { AppConfig } from '../config/app-config.service'; +import { MAIL_TRANSPORT, MailWorker } from './mail-worker.service'; +import { MailService } from './mail.service'; + +@Module({ + providers: [ + MailService, + MailWorker, + { + provide: MAIL_TRANSPORT, + inject: [AppConfig], + useFactory: (config: AppConfig) => + createTransport({ + host: config.env.SMTP_HOST, + port: config.env.SMTP_PORT, + secure: config.env.SMTP_SECURE, + auth: config.env.SMTP_USER + ? { user: config.env.SMTP_USER, pass: config.env.SMTP_PASS } + : undefined, + }), + }, + ], + exports: [MailService], +}) +export class MailModule {} diff --git a/apps/api/src/mail/mail.service.ts b/apps/api/src/mail/mail.service.ts new file mode 100644 index 0000000..89387d7 --- /dev/null +++ b/apps/api/src/mail/mail.service.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@nestjs/common'; + +import { PrismaService } from '../prisma/prisma.service'; +import { MailTemplate, renderMail } from './mail-templates'; + +/** + * Enqueues transactional mail into the outbox. Actual delivery happens + * asynchronously in MailWorker — callers never wait on SMTP. + */ +@Injectable() +export class MailService { + constructor(private readonly prisma: PrismaService) {} + + async enqueue( + to: string, + template: MailTemplate, + params: { displayName: string; link: string }, + locale: 'de' | 'en', + ): Promise { + const rendered = renderMail(template, params, locale); + await this.prisma.mailOutbox.create({ + data: { + toAddress: to, + subject: rendered.subject, + textBody: rendered.text, + htmlBody: rendered.html, + }, + }); + } +} diff --git a/deploy/compose/compose.dev.yml b/deploy/compose/compose.dev.yml index 967ef87..5daba9c 100644 --- a/deploy/compose/compose.dev.yml +++ b/deploy/compose/compose.dev.yml @@ -56,6 +56,14 @@ services: # instances (5432 system, 5433 wochenplan-staging). - '127.0.0.1:5434:5432' + # Local SMTP catcher: UI on http://localhost:8025, SMTP on 1025 — + # matches the api's SMTP_* defaults. + mailpit: + image: axllent/mailpit:latest + ports: + - '127.0.0.1:1025:1025' + - '127.0.0.1:8025:8025' + volumes: web-root-modules: web-app-modules: diff --git a/packages/shared/i18n/de/mails.json b/packages/shared/i18n/de/mails.json new file mode 100644 index 0000000..756b4b0 --- /dev/null +++ b/packages/shared/i18n/de/mails.json @@ -0,0 +1,19 @@ +{ + "common": { + "greeting": "Hallo {{displayName}},", + "signoff": "Dein Dorfteich", + "ignoreHint": "Falls du diese E-Mail nicht angefordert hast, kannst du sie einfach ignorieren." + }, + "verifyEmail": { + "subject": "Bestätige deine E-Mail-Adresse", + "body": "willkommen im Dorfteich! Bitte bestätige deine E-Mail-Adresse, um dein Konto zu aktivieren:", + "action": "E-Mail-Adresse bestätigen", + "expiry": "Der Link ist 24 Stunden gültig." + }, + "resetPassword": { + "subject": "Passwort zurücksetzen", + "body": "jemand (hoffentlich du) hat das Zurücksetzen des Passworts für dein Konto angefordert. Über diesen Link kannst du ein neues Passwort setzen:", + "action": "Neues Passwort setzen", + "expiry": "Der Link ist eine Stunde gültig. Dein aktuelles Passwort bleibt gültig, bis du ein neues gesetzt hast." + } +} diff --git a/packages/shared/i18n/en/mails.json b/packages/shared/i18n/en/mails.json new file mode 100644 index 0000000..f25d633 --- /dev/null +++ b/packages/shared/i18n/en/mails.json @@ -0,0 +1,19 @@ +{ + "common": { + "greeting": "Hello {{displayName}},", + "signoff": "Your Dorfteich", + "ignoreHint": "If you did not request this e-mail, you can safely ignore it." + }, + "verifyEmail": { + "subject": "Confirm your e-mail address", + "body": "welcome to Dorfteich! Please confirm your e-mail address to activate your account:", + "action": "Confirm e-mail address", + "expiry": "The link is valid for 24 hours." + }, + "resetPassword": { + "subject": "Reset your password", + "body": "someone (hopefully you) requested a password reset for your account. Use this link to set a new password:", + "action": "Set new password", + "expiry": "The link is valid for one hour. Your current password stays valid until you set a new one." + } +} diff --git a/packages/shared/src/env.ts b/packages/shared/src/env.ts index 0bfce23..dbfe598 100644 --- a/packages/shared/src/env.ts +++ b/packages/shared/src/env.ts @@ -24,6 +24,22 @@ export const apiEnvSchema = z.object({ .enum(['true', 'false']) .default('true') .transform((value) => value === 'true'), + /** Public base URL of this instance — used in e-mail links. */ + APP_BASE_URL: z.string().url().default('http://localhost:5173'), + /** + * SMTP delivery. Defaults match the Mailpit container from the dev + * overlay; production instances configure their real relay here (the + * M8 setup wizard writes these). + */ + SMTP_HOST: z.string().default('localhost'), + SMTP_PORT: z.coerce.number().int().default(1025), + SMTP_SECURE: z + .enum(['true', 'false']) + .default('false') + .transform((value) => value === 'true'), + SMTP_USER: z.string().optional(), + SMTP_PASS: z.string().optional(), + SMTP_FROM: z.string().default('Dorfteich '), }); export type ApiEnv = z.infer; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5d3463d..702516c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: nestjs-pino: specifier: ^4.3.0 version: 4.6.1(@nestjs/common@11.1.27(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@10.5.0)(pino@9.14.0)(rxjs@7.8.2) + nodemailer: + specifier: ^9.0.3 + version: 9.0.3 pino: specifier: ^9.6.0 version: 9.14.0 @@ -81,6 +84,9 @@ importers: '@types/express': specifier: ^5.0.0 version: 5.0.6 + '@types/nodemailer': + specifier: ^8.0.1 + version: 8.0.1 '@types/supertest': specifier: ^6.0.0 version: 6.0.3 @@ -1426,6 +1432,9 @@ packages: '@types/node@26.1.0': resolution: {integrity: sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==} + '@types/nodemailer@8.0.1': + resolution: {integrity: sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==} + '@types/qs@6.15.1': resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} @@ -2630,6 +2639,10 @@ packages: resolution: {integrity: sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==} engines: {node: '>=18'} + nodemailer@9.0.3: + resolution: {integrity: sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==} + engines: {node: '>=6.0.0'} + nypm@0.6.8: resolution: {integrity: sha512-Q9K4Diu6l5u6xJQogeFSs/zKtyMSgFKFtRQV+tHP4kL7KPm2grpBU0dFIwFaXwNxN0MtfKWc43VpCugAa+LPsw==} engines: {node: '>=18'} @@ -4545,6 +4558,10 @@ snapshots: dependencies: undici-types: 8.3.0 + '@types/nodemailer@8.0.1': + dependencies: + '@types/node': 26.1.0 + '@types/qs@6.15.1': {} '@types/range-parser@1.2.7': {} @@ -5840,6 +5857,8 @@ snapshots: node-releases@2.0.50: {} + nodemailer@9.0.3: {} + nypm@0.6.8: dependencies: citty: 0.2.2