Files
tessera-ctl/.planning/phases/07-dkv-fleet-module/07-03-PLAN.md
T
schalli de06794e67
Tessera CI/CD / Build & Deploy (push) Blocked by required conditions
Tessera CI/CD / Lint & Type Check (push) Successful in 38s
Tessera CI/CD / Tests (push) Waiting to run
docs(07): create phase 7 execution plans for DKV fleet module
6 plans covering full pipeline: PDF parsing foundation (Wave 0),
inbox providers + export/SMTP services (Wave 1), pipeline
orchestration + frontend pages + settings UI (Wave 2). Includes
D-06 MailModule DB-config migration and Nyquist validation strategy.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 18:56:59 +02:00

22 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, user_setup, must_haves
phase plan type wave depends_on files_modified autonomous requirements user_setup must_haves
07-dkv-fleet-module 03 execute 1
07-01
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/dkv/dkv-mail.service.ts
apps/api/src/dkv/dkv-export.service.ts
apps/api/src/mail/mail.module.ts
apps/api/src/mail/mail.service.ts
apps/api/src/app.module.ts
true
DKV-04
DKV-05
truths artifacts key_links
SMTP config (host, port, encryption, username, encrypted password, from address) can be saved and read per tenant
GET /settings/smtp never returns the decrypted or encrypted password to the client
An SMTP connection can be verified via POST /settings/smtp/test
An xlsx buffer with exactly 5 columns can be generated from export rows
Export files are written to user-files/ and pruned to the last 10
An email with an xlsx attachment can be sent via a runtime nodemailer transport built from DB SMTP config
The existing MailModule reads its SMTP transport from the DB SmtpConfig (priority 1) and falls back to env vars when no DB config exists (D-06)
path provides contains
apps/api/src/settings/settings.service.ts SmtpConfig CRUD + encryption/decryption + test + startup transport accessor getSmtpConfig
path provides min_lines
apps/api/src/dkv/dkv-export.service.ts buildExcelBuffer + resolveFahrzeug + 10-file prune 40
path provides contains
apps/api/src/dkv/dkv-mail.service.ts dynamic nodemailer transport send with attachment createTransport
path provides contains
apps/api/src/mail/mail.module.ts 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.

<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/phases/07-dkv-fleet-module/07-CONTEXT.md @.planning/phases/07-dkv-fleet-module/07-RESEARCH.md @.planning/phases/07-dkv-fleet-module/07-PATTERNS.md @apps/api/src/dkv/dkv.types.ts @apps/api/src/calendar/crypto.service.ts @apps/api/src/calendar/calendar.service.ts @apps/api/src/ldap/ldap.controller.ts @apps/api/src/mail/mail.service.ts @apps/api/src/mail/mail.module.ts

Artifacts this phase produces (Plan 03 portion)

New symbols (exclude from drift verification):

  • Classes SettingsModule, SettingsController, SettingsService
  • DTO SmtpConfigDto
  • 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)
T-07-11 Information Disclosure mail.module.ts / settings.service.getStartupSmtpConfig mitigate Decrypted SMTP password used only to build the transport at startup; never logged (T-05-13); env fallback values are not echoed
T-07-16 Information Disclosure settings.service.testSmtpConfig mitigate Connection test returns only a boolean; verify() failures logged generically without credentials (T-05-13)
</threat_model>
- `pnpm --filter @tessera/api type-check` exits 0 - SettingsModule registered + exports SettingsService; SMTP CRUD never leaks password; test endpoint returns boolean - Export buffer has exact 5-column contract; DkvMailService uses runtime transport - MailModule factory reads DB SmtpConfig first, env fallback second (D-06)

<success_criteria>

  • DKV-05 SMTP storage backend complete (encrypted, ADMIN-only, password never returned, connection-testable)
  • DKV-04 export + delivery primitives complete (xlsx 5-column buffer, 10-file prune, runtime SMTP send)
  • 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).