Build the interchangeable inbox-access layer: an `InboxProvider` interface and two implementations (IMAP via imapflow, Exchange via ews-javascript-api) that connect to a configured inbox, search for emails from the DKV sender, and download PDF attachments. Also define the class-validator DTOs that the controller (Plan 04) will use to validate module config and vehicle input.
Purpose: This is the first half of the DKV-01 "detect invoices from inbox" capability. Defining the provider contract first (interface-first ordering) lets Plan 04's orchestration code be written against a stable contract.
Output: Inbox interface + IMAP provider + Exchange provider + three DTOs.
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 inbox-reading capability — the entry point of the pipeline.
Task 1: InboxProvider interface + config/vehicle/history DTOs
apps/api/src/dkv/providers/inbox-provider.interface.ts, apps/api/src/dkv/dto/dkv-config.dto.ts, apps/api/src/dkv/dto/dkv-vehicle.dto.ts, apps/api/src/dkv/dto/dkv-history.dto.ts
- apps/api/src/dkv/dkv.types.ts — InboxConfig, InboxEmail, InboxAttachment already defined in Plan 01; interface re-uses these
- apps/api/src/calendar/dto/create-calendar-source.dto.ts — class-validator decorator conventions (IsString, IsInt, IsIn, IsEmail, IsOptional, Min, Max)
- 07-PATTERNS.md "dkv-config.dto.ts" — exact validator set per field
- 07-RESEARCH.md Security Domain V5 — senderFilter must use IsEmail(); folder must be constrained
Create inbox-provider.interface.ts exporting the `InboxProvider` interface with `fetchPdfAttachments(config: InboxConfig): Promise` and `testConnection(config: InboxConfig): Promise`. Import InboxConfig/InboxEmail/InboxAttachment from `../dkv.types` (do not redeclare them).
Create dkv-config.dto.ts exporting `DkvConfigDto` with these validators: protocol `@IsIn(['imap','exchange'])`; host optional string; port optional `@IsInt() @Min(1) @Max(65535)`; encryption `@IsIn(['none','starttls','ssl-tls'])`; folder optional string; senderFilter optional `@IsEmail()` (mitigates injection per V5); exportRecipient optional `@IsEmail()`; pollIntervalMin optional `@IsInt() @Min(5)` (UI-SPEC min=5); isActive optional `@IsBoolean()`; vehicleFormatString optional string; username optional string; password optional string.
Create dkv-vehicle.dto.ts exporting `CreateVehicleDto` (kennzeichen, marke, modell, fahrer — all `@IsString() @IsNotEmpty()` except validate kennzeichen non-empty) and `UpdateVehicleDto` (all four optional).
Create dkv-history.dto.ts exporting `DkvHistoryQueryDto` with `page` and `limit` optional `@IsInt() @Min(1)` (pagination mitigates the history-accumulation DoS, Research Security Domain).
pnpm --filter @tessera/api type-check && grep -q "IsEmail" apps/api/src/dkv/dto/dkv-config.dto.ts && grep -q "InboxProvider" apps/api/src/dkv/providers/inbox-provider.interface.ts
- `apps/api/src/dkv/providers/inbox-provider.interface.ts` exports `InboxProvider` with both `fetchPdfAttachments` and `testConnection`
- `DkvConfigDto` decorates `senderFilter` and `exportRecipient` with `@IsEmail()`
- `DkvConfigDto` decorates `pollIntervalMin` with `@Min(5)`
- `DkvHistoryQueryDto` exposes `page` and `limit` with `@IsInt()`
- `pnpm --filter @tessera/api type-check` exits 0
Provider contract and all input DTOs exist and type-check.
Task 2: ImapProvider (imapflow)
apps/api/src/dkv/providers/imap.provider.ts
- 07-RESEARCH.md "Pattern 2: imapflow IMAP Provider" — connect, search by sender, fetchAll then download, streamToBuffer
- 07-RESEARCH.md "Pitfall 1" — NEVER call download() inside a fetch() async iterator (deadlock); always fetchAll() first, then loop and download()
- 07-PATTERNS.md "imap.provider.ts" — class shell, `logger: false` to suppress credential logging
- apps/api/src/dkv/providers/inbox-provider.interface.ts — contract to implement
- apps/api/src/dkv/dkv.types.ts — InboxEmail / InboxAttachment shapes to return
Implement `@Injectable() ImapProvider implements InboxProvider` with `new Logger(ImapProvider.name)`. Construct `ImapFlow` with `host`, `port`, `secure: encryption === 'ssl-tls'`, `requireTLS: encryption === 'starttls'`, `auth` only when username present, and `logger: false` (T-07 credential safety). In `fetchPdfAttachments`: connect, acquire mailbox lock on `config.folder`, `client.search({ from: config.senderFilter }, { uid: true })`, then `client.fetchAll(uids, { envelope: true, bodyStructure: true }, { uid: true })`, then loop messages and call `client.download(...)` separately (never inside the fetch iterator). Collect only parts whose content type is application/pdf. Enforce a max attachment size guard (skip/raise on attachments larger than a sane limit, e.g. 25 MB — mitigates the PDF-bomb DoS) before buffering. Always release the lock and `logout()` in finally. `testConnection`: connect + logout, return boolean. Wrap errors in generic log messages (T-05-13) and never include credentials.
pnpm --filter @tessera/api type-check && grep -q "fetchAll" apps/api/src/dkv/providers/imap.provider.ts && grep -q "logger: false" apps/api/src/dkv/providers/imap.provider.ts
- `ImapProvider` implements `InboxProvider` (type-check enforces the contract)
- Source contains `fetchAll(` and does NOT call `download(` inside a `for await` / `client.fetch(` iterator
- `ImapFlow` is constructed with `logger: false`
- An attachment size guard (numeric byte limit) is present before buffering
- `pnpm --filter @tessera/api type-check` exits 0
IMAP provider fetches sender-filtered PDF attachments without the documented deadlock and without logging credentials.
Task 3: ExchangeInboxProvider (ews-javascript-api)
apps/api/src/dkv/providers/exchange-inbox.provider.ts
- apps/api/src/calendar/providers/exchange.provider.ts — full EWS pattern: dynamic `await import('ews-javascript-api')`, ExchangeService, WebCredentials, error handling lines 42-48
- 07-RESEARCH.md "Pitfall 6" — use `WellKnownFolderName.Inbox` + `FindItems` + `EmailMessage.Bind`, NOT FindAppointments/CalendarView
- 07-PATTERNS.md "exchange-inbox.provider.ts" — class shell mirroring ExchangeProvider
- apps/api/src/dkv/providers/inbox-provider.interface.ts — contract to implement
Implement `@Injectable() ExchangeInboxProvider implements InboxProvider` mirroring the existing ExchangeProvider structure. Use the dynamic import `const ews: any = await import('ews-javascript-api')`, construct `ExchangeService`, set `Url` from config.host and `Credentials = new ews.WebCredentials(username, password)`. Differ from the calendar provider per Pitfall 6: bind to `ews.WellKnownFolderName.Inbox`, use `service.FindItems(...)` with an item view, filter by sender (config.senderFilter), then `EmailMessage.Bind` to load each message and read its attachments; download FileAttachment content for application/pdf parts into a Buffer. Apply the same max attachment size guard as the IMAP provider. `testConnection`: attempt a minimal FindItems on the Inbox, return boolean. On error, log a generic message only (T-05-13), never credentials, and return `[]` for fetch (consistent with calendar exchange.provider error path).
pnpm --filter @tessera/api type-check && grep -q "WellKnownFolderName.Inbox" apps/api/src/dkv/providers/exchange-inbox.provider.ts && grep -q "import('ews-javascript-api')" apps/api/src/dkv/providers/exchange-inbox.provider.ts
- `ExchangeInboxProvider` implements `InboxProvider`
- Source references `WellKnownFolderName.Inbox` and `FindItems` (not `FindAppointments`)
- Uses dynamic `import('ews-javascript-api')` (matches calendar provider lazy-load convention)
- Error path logs generic message and does not include credential values
- `pnpm --filter @tessera/api type-check` exits 0
Exchange provider reads PDF attachments from the Inbox folder using the email (not calendar) EWS item type.
<threat_model>
Trust Boundaries
Boundary
Description
external mail server → API
Untrusted IMAP/EWS responses and attachments cross into the server
admin form input → DTO
User-supplied config (host, sender, credentials) enters via DTO
STRIDE Threat Register
Threat ID
Category
Component
Disposition
Mitigation Plan
T-07-03
Information Disclosure
imap.provider.ts / exchange-inbox.provider.ts
mitigate
logger: false on ImapFlow; generic error logging (T-05-13); credentials never serialized into log strings
T-07-04
Tampering
dkv-config.dto.ts
mitigate
senderFilter validated with @IsEmail(), port with @Min(1)@Max(65535), encryption with @IsIn(...) — prevents injection/malformed config
T-07-05
Denial of Service
imap.provider.ts / exchange-inbox.provider.ts
mitigate
Max attachment byte-size guard before buffering/parsing (PDF-bomb defense)
T-07-06
Denial of Service
dkv-history.dto.ts
mitigate
Pagination params (page/limit) on history query
</threat_model>
- `pnpm --filter @tessera/api type-check` exits 0
- Both providers structurally implement the InboxProvider contract (type-check enforced)
- imapflow deadlock pattern avoided (fetchAll before download)
<success_criteria>
InboxProvider contract defined and implemented by both IMAP and Exchange providers
DTOs validate all module-config and vehicle inputs with the security constraints from Research V5
DKV-01 inbox-access layer ready for orchestration in Plan 04
</success_criteria>
Create `.planning/phases/07-dkv-fleet-module/07-02-SUMMARY.md` when done. Record the chosen max attachment size limit and any EWS API adjustments made versus the calendar ExchangeProvider.