---
phase: 14-rss-email-alert-ingestion-module-rollout
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- apps/api/src/inbox/inbox.module.ts
- apps/api/src/inbox/inbox.types.ts
- apps/api/src/inbox/inbox-provider.interface.ts
- apps/api/src/inbox/imap.provider.ts
- apps/api/src/inbox/exchange-inbox.provider.ts
- apps/api/src/inbox/imap.provider.spec.ts
- apps/api/src/inbox/exchange-inbox.provider.spec.ts
- apps/api/src/dkv/dkv.service.ts
- apps/api/src/dkv/dkv.module.ts
- apps/api/src/dkv/dkv.types.ts
autonomous: true
requirements: [INGEST-05]
must_haves:
truths:
- "DKV's existing fetchPdfAttachments behavior is unchanged after providers move to inbox/ (full API suite + tsc green)"
- "A new fetchMessages(config) method on both providers returns each unread message's subject + html/text body"
artifacts:
- "apps/api/src/inbox/ module exporting ImapProvider, ExchangeInboxProvider, InboxProvider, InboxMessage"
- "apps/api/src/inbox/imap.provider.spec.ts + exchange-inbox.provider.spec.ts (net-new coverage)"
key_links:
- "dkv.service.ts + dkv.module.ts import the two providers from '../inbox/...' instead of './providers/...'"
- "InboxProvider.fetchMessages is the seam the Phase-14 EmailAlertAdapter (Plan 14-03) consumes"
---
Extract DKV's IMAP/Exchange inbox providers into a shared `apps/api/src/inbox/` module (D-01) and add an additive `fetchMessages()` method (D-02) that returns whole messages with their subject + HTML/text body. This is the one-time prerequisite refactor that lets the Plan 14-03 email-alert adapter reuse DKV's connection mechanics without any DKV behavior change.
Purpose: Share connection code only — not config (D-03). DKV runs in production and its Exchange/NTLM path is known-fragile ([[project_calendar_ews_fix]]); regression risk must be zero.
Output: `inbox/` module (moved files + new `fetchMessages`), DKV switched to the new import path only, and net-new unit coverage for the new method on both providers.
## Phase Goal (MVP user story)
**As a** tenant admin, **I want to** have tenders parsed from my portal-alert mailbox, **so that** I stop missing Unterschwellen-Ausschreibungen that only arrive by email.
This plan is the enabling Wave-1 prerequisite for that story: it creates the shared inbox seam the email-alert slice (14-03) builds on. No user-facing behavior changes here; the user-visible outcome lands in 14-03.
## Artifacts this phase produces
- New module `apps/api/src/inbox/` with: `inbox.module.ts` (provides+exports both providers), `inbox.types.ts` (`InboxConfig`, `InboxAttachment`, `InboxEmail`, new `InboxMessage`), `inbox-provider.interface.ts` (adds `fetchMessages`), `imap.provider.ts`, `exchange-inbox.provider.ts` (both gain `fetchMessages`).
- New interface member `InboxProvider.fetchMessages(config: InboxConfig): Promise`.
- New type `InboxMessage` = `{ uid: number|string; messageId: string; subject: string; from: string; date: Date; bodyHtml: string | null; bodyText: string }`.
- New spec files `apps/api/src/inbox/imap.provider.spec.ts`, `apps/api/src/inbox/exchange-inbox.provider.spec.ts`.
- DKV re-export shim: `dkv.types.ts` re-exports the inbox types from `../inbox/inbox.types` so existing DKV imports keep compiling.
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/14-rss-email-alert-ingestion-module-rollout/14-CONTEXT.md
@.planning/phases/14-rss-email-alert-ingestion-module-rollout/14-RESEARCH.md
Task 1: Move inbox providers into apps/api/src/inbox/ (behavior-preserving)
apps/api/src/inbox/inbox.module.ts, apps/api/src/inbox/inbox.types.ts, apps/api/src/inbox/inbox-provider.interface.ts, apps/api/src/inbox/imap.provider.ts, apps/api/src/inbox/exchange-inbox.provider.ts, apps/api/src/dkv/dkv.service.ts, apps/api/src/dkv/dkv.module.ts, apps/api/src/dkv/dkv.types.ts
- apps/api/src/dkv/providers/inbox-provider.interface.ts (interface to move)
- apps/api/src/dkv/providers/imap.provider.ts (move + relative import fix)
- apps/api/src/dkv/providers/exchange-inbox.provider.ts (move + relative import fix)
- apps/api/src/dkv/dkv.types.ts (InboxConfig/InboxAttachment/InboxEmail live here; §47/§69/§78)
- apps/api/src/dkv/dkv.service.ts (imports at §16-17, injects at §82-83)
- apps/api/src/dkv/dkv.module.ts (declares the 2 providers directly)
- RESEARCH.md "Runtime State Inventory" + "Recommended Project Structure" (confirms only 2 import-path edits + a re-export shim)
Create apps/api/src/inbox/. Move `InboxConfig`, `InboxAttachment`, `InboxEmail` out of dkv.types.ts into a new inbox.types.ts (verbatim). In dkv.types.ts, replace those three interface declarations with a re-export: `export type { InboxConfig, InboxAttachment, InboxEmail } from '../inbox/inbox.types';` so every existing DKV consumer keeps compiling unchanged. Move inbox-provider.interface.ts, imap.provider.ts, exchange-inbox.provider.ts into inbox/ verbatim; update their relative imports to point at './inbox.types' (interface) and './inbox-provider.interface' (providers). Create inbox.module.ts: a NestJS @Module that declares `ImapProvider`, `ExchangeInboxProvider` as providers and lists both in `exports`. In dkv.module.ts, remove the two direct provider declarations from `providers[]`, add `InboxModule` to `imports[]`, and drop the two `./providers/...` import lines in favor of `import { InboxModule } from '../inbox/inbox.module';` (DkvService still injects ImapProvider/ExchangeInboxProvider — now resolved via the imported+exported providers). In dkv.service.ts, change the two provider import paths from `./providers/...` to `../inbox/...`. Delete the now-empty apps/api/src/dkv/providers/ directory. Do NOT change any provider method body, any InboxConfig field, or any DKV logic — this task is a pure move + import-path swap (D-01/D-02: DKV stays byte-behavior-identical).
cd apps/api && npx tsc --noEmit -p tsconfig.json && npx vitest run src/dkv
- `apps/api/src/dkv/providers/` no longer exists (`test ! -d apps/api/src/dkv/providers`).
- `grep -rn "dkv/providers" apps/api/src` returns no matches.
- `npx tsc --noEmit` exits 0; the full DKV spec set passes unchanged (regression guard for the fragile Exchange/NTLM path).
All three inbox files live under apps/api/src/inbox/, DKV imports them via '../inbox/...' + InboxModule, DKV tests and typecheck are green, and no fetchPdfAttachments/InboxConfig semantics changed.
Task 2: Add fetchMessages() to InboxProvider + both implementations (net-new coverage)
apps/api/src/inbox/inbox-provider.interface.ts, apps/api/src/inbox/inbox.types.ts, apps/api/src/inbox/imap.provider.ts, apps/api/src/inbox/exchange-inbox.provider.ts, apps/api/src/inbox/imap.provider.spec.ts, apps/api/src/inbox/exchange-inbox.provider.spec.ts
- apps/api/src/inbox/imap.provider.ts (collectPdfParts/streamToBuffer patterns to mirror for body-part walking)
- apps/api/src/inbox/exchange-inbox.provider.ts (getItemSoap/extractAll/splitMessageBlocks/escapeXml helpers to extend)
- apps/api/src/tenders/adapters/cosinex.adapter.spec.ts (fixture/mock spec style to mirror)
- RESEARCH.md Pattern 2 "Additive inbox-provider method" (IMAP findBodyParts sketch; EWS `item:Body` FieldURI + BodyType extraction) and Pitfall 4 (EWS body-fetch is untested-fragile — mock-based coverage now, live human-verify is 14-03)
- InboxMessage type added to inbox.types.ts: `{ uid, messageId, subject, from, date, bodyHtml: string|null, bodyText: string }`.
- IMAP fetchMessages: given a mocked ImapFlow whose bodyStructure has a text/html part and a text/plain part, returns one InboxMessage with bodyHtml from the html part and bodyText from the plain part; marks each processed message \Seen (idempotency for re-polls); honors the same UNSEEN + optional senderFilter search as fetchPdfAttachments; returns [] on connect/search error without throwing.
- EWS fetchMessages: given a mocked httpntlm.post returning a GetItem SOAP body with `...`, returns one InboxMessage with bodyHtml populated and bodyText empty; a `BodyType="Text"` response populates bodyText with bodyHtml null; marks the item read (IsRead) like fetchPdfAttachments; returns [] on non-200/error.
Add `fetchMessages(config: InboxConfig): Promise` to InboxProvider (inbox-provider.interface.ts) and implement it in both providers WITHOUT touching fetchPdfAttachments (D-02). IMAP: add a `findBodyParts(node)` sibling to collectPdfParts that walks the MIME tree collecting the first `text/html` and first `text/plain` part IDs; reuse the connect/lock/search/fetchAll(envelope+bodyStructure) skeleton, then download the html/text part IDs and streamToBuffer(...).toString('utf8'); mark \Seen after processing each message. EWS: add one `` to a new getItemBodySoap (or extend getItemSoap usage for the messages path), read `BodyType` via extractAttr(block,'t:Body','BodyType') and the body via extractAll(block,'t:Body')[0]; map HTML→bodyHtml, Text→bodyText; reuse markReadSoap for idempotency. Create imap.provider.spec.ts and exchange-inbox.provider.spec.ts mirroring cosinex.adapter.spec.ts's mock style (stub ImapFlow / stub httpntlm.post) — these are the codebase's first inbox-provider tests. Keep all logging credential-free (T-07-03 convention): never log config.username/config.password.
cd apps/api && npx vitest run src/inbox/imap.provider.spec.ts src/inbox/exchange-inbox.provider.spec.ts && npx tsc --noEmit -p tsconfig.json
- Both new spec files exist and pass; each asserts an InboxMessage with a populated body from a mocked provider response.
- `grep -n "fetchMessages" apps/api/src/inbox/inbox-provider.interface.ts apps/api/src/inbox/imap.provider.ts apps/api/src/inbox/exchange-inbox.provider.ts` shows the method on all three.
- fetchPdfAttachments is unmodified (`git diff` shows no edits inside its body) and DKV suite still green.
Both providers expose a tested fetchMessages returning subject + html/text body per unread message, idempotent via \Seen/IsRead, with fetchPdfAttachments untouched.
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Tessera API → IMAP/EWS mail server | decrypted mailbox credentials + network I/O cross here |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-14-01-01 | Information Disclosure | fetchMessages logging | high | mitigate | Reuse `logger: false` (imapflow) / generic-error (EWS) conventions; never log config.username/password (T-07-03) |
| T-14-01-02 | Denial of Service | IMAP body download | medium | mitigate | Reuse the existing MAX_ATTACHMENT_BYTES/streamToBuffer size ceiling for body-part downloads |
| T-14-01-03 | Tampering | DKV regression via refactor | high | mitigate | Pure move + import-path swap; fetchPdfAttachments body untouched; full DKV suite + tsc gate every task |
| T-14-01-SC | Tampering | npm/pip/cargo installs | low | accept | No new packages — imapflow/httpntlm already installed (RESEARCH Package Legitimacy Audit) |
- `cd apps/api && npx vitest run && npx tsc --noEmit -p tsconfig.json` — full API suite green (DKV regression) + new inbox specs pass.
- `grep -rn "dkv/providers" apps/api/src` — zero matches (move complete).
- inbox/ module exists and is imported by DKV; DKV behavior unchanged (suite green).
- fetchMessages tested on both providers; fetchPdfAttachments untouched.
- No new external dependency added.