Add mail outbox with SMTP delivery worker and templates

MailService renders transactional mails (verify-email, reset-password)
from the new de/en `mails` i18n namespace — text plus minimal HTML
with escaped interpolation — and enqueues them into mail_outbox.
MailWorker delivers pending rows every 15s through an injectable
transport (nodemailer; faked in tests) with quadratic backoff and a
permanent FAILED state after five attempts, logged as a warning.
SMTP_* and APP_BASE_URL join the environment schema with defaults
matching the new Mailpit container in the dev overlay.

Closes #12

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Claude Fable 5 2026-07-05 00:46:12 +02:00
parent 31f23c12a2
commit f00fb19f32
13 changed files with 409 additions and 2 deletions

View File

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

View File

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

View File

@ -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'],

View File

@ -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, string> = {}): 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 = `<!doctype html>
<html><body style="font-family: system-ui, sans-serif; color: #1f2933; max-width: 32rem; margin: 0 auto; padding: 1.5rem;">
<p>${escapeHtml(greeting)}</p>
<p>${escapeHtml(body)}</p>
<p style="margin: 1.5rem 0;">
<a href="${params.link}" style="background: #2f6f4f; color: #ffffff; padding: 0.6rem 1.2rem; border-radius: 6px; text-decoration: none;">${escapeHtml(action)}</a>
</p>
<p style="font-size: 0.85rem; color: #616e7c;">${escapeHtml(expiry)}<br>${escapeHtml(ignore)}</p>
<p>${escapeHtml(signoff)}</p>
</body></html>`;
return { subject: t(`${template}.subject`), text, html };
}
function escapeHtml(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
}

View File

@ -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<unknown>;
}
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<void> {
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<void> {
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');
}
}
}
}

View File

@ -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: '<script>x</script>', link: 'https://example.org/r' },
'en',
);
expect(mail.html).not.toContain('<script>x</script>');
});
});
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');
}
});
});

View File

@ -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 {}

View File

@ -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<void> {
const rendered = renderMail(template, params, locale);
await this.prisma.mailOutbox.create({
data: {
toAddress: to,
subject: rendered.subject,
textBody: rendered.text,
htmlBody: rendered.html,
},
});
}
}

View File

@ -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:

View File

@ -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."
}
}

View File

@ -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."
}
}

View File

@ -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 <no-reply@localhost>'),
});
export type ApiEnv = z.infer<typeof apiEnvSchema>;

19
pnpm-lock.yaml generated
View File

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