# Phase 14: RSS, Email-Alert Ingestion & Module Rollout - Research **Researched:** 2026-07-23 **Domain:** NestJS backend ingestion adapters (RSS/XML, IMAP/EWS email), per-tenant encrypted config, Next.js i18n (next-intl) **Confidence:** MEDIUM (HIGH for code-pattern reuse, MEDIUM for live-feed shapes captured this session, LOW/flagged where a concrete decision is still open) ## User Constraints (from CONTEXT.md) ### Locked Decisions **Inbox-Modul-Extraktion (INGEST-05, Refactor)** - **D-01:** Additiver Refactor, DKV bleibt verhaltensgleich. Die bestehenden IMAP-/Exchange-Provider (`ImapProvider`, `ExchangeInboxProvider`) und das `InboxProvider`-Interface werden aus `apps/api/src/dkv/providers/` in ein geteiltes `apps/api/src/inbox/`-Modul gehoben. Geteilt wird ausschließlich die **Verbindungsmechanik** (IMAP/Exchange-Anmeldung, TLS, NTLM via httpntlm, Timeouts, Mail-Abruf). - **D-02:** Die bestehende PDF-Anhang-Methode (`fetchPdfAttachments`) bleibt unverändert erhalten. Für die Ausschreibungs-Ingestion wird eine **zusätzliche** Methode ergänzt, die den Mail-Body (HTML/Text) und ganze Nachrichten liefert. DKV wird NICHT auf die neue Methode umgestellt — nur der Import-Pfad wechselt auf `inbox/`. - **D-03:** Jedes Modul behält seine **eigene, unabhängige** Postfach-Konfiguration (eigene Adresse, eigene Credentials). Geteilt ist nur der Code, nicht die Config. **E-Mail-Alert-Parsing (INGEST-05)** - **D-04:** Generische Auswertung statt portalspezifischer Parser. Aus jeder Alert-Mail werden die enthaltenen Links (auf die Ausschreibungs-Detailseite) plus Betreff/erste Textzeile als Titel extrahiert. Feldarmut (kein CPV/Buyer/Frist) wird bewusst und offen dokumentiert. - **D-05:** Konsequenz für Cross-Source-Dedup: E-Mail-/RSS-Records erben die Fingerprint-Asymmetrie aus Phase 13 (residual_gap_decision = Option C, dormant). Kein neuer Dedup-Mechanismus in Phase 14. **Config-Modell + Admin-UI (CONFIG-02, CONFIG-03)** - **D-06:** Postfach-Konfiguration ist **pro Mandant** (tenantId-scoped), Muster wie `DkvModuleConfig`. Neues Prisma-Modell, kein Ausbau des globalen `TenderSourcePollConfig`-Singletons. - **D-07:** Zugangsdaten werden mit `CalendarCryptoService` (AES-256-GCM) verschlüsselt. Verschlüsselte Felder werden aus GET-Responses ausgeschlossen (Safe-Select-Muster, T-07-12). - **D-08:** RSS-Feeds sind öffentlich und für alle gleich → globale Plattform-Konfiguration (bestehendes Singleton-Muster), nicht pro Mandant. - **D-09:** Die bestehende Admin-Seite (`apps/web/.../tender-radar/settings/`) wird **erweitert**, nicht neu gebaut: ein Abschnitt „E-Mail-Alerts" (pro Mandant) und einer „RSS-Feeds" (global). Rollen-Trennung über bestehendes `@Roles(ADMIN, SUPER_ADMIN)`. **i18n (UI-06)** - **D-10:** Neuer `tenderRadar`-Namensraum in `de.json`/`en.json`; alle hartcodierten deutschen Strings der tender-radar-Komponenten in `useTranslations`-Keys überführt. - **D-11:** KEIN sichtbarer Sprachumschalter. Die Sprachwahl folgt dem bestehenden next-intl-Mechanismus. **Denylist-Transparenz (UI-06)** - **D-12:** vergabe24 und aumass werden im UI als „manuell beobachten" mit Direktlink dargestellt. `CoverageBanner.tsx` ist der natürliche Ort. Denylist-Quelle bleibt der code-level Gate `DENYLISTED_PORTALS` (`source-registry.ts:12`). ### Claude's Discretion - Wahl der RSS-Parsing-Bibliothek und das genaue Feld-Mapping der RSS-Items auf `NormalizedTenderFields`. - Genaue Struktur des neuen `inbox/`-Modul-Interfaces und der zusätzlichen Message-Methode. - Scheduler-Anbindung der neuen Quellen (RSS-Adapter, E-Mail-Adapter) über das bestehende `SourceRegistry` + `pollDueSources`-Muster. - EN-Übersetzungen erstellt Claude selbst (Fachbegriffe der Vergabe-Domäne konsistent halten). ### Deferred Ideas (OUT OF SCOPE) - **App-weiter Sprachumschalter** — eigene Plattform-Phase, nicht Teil von Phase 14. - **Portalspezifische E-Mail-Parser** — erst sinnvoll, wenn konkrete Alert-Portale/echte Mail-Samples bekannt sind. - **Cross-Source-Fingerprint für dünne Quellen** — erst bei Live-Aktivierung einer zweiten Quelle. ## Phase Requirements | ID | Description | Research Support | |----|-------------|------------------| | INGEST-04 | System importiert Ausschreibungen aus RSS-Feeds (subreport-elvis, service.bund.de) | Live feed shapes captured this session (service.bund.de + a subreport-elvis instance); `fast-xml-parser` (already installed) recommended over adding `rss-parser`; adapter design mirrors `NetServerAdapter`'s multi-portal-in-one-adapter pattern; RSS feed URLs recommended as an admin-CRUD list (new global model), not hardcoded — see "RSS Ingestion" below | | INGEST-05 | System liest Portal-Benachrichtigungs-E-Mails aus einem konfigurierten Postfach und extrahiert Ausschreibungen (nutzt bestehende DKV-Inbox-Infrastruktur) | Concrete file-move list, new `fetchMessages()` method design (IMAP via imapflow body-part download, EWS via `t:Body` FieldURI addition), generic link+subject extraction algorithm, per-tenant fan-out design that reuses the poll-once-fan-out-many pattern **inside** a single adapter (avoids touching `TenderIngestionService`) — see "Inbox Module Extraction" and "Email-Alert Parsing" below | | CONFIG-02 | Admin verwaltet Quellen-Poll-Konfiguration sowie das E-Mail-Ingestion-Postfach pro Mandant (Zugangsdaten verschlüsselt) | Exact Prisma model shape (mirrors `DkvModuleConfig`), Safe-Select pattern, `CalendarCryptoService` injection path, new `GET/PUT /modules/tender-radar/email-config` route pattern (mirrors `DkvController`) — see "Per-Tenant Config + Encryption" below | | CONFIG-03 | Modul-UI ist mehrsprachig (DE/EN) | Confirmed next-intl locale mechanism (cookie `NEXT_LOCALE`, default `de`, no code change needed for D-11); concrete list of 8 files/~90 string-sites requiring conversion; namespace shape and key-parity test recommendation — see "i18n" below | | UI-06 | Ausgeschlossene Portale werden als „manuell zu überwachen" mit Direktlink angezeigt | Confirmed real URLs (vergabe24.de, aumass.de); confirmed `DENYLISTED_PORTALS` has no API exposure today; recommended new small backend endpoint sourced from the same constant — see "Denylist UI Transparency" below | ## Summary Phase 14 is almost entirely **pattern replication**, not new architecture: every one of the five requirements has a directly analogous precedent already built and tested in this codebase (NetServer/cosinex adapters for scraping-into-bag ingestion; DKV for encrypted per-tenant mailbox config; the existing `dkvFleet` i18n namespace for the message-file shape). The main net-new engineering is (1) a `fetchMessages()` method on the inbox providers that doesn't exist yet, and (2) a per-tenant fan-out happening *inside* a single new adapter rather than at the `TenderSourcePollConfig`/`TenderIngestionService` level — because `Tender` and `TenderSourcePollConfig` are both structurally singleton/global (D-03 from Phase 10), and re-opening that architecture is explicitly out of scope. Two real, previously-undocumented technical findings surfaced during this research and materially affect planning: 1. **Day-cursor throttling would silently cap RSS/email polling to once per calendar day**, regardless of the admin-configured `pollIntervalMin`, if the new sources are plugged into `TenderIngestionService.pollDueSources()` unmodified — the day-cursor gate (`nextDayToFetch`) was built for DÖE's daily batch export and is reused as-is by NetServer/cosinex today. This needs an explicit decision (see Common Pitfalls #1). 2. **subreport-elvis has no single global feed URL** — each municipality/Vergabestelle runs its own feed instance (e.g. `subreport-elvis.de/elvis/secure/rss.pl?id=4615` for Stadt Neuss). D-08 calls RSS "global, same for all," which is true for service.bund.de but not for subreport-elvis. The admin-CRUD "RSS-Feeds" list already implied by D-09 resolves this cleanly (admin adds whichever feed URLs are relevant) — this is a design confirmation, not a re-litigation of D-08. **Primary recommendation:** Reuse `fast-xml-parser` (already a dependency, already configured with `removeNSPrefix`/CDATA-merge semantics in `doe-opendata.adapter.ts`) for RSS/Atom parsing instead of adding `rss-parser` as a new dependency. Build both new adapters (`RssAdapter`, `EmailAlertAdapter`) to route through the existing generic `normalizeBag()` normalizer path unchanged. Extract `ImapProvider`/`ExchangeInboxProvider`/`InboxProvider` into `apps/api/src/inbox/` with only import-path changes in `dkv.service.ts`/`dkv.module.ts`, and add one new interface method (`fetchMessages`) implemented in both providers without touching `fetchPdfAttachments`. ## Architectural Responsibility Map | Capability | Primary Tier | Secondary Tier | Rationale | |------------|-------------|----------------|-----------| | RSS feed fetch + XML parse | API / Backend | — | Server-side scheduled job (`TenderSourceAdapter`), no browser involvement, mirrors `DoeOpenDataAdapter`/`NetServerAdapter` | | RSS feed URL admin CRUD | API / Backend | Frontend Server (SSR form) | New global Prisma model + `@Roles(ADMIN,SUPER_ADMIN)` routes; frontend is a thin form, same tier split as existing `SourceConfigForm` | | Email mailbox connect + fetch | API / Backend | — | IMAP/EWS network I/O must happen server-side (credentials, TLS/NTLM); mirrors `ImapProvider`/`ExchangeInboxProvider` | | Email mailbox config CRUD (per tenant) | API / Backend | Frontend Server (SSR form) | Mirrors `DkvModuleConfig` + `DkvController`/`InboxConfigForm` split exactly | | Credential encryption | API / Backend | Database (encrypted-at-rest column) | `CalendarCryptoService`, never touches browser or CDN tier | | Denylist portal transparency | Frontend Server (SSR/client component) | API / Backend (data source) | Static-ish informational content; backend only needs to expose the denylist list, no new business logic | | i18n message resolution | Frontend Server (SSR, next-intl `getRequestConfig`) | Browser (client components read via `useTranslations`) | Locale already resolved server-side from a cookie; client components consume via the existing `NextIntlClientProvider` | | Cross-source dedup for new sources | API / Backend | Database (fingerprint column) | Explicitly **not** touched this phase (D-05) — inherits the dormant Phase-13 mechanism unchanged | ## Standard Stack ### Core | Library | Version (installed) | Purpose | Why Standard (for this codebase) | |---------|---------|---------|--------------| | `fast-xml-parser` | `^5.10.1` [VERIFIED: already in apps/api/package.json, used live in `doe-opendata.adapter.ts`] | Parse RSS 2.0 XML into JS objects | Zero new dependency; already configured for `removeNSPrefix`/attribute handling; RSS 2.0 is plain XML, no need for a dedicated RSS-specific parser | | `imapflow` | `^1.4.3` [VERIFIED: already in apps/api/package.json, used in `imap.provider.ts`] | IMAP protocol client | Already the codebase's IMAP client; the new `fetchMessages()` method is an additive use of the same client | | `httpntlm` | `^1.8.13` [VERIFIED: already in apps/api/package.json, used in `exchange-inbox.provider.ts`] | NTLM-authenticated HTTP for EWS | Already the codebase's EWS/NTLM transport; reused as-is for the new EWS body-fetch SOAP call | | `cheerio` | `^1.2.0` [VERIFIED: already in apps/api/package.json, used in `netserver.adapter.ts`] | HTML parsing (link extraction from HTML email bodies; optional RSS `` sub-parsing) | Already the codebase's HTML-parsing tool (chosen over `node-html-parser` in Phase 13 after a package-legitimacy false flag — see 13-04 decision log); reused for extracting `` links from HTML email bodies and, optionally, structured sub-fields from RSS `` HTML fragments | ### Supporting | Library | Version | Purpose | When to Use | |---------|---------|---------|-------------| | `class-validator` | (already a dep, used throughout DTOs) | Validate new `TenderEmailConfigDto`/`TenderRssFeedSourceDto` | Same `@IsIn`/`@IsInt`/`@Min`/`@Max`/`@IsUrl` pattern as `DkvConfigDto`/`SourceConfigDto` | ### Alternatives Considered | Instead of | Could Use | Tradeoff | |------------|-----------|----------| | `fast-xml-parser` (reuse) | `rss-parser` | `rss-parser` [OK verdict, see Package Legitimacy Audit] is purpose-built (handles more RSS/Atom edge cases, e.g. ``, out of the box) but adds a new dependency for a format (RSS 2.0) that is plain XML the codebase already parses successfully elsewhere. Only worth it if Atom-format feeds (not confirmed for either target source) turn out to need handling. | | Generic bag + regex link extraction (email) | A dedicated HTML-email-parsing library (e.g. `mailparser`'s `simpleParser` output, or `linkifyjs`) | Not needed: `cheerio` (already installed) extracts `` from HTML bodies directly; a plain regex covers the plaintext-body fallback. Adding a new library for this narrow, already-covered need is unjustified. | | Admin-CRUD list of RSS feed URLs (recommended) | Hardcoded feed URLs mirroring `NETSERVER_PORTALS` | Rejected because subreport-elvis has **no single URL** to hardcode (see Summary finding #2) — an admin-managed list is the only way to support "subreport-elvis" as a named source without picking one arbitrary municipality's feed at code-time. | **Installation:** ```bash # No new packages required — fast-xml-parser, imapflow, httpntlm, cheerio, # and class-validator are all already dependencies of apps/api. ``` **Version verification:** All four libraries above are already installed and in live production use elsewhere in this codebase (confirmed via `grep` on `apps/api/package.json` and direct file reads of `doe-opendata.adapter.ts`, `imap.provider.ts`, `exchange-inbox.provider.ts`, `netserver.adapter.ts`). No `npm view` was needed since no new package is proposed; `rss-parser` (the one alternative considered) was checked via the package-legitimacy tool for completeness — see Package Legitimacy Audit. ## Package Legitimacy Audit **No new external packages are proposed for this phase** — all required libraries (`fast-xml-parser`, `imapflow`, `httpntlm`, `cheerio`, `class-validator`) are already installed dependencies of `apps/api`, confirmed via direct inspection of `apps/api/package.json` and their live usage in existing adapters/providers. One alternative package was checked for completeness (considered and rejected — see Alternatives Considered): | Package | Registry | Age | Downloads | Source Repo | Verdict | Disposition | |---------|----------|-----|-----------|-------------|---------|-------------| | `rss-parser` | npm | published 2023-04-11 (~3 yrs) | ~825k/week | github.com/bobby-brennan/rss-parser | OK | Not adopted — `fast-xml-parser` (already installed) covers this phase's need with zero new dependency | **Packages removed due to [SLOP] verdict:** none **Packages flagged as suspicious [SUS]:** none ## Architecture Patterns ### System Architecture Diagram ``` ┌─────────────────────────────────────────────────────────────────────┐ │ TenderSchedulerService (cron) │ │ single global tick — no tenant dimension (unchanged) │ └───────────────────────────────┬───────────────────────────────────────┘ │ tick ▼ ┌─────────────────────────────────────────────────────────────────────┐ │ TenderIngestionService.pollDueSources() │ │ findMany(TenderSourcePollConfig where isActive) → fan-out per row │ └───┬─────────────┬─────────────┬─────────────┬─────────────┬─────────┘ │ doe-opendata│ ai-netserver│ cosinex-dtvp│ rss (NEW) │ email-alert (NEW) ▼ ▼ ▼ ▼ ▼ ┌────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌───────────────────┐ │DoeAdap-│ │NetServer │ │Cosinex │ │RssAdapter│ │EmailAlertAdapter │ │ter │ │Adapter │ │Adapter │ │(NEW) │ │(NEW) │ └────────┘ └──────────┘ └──────────┘ └────┬─────┘ └────────┬───────────┘ │ │ internal fan-out: │ fetch each │ findMany(TenderEmailConfig │ admin-added │ where isActive) — ONE tenant │ feed URL │ mailbox per row, catch-per-tenant ▼ ▼ ┌──────────────┐ ┌──────────────────────┐ │TenderRssFeed │ │inbox/ module │ │Source (NEW, │ │ ImapProvider / │ │global, admin │ │ ExchangeInboxProvider│ │CRUD list) │ │ .fetchMessages(cfg) │ └──────────────┘ │ → decrypt creds via │ │ CalendarCryptoService│ │ from TenderEmailConfig│ │ (per-tenant, NEW) │ └───────────┬───────────┘ │ subject + body ▼ ┌──────────────────────┐ │ generic link+subject │ │ extraction (cheerio/ │ │ regex) → RawTender- │ │ Record per link │ └───────────┬───────────┘ └──────────────────────────┬──────────────────────────────────────┘ ▼ TenderNormalizerService.normalize() (dispatches 'rss'/'email-alert' → normalizeBag(), UNCHANGED from Phase 13's NetServer/cosinex path) │ ▼ TenderDedupService.resolve() → Tender (global, D-03) │ ▼ TendersController (GET/PUT /modules/tender-radar/*) │ ▼ Next.js: ResultsList / TenderDetail / CoverageBanner (+ denylist block) all converted to useTranslations('tenderRadar') (D-10) ``` ### Recommended Project Structure ``` apps/api/src/ ├── inbox/ # NEW — extracted shared module (D-01/D-02/D-03) │ ├── inbox.module.ts # provides+exports ImapProvider, ExchangeInboxProvider │ ├── inbox.types.ts # InboxConfig/InboxAttachment/InboxEmail/InboxMessage (moved+extended) │ ├── inbox-provider.interface.ts # moved from dkv/providers/, +fetchMessages() │ ├── imap.provider.ts # moved from dkv/providers/, +fetchMessages() │ └── exchange-inbox.provider.ts # moved from dkv/providers/, +fetchMessages() ├── dkv/ │ ├── dkv.service.ts # import path only: '../inbox/...' instead of './providers/...' │ ├── dkv.module.ts # imports InboxModule instead of declaring the 2 providers directly │ ├── dkv.types.ts # re-exports InboxConfig/InboxAttachment/InboxEmail from '../inbox/inbox.types' │ └── providers/ # now empty of Inbox* files — directory removed if nothing else lives there └── tenders/ ├── adapters/ │ ├── rss.adapter.ts # NEW │ ├── rss.adapter.spec.ts # NEW (fixture-based, mirrors cosinex.adapter.spec.ts) │ ├── email-alert.adapter.ts # NEW │ └── email-alert.adapter.spec.ts # NEW (mocked InboxProvider) ├── __fixtures__/ │ ├── service-bund-feed.xml # NEW — live-captured fixture │ └── subreport-elvis-feed.xml # NEW — live-captured fixture (see Open Questions) ├── dto/ │ ├── tender-email-config.dto.ts # NEW │ └── tender-rss-feed.dto.ts # NEW ├── tender-email-config.service.ts # NEW — mirrors DkvService's config half └── tender.types.ts # SourceType extended: + 'rss' | 'email-alert' apps/web/src/app/(portal)/modules/tender-radar/ ├── settings/components/ │ ├── EmailAlertConfigForm.tsx # NEW — mirrors InboxConfigForm.tsx (per-tenant, password field) │ └── RssFeedListForm.tsx # NEW — admin CRUD list (add/remove feed URL + label) └── components/CoverageBanner.tsx # extended with a denylist block (UI-06) apps/web/src/messages/ ├── de.json # + "tenderRadar": { ... } namespace └── en.json # + "tenderRadar": { ... } namespace (EN translations) ``` ### Pattern 1: Multi-source adapter with internal fan-out (RSS + per-tenant email) **What:** A single `TenderSourceAdapter` implementation internally iterates over multiple concrete targets (multiple RSS feed URLs, or multiple tenants' mailboxes) rather than the fan-out happening at the `TenderSourcePollConfig`/`SourceRegistry` level. **When to use:** When the natural "one row per pollable thing" doesn't fit the existing singleton/global `TenderSourcePollConfig` (one row per `sourceType`) — exactly the situation for both RSS (multiple feed URLs under one `sourceType: 'rss'`) and email-alert (multiple tenant mailboxes under one `sourceType: 'email-alert'`). **Example (already proven in this codebase, `netserver.adapter.ts:75-94`):** ```typescript // Source: apps/api/src/tenders/adapters/netserver.adapter.ts (existing, Phase 13) async fetchTenders(_dayCursor: string): Promise { const records: RawTenderRecord[] = []; for (const portal of this.portals) { try { const html = await this.fetchPortalHtml(NETSERVER_PORTALS[portal].baseUrl); records.push(...this.parseSearchResults(html, portal, new Date())); } catch (error) { this.logger.warn(`NetServer portal '${portal}' fetch failed, skipping: ${(error as Error).message}`); // catch-per-target — one broken target never aborts the others (D-01 from Phase 13) } } return records; } ``` Recommended `EmailAlertAdapter.fetchTenders()` shape (new, mirrors the above exactly, substituting "portal" with "tenant mailbox config row"): ```typescript async fetchTenders(_dayCursor: string): Promise { const configs = await this.prisma.tenderEmailConfig.findMany({ where: { isActive: true } }); const records: RawTenderRecord[] = []; for (const cfg of configs) { try { const inboxConfig = await this.resolveDecryptedConfig(cfg); // CalendarCryptoService.decrypt const provider = cfg.protocol === 'exchange' ? this.exchangeProvider : this.imapProvider; const messages = await provider.fetchMessages(inboxConfig); records.push(...this.extractCandidates(messages, cfg.tenantId)); } catch (error) { this.logger.warn(`Email-alert mailbox for tenant ${cfg.tenantId} failed, skipping: ${(error as Error).message}`); // catch-per-tenant — one tenant's broken mailbox never blocks the others } } return records; } ``` This keeps `TenderIngestionService.pollDueSources()`, `TenderSourcePollConfig`, and `SourceRegistry` **completely untouched** except for two additive `registry.register()` calls and two additive `tenderSourcePollConfig.upsert()` seed calls in `tenders.module.ts` — exactly the same additive pattern Plans 13-04/13-05 already used for NetServer/cosinex. ### Pattern 2: Additive inbox-provider method (D-02) **What:** Add `fetchMessages(config): Promise` to `InboxProvider` alongside the existing `fetchPdfAttachments`, without modifying it. **IMAP implementation sketch** (extends the existing `imap.provider.ts` MIME-tree-walking pattern used by `collectPdfParts`): ```typescript // New sibling to collectPdfParts() in imap.provider.ts / inbox/imap.provider.ts function findBodyParts(node: MessageStructureObject | undefined): { htmlPart?: string; textPart?: string } { // Walk node.childNodes exactly like collectPdfParts, but match // type === 'text/html' → htmlPart, type === 'text/plain' → textPart (first match wins for each). } async fetchMessages(config: InboxConfig): Promise { // Same connect/lock/search/fetchAll(envelope+bodyStructure) skeleton as fetchPdfAttachments, // but instead of collectPdfParts + client.download() for PDF parts, use findBodyParts + // client.download() for the html/text part IDs, then streamToBuffer(...).toString('utf8'). // Mark \Seen after processing (same as fetchPdfAttachments) — this IS the idempotency // mechanism for re-polls (D-04's "re-polling the mailbox doesn't create duplicates"). } ``` **EWS implementation sketch** (extends `exchange-inbox.provider.ts`'s SOAP builders): ```typescript // getItemSoap() needs one more AdditionalProperties FieldURI: // // The GetItem response then contains ...escaped HTML... // or BodyType="Text". Extract via: const bodyType = extractAttr(block, 't:Body', 'BodyType'); // 'HTML' | 'Text' const bodyRaw = extractAll(block, 't:Body')[0] ?? ''; // EWS SOAP-escapes the body content (already handled by the existing escapeXml/extractAll // pair used elsewhere in this file) — verify against a captured fixture, not live, per // Validation Architecture below (EWS/NTLM is a documented fragile path, see Pitfall 2). ``` ### Pattern 3: RSS item → generic bag mapping (reuses Phase 13's `normalizeBag()` unchanged) Given the live-captured shapes (see "RSS Ingestion" findings below), map each `` to the SAME flat bag shape `NetServerAdapter`/`CosinexAdapter` already produce — no normalizer changes needed: ```typescript // Source: pattern mirrors apps/api/src/tenders/adapters/netserver.adapter.ts's ocdsPayload bag records.push({ sourceType: 'rss', sourcePortal: feedLabel, // admin-provided label or hostname-derived slug, e.g. 'service-bund' sourceNoticeId: guid || sha256(link).slice(0, 40), sourceUrl: link, fetchedAt: new Date(), publishedAt: pubDate ? new Date(pubDate) : null, eformsPayload: null, ocdsPayload: { title, // item.title, CDATA-merged automatically by fast-xml-parser buyerName: null, // OPTIONAL enhancement: regex/cheerio-extract from procedureType: null, legalFramework: null, deadlineAt: null, // OPTIONAL enhancement: regex-extract "Angebotsfrist: ..." from }, }); ``` **Optional enhancement (higher data quality, Claude's discretion):** service.bund.de's `` is a CDATA-wrapped HTML fragment with labeled fields (`Erfüllungsort: PLZ Ort`, `Vergabestelle: Name`, `Angebotsfrist: DD.MM.YYYY HH:MM` — confirmed live, see below). Loading this fragment with `cheerio.load()` and matching label text via `:contains()` can populate `buyerName`/`deadlineAt`/`region` beyond the bare-minimum bag — the same technique `NetServerAdapter` uses for its HTML table. This is worth doing for service.bund.de specifically (structure confirmed); do **not** assume the same label set for subreport-elvis without a live fixture capture first (its `` was observed to be an HTML ``, structure not fully characterized this session — see Open Questions). ### Anti-Patterns to Avoid - **Extending `TenderSourcePollConfig` with a `tenantId` column:** Would break the "singleton, RLS-exempt, global" invariant that `TenderIngestionService`/`TenderSchedulerService` depend on throughout (D-03 from Phase 10). Use the internal-fan-out pattern above instead. - **Wrapping the new per-tenant queries in `forTenant()`/RLS:** `TenderEmailConfig` rows ARE per-tenant data, but the `EmailAlertAdapter`'s poll-time read needs to see **all** tenants' active configs in one query (`findMany` across all tenants) — this is a deliberate, audited cross-tenant read at the platform-scheduler level, analogous to how `TenderIngestionService` itself never uses `forTenant()`. Document this exception clearly in the adapter's docstring (mirroring the existing docstring convention) so it isn't "fixed" into an RLS-wrapped query later. - **Portal-specific email parsers:** explicitly deferred (D-04, Deferred Ideas) — do not add sender-domain-based branching logic even if it seems easy for one obvious case. ## Don't Hand-Roll | Problem | Don't Build | Use Instead | Why | |---------|-------------|-------------|-----| | RSS/XML parsing | A custom regex-based RSS tag extractor | `fast-xml-parser` (already installed, already configured) | Already handles CDATA-merge, attributes, namespace stripping — a hand-rolled regex parser would re-solve a problem this dependency already solves correctly elsewhere in the codebase | | Credential encryption | A new AES implementation or a different crypto library | `CalendarCryptoService` (D-07, already exported by `CalendarModule`) | Exact same AES-256-GCM/`iv:authTag:ciphertext` format already used by DKV and SMTP; introducing a second crypto scheme would fragment key management and audit surface | | HTML link extraction from email bodies | A regex-only HTML tag stripper | `cheerio` (already installed) | Regex-based HTML parsing is a well-known correctness trap (nested tags, malformed markup, HTML entities); cheerio already handles this correctly for NetServer's table parsing | | NTLM/EWS SOAP construction | A new EWS client library | The existing hand-rolled SOAP builders in `exchange-inbox.provider.ts` | Extending the existing, already-battle-tested (if fragile) SOAP-string builders is lower-risk than introducing a second EWS abstraction layer for one new field | **Key insight:** every "don't hand-roll" item in this phase already has a proven, in-repo solution from a previous phase — the engineering discipline this phase requires is **restraint** (reuse the existing tool) more than net-new tool selection. ## Runtime State Inventory > Scoped to the INGEST-05 inbox-module-extraction refactor only (the rest of this phase is greenfield). | Category | Items Found | Action Required | |----------|-------------|------------------| | Stored data | None — no database rows reference the file path `dkv/providers/` or the class names by string; Prisma models (`DkvModuleConfig`) reference no file paths | None | | Live service config | None — IMAP/EWS server configuration lives entirely in `DkvModuleConfig` rows (host/port/credentials), not tied to which TS file implements the connection | None | | OS-registered state | None — no OS-level task/service registration references these file paths | None | | Secrets/env vars | None — `CALENDAR_ENCRYPTION_KEY` and DKV mailbox credentials are addressed by `tenantId`, not by import path | None | | Build artifacts | `apps/api/dist/dkv/providers/*.js` will simply stop being emitted once the source files move; nothing external references the old `dist/` path (DKV's `__dirname`-relative `user-files/` path resolution in `dkv.service.ts` is unrelated to the providers/ subfolder) | Standard rebuild after the move — no migration script needed | **Confirmed via:** direct reads of `dkv.service.ts`, `dkv.module.ts`, `dkv.types.ts`, and `inbox-provider.interface.ts` — the only two files that need an import-path edit are `dkv.service.ts` (2 import lines) and `dkv.module.ts` (2 import lines + swap direct-provider-declaration for `InboxModule` import). ## Common Pitfalls ### Pitfall 1: Day-cursor gate silently throttles RSS/email to once per calendar day **What goes wrong:** `TenderIngestionService.pollDueSources()` computes `nextDayToFetch(config.lastIngestedDay)` per source row and only calls `adapter.fetchTenders(cursorDay)` when the result is a day strictly before "today" — then advances `lastIngestedDay` and stops. This means each source gets **at most one fetch per calendar day**, no matter how frequently the cron tick fires or what `pollIntervalMin` is configured to. This is correct for DÖE (a genuine daily batch export) and is currently *also* silently applied to `ai-netserver`/`cosinex-dtvp` (dormant, unconfirmed live) — but for RSS and especially email-alerts, this defeats the purpose of a configurable poll interval: an admin setting "check every 30 minutes" would still only see new items once every 24 hours. **Why it happens:** The day-cursor gate and the cron-tick interval are two independent mechanisms (documented as "Pitfall A" in `tender-scheduler.service.ts`) — the existing code was written exclusively for a day-batch-export source and adapters since have inherited the same gate without re-examining the fit. **How to avoid:** Add an explicit flag distinguishing day-batch sources from tick-driven sources, e.g. a `pollGranularity: String @default("day")` column on `TenderSourcePollConfig` (`'day' | 'tick'`), seeded `'tick'` for `rss` and `'email-alert'`. In `pollDueSources()`, branch: `'day'` sources keep the existing `nextDayToFetch` gate unchanged (zero regression risk for `doe-opendata`/`ai-netserver`/`cosinex-dtvp`); `'tick'` sources call `adapter.fetchTenders(berlinToday)` unconditionally on every tick where `isActive`, relying purely on the cron interval (already bounded to a minimum by `SourceConfigDto`'s validation, and "sub-hourly polling" is already out of scope per REQUIREMENTS.md) for pacing, and on IMAP `\Seen`/EWS `IsRead` marking (email) or feed-item GUID dedup (RSS, via `dedupKey` upsert — already idempotent) for not reprocessing. **Warning signs:** A newly-activated RSS/email source shows zero new tenders for up to 24h after activation even though the feed/mailbox clearly has new items; `lastIngestedDay` advancing by exactly one calendar day per admin-configured interval regardless of the interval's actual value. ### Pitfall 2: subreport-elvis has no single canonical feed URL **What goes wrong:** Treating "subreport-elvis" as one hardcoded portal (like `NETSERVER_PORTALS`) is structurally impossible — the platform issues one feed per contracting authority/municipality (confirmed live: Stadt Neuss's feed is `https://www.subreport-elvis.de/elvis/secure/rss.pl?id=4615`; other municipalities have different numeric `id` values on the same base path). Hardcoding one municipality's feed would silently scope "subreport-elvis ingestion" to that one city only, contradicting the intent of a platform-wide long-tail source. **Why it happens:** The phase description names "subreport-elvis" as if it were a single source, matching the `service.bund.de` pattern (which genuinely IS one national feed) — but subreport-elvis's product model is per-customer feed subscriptions. **How to avoid:** Build the admin-managed list (`TenderRssFeedSource`, already implied by D-09's "RSS-Feeds" admin section) so an admin can add whichever concrete subreport-elvis feed URL(s) are relevant, rather than baking one into code. Seed the DB with zero subreport-elvis rows by default (there is no universally-correct one); optionally seed the confirmed service.bund.de national feed as a sane global default since that one genuinely is Germany-wide. **Warning signs:** A hardcoded `SUBREPORT_ELVIS_URL` constant with only one municipality's feed id. ### Pitfall 3: Runtime-added RSS feed URLs bypass the INGEST-07 code-level denylist gate **What goes wrong:** `SourceRegistry.register()` checks `DENYLISTED_PORTALS` against an adapter's **statically declared** `portals: readonly string[]` array at DI-boot time. An admin-CRUD RSS feed URL is added at **runtime** (via the new `TenderRssFeedSource` model), long after boot — the `RssAdapter`'s `portals` array can only contain a symbolic placeholder (e.g. `['rss']`), not the actual runtime feed hostnames. This means the code-level denylist gate provides **zero protection** against an admin accidentally adding an RSS feed URL that points at vergabe24.de/aumass.de. **Why it happens:** INGEST-07's denylist gate was designed for adapter-registration-time enforcement (a developer registering a whole new scraping adapter), not for runtime, per-row admin input — RSS is the first source type in this codebase where the "portal" is admin-supplied data rather than code. **How to avoid:** Add a parallel, save-time hostname validation in the `TenderRssFeedSource` create/update service (reject any URL whose hostname contains `vergabe24` or `aumass`, mirroring the spirit of `DENYLISTED_PORTALS` even though it's enforced in a different code path). Document in the DTO/service that this is a **second, independent** enforcement point, not a bypass of the existing one. **Warning signs:** A `TenderRssFeedSource` row pointing at a vergabe24/aumass hostname existing in the DB without any validation error at save time. ### Pitfall 4: EWS body-fetch is a new, untested addition to a documented-fragile file **What goes wrong:** `exchange-inbox.provider.ts` implements NTLM/EWS SOAP by hand (no EWS client library) and is called out in project memory as fragile (`project_calendar_ews_fix`: "Exchange EWS nutzt NTLM via httpntlm... nie ews-javascript-api"). Adding a new `fetchMessages()` method here — even though additive and not touching `fetchPdfAttachments` — introduces new hand-written SOAP/XML-extraction code (`t:Body`/`BodyType` handling) with **zero existing test coverage** to fall back on (there are currently no `.spec.ts` files for either `ImapProvider` or `ExchangeInboxProvider` in this codebase at all). **Why it happens:** DKV's EWS path has historically been validated live against a real Exchange server rather than via unit tests (confirmed: `find apps/api/src/dkv -iname "*.spec.ts"` returns nothing). **How to avoid:** Do not skip unit tests for the new method just because the existing methods also lack them — this phase is the right moment to add fixture/mock-based coverage for `fetchMessages()` on both providers (mock `ImapFlow`/`httpntlm.post`), even if `fetchPdfAttachments` remains untested (out of scope to backfill). Flag the EWS body-fetch specifically for a `checkpoint:human-verify` against a real Exchange mailbox before considering INGEST-05's Exchange path production-ready — this mirrors Phase 13's human-verification-required pattern for anything requiring live external access. **Warning signs:** `fetchMessages()` on the Exchange path passing all mocked unit tests but returning empty/garbled bodies against a real mailbox (EWS body escaping/whitespace handling is a common source of subtle bugs). ### Pitfall 5: Email-derived Tender rows become visible to every tenant, not just the one whose mailbox produced them **What goes wrong:** `Tender` has deliberately no `tenantId` column (D-03, Phase 10: "Tender ist plattform-global"). Every prior source (DÖE, NetServer, cosinex) is a public portal anyway, so global visibility was never in tension with the data's origin. An email-alert record, by contrast, is discovered via **one specific tenant's private mailbox subscription** — under the current architecture it will still be inserted as a global `Tender` row, visible to every other tenant that has the tender-radar module activated, even though they never configured that mailbox. **Why it happens:** This is an intentional consequence of D-03 (locked in Phase 10) combined with D-06 (email config is per-tenant) — CONTEXT.md for this phase does not revisit or discuss this interaction explicitly. **How to avoid:** This is **not** a code bug to fix silently — it is a product-policy question that should be explicitly confirmed with the user before/while planning INGEST-05, since it means one tenant's paid/subscribed alert channel effectively becomes a shared benefit for all tenants on the platform. Surface this plainly to the planner/user (see Open Questions #1) rather than assuming either "this is fine" or "this needs per-tenant scoping" — both are defensible depending on product intent, but only one matches what CONTEXT.md's silence implies. **Warning signs:** None at the code level (it will work exactly as designed) — the risk is entirely in unstated product expectations surfacing after Tenant B starts seeing tenders Tenant A's mailbox produced. ## Code Examples ### RSS parsing with the existing `fast-xml-parser` configuration ```typescript // Source: mirrors apps/api/src/tenders/adapters/doe-opendata.adapter.ts's XMLParser config import { XMLParser } from 'fast-xml-parser'; const xmlParser = new XMLParser({ removeNSPrefix: true, ignoreAttributes: false, attributeNamePrefix: '@_', // cdataPropName defaults to `false`, which means CDATA content is merged // directly into the tag's text value — confirmed in fast-xml-parser v5.10.1 // source (OptionsBuilder.js `cdataPropName: false`), no extra config needed // for RSS <![CDATA[...]]> to parse straight to a string. }); const parsed = xmlParser.parse(rssXmlText) as { rss?: { channel?: { item?: unknown | unknown[] } }; }; const channel = parsed.rss?.channel; const items = Array.isArray(channel?.item) ? channel.item : channel?.item ? [channel.item] : []; ``` ### Live-captured RSS shapes (this session, both fetched directly against the real endpoints) **service.bund.de** — `https://www.service.bund.de/Content/Globals/Functions/RSSFeed/RSSGenerator_Ausschreibungen.xml` [CITED: live fetch, 2026-07-23]: ```xml service.bund.de - öffentliche Ausschreibungen http://www.service.bund.de/ausschreibungen RSS-Feed mit aktuellen Ausschreibungen der Vergabestellen... de-de Thu, 23 Jul 2026 11:27:44 +0200 60 Vhv 120_26 UFZ - Bench-scale Anaerobic Bioreactor System https://www.service.bund.de/IMPORTE/Ausschreibungen/editor/Helmholtz-Zentrum-fuer-Umweltforschung-GmbH/2026/07/6585334.html#track=feed-callforbids 04318 Leipzig
Vergabestelle: Helmholtz-Zentrum für Umweltforschung GmbH - UFZ
Angebotsfrist: 07.08.2026 10:00...]]>
Thu, 23 Jul 2026 11:00:00 +0200 https://www.service.bund.de/IMPORTE/Ausschreibungen/editor/Helmholtz-Zentrum-fuer-Umweltforschung-GmbH/2026/07/6585334.html
``` Field mapping: `title`→title, `link`/`guid`→sourceUrl/sourceNoticeId, `pubDate`→publishedAt, `description` CDATA contains labeled HTML fields (`Erfüllungsort`, `Vergabestelle`, `Angebotsfrist`) extractable via cheerio for the optional enhancement (see Pattern 3). **subreport-elvis** (one live municipal instance, Stadt Neuss) — `https://www.subreport-elvis.de/elvis/secure/rss.pl?id=4615` [CITED: live fetch, 2026-07-23]: ```xml <![CDATA[subreport ELViS]]> https://www.subreport-elvis.de/ <![CDATA[E73433797:Objektbezogene Schadensanalysen...]]> ...
]]>
https://www.subreport-elvis.de/browseVerdingungsunterlagen.html#ELVISID:E73433797 E73433797-558464
``` **Notable difference from service.bund.de:** no `pubDate` on items (⇒ `publishedAt: null`, already nullable-tolerant everywhere downstream); `` is an HTML `` whose internal field structure was **not** fully characterized this session (see Open Questions #2) — do not assume the same label-based extraction as service.bund.de without a fixture capture first. ### Generic link extraction from an alert email body (D-04) ```typescript import * as cheerio from 'cheerio'; import { createHash } from 'crypto'; const FOOTER_NOISE = ['unsubscribe', 'abmelden', 'einstellungen', 'preferences']; const MAX_LINKS_PER_EMAIL = 5; // safety valve against link-spam producing excess records function extractCandidateLinks(bodyHtml: string | null, bodyText: string): string[] { let links: string[]; if (bodyHtml) { const $ = cheerio.load(bodyHtml); links = $('a[href]') .map((_, el) => $(el).attr('href')) .get() .filter((href): href is string => typeof href === 'string' && /^https?:\/\//i.test(href)); } else { links = Array.from(bodyText.matchAll(/https?:\/\/[^\s"'<>]+/g)).map((m) => m[0]); } return Array.from(new Set(links)) .filter((url) => !FOOTER_NOISE.some((noise) => url.toLowerCase().includes(noise))) .slice(0, MAX_LINKS_PER_EMAIL); } function titleFromEmail(subject: string, bodyText: string): string { if (subject.trim()) return subject.trim(); const firstLine = bodyText.split('\n').map((l) => l.trim()).find(Boolean); return firstLine || 'Unbenannte Ausschreibung'; // matches normalizeBag()'s existing fallback } function sourceNoticeIdFor(link: string): string { return createHash('sha256').update(link).digest('hex').slice(0, 40); } ``` ### Per-tenant config service (mirrors `DkvService`'s config half + `SettingsService`'s Safe-Select) ```typescript // Source: pattern mirrors apps/api/src/dkv/dkv.service.ts §21-41, §95-125 const EMAIL_CONFIG_SAFE_SELECT = { id: true, tenantId: true, protocol: true, host: true, port: true, encryption: true, folder: true, senderFilter: true, domain: true, isActive: true, createdAt: true, updatedAt: true, // encryptedInboxCreds: NEVER included — T-07-12 pattern } as const; async function getConfigForApi(prisma: PrismaService, crypto: CalendarCryptoService, tenantId: string) { const safe = await prisma.tenderEmailConfig.findUnique({ where: { tenantId }, select: EMAIL_CONFIG_SAFE_SELECT }); if (!safe) return null; let username: string | null = null; let hasPassword = false; try { const raw = await prisma.tenderEmailConfig.findUnique({ where: { tenantId } }); if (raw?.encryptedInboxCreds) { const creds = JSON.parse(crypto.decrypt(raw.encryptedInboxCreds)) as { username?: string; password?: string }; username = creds.username ?? null; hasPassword = Boolean(creds.password); } } catch { /* ignore decrypt errors — return empty username, matches DkvService.getConfigForApi */ } return { ...safe, username, hasPassword }; } ``` ## State of the Art | Old Approach | Current Approach | When Changed | Impact | |--------------|------------------|---------------|--------| | ews-javascript-api (Basic Auth only) | Hand-rolled NTLM/EWS SOAP via `httpntlm` | Phase 7 (DKV build) | The new `fetchMessages()` EWS path must extend the hand-rolled SOAP builders, not reach for a library — consistent with `project_calendar_ews_fix` memory | | `node-html-parser` (initially considered for NetServer HTML) | `cheerio` | Phase 13, Plan 13-04 (package-legitimacy false-positive on `node-html-parser`'s age check, human-approved switch to `cheerio`) | Confirms `cheerio` is the codebase's settled HTML-parsing choice — use it for email-body link extraction and optional RSS description parsing, don't reconsider `node-html-parser` | | Hardcoded tender-radar UI strings ("MVP-stub convention," explicitly noted in `SourceConfigForm.tsx`/`settings/page.tsx` comments) | `useTranslations`-driven i18n | Phase 14 (this phase, CONFIG-03) | This phase is the point where the previously-accepted stub convention is retired for the tender-radar module specifically | **Deprecated/outdated:** none beyond the above — this is a young, actively-developed module with no legacy cruft to remove. ## Assumptions Log | # | Claim | Section | Risk if Wrong | |---|-------|---------|---------------| | A1 | subreport-elvis's `` HTML `
` structure was not fully captured this session (only a summarized excerpt was obtained via WebFetch, not the complete raw table markup) | RSS Ingestion / Code Examples | If the planner assumes a specific field-label structure without a fresh live fixture capture, the optional enhancement (buyerName/deadlineAt extraction) could silently extract wrong/garbled data for subreport-elvis specifically. The bare-minimum bag mapping (title/link/guid only) is NOT at risk — it only needs ``/`<link>`/`<guid>`, all confirmed. | | A2 | The exact EWS `<t:Body>`/`BodyType` XML shape returned by a real Exchange server for the new `fetchMessages()` GetItem call was not verified live this session (based on Microsoft EWS schema documentation / training knowledge, not a live capture against this codebase's actual target mailbox) | Architecture Patterns / Pattern 2 | If the real server's response shape differs from the assumed `<t:Body BodyType="HTML">...</t:Body>` structure (e.g. additional wrapping elements, different escaping), the EWS body-fetch will silently return empty/garbled content rather than throwing — exactly the failure mode flagged in Pitfall 4. Requires a `checkpoint:human-verify` against a real mailbox. | | A3 | Whether "email-derived Tender rows visible platform-wide" (Pitfall 5) is the intended product behavior or an unstated gap in this phase's scoping — not discussed in CONTEXT.md | Common Pitfalls / Pitfall 5 | If wrongly assumed acceptable, one tenant's private mailbox subscription becomes free platform-wide data for every other tenant — a potential customer-trust/contract issue for a soon-to-be-sold multi-tenant product. If wrongly assumed to need per-tenant scoping, unnecessary architecture work (breaking D-03) may be attempted. | **If this table is empty:** N/A — see rows above; all three should be confirmed with the user or resolved via a `checkpoint:human-verify`/explicit discuss-phase note before the planner locks task-level detail for the affected areas. ## Open Questions (RESOLVED) 1. **Email-alert Tender visibility across tenants (see Pitfall 5 / A3)** — **RESOLVED via CONTEXT.md D-13 (User-Entscheidung 2026-07-23):** E-Mail-Tender sind nur für den konfigurierenden Mandanten sichtbar (nullable `Tender.ownerTenantId`), öffentliche Quellen inkl. RSS bleiben global. Umgesetzt in Plan 14-03. - What we know: `Tender` is architecturally platform-global (D-03); `TenderEmailConfig` is architecturally per-tenant (D-06). CONTEXT.md doesn't reconcile the two for this specific case. - What's unclear: Whether this is accepted product behavior (matches the existing "all discovered tenders are shared" philosophy) or an oversight. - Recommendation: Surface explicitly to the user in `/gsd-discuss-phase` follow-up or as a plan-time `checkpoint:human-verify` before implementing INGEST-05's dedup/storage step — this is a five-minute confirmation that prevents a much larger later rework if the answer is "no, that's not okay." 2. **subreport-elvis `<description>` field structure (see A1)** — **RESOLVED via Plan 14-02 Task 1:** Baseline schickt nur die bare-minimum Bag (title/link/guid), reicht für INGEST-04; optionale Feld-Anreicherung aus der HTML-Tabelle ist nicht Teil des Baselines. Live-Fixture-Capture bei Bedarf zur Ausführungszeit. - What we know: It's an HTML `<table>`; the outer feed/item shape (title/link/guid/author) is fully confirmed live. - What's unclear: The internal table structure (column labels, whether "Angebotsfrist"-equivalent data exists at all for subreport-elvis, unlike service.bund.de where it's confirmed present in the description). - Recommendation: Capture a fresh, complete raw XML fixture from a live subreport-elvis feed at Wave-0/plan-time (mirroring Phase 13's live-fixture-capture convention for NetServer/cosinex HTML) before deciding whether to attempt the optional field-enrichment for this source, or to ship it with the bare-minimum bag only (title/link/guid — already fully sufficient for INGEST-04's baseline requirement). 3. **Default seeded RSS feeds** - **RESOLVED via Plan 14-02 Task 2:** service.bund.de wird active-by-default geseedet, die Feed-Liste sonst leer für admin-getriebene Ergänzungen (matcht die „framework ready, activation deferred"-Haltung). - What we know: service.bund.de's feed is genuinely national/global and safe to seed by default; subreport-elvis has no such single URL. - What's unclear: Whether to seed zero rows and require an admin action before any RSS ingestion happens, or seed the service.bund.de URL as an always-on default. - Recommendation: Seed service.bund.de active-by-default (mirrors DÖE's `isActive: true` seed from Phase 10), leave the RSS feed list otherwise empty for admin-driven additions — matches the "framework ready, activation deferred" stance already established for ai-netserver/cosinex-dtvp in Phase 13. ## Environment Availability | Dependency | Required By | Available | Version | Fallback | |------------|------------|-----------|---------|----------| | `fast-xml-parser` | RSS parsing (INGEST-04) | ✓ | ^5.10.1 (installed) | — | | `imapflow` | Email fetchMessages (INGEST-05) | ✓ | ^1.4.3 (installed) | — | | `httpntlm` | EWS fetchMessages (INGEST-05) | ✓ | ^1.8.13 (installed) | — | | `cheerio` | Link extraction / optional RSS enrichment | ✓ | ^1.2.0 (installed) | — | | Live subreport-elvis test feed | RSS adapter fixture capture (Wave 0) | ✓ (public, one instance confirmed reachable) | — | If a specific tenant's real feed is unavailable at plan/build time, use the Stadt Neuss instance captured this session as a placeholder fixture, clearly marked as a third-party example, not a Tessera-owned endpoint | | Live Exchange/EWS mailbox for `fetchMessages()` verification | Pitfall 4 human-verify | ✗ (not available in this research session — no live mailbox credentials) | — | Build against mocked SOAP responses (fixture-based unit tests); require a `checkpoint:human-verify` against a real mailbox before considering the Exchange email-alert path production-ready | **Missing dependencies with no fallback:** none blocking — the one genuinely missing environment piece (a live Exchange mailbox to verify EWS body-fetch against) has an accepted fallback (mock-based tests + human-verify checkpoint). **Missing dependencies with fallback:** live Exchange/EWS mailbox (see above). ## Validation Architecture ### Test Framework | Property | Value | |----------|-------| | Framework | Vitest (apps/api: node environment; apps/web: jsdom environment) | | Config file | `apps/api/vitest.config.ts` (`include: ['src/**/*.spec.ts']`), `apps/web/vitest.config.ts` | | Quick run command | `cd apps/api && npx vitest run src/tenders/adapters/rss.adapter.spec.ts` (swap path per file) | | Full suite command | `cd apps/api && npx vitest run` / `cd apps/web && npx vitest run` | ### Phase Requirements → Test Map | Req ID | Behavior | Test Type | Automated Command | File Exists? | |--------|----------|-----------|-------------------|-------------| | INGEST-04 | RSS feed fetch+parse maps real feed XML to `RawTenderRecord[]` correctly (both confirmed shapes: service.bund.de with pubDate, subreport-elvis without) | unit (fixture-based, mirrors `cosinex.adapter.spec.ts`) | `npx vitest run src/tenders/adapters/rss.adapter.spec.ts` | ❌ Wave 0 — new file, fixtures need live capture (see Environment Availability) | | INGEST-04 | Extended normalizer dispatch for `'rss'` sourceType (should reuse existing `normalizeBag()` — extend the `switch` in `tender-normalizer.service.ts` and its spec) | unit | `npx vitest run src/tenders/tender-normalizer.service.spec.ts` | ✅ file exists, needs 1-2 new test cases added | | INGEST-05 | `fetchMessages()` on `ImapProvider` extracts html/text body from a mocked `ImapFlow` MIME tree | unit (mocked, no existing coverage to extend — net new) | `npx vitest run src/inbox/imap.provider.spec.ts` | ❌ Wave 0 — **no `.spec.ts` exists for either inbox provider today** (confirmed via directory search) | | INGEST-05 | `fetchMessages()` on `ExchangeInboxProvider` extracts body from a mocked `httpntlm.post` SOAP response | unit (mocked) | `npx vitest run src/inbox/exchange-inbox.provider.spec.ts` | ❌ Wave 0 — new file | | INGEST-05 | Generic link+subject extraction (`extractCandidateLinks`/`titleFromEmail`) — pure functions, HTML and plaintext fallback both covered | unit (pure function, no I/O) | `npx vitest run src/tenders/adapters/email-alert.adapter.spec.ts` | ❌ Wave 0 — new file | | INGEST-05 | Per-tenant fan-out: one broken tenant mailbox doesn't block others (catch-per-tenant) | unit (mocked `prisma.tenderEmailConfig.findMany` + mocked provider) | `npx vitest run src/tenders/adapters/email-alert.adapter.spec.ts` | ❌ same file as above | | INGEST-05 | DKV regression: moving `Imap`/`ExchangeInboxProvider` into `inbox/` does not change DKV's existing `fetchPdfAttachments` behavior | manual/full-suite | `cd apps/api && npx vitest run` (full suite green) + `npx tsc --noEmit` | ✅ existing DKV spec files (0 today) mean this is really "no regression in the full suite," not a dedicated regression test — see Wave 0 Gaps | | CONFIG-02 | Safe-Select excludes `encryptedInboxCreds`; encrypt/decrypt round-trips correctly | unit | `npx vitest run src/tenders/tender-email-config.service.spec.ts` | ❌ Wave 0 — new file, mirrors absent-but-should-exist `dkv.service.spec.ts` pattern | | CONFIG-02 | `GET/PUT /modules/tender-radar/email-config` and `/rss-feeds` are `@Roles(ADMIN,SUPER_ADMIN)`-guarded | integration (controller test) | `npx vitest run src/tenders/tenders.controller.spec.ts` | ✅ file exists, needs new route test cases added | | CONFIG-02 | Runtime host-based denylist check rejects a vergabe24/aumass RSS feed URL at save time (Pitfall 3) | unit | `npx vitest run src/tenders/tender-rss-feed.service.spec.ts` | ❌ Wave 0 — new file | | CONFIG-03 | `de.json`/`en.json` have identical key sets under the new `tenderRadar` namespace (no missing EN/DE translation) | unit (web side, pure JSON structural comparison) | `cd apps/web && npx vitest run src/messages/tenderRadar-parity.spec.ts` | ❌ Wave 0 — no existing i18n key-parity test in the codebase at all; recommend adding one (cheap, catches the exact class of bug fixed by quick task 260701-abc) | | UI-06 | `CoverageBanner` renders the denylist "manually monitor" block with correct vergabe24/aumass links | component test | `cd apps/web && npx vitest run src/app/\(portal\)/modules/tender-radar/components/CoverageBanner.test.tsx` | ❌ Wave 0 — no test file exists for `CoverageBanner` today | | UI-06 | New denylist read endpoint returns the two portals with correct URLs | integration | `npx vitest run src/tenders/tenders.controller.spec.ts` | ✅ extend existing file | ### Sampling Rate - **Per task commit:** run the single most relevant spec file from the table above (`npx vitest run <file>`) - **Per wave merge:** `cd apps/api && npx vitest run && npx tsc --noEmit -p tsconfig.json`, then `cd apps/web && npx vitest run` - **Phase gate:** both full suites green + `tsc --noEmit` clean before `/gsd-verify-work`, matching Phase 13's verification convention ### Wave 0 Gaps - [ ] `apps/api/src/tenders/__fixtures__/service-bund-feed.xml` — live-captured, full (not summarized) raw XML; the excerpt in this RESEARCH.md is representative but should be re-captured in full at plan/build time for the actual fixture file - [ ] `apps/api/src/tenders/__fixtures__/subreport-elvis-feed.xml` — live-captured, full raw XML including the complete `<description>` `<table>` markup (currently only a summarized excerpt exists — see Open Questions #2) - [ ] `apps/api/src/inbox/imap.provider.spec.ts` + `exchange-inbox.provider.spec.ts` — genuinely net-new test infrastructure; no prior DKV provider tests exist to extend - [ ] `apps/web/src/messages/` key-parity check — no existing test enforces `de.json`/`en.json` structural parity; recommend adding one as part of this phase given the module now doubles the message-file surface area - [ ] Prisma migration for `TenderEmailConfig` (per-tenant) and `TenderRssFeedSource` (global) — neither model exists yet ## Security Domain ### Applicable ASVS Categories | ASVS Category | Applies | Standard Control | |---------------|---------|-----------------| | V2 Authentication | no (new routes reuse existing session auth, no new auth mechanism) | — | | V3 Session Management | no | — | | V4 Access Control | yes | `@Roles(Role.ADMIN, Role.SUPER_ADMIN)` on all new config routes (email-config, rss-feeds), mirroring `DkvController`/existing `source-config` route | | V5 Input Validation | yes | `class-validator` DTOs for the new `TenderEmailConfigDto`/`TenderRssFeedSourceDto` (`@IsIn` for protocol/encryption, `@IsUrl` for feed URLs, `@IsEmail` for senderFilter — mirrors `DkvConfigDto`); host-based denylist validation on RSS feed URL save (Pitfall 3); email body HTML must never be rendered raw in any UI — only extracted plain-text title/links are ever stored/displayed | | V6 Cryptography | yes | `CalendarCryptoService` (AES-256-GCM) — never a new/different crypto implementation for the new mailbox credentials | ### Known Threat Patterns for this stack | Pattern | STRIDE | Standard Mitigation | |---------|--------|---------------------| | SSRF via admin-supplied RSS feed URL (admin could enter an internal/private-network URL) | Tampering / Information Disclosure | Validate feed URL scheme is `https?://` and reject private/loopback IP ranges before fetching, mirroring the SSRF-guard intent already documented for `NETSERVER_PORTALS` (hardcoded there; here it must be a runtime check since the URL is admin input) — new for this phase since it's the first admin-input-driven fetch target in `tenders/` | | Email-injection / stored-XSS via alert email body (HTML) | Tampering | Only extract plain-text `title` and raw `href` URL strings — never store or render the HTML body itself; mirrors the existing `.text()`-not-`.html()` discipline already documented in `NetServerAdapter` (V5) | | Credential leakage via logs (IMAP/EWS) | Information Disclosure | Reuse the existing `logger: false` (imapflow) / generic-error-message (EWS) conventions for any new log statement in `fetchMessages()` — do not introduce a new logging call that includes `config.password`/`config.username` | | Decompression/parsing DoS via a malicious/huge RSS feed response | Denial of Service | Apply the same `AbortController` 15s timeout pattern already used by `DoeOpenDataAdapter`/`NetServerAdapter`; consider a response-size ceiling for RSS fetches (RSS feeds are normally small, but an admin-supplied URL is less trusted than a hardcoded one) | ## Sources ### Primary (HIGH confidence) - Direct codebase reads (this session): `inbox-provider.interface.ts`, `imap.provider.ts`, `exchange-inbox.provider.ts`, `dkv.types.ts`, `dkv.service.ts`, `dkv.module.ts`, `crypto.service.ts`, `dkv-config.dto.ts`, `dkv.controller.ts`, `tender-source-adapter.interface.ts`, `source-registry.ts`, `tenders.module.ts`, `tender.types.ts`, `tender-normalizer.service.ts`, `tender-ingestion.service.ts`, `tender-scheduler.service.ts`, `netserver.adapter.ts`, `doe-opendata.adapter.ts`, `tender-mail.service.ts`, `settings.service.ts`, `prisma-tenant.extension.ts`, `tenders.controller.ts`, `SourceConfigForm.tsx`, `InboxConfigForm.tsx`, `tender-radar-api.ts`, `CoverageBanner.tsx`, `layout.tsx`, `i18n/request.ts`, `middleware.ts`, `schema.prisma` (DkvModuleConfig/TenderSourcePollConfig/Tender models), `apps/api/package.json`, `apps/web/src/messages/de.json`/`en.json` - `gsd-tools query package-legitimacy check` — `rss-parser` verdict OK (825k/wk, github.com/bobby-brennan/rss-parser, not deprecated, no postinstall) - fast-xml-parser v5.10.1 source (`OptionsBuilder.js`, `fxp.d.ts`) — confirmed `cdataPropName` default behavior ### Secondary (MEDIUM confidence) - Live WebFetch of `https://www.service.bund.de/Content/Globals/Functions/RSSFeed/RSSGenerator_Ausschreibungen.xml` (2026-07-23) — full channel/item XML shape - Live WebFetch of `https://www.subreport-elvis.de/elvis/secure/rss.pl?id=4615` (2026-07-23) — channel/item XML shape (description table content not fully captured, see A1) - WebSearch confirming subreport-elvis's per-municipality feed model (Neuss city page) and vergabe24.de/aumass.de canonical URLs ### Tertiary (LOW confidence) - EWS `<t:Body BodyType="...">` response shape for the new `fetchMessages()` GetItem call — based on general EWS schema knowledge, not verified against a live Exchange server this session (A2) ## Metadata **Confidence breakdown:** - Standard stack: HIGH — zero new dependencies, all four libraries already proven in this exact codebase - Architecture: HIGH for the adapter/config/scheduler patterns (direct precedent in Phase 13); MEDIUM for the exact EWS body-fetch shape (A2) - Pitfalls: HIGH for #1/#3/#5 (derived from direct code reading, not speculation); MEDIUM for #2/#4 (well-grounded but partially forward-looking) **Research date:** 2026-07-23 **Valid until:** 30 days for code-pattern findings (stable, in-repo); the two live-fetched RSS feed snapshots should be re-verified at plan/build time regardless of age, since external feed structures can change without notice