docs(14): create phase plan (5 plans, 4 waves)
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
---
|
||||
phase: 14-rss-email-alert-ingestion-module-rollout
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: [14-01, 14-02]
|
||||
files_modified:
|
||||
- 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
|
||||
autonomous: false
|
||||
requirements: [INGEST-05, CONFIG-02]
|
||||
user_setup:
|
||||
- service: portal-alert mailbox (IMAP or Exchange/EWS)
|
||||
why: "Per-tenant inbox that receives portal notification emails; separate from the DKV invoice mailbox"
|
||||
env_vars: []
|
||||
dashboard_config:
|
||||
- task: "Provide mailbox host/port/credentials/folder via the tender-radar Email-Alerts settings form"
|
||||
location: "Tessera → Ausschreibungs-Radar → Einstellungen → E-Mail-Alerts"
|
||||
must_haves:
|
||||
truths:
|
||||
- "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)"
|
||||
artifacts:
|
||||
- "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"
|
||||
key_links:
|
||||
- "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'"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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).
|
||||
</objective>
|
||||
|
||||
## 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>
|
||||
|
||||
<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
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Generic link+subject extraction + 'email-alert' normalizer dispatch (test-first)</name>
|
||||
<files>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</files>
|
||||
<read_first>
|
||||
- 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)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- 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).
|
||||
</behavior>
|
||||
<action>
|
||||
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).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>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</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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).
|
||||
</acceptance_criteria>
|
||||
<done>Generic link+subject extraction is proven for HTML and plaintext bodies, and 'email-alert' records normalize via normalizeBag.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: TenderEmailConfig (encrypted, per-tenant) + ownerTenantId write-side + per-tenant fan-out + wiring</name>
|
||||
<files>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</files>
|
||||
<read_first>
|
||||
- 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
|
||||
</read_first>
|
||||
<action>
|
||||
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).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>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</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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.
|
||||
</acceptance_criteria>
|
||||
<done>Per-tenant encrypted mailbox config + email adapter produce ownerTenantId-tagged Tender rows via the shared inbox providers, without leaking across tenants at write time.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: email-config routes + D-13 read filter + EmailAlertConfigForm</name>
|
||||
<files>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</files>
|
||||
<read_first>
|
||||
- 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')
|
||||
</read_first>
|
||||
<action>
|
||||
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: <tenant> }] }`; 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 <EmailAlertConfigForm/> (D-09: extend the existing settings page). Keep hardcoded German strings — i18n is Plan 14-05. Then STOP for the checkpoint below.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd apps/api && npx vitest run src/tenders/tenders.controller.spec.ts && npx tsc --noEmit -p tsconfig.json && cd ../web && npx tsc --noEmit</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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.
|
||||
</acceptance_criteria>
|
||||
<done>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.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking">
|
||||
<name>Task 4: Human-verify EWS live body-fetch against a real mailbox (Pitfall 4)</name>
|
||||
<files>apps/api/src/inbox/exchange-inbox.provider.ts</files>
|
||||
<what-built>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.</what-built>
|
||||
<how-to-verify>
|
||||
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).
|
||||
</how-to-verify>
|
||||
<action>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.</action>
|
||||
<verify>
|
||||
<human-check>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.</human-check>
|
||||
</verify>
|
||||
<resume-signal>Type "approved" or describe the mailbox/body issues observed</resume-signal>
|
||||
<done>A real portal-alert mailbox (incl. the Exchange/EWS path) is confirmed to produce a tenant-private tender with a correctly-fetched body.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<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>
|
||||
|
||||
<verification>
|
||||
- `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.
|
||||
</verification>
|
||||
|
||||
<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>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/14-rss-email-alert-ingestion-module-rollout/14-03-SUMMARY.md` when done.
|
||||
</output>
|
||||
Reference in New Issue
Block a user