Files
tessera-ctl/.planning/phases/14-rss-email-alert-ingestion-module-rollout/14-03-PLAN.md
T

21 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, user_setup, must_haves
phase plan type wave depends_on files_modified autonomous requirements user_setup must_haves
14-rss-email-alert-ingestion-module-rollout 03 execute 2
14-01
14-02
apps/api/src/tenders/adapters/email-alert.adapter.ts
apps/api/src/tenders/adapters/email-alert.adapter.spec.ts
apps/api/src/tenders/tender.types.ts
apps/api/src/tenders/tender-normalizer.service.ts
apps/api/src/tenders/tender-normalizer.service.spec.ts
apps/api/src/tenders/tender-dedup.service.ts
apps/api/src/tenders/tender-email-config.service.ts
apps/api/src/tenders/tender-email-config.service.spec.ts
apps/api/src/tenders/dto/tender-email-config.dto.ts
apps/api/src/tenders/tender-query.builder.ts
apps/api/src/tenders/tenders.module.ts
apps/api/src/tenders/tenders.controller.ts
apps/api/src/tenders/tenders.controller.spec.ts
apps/api/prisma/schema.prisma
apps/web/src/lib/tender-radar-api.ts
apps/web/src/app/(portal)/modules/tender-radar/settings/page.tsx
apps/web/src/app/(portal)/modules/tender-radar/settings/components/EmailAlertConfigForm.tsx
false
INGEST-05
CONFIG-02
service why env_vars dashboard_config
portal-alert mailbox (IMAP or Exchange/EWS) Per-tenant inbox that receives portal notification emails; separate from the DKV invoice mailbox
task location
Provide mailbox host/port/credentials/folder via the tender-radar Email-Alerts settings form Tessera → Ausschreibungs-Radar → Einstellungen → E-Mail-Alerts
truths artifacts key_links
Tenders parsed from a tenant's configured mailbox alert emails appear in that tenant's results list, built on the shared inbox/ module
Email-sourced tenders are visible ONLY to the configuring tenant; existing global tenders remain visible to all (D-13)
Admin can save a per-tenant mailbox config with credentials AES-256-GCM-encrypted; GET never returns the password (D-06/D-07)
apps/api/src/tenders/adapters/email-alert.adapter.ts (per-tenant internal fan-out over inbox fetchMessages) + spec
Prisma model TenderEmailConfig (per-tenant) + Tender.ownerTenantId nullable + migration
GET/PUT /modules/tender-radar/email-config (per-tenant, safe-select) + EmailAlertConfigForm UI
EmailAlertAdapter consumes InboxProvider.fetchMessages from apps/api/src/inbox (Plan 14-01)
ownerTenantId threads RawTenderRecord -> NormalizedTenderFields -> dedup create; read query filters on it (D-13)
EmailAlertAdapter registered + 'email-alert' poll config seeded pollGranularity='tick'
Deliver email-alert ingestion end-to-end (INGEST-05) on the shared inbox module (14-01): a per-tenant mailbox config (encrypted, D-06/D-07), an `EmailAlertAdapter` that fans out over all active tenant mailboxes (internal fan-out, catch-per-tenant) and extracts links+subject generically (D-04), and the critical per-tenant visibility mechanism (D-13) so one tenant's private alert tenders never leak to others while existing global tenders stay global.

Purpose: Close the Unterschwellen long-tail via portal alert emails without portal-specific parsers (D-04) and without weakening multi-tenant isolation. Thin email fields (empty CPV) inherit Phase 13's dormant fingerprint-dedup deferral — no new dedup mechanism (D-05). Output: TenderEmailConfig model + service + admin UI, EmailAlertAdapter, ownerTenantId visibility on Tender, and an EWS live-path human-verify checkpoint (Pitfall 4).

Phase Goal (MVP user story)

As a tenant admin, I want to configure my portal-alert mailbox, so that tenders from my alert emails appear in my results list and only mine.

Slice progression: Task 1 proves generic link/subject extraction (failing-first), Task 2 makes ingestion real per-tenant with encrypted config + D-13 write-side visibility, Task 3 adds the read-side visibility filter + admin UI + EWS live human-verify.

Artifacts this phase produces

  • apps/api/src/tenders/adapters/email-alert.adapter.ts: EmailAlertAdapter implements TenderSourceAdapter, sourceType:'email-alert', portals:['email-alert']; pure extractCandidateLinks(bodyHtml, bodyText), titleFromEmail(subject, bodyText), sourceNoticeIdFor(link); fetchTenders() internal per-tenant fan-out.
  • SourceType extended with 'email-alert'; normalize() case 'email-alert' → normalizeBag(); ownerTenantId threaded through NormalizedTenderFields + dedup create.
  • Prisma model TenderEmailConfig (per-tenant, tenantId @unique, encryptedInboxCreds, protocol/host/port/encryption/folder/senderFilter/domain/isActive) mirroring DkvModuleConfig; Tender.ownerTenantId String? (null=global) + index; migration.
  • TenderEmailConfigService (safe-select + encrypt via CalendarCryptoService) + TenderEmailConfigDto.
  • Controller GET/PUT /modules/tender-radar/email-config (per-tenant, @Roles(ADMIN, SUPER_ADMIN)); listTenders/getTender visibility filter.
  • Web: EmailAlertConfigForm.tsx, api client email-config fns, settings "E-Mail-Alerts" section.

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

@.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 @.planning/phases/14-rss-email-alert-ingestion-module-rollout/14-01-SUMMARY.md @.planning/phases/14-rss-email-alert-ingestion-module-rollout/14-02-SUMMARY.md Task 1: Generic link+subject extraction + 'email-alert' normalizer dispatch (test-first) apps/api/src/tenders/adapters/email-alert.adapter.ts, apps/api/src/tenders/adapters/email-alert.adapter.spec.ts, apps/api/src/tenders/tender.types.ts, apps/api/src/tenders/tender-normalizer.service.ts, apps/api/src/tenders/tender-normalizer.service.spec.ts - apps/api/src/inbox/inbox-provider.interface.ts + inbox.types.ts (InboxMessage shape from Plan 14-01: subject, bodyHtml, bodyText) - apps/api/src/tenders/adapters/cosinex.adapter.spec.ts (pure-function spec style) - apps/api/src/tenders/tender-normalizer.service.ts §53-64,§125-147 (normalize switch + normalizeBag bag shape) - RESEARCH.md "Generic link extraction from an alert email body (D-04)" code example (FOOTER_NOISE, MAX_LINKS_PER_EMAIL, cheerio a[href], plaintext regex fallback, titleFromEmail, sourceNoticeIdFor) - RESEARCH.md Anti-Patterns (no portal-specific/sender-domain branching — D-04 deferred) - extractCandidateLinks(html, text): from an HTML body returns deduped absolute http(s) hrefs via cheerio, drops footer-noise links (unsubscribe/abmelden/einstellungen/preferences), caps at MAX_LINKS_PER_EMAIL; when html is null falls back to a plaintext URL regex. - titleFromEmail(subject, text): returns trimmed subject, else first non-empty body line, else 'Unbenannte Ausschreibung'. - sourceNoticeIdFor(link): stable sha256(link).slice(0,40). - normalize() with sourceType 'email-alert' routes through normalizeBag() (cpv/region/value null, title preserved). Create email-alert.adapter.ts with the three pure helpers exactly per the RESEARCH code example (cheerio for HTML, regex fallback for plaintext, footer-noise filter, link cap). Extend SourceType in tender.types.ts to add `'email-alert'`. Add `case 'email-alert':` to normalize() returning normalizeBag(raw), plus a spec case in tender-normalizer.service.spec.ts. Create email-alert.adapter.spec.ts (pure-function tests: HTML body, plaintext-only body, footer-noise stripping, link cap, subject-vs-first-line title fallback). Do NOT add any sender-domain/portal-specific branching (D-04, deferred). Only plain-text title + href strings are produced — never store or emit raw email HTML (stored-XSS guard). cd apps/api && npx vitest run src/tenders/adapters/email-alert.adapter.spec.ts src/tenders/tender-normalizer.service.spec.ts && npx tsc --noEmit -p tsconfig.json - email-alert.adapter.spec.ts covers HTML + plaintext bodies, footer-noise removal, and the link cap. - `grep -n "'email-alert'" apps/api/src/tenders/tender.types.ts` shows the union member; normalize() 'email-alert' case present. - No sender-domain conditional exists in the adapter (D-04 restraint). Generic link+subject extraction is proven for HTML and plaintext bodies, and 'email-alert' records normalize via normalizeBag. Task 2: TenderEmailConfig (encrypted, per-tenant) + ownerTenantId write-side + per-tenant fan-out + wiring apps/api/prisma/schema.prisma, apps/api/src/tenders/tender-email-config.service.ts, apps/api/src/tenders/tender-email-config.service.spec.ts, apps/api/src/tenders/dto/tender-email-config.dto.ts, apps/api/src/tenders/tender.types.ts, apps/api/src/tenders/tender-normalizer.service.ts, apps/api/src/tenders/tender-dedup.service.ts, apps/api/src/tenders/adapters/email-alert.adapter.ts, apps/api/src/tenders/adapters/email-alert.adapter.spec.ts, apps/api/src/tenders/tenders.module.ts - apps/api/prisma/schema.prisma §179-198 (DkvModuleConfig template) + §264-302 (Tender model, note NO tenantId today) - apps/api/src/dkv/dkv.service.ts §95-235 (getConfigForApi safe-select, saveConfig encrypt-preserve, CONFIG_SAFE_SELECT pattern) - apps/api/src/calendar/crypto.service.ts §43-76 (encrypt/decrypt, iv:authTag:ciphertext) - apps/api/src/tenders/tender-dedup.service.ts §56-142 (resolve: create data block to thread ownerTenantId; create-only) - apps/api/src/tenders/tender-mail.service.ts §62,§153 (per-tenant resolution pattern) - apps/api/src/tenders/tenders.module.ts §98-214 (providers + onModuleInit register/seed; imports SettingsModule — add CalendarModule for crypto) - RESEARCH.md Pattern 1 (EmailAlertAdapter.fetchTenders per-tenant fan-out sketch, cross-tenant findMany is deliberate — document it) + Pitfall 5 / D-13 (per-tenant visibility) + "Per-tenant config service" code example (EMAIL_CONFIG_SAFE_SELECT) - CLAUDE.md memory: local migrations from host via container-IP; do not restart services Add Prisma `model TenderEmailConfig` mirroring DkvModuleConfig (id, `tenantId @unique`, protocol default 'imap', host?, port?, encryption default 'ssl-tls', folder default 'INBOX', senderFilter?, domain?, isActive default false, `encryptedInboxCreds String?`, timestamps, `@@index([tenantId])`). Add `ownerTenantId String?` to Tender (null=global visible to all; set=only that tenant, D-13) plus `@@index([ownerTenantId])`. Generate + apply the migration from the host (CLAUDE.md convention; existing Tender rows get ownerTenantId=null → stay global, satisfying "MUST NOT change visibility of existing global tenders"). Create tender-email-config.service.ts (inject PrismaService + CalendarCryptoService): `getConfigForApi(tenantId)` returns EMAIL_CONFIG_SAFE_SELECT fields + decrypted username + hasPassword (never the password, T-07-12); `saveConfig(tenantId, dto)` encrypts {username,password} via crypto.encrypt with the DKV preserve-empty semantics. Create dto/tender-email-config.dto.ts (`@IsIn` protocol/encryption, `@IsInt @Min(1) @Max(65535)` port, `@IsOptional @IsEmail` senderFilter — mirrors DkvConfigDto). Thread visibility write-side: add optional `ownerTenantId?: string` to RawTenderRecord and NormalizedTenderFields; assemble() passes it through; EmailAlertAdapter sets it per record = the config's tenantId; tender-dedup.service.ts adds `ownerTenantId: n.ownerTenantId ?? null` to the CREATE data block ONLY (never the update branch — a source later seen globally must not be hidden). Implement EmailAlertAdapter.fetchTenders(): `tenderEmailConfig.findMany({where:{isActive:true}})` (deliberate cross-tenant platform-scheduler read — document in the docstring that it must NOT be forTenant()-wrapped, per RESEARCH Anti-Pattern), per config decrypt creds → pick imap/exchange provider from inbox module → provider.fetchMessages(cfg) → extract candidates tagged with ownerTenantId=cfg.tenantId; wrap each tenant in try/catch (one broken mailbox never blocks others). In tenders.module.ts: import CalendarModule (crypto), add TenderEmailConfigService + EmailAlertAdapter providers, register EmailAlertAdapter in onModuleInit, seed an 'email-alert' TenderSourcePollConfig with `pollGranularity:'tick', isActive:false` (framework ready, activation deferred). cd apps/api && npx vitest run src/tenders/tender-email-config.service.spec.ts src/tenders/adapters/email-alert.adapter.spec.ts && npx tsc --noEmit -p tsconfig.json - Migration adds TenderEmailConfig + Tender.ownerTenantId (nullable, default null); `npx prisma validate` passes. - tender-email-config.service.spec.ts asserts encrypt→decrypt round-trip and that getConfigForApi never returns a password field (only hasPassword). - email-alert.adapter.spec.ts covers per-tenant fan-out with catch-per-tenant (one mocked mailbox throws, others still return records tagged with their ownerTenantId). - dedup create writes ownerTenantId; update branch does NOT reference ownerTenantId. Per-tenant encrypted mailbox config + email adapter produce ownerTenantId-tagged Tender rows via the shared inbox providers, without leaking across tenants at write time. Task 3: email-config routes + D-13 read filter + EmailAlertConfigForm apps/api/src/tenders/tenders.controller.ts, apps/api/src/tenders/tenders.controller.spec.ts, apps/api/src/tenders/tender-query.builder.ts, apps/web/src/lib/tender-radar-api.ts, apps/web/src/app/(portal)/modules/tender-radar/settings/components/EmailAlertConfigForm.tsx, apps/web/src/app/(portal)/modules/tender-radar/settings/page.tsx - apps/api/src/tenders/tenders.controller.ts §56-84 (extractTriageContext for tenantId) + §140-157,§354-404 (route-order, @Roles, per-handler auth-context) - apps/api/src/tenders/tender-query.builder.ts §35-52 (buildTenderWhere signature + AND composition) - apps/api/src/dkv/dkv.controller.ts §58-93 (config GET/PUT route shape) + apps/web/.../dkv-fleet/settings/components/InboxConfigForm.tsx (password field, T-07-12 blank-on-load) - apps/api/src/tenders/tenders.controller.spec.ts (route tests to extend) - apps/web/src/lib/tender-radar-api.ts §86-117 (client convention) - RESEARCH.md Pitfall 4 / Assumption A2 (EWS body-fetch untested against a real server → human-verify required before Exchange path is production-ready) - CLAUDE.md memory: static routes before @Get(':id') Add controller routes `GET /modules/tender-radar/email-config` and `PUT /modules/tender-radar/email-config`, both `@Roles(Role.ADMIN, Role.SUPER_ADMIN)`, resolving tenantId from the auth context (never the body) and delegating to TenderEmailConfigService; declare them before `@Get(':id')`. Implement D-13 read-side visibility: extend buildTenderWhere with an optional `ownerTenantId?: string` param that pushes `{ OR: [{ ownerTenantId: null }, { ownerTenantId: }] }`; in listTenders resolve the requesting tenantId from the auth context and pass it in; in getTender add a guard — if the found tender has a non-null ownerTenantId not equal to the requesting tenant, throw NotFoundException (no cross-tenant detail leak). Extend tenders.controller.spec.ts: email-config routes are Roles-guarded; a tender with ownerTenantId=T1 is absent from T2's list and 404s on T2's detail while a null-owner tender is visible to both. In tender-radar-api.ts add `EmailAlertConfig` type + `fetchEmailConfig()`, `saveEmailConfig(payload)`. Create EmailAlertConfigForm.tsx mirroring DKV's InboxConfigForm (protocol/host/port/encryption/folder/senderFilter/domain/username/password/isActive; password blank on load, only sent when typed — T-07-12). Add an "E-Mail-Alerts" section to settings/page.tsx rendering (D-09: extend the existing settings page). Keep hardcoded German strings — i18n is Plan 14-05. Then STOP for the checkpoint below. cd apps/api && npx vitest run src/tenders/tenders.controller.spec.ts && npx tsc --noEmit -p tsconfig.json && cd ../web && npx tsc --noEmit - email-config GET/PUT are Roles-guarded, tenant-scoped from auth context, declared before `@Get(':id')`. - Controller spec proves D-13: ownerTenantId=T1 tender hidden from T2 list + 404 on T2 detail; null-owner tender visible to both. - EmailAlertConfigForm renders in settings; web tsc clean. Per-tenant email-config admin surface works, email-sourced tenders are tenant-private on read (D-13), and the plan pauses for live EWS verification. Task 4: Human-verify EWS live body-fetch against a real mailbox (Pitfall 4) apps/api/src/inbox/exchange-inbox.provider.ts Per-tenant email-alert mailbox config + EmailAlertAdapter fetching whole-message bodies via the shared inbox providers (IMAP + Exchange/EWS). The EWS `fetchMessages` body-fetch is new hand-written SOAP against a documented-fragile NTLM path (Pitfall 4) and was only verified against mocked responses. 1. In Tessera → Ausschreibungs-Radar → Einstellungen → E-Mail-Alerts, enter a real portal-alert mailbox (try the Exchange/EWS path specifically) and save. 2. Ensure at least one portal alert email with a detail link is in the configured folder, unread. 3. Activate the 'email-alert' poll config (set isActive=true) and let one poll tick run (or trigger it). 4. Confirm in the results list (as that tenant) a new tender appears with the email subject as title and the alert link as source URL; confirm a DIFFERENT tenant does NOT see it. 5. Confirm the EWS body came back non-empty/non-garbled (Pitfall 4 failure mode is silent empty/garbled bodies). Human-only verification — no code changes. Confirm the EWS/IMAP live path returns real message bodies and that email-sourced tenders are visible only to the configuring tenant. This gates INGEST-05's Exchange path being considered production-ready; automated tests only cover mocked SOAP responses. Operator confirms: (a) a real alert email produced a tender for the configuring tenant, (b) a second tenant does not see it, (c) the EWS body was non-empty/non-garbled. Type "approved" or describe the mailbox/body issues observed A real portal-alert mailbox (incl. the Exchange/EWS path) is confirmed to produce a tenant-private tender with a correctly-fetched body.

<threat_model>

Trust Boundaries

Boundary Description
Tenant admin → mailbox credentials credentials stored per tenant, encrypted at rest
Tessera API → tenant IMAP/EWS server network I/O with decrypted creds
Email body → Tender store untrusted HTML crosses into extraction
Tenant A email tender → Tenant B view cross-tenant visibility boundary (D-13)

STRIDE Threat Register

Threat ID Category Component Severity Disposition Mitigation Plan
T-14-03-01 Information Disclosure cross-tenant tender leak critical mitigate ownerTenantId (null=global, set=owner); read filter OR[null, tenant]; getTender 404 guard; create-only write (D-13)
T-14-03-02 Information Disclosure mailbox credential storage high mitigate AES-256-GCM via CalendarCryptoService; safe-select excludes encryptedInboxCreds; password never in GET (T-07-12)
T-14-03-03 Tampering (stored XSS) alert email HTML body high mitigate Extract only plain-text title + href strings via cheerio .text()/href; never store/render raw HTML
T-14-03-04 Information Disclosure fetchMessages logging high mitigate Reuse credential-free logging conventions from Plan 14-01
T-14-03-05 Elevation of Privilege (IDOR) email-config routes high mitigate @Roles(ADMIN, SUPER_ADMIN); tenantId from auth context, never body
T-14-03-06 Spoofing/Tampering EWS body-fetch fragile path high mitigate Mock-based unit tests + blocking human-verify against a real mailbox (Pitfall 4)
T-14-03-SC Tampering npm/pip/cargo installs low accept No new packages — cheerio/imapflow/httpntlm already installed
</threat_model>
- `cd apps/api && npx vitest run && npx tsc --noEmit -p tsconfig.json` — full API suite + new specs green. - `cd apps/web && npx tsc --noEmit` — clean. - Human-verify checkpoint approved for the EWS live path.

<success_criteria>

  • Email alert tenders appear for the configuring tenant only (INGEST-05, D-13); existing global tenders unchanged.
  • Per-tenant mailbox config encrypted, password never returned (CONFIG-02, D-06/D-07).
  • Built on the shared inbox/ module (14-01), no DKV behavior change. </success_criteria>
Create `.planning/phases/14-rss-email-alert-ingestion-module-rollout/14-03-SUMMARY.md` when done.