--- phase: 07-dkv-fleet-module plan: 03 type: execute wave: 1 depends_on: ["07-01"] files_modified: - 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 autonomous: true requirements: [DKV-04, DKV-05] user_setup: [] must_haves: truths: - "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)" artifacts: - path: "apps/api/src/settings/settings.service.ts" provides: "SmtpConfig CRUD + encryption/decryption + test + startup transport accessor" contains: "getSmtpConfig" - path: "apps/api/src/dkv/dkv-export.service.ts" provides: "buildExcelBuffer + resolveFahrzeug + 10-file prune" min_lines: 40 - path: "apps/api/src/dkv/dkv-mail.service.ts" provides: "dynamic nodemailer transport send with attachment" contains: "createTransport" - path: "apps/api/src/mail/mail.module.ts" provides: "MailerModule.forRootAsync factory sourcing SMTP from DB SmtpConfig with env fallback (D-06)" contains: "forRootAsync" key_links: - from: "apps/api/src/dkv/dkv-mail.service.ts" to: "nodemailer.createTransport" via: "transport built per-send from SmtpConfig (NOT @nestjs-modules/mailer)" pattern: "createTransport" - from: "apps/api/src/settings/settings.service.ts" to: "CalendarCryptoService" via: "encrypt/decrypt SMTP password" pattern: "crypto.(encrypt|decrypt)" - from: "apps/api/src/mail/mail.module.ts" to: "SettingsService" via: "MailerModule.forRootAsync factory injects SettingsService to read DB SMTP config (D-06)" pattern: "forRootAsync" - from: "apps/api/src/app.module.ts" to: "SettingsModule" via: "imports array registration" pattern: "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. @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md @.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. ## 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_.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) | - `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) - 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 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).