MailerModule.forRootAsync factory sourcing SMTP from DB SmtpConfig with env fallback (D-06)
forRootAsync
from
to
via
pattern
apps/api/src/dkv/dkv-mail.service.ts
nodemailer.createTransport
transport built per-send from SmtpConfig (NOT @nestjs-modules/mailer)
createTransport
from
to
via
pattern
apps/api/src/settings/settings.service.ts
CalendarCryptoService
encrypt/decrypt SMTP password
crypto.(encrypt|decrypt)
from
to
via
pattern
apps/api/src/mail/mail.module.ts
SettingsService
MailerModule.forRootAsync factory injects SettingsService to read DB SMTP config (D-06)
forRootAsync
from
to
via
pattern
apps/api/src/app.module.ts
SettingsModule
imports array registration
SettingsModule
Build the export-and-deliver half of the pipeline plus shared SMTP configuration: the SettingsModule (SmtpConfig CRUD with encryption + connection test, ADMIN-only), the DkvExportService (xlsx generation + user-files/ management with 10-file prune), and the DkvMailService (runtime nodemailer transport that reads SMTP config from DB so config changes take effect immediately). Register SettingsModule in AppModule. Finally, migrate the existing MailModule (`apps/api/src/mail/`) so its transport is sourced from the DB SmtpConfig with an env-var fallback — this satisfies D-06 ("existing mail module must read SMTP config from DB instead of hardcoded env vars").
Purpose: Delivers DKV-04 (Excel + SMTP send) backend and DKV-05 (SMTP settings storage), and closes D-06 by removing the hardcoded env-only SMTP transport from the existing MailModule. The runtime-transport approach for DkvMailService is mandatory because @nestjs-modules/mailer cannot change its transport after startup (Research Pitfall 3); the existing MailModule keeps @nestjs-modules/mailer but builds its startup transport from the DB default SmtpConfig.
Output: SettingsModule (SMTP backend + test), DkvExportService, DkvMailService, migrated MailModule.
Phase Goal
Als Administrator möchte ich DKV-Tankkarten-Rechnungen automatisch aus einem E-Mail-Postfach verarbeiten lassen, damit Flotten-Tankdaten ohne manuelle Eingabe als Excel-Datei exportiert und per SMTP zugestellt werden.
This plan delivers the export + delivery + SMTP-config capability — the exit point of the pipeline.
Class DkvExportService (buildExcelBuffer, resolveFahrzeug, writeAndPrune)
Class DkvMailService (sendExportEmail)
SettingsModule registered in apps/api/src/app.module.ts
New SettingsService accessors testSmtpConfig() and getStartupSmtpConfig() (tenant-agnostic, used by the MailModule factory)
Modified (existing) symbols:
apps/api/src/mail/mail.module.ts — MailerModule.forRootAsync factory rewired to source the transport from the DB default SmtpConfig with env fallback (D-06)
apps/api/src/mail/mail.service.ts — unchanged logic; verified to still compile against the rewired MailerModule
Task 1: SettingsModule — SMTP config backend + connection test (DKV-05)
apps/api/src/settings/settings.module.ts, apps/api/src/settings/settings.controller.ts, apps/api/src/settings/settings.service.ts, apps/api/src/settings/dto/smtp-config.dto.ts, apps/api/src/app.module.ts
- apps/api/src/ldap/ldap.controller.ts — controller shell, `@Roles(Role.ADMIN, Role.SUPER_ADMIN)`, tenant extraction `req.tenantId` + BadRequestException
- apps/api/src/calendar/calendar.service.ts lines 50-67 — SAFE_SELECT pattern excluding encrypted fields
- apps/api/src/calendar/crypto.service.ts — encrypt/decrypt API (CalendarCryptoService, exported from CalendarModule in Plan 01)
- apps/api/src/calendar/calendar.module.ts — module wiring reference (how CalendarCryptoService is provided/imported)
- apps/api/src/app.module.ts — imports array (add SettingsModule; ScheduleModule already added in Plan 01)
- 07-PATTERNS.md "settings.controller.ts" — GET/PUT /settings/smtp routes, hasPassword boolean response
- 07-RESEARCH.md Prisma SmtpConfig model field names + "Pattern 6: Dynamic SMTP via Nodemailer" (transport build / verify)
Create the SettingsModule (controller + service + dto). Import CalendarModule (or provide CalendarCryptoService) so `CalendarCryptoService` can be injected; PrismaModule is global. The module MUST list `SettingsService` in its `exports` array (Task 4 wires it into MailModule).
`SmtpConfigDto`: host `@IsString() @IsNotEmpty()`; port `@IsInt() @Min(1) @Max(65535)`; encryption `@IsIn(['none','starttls','ssl-tls'])`; username optional string; password optional string; fromAddress `@IsEmail()`.
`SettingsController` with `@Controller('settings')`, every handler `@Roles(Role.ADMIN, Role.SUPER_ADMIN)`, tenant extracted from `req.tenantId` (throw BadRequestException 'No tenant context' when absent):
- `GET /settings/smtp` → returns config with `encryptedPassword` stripped and a `hasPassword` boolean added; returns null when none.
- `PUT /settings/smtp` → upsert via service.
- `POST /settings/smtp/test` (body via `SmtpConfigDto`) → calls `service.testSmtpConfig(tenantId, dto)` and returns `{ success: boolean }`. This backs the UI-SPEC Surface C "Verbindung testen" button consumed by Plan 07-06.
`SettingsService`: `getSmtpConfig(tenantId)` using a `SMTP_SAFE_SELECT` that excludes `encryptedPassword`; `saveSmtpConfig(tenantId, dto)` that encrypts `dto.password` with `crypto.encrypt(...)` only when a new password is provided (preserve existing on empty), upserts on `tenantId @unique`; `getDecryptedSmtpConfig(tenantId)` (internal, used by DkvMailService) returning host/port/encryption/username/fromAddress plus `decryptedPassword`; `testSmtpConfig(tenantId, dto)` that builds a `nodemailer.createTransport(...)` from the submitted dto (use `dto.password` when provided, else the stored decrypted password) and runs `transport.verify()`, returning a boolean (generic error on failure, never logging credentials — T-05-13). Never log decrypted values (T-05-13).
Register `SettingsModule` in apps/api/src/app.module.ts imports array.
pnpm --filter @tessera/api type-check && grep -q "getSmtpConfig" apps/api/src/settings/settings.service.ts && grep -q "testSmtpConfig" apps/api/src/settings/settings.service.ts && grep -q "SettingsModule" apps/api/src/app.module.ts && grep -q "Roles(Role.ADMIN" apps/api/src/settings/settings.controller.ts && grep -q "exports" apps/api/src/settings/settings.module.ts
- `GET /settings/smtp` handler strips `encryptedPassword` and adds `hasPassword` boolean
- All controller handlers (GET, PUT, POST test) carry `@Roles(Role.ADMIN, Role.SUPER_ADMIN)` (V4 access control)
- `SettingsService.saveSmtpConfig` calls `crypto.encrypt` for the password
- `SettingsService` exposes an internal decrypt method returning `decryptedPassword`
- `SettingsService.testSmtpConfig` builds a transport and calls `transport.verify()`, returning a boolean; `POST /settings/smtp/test` returns `{ success: boolean }`
- `SettingsModule` exports `SettingsService` and appears in `apps/api/src/app.module.ts` imports
- `pnpm --filter @tessera/api type-check` exits 0
Per-tenant SMTP config can be stored encrypted, read back safely, and connection-tested; module registered and exports SettingsService.
Task 2: DkvExportService — xlsx generation + user-files/ prune (DKV-04)
apps/api/src/dkv/dkv-export.service.ts
- 07-RESEARCH.md "Pattern 5: SheetJS Excel Generation" — aoa_to_sheet, book_new, XLSX.write buffer
- 07-RESEARCH.md "Fahrzeug Format String Resolution" — resolveFahrzeug template replacement
- 07-RESEARCH.md "Pitfall 7" — 10-file prune race condition; D-12 filename, D-13 columns, D-14 one file per invoice, D-15 keep last 10
- 07-PATTERNS.md "dkv-export.service.ts" — service shell, buildExcelBuffer, processing-lock note
- apps/api/src/dkv/dkv.types.ts — ExportRow, DkvVehicleMaster shape
Implement `@Injectable() DkvExportService` with `new Logger(...)`.
`resolveFahrzeug(vehicle, formatString)`: replace `{Marke}`,`{Modell}`,`{Kennzeichen}`,`{Fahrer}` tokens (default format `{Marke}/{Modell}/{Kennzeichen}` per D-19).
`buildExcelBuffer(rows: ExportRow[]): Buffer`: header row exactly `['Lieferdatum','Fahrzeug','Fahrer','Ort','Kilometerstand']` (D-13 order), write Lieferdatum as the already-formatted German date STRING (never a JS Date — Research anti-pattern), Kilometerstand as number; `XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' })`.
`writeAndPrune(buffer, rechnungsnummer, invoiceMonth): string`: compute filename `DKV_YYYY-MM_.xlsx` (D-12) server-side only (never user-supplied — path-traversal mitigation), write into the `user-files/` directory (create if missing), then list DKV_*.xlsx files sorted by mtime and delete all but the newest 10 (D-15). Return the filename.
Guard against the prune race (Pitfall 7): expose the write+prune as a single method so the orchestrator (Plan 04) serializes processing; do not interleave count-check and write.
Resolve the user-files/ path relative to the repo/app root, not from request input.
pnpm --filter @tessera/api type-check && grep -q "aoa_to_sheet" apps/api/src/dkv/dkv-export.service.ts && grep -q "Lieferdatum" apps/api/src/dkv/dkv-export.service.ts
- `buildExcelBuffer` produces a sheet whose first row is exactly the 5 headers in D-13 order
- Lieferdatum is written as a string (no `new Date(` wrapping of the Lieferdatum value)
- `writeAndPrune` builds the filename server-side as `DKV_YYYY-MM_.xlsx` and never from request input
- Prune logic keeps at most 10 `DKV_*.xlsx` files
- `resolveFahrzeug` replaces all four placeholder tokens
- `pnpm --filter @tessera/api type-check` exits 0
Excel buffers with the exact 5-column contract are generated and export files are pruned to the last 10.
Task 3: DkvMailService — runtime SMTP transport with attachment (DKV-04)
apps/api/src/dkv/dkv-mail.service.ts
- 07-RESEARCH.md "Pattern 6: Dynamic SMTP via Nodemailer" — createTransport at send time, secure/requireTLS mapping
- 07-RESEARCH.md "Pitfall 3" — @nestjs-modules/mailer cannot change transport at runtime; use nodemailer directly
- 07-PATTERNS.md "dkv-mail.service.ts" — imports, error handling (log + rethrow for retry), do NOT use MailerService
- apps/api/src/mail/mail.service.ts — existing MailService (its logic is left unchanged in Task 3; this DkvMailService is a separate service)
Implement `@Injectable() DkvMailService` with `new Logger(...)`. Inject SettingsService (or PrismaService + CalendarCryptoService) to load decrypted SMTP config.
`sendExportEmail(tenantId, recipient, attachmentBuffer, filename)`: load decrypted SMTP config for the tenant; build `nodemailer.createTransport({ host, port, secure: encryption==='ssl-tls', requireTLS: encryption==='starttls', auth: username ? { user, pass: decryptedPassword } : undefined })`; `sendMail({ from: fromAddress, to: recipient, subject: 'DKV Flottenabrechnung: '+filename, text, attachments: [{ filename, content: attachmentBuffer, contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }] })`.
On failure: log a generic message (never credentials, T-05-13) and RETHROW so the orchestrator (Plan 04) can run the 3-retry exponential backoff per D-16. Do NOT swallow the error (unlike MailService).
Do NOT import or use `MailerService` from @nestjs-modules/mailer.
pnpm --filter @tessera/api type-check && grep -q "createTransport" apps/api/src/dkv/dkv-mail.service.ts && ! grep -q "MailerService" apps/api/src/dkv/dkv-mail.service.ts
- Source uses `nodemailer.createTransport(` built from DB config at send time
- Source does NOT reference `MailerService`
- Attachment contentType is the xlsx OOXML mime type
- Error path rethrows (does not swallow) so retries can run
- `pnpm --filter @tessera/api type-check` exits 0
DKV exports can be emailed with the xlsx attachment via a per-send transport that reflects current DB SMTP config.
Task 4: Migrate MailModule to DB-sourced SMTP transport with env fallback (D-06)
apps/api/src/mail/mail.module.ts, apps/api/src/mail/mail.service.ts, apps/api/src/settings/settings.service.ts
- apps/api/src/mail/mail.module.ts — current `MailerModule.forRootAsync` factory reading TESSERA_SMTP_* env vars only (this is what D-06 forbids; it must read DB config first)
- apps/api/src/mail/mail.service.ts — consumes `MailerService`; verify it still compiles after the factory change (no logic change expected)
- apps/api/src/settings/settings.service.ts — add a tenant-agnostic startup accessor here (Task 1 created this file)
- apps/api/src/calendar/crypto.service.ts — decrypt API for the stored SMTP password
- 07-CONTEXT.md D-05 (SMTP lives in general settings) and D-06 (existing mail module must read SMTP from DB)
This task closes the D-06 violation. User decision: Option 2 — migrate the existing `mail.service.ts`/`mail.module.ts` to DB-config + env-fallback rather than building a parallel service.
1. In `apps/api/src/settings/settings.service.ts`, add a tenant-agnostic accessor `async getStartupSmtpConfig()`: `prisma.smtpConfig.findFirst()`; if a row exists, decrypt its password via `CalendarCryptoService` and return `{ host, port, secure: encryption==='ssl-tls', requireTLS: encryption==='starttls', username, password: decryptedPassword, fromAddress }`; return `null` when no row exists. Never log the decrypted password (T-05-13). This is the DB default transport used for system mail (e.g. password-reset) — document in the SUMMARY that single-tenant deployments use the first SmtpConfig row.
2. Rewrite `apps/api/src/mail/mail.module.ts` to keep `MailerModule.forRootAsync(...)` but change the factory to source the transport from the DB SmtpConfig (priority 1) with an env-var fallback (priority 2):
- `imports: [SettingsModule]` and `inject: [SettingsService, ConfigService]`.
- Factory body: `const db = await settingsService.getStartupSmtpConfig();` Build `transport`/`defaults` from `db` when non-null. When `db` is null, fall back to env vars per the checker contract: `MAIL_HOST`, `MAIL_PORT`, `MAIL_USER`, `MAIL_PASS` (read via `configService.get`). Preserve backward compatibility by chaining to the existing `TESSERA_SMTP_*` names and current defaults (`localhost:1025`) as the final fallback so the password-reset flow keeps working: e.g. `host = MAIL_HOST ?? TESSERA_SMTP_HOST ?? 'localhost'`, `port = MAIL_PORT ?? TESSERA_SMTP_PORT ?? 1025`, `user = MAIL_USER ?? TESSERA_SMTP_USER ?? ''`, `pass = MAIL_PASS ?? TESSERA_SMTP_PASSWORD ?? ''`. Map `secure`: from DB use `encryption==='ssl-tls'`; from env keep `TESSERA_SMTP_SECURE==='true'`. `defaults.from` from DB `fromAddress` else `TESSERA_SMTP_FROM` default.
- This is an async factory; `useFactory` must be `async`.
3. `apps/api/src/mail/mail.service.ts`: no behavioral change is required (it injects `MailerService`, which still exists). Confirm it compiles against the rewired module. Do not alter the password-reset / welcome-email logic.
Beware circular imports: the chain `AuthModule → MailModule → SettingsModule → CalendarModule` has no cycle; do NOT make SettingsModule import MailModule.
pnpm --filter @tessera/api type-check && grep -q "forRootAsync" apps/api/src/mail/mail.module.ts && grep -q "getStartupSmtpConfig" apps/api/src/settings/settings.service.ts && grep -q "getStartupSmtpConfig" apps/api/src/mail/mail.module.ts && grep -Eq "MAIL_HOST" apps/api/src/mail/mail.module.ts && grep -q "SettingsModule" apps/api/src/mail/mail.module.ts
- `apps/api/src/mail/mail.module.ts` still calls `MailerModule.forRootAsync(`
- The factory injects `SettingsService` and calls `getStartupSmtpConfig()` (DB priority 1)
- When no DB SmtpConfig row exists, the factory falls back to env vars `MAIL_HOST`/`MAIL_PORT`/`MAIL_USER`/`MAIL_PASS` (with existing `TESSERA_SMTP_*` preserved as secondary fallback)
- `SettingsService.getStartupSmtpConfig()` decrypts the stored password and never logs it
- `apps/api/src/mail/mail.module.ts` imports `SettingsModule`
- `pnpm --filter @tessera/api type-check` exits 0
The existing MailModule sources its SMTP transport from the DB SmtpConfig with an env-var fallback — D-06 satisfied without a parallel mail service.
<threat_model>
Trust Boundaries
Boundary
Description
client → /settings/smtp
Admin supplies SMTP host/credentials
API → external SMTP server
Outbound mail with attachment + connection verify
API → user-files/ filesystem
Export file writes
DB SmtpConfig → MailModule factory
Stored encrypted SMTP password decrypted at startup
STRIDE Threat Register
Threat ID
Category
Component
Disposition
Mitigation Plan
T-07-07
Information Disclosure
settings.service/controller
mitigate
SMTP_SAFE_SELECT excludes encryptedPassword; GET returns hasPassword boolean only; password encrypted via CalendarCryptoService (V6)
T-07-08
Tampering
settings.controller.ts
mitigate
All SMTP endpoints (GET/PUT/test) are @Roles(Role.ADMIN, Role.SUPER_ADMIN) only (V4); host/port/encryption validated via SmtpConfigDto (open-relay defense)
T-07-09
Tampering
dkv-export.service.ts
mitigate
Export filename generated server-side (DKV_YYYY-MM_<nr>.xlsx), never from request — path-traversal prevented
T-07-10
Information Disclosure
dkv-mail.service.ts
mitigate
Generic error logging; decrypted SMTP password never logged (T-05-13)
D-06 closed: existing MailModule sources its SMTP transport from the DB with env fallback
</success_criteria>
Create `.planning/phases/07-dkv-fleet-module/07-03-SUMMARY.md` when done. Record how the user-files/ path is resolved, the exact prune ordering used, and how the MailModule factory resolves the DB-vs-env transport (including the tenant chosen by getStartupSmtpConfig).