From 1be6b15249639f300728d7f4c0063d8f1a5bf234 Mon Sep 17 00:00:00 2001 From: Schalli Date: Thu, 23 Jul 2026 13:45:11 +0200 Subject: [PATCH] feat(14-03): add per-tenant encrypted TenderEmailConfig + ownerTenantId write-side (D-13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prisma: new TenderEmailConfig model (per-tenant, tenantId @unique, mirrors DkvModuleConfig) + Tender.ownerTenantId nullable column + index (D-13: null = global/platform-wide, unchanged for all existing rows and every public source; set = visible only to that tenant). Migration 20260723113917_tender_email_config_owner_tenant_id applied locally. TenderEmailConfigService: safe-select admin CRUD (GET never returns the password, only hasPassword — T-07-12) with DkvService's encrypt-preserve- empty semantics, via CalendarCryptoService (AES-256-GCM). RawTenderRecord/NormalizedTenderFields gain optional ownerTenantId, threaded through TenderNormalizerService.assemble() unchanged. TenderDedupService's CREATE branch writes ownerTenantId (defaulting to null); the UPDATE branch deliberately never references it, so a tender later also seen on a public source is never retroactively hidden. EmailAlertAdapter.fetchTenders() now does the real per-tenant fan-out: findMany({isActive:true}) across ALL tenants (deliberate, documented cross-tenant platform-scheduler read, never forTenant()/RLS), decrypts each tenant's credentials, picks imap/exchange provider, and tags every extracted candidate with ownerTenantId — catch-per-tenant so one broken mailbox never blocks the others. tenders.module.ts: imports CalendarModule/InboxModule, registers EmailAlertAdapter + TenderEmailConfigService, seeds an 'email-alert' TenderSourcePollConfig row (pollGranularity='tick', isActive=false — no default mailbox to activate yet, D-02 framework-ready stance). Co-Authored-By: Claude Opus 4.8 --- .../migration.sql | 30 +++ apps/api/prisma/schema.prisma | 39 ++- .../adapters/email-alert.adapter.spec.ts | 251 +++++++++++++++++- .../tenders/adapters/email-alert.adapter.ts | 165 +++++++++++- .../tenders/dto/tender-email-config.dto.ts | 79 ++++++ .../src/tenders/tender-dedup.service.spec.ts | 51 ++++ apps/api/src/tenders/tender-dedup.service.ts | 6 + .../tender-email-config.service.spec.ts | 169 ++++++++++++ .../tenders/tender-email-config.service.ts | 145 ++++++++++ .../src/tenders/tender-normalizer.service.ts | 3 + apps/api/src/tenders/tender.types.ts | 14 + apps/api/src/tenders/tenders.module.ts | 63 ++++- 12 files changed, 994 insertions(+), 21 deletions(-) create mode 100644 apps/api/prisma/migrations/20260723113917_tender_email_config_owner_tenant_id/migration.sql create mode 100644 apps/api/src/tenders/dto/tender-email-config.dto.ts create mode 100644 apps/api/src/tenders/tender-email-config.service.spec.ts create mode 100644 apps/api/src/tenders/tender-email-config.service.ts diff --git a/apps/api/prisma/migrations/20260723113917_tender_email_config_owner_tenant_id/migration.sql b/apps/api/prisma/migrations/20260723113917_tender_email_config_owner_tenant_id/migration.sql new file mode 100644 index 0000000..b6d8d23 --- /dev/null +++ b/apps/api/prisma/migrations/20260723113917_tender_email_config_owner_tenant_id/migration.sql @@ -0,0 +1,30 @@ +-- AlterTable +ALTER TABLE "Tender" ADD COLUMN "ownerTenantId" TEXT; + +-- CreateTable +CREATE TABLE "TenderEmailConfig" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "protocol" TEXT NOT NULL DEFAULT 'imap', + "host" TEXT, + "port" INTEGER, + "encryption" TEXT NOT NULL DEFAULT 'ssl-tls', + "folder" TEXT NOT NULL DEFAULT 'INBOX', + "senderFilter" TEXT, + "domain" TEXT, + "isActive" BOOLEAN NOT NULL DEFAULT false, + "encryptedInboxCreds" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "TenderEmailConfig_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "TenderEmailConfig_tenantId_key" ON "TenderEmailConfig"("tenantId"); + +-- CreateIndex +CREATE INDEX "TenderEmailConfig_tenantId_idx" ON "TenderEmailConfig"("tenantId"); + +-- CreateIndex +CREATE INDEX "Tender_ownerTenantId_idx" ON "Tender"("ownerTenantId"); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 347b973..3a3f8ab 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -197,6 +197,31 @@ model DkvModuleConfig { @@index([tenantId]) } +// Phase 14, Plan 03 (INGEST-05, CONFIG-02, D-06/D-07) — per-tenant portal- +// alert mailbox config, mirroring DkvModuleConfig's shape/pattern exactly +// (own tenantId @unique row, own encrypted creds — D-03: each module keeps +// its own independent mailbox config, this is a SEPARATE mailbox from the +// DKV invoice inbox). Credentials are encrypted via CalendarCryptoService +// (same AES-256-GCM iv:authTag:ciphertext format as DkvModuleConfig/ +// SmtpConfig) and excluded from every API response (Safe-Select, T-07-12). +model TenderEmailConfig { + id String @id @default(uuid()) + tenantId String @unique + protocol String @default("imap") // 'imap' | 'exchange' + host String? + port Int? + encryption String @default("ssl-tls") // 'none' | 'starttls' | 'ssl-tls' + folder String @default("INBOX") + senderFilter String? + domain String? // Exchange only: Windows domain (optional) + isActive Boolean @default(false) + encryptedInboxCreds String? // AES-256-GCM: JSON { username, password } encrypted + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([tenantId]) +} + model DkvVehicleMaster { id String @id @default(uuid()) tenantId String @@ -285,6 +310,15 @@ model Tender { // for pre-existing rows by backfill-tender-source.ts (Plan 13-01 Task 2). // dedupKey above stays the SCHEMA-02 upsert target — NOT replaced here. fingerprint String? + // Phase 14, Plan 03 (INGEST-05, D-13) — per-tenant visibility for PRIVATE + // sources only. null = global/platform-wide (D-03, unchanged for all + // public sources: DÖE/NetServer/cosinex/RSS — existing rows stay null, + // no backfill). Set = visible ONLY to that tenant (email-alert tenders, + // since an alert mailbox reflects one tenant's private subscription). + // Read filter: buildTenderWhere OR[{ownerTenantId:null},{ownerTenantId:tenant}]. + // Write: dedup CREATE only sets this — the UPDATE branch never touches it, + // so a source later also seen globally is never retroactively hidden. + ownerTenantId String? publishedAt DateTime createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -295,7 +329,10 @@ model Tender { @@index([bundesland]) // FILTER-02: post-backfill Bundesland-Filter-Performance @@index([cpvDivisions], type: Gin) // FILTER-03: post-backfill CPV-Divisions-Filter-Performance (hasSome) @@index([fingerprint]) // SCHEMA-03: dedup resolver fingerprint-tier lookup - // Deliberately NO tenant column and NO tenant index — this is global data (D-03) + @@index([ownerTenantId]) // D-13: read-filter lookup for private (email-alert) tenders + // Deliberately NO tenant column/index for the platform-wide default (D-03) + // — ownerTenantId above is the sole, additive, nullable exception for + // privately-sourced tenders (D-13). triage TenderTriage[] matches TenderMatch[] sources TenderSource[] // SCHEMA-03/D-03 — all source portals this tender was seen on diff --git a/apps/api/src/tenders/adapters/email-alert.adapter.spec.ts b/apps/api/src/tenders/adapters/email-alert.adapter.spec.ts index aa9ada4..fa76eff 100644 --- a/apps/api/src/tenders/adapters/email-alert.adapter.spec.ts +++ b/apps/api/src/tenders/adapters/email-alert.adapter.spec.ts @@ -1,6 +1,6 @@ import { readFileSync } from 'fs'; import { join } from 'path'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { EmailAlertAdapter, extractCandidateLinks, @@ -11,11 +11,49 @@ import { /** * email-alert.adapter.spec — Task 1 (test-first, TDD) proof for the generic * link/subject extraction (D-04): pure functions only, no I/O, mirroring - * cosinex.adapter.spec.ts's pure-function spec style. + * cosinex.adapter.spec.ts's pure-function spec style. Task 2 adds + * fetchTenders() fan-out coverage (mocked PrismaService/CalendarCryptoService/ + * inbox providers — no live DB/network I/O). */ + +/** Fake CalendarCryptoService — deterministic reversible encode, not real AES. */ +function makeFakeCrypto() { + return { + encrypt: vi.fn((plaintext: string) => `enc:${Buffer.from(plaintext).toString('base64')}`), + decrypt: vi.fn((stored: string) => { + if (!stored.startsWith('enc:')) throw new Error('Invalid encrypted value format'); + return Buffer.from(stored.slice(4), 'base64').toString('utf8'); + }), + }; +} + +function makeAdapter(overrides: { + configs?: any[]; + imapFetchMessages?: ReturnType; + exchangeFetchMessages?: ReturnType; +} = {}) { + const crypto = makeFakeCrypto(); + const prisma = { + tenderEmailConfig: { + findMany: vi.fn(async () => overrides.configs ?? []), + }, + }; + const imapProvider = { fetchMessages: overrides.imapFetchMessages ?? vi.fn(async () => []) }; + const exchangeProvider = { + fetchMessages: overrides.exchangeFetchMessages ?? vi.fn(async () => []), + }; + const adapter = new EmailAlertAdapter( + prisma as any, + crypto as any, + imapProvider as any, + exchangeProvider as any, + ); + return { adapter, prisma, crypto, imapProvider, exchangeProvider }; +} + describe('EmailAlertAdapter', () => { it('declares sourceType email-alert and the single email-alert portal', () => { - const adapter = new EmailAlertAdapter(); + const { adapter } = makeAdapter(); expect(adapter.sourceType).toBe('email-alert'); expect(adapter.portals).toEqual(['email-alert']); }); @@ -131,4 +169,211 @@ describe('EmailAlertAdapter', () => { // extraction only, mirroring the RESEARCH.md Anti-Pattern guard. expect(source).not.toMatch(/senderDomain|from\.includes\(|sender\.includes\(/); }); + + describe('fetchTenders (per-tenant fan-out, Task 2, INGEST-05/D-13)', () => { + const MSG = { + uid: 1, + messageId: '', + subject: 'Neue Ausschreibung Nr. 42', + from: 'alerts@portal.example', + date: new Date('2026-07-23T09:00:00.000Z'), + bodyHtml: 'Details', + bodyText: '', + }; + + it('only queries isActive TenderEmailConfig rows (cross-tenant read, deliberate)', async () => { + const { adapter, prisma } = makeAdapter({ configs: [] }); + + await adapter.fetchTenders('2026-07-23'); + + expect(prisma.tenderEmailConfig.findMany).toHaveBeenCalledWith({ + where: { isActive: true }, + }); + }); + + it('tags each extracted record with ownerTenantId = the configuring tenant (D-13)', async () => { + const imapFetchMessages = vi.fn(async () => [MSG]); + const { adapter } = makeAdapter({ + configs: [ + { + tenantId: 'tenant-a', + protocol: 'imap', + host: 'imap.example.test', + port: 993, + encryption: 'ssl-tls', + folder: 'INBOX', + senderFilter: null, + domain: null, + encryptedInboxCreds: null, + }, + ], + imapFetchMessages, + }); + + const records = await adapter.fetchTenders('2026-07-23'); + + expect(records).toHaveLength(1); + expect(records[0]?.ownerTenantId).toBe('tenant-a'); + expect(records[0]?.sourceType).toBe('email-alert'); + expect(records[0]?.sourcePortal).toBe('email-alert'); + expect(records[0]?.sourceUrl).toBe('https://portal.example/detail/42'); + }); + + it('routes protocol=exchange configs to the ExchangeInboxProvider, imap to ImapProvider', async () => { + const imapFetchMessages = vi.fn(async () => []); + const exchangeFetchMessages = vi.fn(async () => [MSG]); + const { adapter } = makeAdapter({ + configs: [ + { + tenantId: 'tenant-ews', + protocol: 'exchange', + host: 'https://mail.example.test/EWS/Exchange.asmx', + port: null, + encryption: 'ssl-tls', + folder: 'INBOX', + senderFilter: null, + domain: 'CONTOSO', + encryptedInboxCreds: null, + }, + ], + imapFetchMessages, + exchangeFetchMessages, + }); + + const records = await adapter.fetchTenders('2026-07-23'); + + expect(imapFetchMessages).not.toHaveBeenCalled(); + expect(exchangeFetchMessages).toHaveBeenCalledTimes(1); + expect(records).toHaveLength(1); + expect(records[0]?.ownerTenantId).toBe('tenant-ews'); + }); + + it('decrypts encryptedInboxCreds and passes username/password into InboxConfig', async () => { + const crypto = makeFakeCrypto(); + const encrypted = crypto.encrypt( + JSON.stringify({ username: 'alerts@example.test', password: 'secret' }), + ); + const imapFetchMessages = vi.fn(async () => []); + const prisma = { + tenderEmailConfig: { + findMany: vi.fn(async () => [ + { + tenantId: 'tenant-a', + protocol: 'imap', + host: 'imap.example.test', + port: 993, + encryption: 'ssl-tls', + folder: 'INBOX', + senderFilter: null, + domain: null, + encryptedInboxCreds: encrypted, + }, + ]), + }, + }; + const adapter = new EmailAlertAdapter( + prisma as any, + crypto as any, + { fetchMessages: imapFetchMessages } as any, + { fetchMessages: vi.fn(async () => []) } as any, + ); + + await adapter.fetchTenders('2026-07-23'); + + expect(imapFetchMessages).toHaveBeenCalledWith( + expect.objectContaining({ username: 'alerts@example.test', password: 'secret' }), + ); + }); + + it('catch-per-tenant: one mocked mailbox throws, others still return records tagged with their own ownerTenantId', async () => { + const throwingFetch = vi.fn(async () => { + throw new Error('connection refused'); + }); + const workingFetch = vi.fn(async () => [MSG]); + const crypto = makeFakeCrypto(); + const prisma = { + tenderEmailConfig: { + findMany: vi.fn(async () => [ + { + tenantId: 'tenant-broken', + protocol: 'imap', + host: 'imap.broken.test', + port: 993, + encryption: 'ssl-tls', + folder: 'INBOX', + senderFilter: null, + domain: null, + encryptedInboxCreds: null, + }, + { + tenantId: 'tenant-ok', + protocol: 'imap', + host: 'imap.ok.test', + port: 993, + encryption: 'ssl-tls', + folder: 'INBOX', + senderFilter: null, + domain: null, + encryptedInboxCreds: null, + }, + ]), + }, + }; + // First mailbox throws, second succeeds — same imapProvider instance, + // called twice (once per tenant), mockImplementationOnce per call. + const imapProvider = { + fetchMessages: vi + .fn() + .mockImplementationOnce(throwingFetch) + .mockImplementationOnce(workingFetch), + }; + const adapter = new EmailAlertAdapter( + prisma as any, + crypto as any, + imapProvider as any, + { fetchMessages: vi.fn(async () => []) } as any, + ); + + const records = await adapter.fetchTenders('2026-07-23'); + + expect(imapProvider.fetchMessages).toHaveBeenCalledTimes(2); + expect(records).toHaveLength(1); + expect(records[0]?.ownerTenantId).toBe('tenant-ok'); + }); + + it('returns [] when there are no active configs', async () => { + const { adapter } = makeAdapter({ configs: [] }); + const records = await adapter.fetchTenders('2026-07-23'); + expect(records).toEqual([]); + }); + + it('produces one RawTenderRecord per extracted candidate link (multiple links in one message)', async () => { + const multiLinkMsg = { + ...MSG, + bodyHtml: + 'AB', + }; + const { adapter } = makeAdapter({ + configs: [ + { + tenantId: 'tenant-a', + protocol: 'imap', + host: 'imap.example.test', + port: 993, + encryption: 'ssl-tls', + folder: 'INBOX', + senderFilter: null, + domain: null, + encryptedInboxCreds: null, + }, + ], + imapFetchMessages: vi.fn(async () => [multiLinkMsg]), + }); + + const records = await adapter.fetchTenders('2026-07-23'); + + expect(records).toHaveLength(2); + expect(records.every((r) => r.ownerTenantId === 'tenant-a')).toBe(true); + }); + }); }); diff --git a/apps/api/src/tenders/adapters/email-alert.adapter.ts b/apps/api/src/tenders/adapters/email-alert.adapter.ts index 2f6b7d3..e0a30ab 100644 --- a/apps/api/src/tenders/adapters/email-alert.adapter.ts +++ b/apps/api/src/tenders/adapters/email-alert.adapter.ts @@ -1,6 +1,11 @@ import { Injectable, Logger } from '@nestjs/common'; import * as cheerio from 'cheerio'; import { createHash } from 'crypto'; +import { CalendarCryptoService } from '../../calendar/crypto.service'; +import { ExchangeInboxProvider } from '../../inbox/exchange-inbox.provider'; +import { ImapProvider } from '../../inbox/imap.provider'; +import type { InboxConfig, InboxMessage } from '../../inbox/inbox-provider.interface'; +import { PrismaService } from '../../prisma/prisma.service'; import type { RawTenderRecord, SourceType } from '../tender.types'; import type { TenderSourceAdapter } from './tender-source-adapter.interface'; @@ -12,16 +17,26 @@ import type { TenderSourceAdapter } from './tender-source-adapter.interface'; * deliberate, documented consequence (D-05) — the same deferral already * accepted for the NetServer/cosinex/RSS scraper adapters. * - * Task 1 (this file, generic extraction + normalizer dispatch) ships the - * three pure helpers below plus a contract-complete but not-yet-wired - * `fetchTenders()`. Task 2 replaces the constructor/fetchTenders body with - * the real per-tenant fan-out over `TenderEmailConfig` rows, consuming - * `InboxProvider.fetchMessages` from the shared `inbox/` module (Plan 14-01). + * `fetchTenders()` (Plan 14-03 Task 2) is an internal per-tenant fan-out + * (RESEARCH.md Pattern 1, same shape as NetServerAdapter's per-portal loop + * and RssAdapter's per-feed loop): reads every ACTIVE `TenderEmailConfig` + * row across ALL tenants in ONE query — this is a DELIBERATE, audited + * cross-tenant read at the platform-scheduler level (mirrors the same + * documented exception for RssAdapter/TenderIngestionService itself) and + * must NEVER be wrapped in `forTenant()`/RLS. Each tenant's mailbox is + * fetched independently via the shared `inbox/` module's + * `InboxProvider.fetchMessages` (Plan 14-01) — catch-per-tenant, so one + * tenant's broken/unreachable mailbox never blocks the others in the same + * tick. Every extracted candidate is tagged with `ownerTenantId` = the + * configuring tenant's id (D-13) so the resulting Tender rows stay private + * to that tenant on the read side (tender-query.builder.ts). * * Security (T-14-03-03, stored-XSS guard): only plain-text `title` and raw * `href` URL strings are ever extracted from an alert email body — cheerio * `.attr('href')`/plaintext regex only, NEVER `.html()` — so no raw email * markup ever crosses into a RawTenderRecord/Tender row. + * Security (T-05-13): decrypted mailbox credentials only ever exist within + * `resolveDecryptedConfig`'s return value scope — never logged. */ /** @@ -87,19 +102,149 @@ export function sourceNoticeIdFor(link: string): string { return createHash('sha256').update(link).digest('hex').slice(0, 40); } +/** Minimal shape read from a TenderEmailConfig row (decrypt input). */ +interface DecryptableEmailConfig { + protocol: string; + host: string | null; + port: number | null; + encryption: string; + folder: string; + senderFilter: string | null; + domain: string | null; + encryptedInboxCreds: string | null; +} + @Injectable() export class EmailAlertAdapter implements TenderSourceAdapter { readonly sourceType: SourceType = 'email-alert'; readonly portals = ['email-alert'] as const; - protected readonly logger = new Logger(EmailAlertAdapter.name); + private readonly logger = new Logger(EmailAlertAdapter.name); + + constructor( + private readonly prisma: PrismaService, + private readonly crypto: CalendarCryptoService, + private readonly imapProvider: ImapProvider, + private readonly exchangeProvider: ExchangeInboxProvider, + ) {} /** - * Task 1 placeholder — keeps the class contract-complete (TenderSourceAdapter) - * for this task's generic-extraction-only scope. Task 2 replaces this with - * the real per-tenant fan-out over TenderEmailConfig + InboxProvider. + * `_dayCursor` is accepted for interface conformance but NOT used as a + * filter — email-alert is a 'tick'-granularity source (D-15, seeded in + * tenders.module.ts), polled every active scheduler tick; idempotency + * comes from IMAP `\Seen`/EWS `IsRead` marking inside the inbox providers, + * not a day-cursor. */ async fetchTenders(_dayCursor: string): Promise { - return []; + // Deliberate cross-tenant read (see class docstring) — NEVER wrap in + // forTenant()/RLS. This is the platform scheduler resolving every + // tenant's active mailbox in one tick, not a per-tenant request. + const configs = await this.prisma.tenderEmailConfig.findMany({ + where: { isActive: true }, + }); + + const fetchedAt = new Date(); + const records: RawTenderRecord[] = []; + + for (const cfg of configs) { + try { + const inboxConfig = this.resolveDecryptedConfig(cfg); + const provider = + cfg.protocol === 'exchange' ? this.exchangeProvider : this.imapProvider; + const messages = await provider.fetchMessages(inboxConfig); + records.push(...this.extractCandidates(messages, cfg.tenantId, fetchedAt)); + } catch (error) { + // Catch-per-tenant (D-01 discipline, mirrors NetServer/RssAdapter): + // one tenant's broken/unreachable mailbox never blocks the others. + this.logger.warn( + `Email-alert mailbox for tenant ${cfg.tenantId} failed, skipping: ${(error as Error).message}`, + ); + } + } + + return records; + } + + /** + * Decrypts a TenderEmailConfig row's credentials into an InboxConfig for + * the shared inbox providers. T-05-13: the decrypted password only ever + * exists within this method's return value — never logged. A decrypt + * failure resolves to no credentials (the provider's own auth failure is + * then caught by fetchTenders' per-tenant try/catch above), matching the + * "ignore decrypt errors" convention already used by + * DkvService/TenderEmailConfigService. + */ + private resolveDecryptedConfig(cfg: DecryptableEmailConfig): InboxConfig { + let username: string | undefined; + let password: string | undefined; + + if (cfg.encryptedInboxCreds) { + try { + const creds = JSON.parse(this.crypto.decrypt(cfg.encryptedInboxCreds)) as { + username?: string; + password?: string; + }; + username = creds.username; + password = creds.password; + } catch { + // Ignore — proceed with no credentials, see method doc above. + } + } + + return { + protocol: cfg.protocol, + host: cfg.host ?? '', + port: cfg.port ?? 993, + username, + password, + encryption: cfg.encryption, + folder: cfg.folder, + senderFilter: cfg.senderFilter ?? undefined, + domain: cfg.domain ?? undefined, + }; + } + + /** + * Maps one tenant's fetched InboxMessages into RawTenderRecord[] — one + * record per extracted candidate link (D-04), every record tagged with + * `ownerTenantId` (D-13) so the resulting Tender rows stay private to + * this tenant on the read side. + */ + private extractCandidates( + messages: InboxMessage[], + ownerTenantId: string, + fetchedAt: Date, + ): RawTenderRecord[] { + const records: RawTenderRecord[] = []; + + for (const message of messages) { + const links = extractCandidateLinks(message.bodyHtml, message.bodyText); + const title = titleFromEmail(message.subject, message.bodyText); + + for (const link of links) { + records.push({ + sourceType: this.sourceType, + sourcePortal: 'email-alert', + sourceNoticeId: sourceNoticeIdFor(link), + sourceUrl: link, + fetchedAt, + publishedAt: message.date ?? null, + eformsPayload: null, + // Same generic bag shape as RssAdapter/NetServerAdapter — + // buyerName/procedureType/deadlineAt stay null (D-04/D-05 thin + // field deferral); normalizeBag() maps this identically. + ocdsPayload: { + title, + buyerName: null, + procedureType: null, + legalFramework: null, + deadlineAt: null, + }, + ownerTenantId, + }); + } + } + + return records; } } diff --git a/apps/api/src/tenders/dto/tender-email-config.dto.ts b/apps/api/src/tenders/dto/tender-email-config.dto.ts new file mode 100644 index 0000000..4e84696 --- /dev/null +++ b/apps/api/src/tenders/dto/tender-email-config.dto.ts @@ -0,0 +1,79 @@ +import { + IsBoolean, + IsEmail, + IsIn, + IsInt, + IsOptional, + IsString, + Max, + Min, +} from 'class-validator'; + +/** + * DTO for creating or updating the per-tenant TenderEmailConfig (Phase 14, + * Plan 03, INGEST-05/CONFIG-02, D-06/D-07). Mirrors DkvConfigDto's mailbox + * fields exactly — this is a SEPARATE, tenant-scoped alert mailbox, not the + * DKV invoice inbox (D-03). + * + * Security: + * - T-14-03-02: senderFilter validated as email address (injection mitigation) + * - T-14-03-02: port constrained to 1-65535 + * - T-14-03-02: protocol/encryption constrained with @IsIn + */ +export class TenderEmailConfigDto { + /** Inbox protocol — 'imap' for IMAP, 'exchange' for Exchange (EWS). */ + @IsIn(['imap', 'exchange']) + protocol!: string; + + /** Mail server hostname/IP (IMAP) or full EWS endpoint URL (Exchange). */ + @IsOptional() + @IsString() + host?: string; + + /** TCP port. Standard values: 993 (IMAP SSL/TLS), 143 (IMAP STARTTLS), 443 (EWS). */ + @IsOptional() + @IsInt() + @Min(1) + @Max(65535) + port?: number; + + /** TLS mode: 'none' | 'starttls' | 'ssl-tls'. */ + @IsIn(['none', 'starttls', 'ssl-tls']) + encryption!: string; + + /** IMAP folder to monitor (e.g. "INBOX"). Exchange resolves via display name. */ + @IsOptional() + @IsString() + folder?: string; + + /** + * Sender email address to filter by. Validated as email address to + * prevent header injection (mirrors DkvConfigDto / T-07-04). + */ + @IsOptional() + @IsEmail() + senderFilter?: string; + + /** Exchange only: Windows domain (optional). */ + @IsOptional() + @IsString() + domain?: string; + + /** Inbox username (stored encrypted; blank on load, T-07-12). */ + @IsOptional() + @IsString() + username?: string; + + /** + * Inbox password (stored encrypted; blank on load). + * T-07-12: never returned to the frontend in responses. + */ + @IsOptional() + @IsString() + password?: string; + + /** Whether the email-alert poll is active for this tenant's mailbox. */ + @IsOptional() + @IsBoolean() + isActive?: boolean; +} diff --git a/apps/api/src/tenders/tender-dedup.service.spec.ts b/apps/api/src/tenders/tender-dedup.service.spec.ts index 498c894..f0ad314 100644 --- a/apps/api/src/tenders/tender-dedup.service.spec.ts +++ b/apps/api/src/tenders/tender-dedup.service.spec.ts @@ -284,3 +284,54 @@ describe('TenderDedupService.resolve — SCHEMA-02 change-detection preservation expect(prisma.tender.update).not.toHaveBeenCalled(); }); }); + +describe('TenderDedupService.resolve — D-13 ownerTenantId (Phase 14, Plan 03, INGEST-05)', () => { + it('CREATE writes ownerTenantId from the normalized record (email-alert, private)', async () => { + const prisma = makeFakePrisma(); + const service = new TenderDedupService(prisma as any); + + const emailRecord: NormalizedTenderFields = { + ...BASE, + ocid: null, + dedupKey: 'email-alert:link-hash-1', + sourcePortal: 'email-alert', + sourceNoticeId: 'link-hash-1', + ownerTenantId: 'tenant-a', + }; + + const result = await service.resolve(emailRecord, { dedupActive: true }); + + expect(result.created).toBe(true); + const row = prisma.__store.tenders.get(result.tenderId); + expect(row.ownerTenantId).toBe('tenant-a'); + }); + + it('CREATE defaults ownerTenantId to null when the record has none (every public source, D-03)', async () => { + const prisma = makeFakePrisma(); + const service = new TenderDedupService(prisma as any); + + const result = await service.resolve(BASE, { dedupActive: true }); + + const row = prisma.__store.tenders.get(result.tenderId); + expect(row.ownerTenantId).toBeNull(); + }); + + it('UPDATE branch (contentHash changed on a match) never references ownerTenantId — a globally-reseen tender is never retroactively hidden', async () => { + const prisma = makeFakePrisma(); + const service = new TenderDedupService(prisma as any); + + await service.resolve(BASE, { dedupActive: true }); + prisma.tender.update.mockClear(); + + const rePoll: NormalizedTenderFields = { + ...BASE, + title: 'Sanierung Stadtbrücke (Frist verlängert)', + contentHash: 'hash-2', + }; + await service.resolve(rePoll, { dedupActive: true }); + + expect(prisma.tender.update).toHaveBeenCalledTimes(1); + const updateData = prisma.tender.update.mock.calls[0][0].data; + expect(updateData).not.toHaveProperty('ownerTenantId'); + }); +}); diff --git a/apps/api/src/tenders/tender-dedup.service.ts b/apps/api/src/tenders/tender-dedup.service.ts index d9a19f0..47e6547 100644 --- a/apps/api/src/tenders/tender-dedup.service.ts +++ b/apps/api/src/tenders/tender-dedup.service.ts @@ -125,6 +125,12 @@ export class TenderDedupService { contentHash: n.contentHash, publishedAt: n.publishedAt, fingerprint, + // D-13: CREATE-only. undefined -> Prisma omits the field -> DB + // default null (global) for every source except email-alert. The + // UPDATE branch above deliberately never sets this — a tender first + // seen privately that is later ALSO seen on a public source must + // never be retroactively re-hidden by a re-poll of the private one. + ownerTenantId: n.ownerTenantId ?? null, }, }); diff --git a/apps/api/src/tenders/tender-email-config.service.spec.ts b/apps/api/src/tenders/tender-email-config.service.spec.ts new file mode 100644 index 0000000..be49874 --- /dev/null +++ b/apps/api/src/tenders/tender-email-config.service.spec.ts @@ -0,0 +1,169 @@ +import { describe, expect, it, vi } from 'vitest'; +import { TenderEmailConfigService } from './tender-email-config.service'; + +/** + * TenderEmailConfigService.spec — Phase 14, Plan 03 (CONFIG-02, D-06/D-07). + * Hand-rolled fake PrismaService (Map) + a fake CalendarCryptoService + * (deterministic reversible encode, NOT real AES) — same convention as + * tender-dedup.service.spec.ts: no live DB/crypto dependency, just proving + * this service's own encrypt-preserve-empty / safe-select contract. + */ + +function makeFakeCrypto() { + return { + encrypt: vi.fn((plaintext: string) => `enc:${Buffer.from(plaintext).toString('base64')}`), + decrypt: vi.fn((stored: string) => { + if (!stored.startsWith('enc:')) throw new Error('Invalid encrypted value format'); + return Buffer.from(stored.slice(4), 'base64').toString('utf8'); + }), + }; +} + +function makeFakePrisma() { + const configs = new Map(); + return { + tenderEmailConfig: { + findUnique: vi.fn(async ({ where, select }: any) => { + const row = configs.get(where.tenantId); + if (!row) return null; + if (!select) return row; + const out: any = {}; + for (const k of Object.keys(select)) out[k] = row[k]; + return out; + }), + upsert: vi.fn(async ({ where, update, create, select }: any) => { + const existing = configs.get(where.tenantId); + const row = existing ? { ...existing, ...update } : { id: 'cfg-1', ...create }; + configs.set(where.tenantId, row); + if (!select) return row; + const out: any = {}; + for (const k of Object.keys(select)) out[k] = row[k]; + return out; + }), + }, + __store: configs, + }; +} + +describe('TenderEmailConfigService', () => { + it('getConfigForApi returns null when no config exists for the tenant', async () => { + const prisma = makeFakePrisma(); + const crypto = makeFakeCrypto(); + const service = new TenderEmailConfigService(prisma as any, crypto as any); + + const result = await service.getConfigForApi('tenant-missing'); + + expect(result).toBeNull(); + }); + + it('saveConfig encrypts {username,password} and getConfigForApi round-trips username, NEVER returns the password field (T-07-12)', async () => { + const prisma = makeFakePrisma(); + const crypto = makeFakeCrypto(); + const service = new TenderEmailConfigService(prisma as any, crypto as any); + + await service.saveConfig('tenant-a', { + protocol: 'imap', + encryption: 'ssl-tls', + host: 'imap.example.test', + port: 993, + folder: 'INBOX', + username: 'alerts@example.test', + password: 'super-secret', + isActive: true, + } as any); + + const apiResult = await service.getConfigForApi('tenant-a'); + + expect(apiResult).not.toBeNull(); + expect(apiResult).not.toHaveProperty('password'); + expect(apiResult).not.toHaveProperty('encryptedInboxCreds'); + expect(apiResult!.username).toBe('alerts@example.test'); + expect(apiResult!.hasPassword).toBe(true); + }); + + it('saveConfig with no username/password leaves hasPassword false and username null (fresh config)', async () => { + const prisma = makeFakePrisma(); + const crypto = makeFakeCrypto(); + const service = new TenderEmailConfigService(prisma as any, crypto as any); + + await service.saveConfig('tenant-b', { + protocol: 'imap', + encryption: 'ssl-tls', + host: 'imap.example.test', + port: 993, + folder: 'INBOX', + isActive: false, + } as any); + + const apiResult = await service.getConfigForApi('tenant-b'); + + expect(apiResult!.hasPassword).toBe(false); + expect(apiResult!.username).toBeNull(); + expect(crypto.encrypt).not.toHaveBeenCalled(); + }); + + it('saveConfig preserves the existing password when only username changes on a re-save', async () => { + const prisma = makeFakePrisma(); + const crypto = makeFakeCrypto(); + const service = new TenderEmailConfigService(prisma as any, crypto as any); + + await service.saveConfig('tenant-c', { + protocol: 'imap', + encryption: 'ssl-tls', + username: 'old@example.test', + password: 'original-secret', + } as any); + + // Re-save with a new username, password left blank (T-07-12 UI convention) + await service.saveConfig('tenant-c', { + protocol: 'imap', + encryption: 'ssl-tls', + username: 'new@example.test', + } as any); + + const raw = prisma.__store.get('tenant-c'); + const decrypted = JSON.parse(crypto.decrypt(raw.encryptedInboxCreds)); + expect(decrypted.username).toBe('new@example.test'); + expect(decrypted.password).toBe('original-secret'); + }); + + it('saveConfig preserves the existing username when only password changes on a re-save', async () => { + const prisma = makeFakePrisma(); + const crypto = makeFakeCrypto(); + const service = new TenderEmailConfigService(prisma as any, crypto as any); + + await service.saveConfig('tenant-d', { + protocol: 'imap', + encryption: 'ssl-tls', + username: 'stable@example.test', + password: 'first-secret', + } as any); + + await service.saveConfig('tenant-d', { + protocol: 'imap', + encryption: 'ssl-tls', + password: 'rotated-secret', + } as any); + + const raw = prisma.__store.get('tenant-d'); + const decrypted = JSON.parse(crypto.decrypt(raw.encryptedInboxCreds)); + expect(decrypted.username).toBe('stable@example.test'); + expect(decrypted.password).toBe('rotated-secret'); + }); + + it('the safe select never includes encryptedInboxCreds in the upsert return value (T-07-12)', async () => { + const prisma = makeFakePrisma(); + const crypto = makeFakeCrypto(); + const service = new TenderEmailConfigService(prisma as any, crypto as any); + + const result = await service.saveConfig('tenant-e', { + protocol: 'imap', + encryption: 'ssl-tls', + username: 'x@example.test', + password: 'y', + } as any); + + expect(result).not.toHaveProperty('encryptedInboxCreds'); + expect(result).not.toHaveProperty('password'); + }); +}); diff --git a/apps/api/src/tenders/tender-email-config.service.ts b/apps/api/src/tenders/tender-email-config.service.ts new file mode 100644 index 0000000..376e984 --- /dev/null +++ b/apps/api/src/tenders/tender-email-config.service.ts @@ -0,0 +1,145 @@ +import { Injectable } from '@nestjs/common'; +import { CalendarCryptoService } from '../calendar/crypto.service'; +import { PrismaService } from '../prisma/prisma.service'; +import type { TenderEmailConfigDto } from './dto/tender-email-config.dto'; + +/** + * Prisma select for TenderEmailConfig — never includes encryptedInboxCreds. + * T-07-12: Encrypted credential blob is excluded from all API responses. + */ +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 +} as const; + +/** + * TenderEmailConfigService — per-tenant admin CRUD for the portal-alert + * mailbox config (Phase 14, Plan 03, INGEST-05/CONFIG-02, D-06/D-07). + * Structural clone of DkvService's config half (safe-select + encrypt- + * preserve-empty semantics), mirroring the exact same pattern already + * proven for DKV's own (separate, D-03) mailbox config. + * + * Security: + * - T-07-12: encryptedInboxCreds is excluded from every read-path select; + * getConfigForApi returns `hasPassword: boolean` instead of the password. + * - T-05-13: decrypted credentials only ever exist within a method's local + * scope — never logged. + * + * This service is used ONLY by the admin GET/PUT /email-config routes + * (TendersController). EmailAlertAdapter's own per-tenant poll-time fan-out + * decrypts credentials independently via a direct CalendarCryptoService + * injection (RESEARCH.md Pattern 1) — it does NOT go through this service, + * since the adapter's cross-tenant `findMany({where:{isActive:true}})` read + * is a deliberate platform-scheduler exception (see EmailAlertAdapter's + * docstring), structurally different from this service's tenant-scoped CRUD. + */ +@Injectable() +export class TenderEmailConfigService { + constructor( + private readonly prisma: PrismaService, + private readonly crypto: CalendarCryptoService, + ) {} + + /** + * Load config for API response: safe fields + decrypted username + + * hasPassword flag. T-07-12: password is NEVER returned. + */ + async getConfigForApi(tenantId: string) { + const safe = await this.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 this.prisma.tenderEmailConfig.findUnique({ where: { tenantId } }); + if (raw?.encryptedInboxCreds) { + const creds = JSON.parse(this.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 }; + } + + /** + * Upsert TenderEmailConfig for a tenant. + * + * Credential handling (identical semantics to DkvService.saveConfig): + * - dto.password non-empty: re-encrypt {username, password} together. + * - dto.username non-empty but dto.password empty: preserve existing + * password, re-encrypt with the new username. + * - both empty/undefined: preserve existing encryptedInboxCreds entirely. + * + * T-07-12: Returns safe select (no encryptedInboxCreds). + * T-05-13: Never logs decrypted credentials. + */ + async saveConfig(tenantId: string, dto: TenderEmailConfigDto) { + let encryptedInboxCreds: string | undefined; + + const credChanged = + (dto.password && dto.password.length > 0) || + (dto.username !== undefined && dto.username !== null); + + if (credChanged) { + let username: string = dto.username ?? ''; + let password: string = dto.password ?? ''; + + if (!dto.password || !dto.username) { + try { + const existing = await this.prisma.tenderEmailConfig.findUnique({ where: { tenantId } }); + if (existing?.encryptedInboxCreds) { + const stored = JSON.parse(this.crypto.decrypt(existing.encryptedInboxCreds)) as { + username?: string; + password?: string; + }; + if (!dto.username) username = stored.username ?? ''; + if (!dto.password) password = stored.password ?? ''; + } + } catch { + // Ignore decrypt errors — will overwrite with whatever was provided + } + } + + encryptedInboxCreds = this.crypto.encrypt(JSON.stringify({ username, password })); + } + + const data: Record = { + protocol: dto.protocol, + encryption: dto.encryption, + ...(dto.host !== undefined && { host: dto.host }), + ...(dto.port !== undefined && { port: dto.port }), + ...(dto.folder !== undefined && { folder: dto.folder }), + ...(dto.senderFilter !== undefined && { senderFilter: dto.senderFilter }), + ...(dto.isActive !== undefined && { isActive: dto.isActive }), + ...(dto.domain !== undefined && { domain: dto.domain }), + ...(encryptedInboxCreds !== undefined && { encryptedInboxCreds }), + }; + + return this.prisma.tenderEmailConfig.upsert({ + where: { tenantId }, + create: { tenantId, ...data }, + update: data, + select: EMAIL_CONFIG_SAFE_SELECT, + }); + } +} diff --git a/apps/api/src/tenders/tender-normalizer.service.ts b/apps/api/src/tenders/tender-normalizer.service.ts index d59a99c..fede804 100644 --- a/apps/api/src/tenders/tender-normalizer.service.ts +++ b/apps/api/src/tenders/tender-normalizer.service.ts @@ -183,6 +183,9 @@ export class TenderNormalizerService { sourceUrl: raw.sourceUrl ?? null, contentHash, publishedAt: raw.publishedAt ?? new Date(), + // D-13: passed through unchanged — undefined for every source except + // EmailAlertAdapter, which sets it to the configuring tenant's id. + ownerTenantId: raw.ownerTenantId, }; } } diff --git a/apps/api/src/tenders/tender.types.ts b/apps/api/src/tenders/tender.types.ts index fa3e608..abe9e19 100644 --- a/apps/api/src/tenders/tender.types.ts +++ b/apps/api/src/tenders/tender.types.ts @@ -56,6 +56,14 @@ export interface RawTenderRecord { eformsPayload: unknown; /** Parsed OCDS release object (the single `releases[0]` entry) for this notice. */ ocdsPayload: unknown; + /** + * D-13 (Phase 14, Plan 03) — set ONLY by EmailAlertAdapter, to the + * configuring tenant's id. Every other source leaves this undefined so + * the resulting Tender row stays global (null). Threaded through + * unchanged by TenderNormalizerService.assemble() and consumed by + * TenderDedupService's CREATE branch only (never the UPDATE branch). + */ + ownerTenantId?: string; } /** @@ -106,4 +114,10 @@ export interface NormalizedTenderFields { * before the resolver exists. */ fingerprint?: string; + /** + * D-13 (Phase 14, Plan 03) — passed through unchanged from + * RawTenderRecord.ownerTenantId by assemble(). null/undefined = global + * (every source except email-alert); set = visible only to that tenant. + */ + ownerTenantId?: string; } diff --git a/apps/api/src/tenders/tenders.module.ts b/apps/api/src/tenders/tenders.module.ts index 141784f..9e7c9ea 100644 --- a/apps/api/src/tenders/tenders.module.ts +++ b/apps/api/src/tenders/tenders.module.ts @@ -1,16 +1,20 @@ import { Logger, Module, OnModuleInit } from '@nestjs/common'; +import { CalendarModule } from '../calendar/calendar.module'; +import { InboxModule } from '../inbox/inbox.module'; import { ModuleRegistryModule } from '../module-registry/module-registry.module'; import { ModuleRegistryService } from '../module-registry/module-registry.service'; import { PrismaService } from '../prisma/prisma.service'; import { SettingsModule } from '../settings/settings.module'; import { CosinexAdapter } from './adapters/cosinex.adapter'; import { DoeOpenDataAdapter } from './adapters/doe-opendata.adapter'; +import { EmailAlertAdapter } from './adapters/email-alert.adapter'; import { NetServerAdapter } from './adapters/netserver.adapter'; import { RssAdapter } from './adapters/rss.adapter'; import { SourceRegistry } from './source-registry'; import { seedTendersModule } from './tenders.seed'; import { TenderDedupService } from './tender-dedup.service'; import { TenderDigestScheduler } from './tender-digest.scheduler'; +import { TenderEmailConfigService } from './tender-email-config.service'; import { TenderIngestionService } from './tender-ingestion.service'; import { TenderMailService } from './tender-mail.service'; import { TenderMatchingService } from './tender-matching.service'; @@ -104,15 +108,29 @@ import { TendersController } from './tenders.controller'; * service.bund.de is seeded as a default-active `TenderRssFeedSource` row * (RESEARCH.md Open Question 3), so RSS ingestion is live out of the box, * not "framework ready, activation deferred" like the Phase 13 sources. + * + * Phase 14, Plan 03 (INGEST-05): adds `EmailAlertAdapter` (registered + * alongside the existing adapters — `email-alert` is not denylisted) and + * `TenderEmailConfigService` (per-tenant admin CRUD for the alert mailbox + * config, D-06/D-07). `CalendarModule`/`InboxModule` are imported so the + * adapter/service can inject `CalendarCryptoService` (credential encryption) + * and `ImapProvider`/`ExchangeInboxProvider` (shared connection mechanics, + * D-01) — the same imports DkvModule already uses for its own, separate + * mailbox config (D-03). Unlike `rss`, the `email-alert` + * `TenderSourcePollConfig` seed is `isActive: false` with + * `pollGranularity: 'tick'` (D-15): the framework is ready, but activation + * requires an admin to actually configure a mailbox first — there is no + * safe default mailbox to seed (unlike RSS's service.bund.de default). */ @Module({ - imports: [ModuleRegistryModule, SettingsModule], + imports: [ModuleRegistryModule, SettingsModule, CalendarModule, InboxModule], controllers: [TendersController], providers: [ DoeOpenDataAdapter, NetServerAdapter, CosinexAdapter, RssAdapter, + EmailAlertAdapter, SourceRegistry, TenderNormalizerService, TenderDedupService, @@ -125,6 +143,7 @@ import { TendersController } from './tenders.controller'; TenderDigestScheduler, TenderNotificationPrefService, TenderRssFeedSourceService, + TenderEmailConfigService, ], }) export class TendersModule implements OnModuleInit { @@ -138,6 +157,7 @@ export class TendersModule implements OnModuleInit { private readonly netServerAdapter: NetServerAdapter, private readonly cosinexAdapter: CosinexAdapter, private readonly rssAdapter: RssAdapter, + private readonly emailAlertAdapter: EmailAlertAdapter, ) {} async onModuleInit(): Promise { @@ -145,16 +165,20 @@ export class TendersModule implements OnModuleInit { // D-06/INGEST-07: registration itself is the denylist-gate enforcement // point — SourceRegistry.register() throws for any adapter serving a // denylisted portal. DoeOpenDataAdapter, NetServerAdapter, - // CosinexAdapter, and RssAdapter are all legitimate (none of - // tender24/lhs-vpbw/vergabe.landbw/cosinex-dtvp/rss are on the - // denylist). Note: RssAdapter's `portals: ['rss']` is a SYMBOLIC - // placeholder — the actual admin-supplied feed hostnames are NOT - // covered by this gate at all (RESEARCH.md Pitfall 3); that runtime - // check lives in TenderRssFeedSourceService instead (D-14). + // CosinexAdapter, RssAdapter, and EmailAlertAdapter are all + // legitimate (none of tender24/lhs-vpbw/vergabe.landbw/cosinex-dtvp/ + // rss/email-alert are on the denylist). Note: RssAdapter's + // `portals: ['rss']` is a SYMBOLIC placeholder — the actual + // admin-supplied feed hostnames are NOT covered by this gate at all + // (RESEARCH.md Pitfall 3); that runtime check lives in + // TenderRssFeedSourceService instead (D-14). EmailAlertAdapter's + // `portals: ['email-alert']` is likewise symbolic — mailbox hosts + // are per-tenant admin input, not a portal the denylist gate models. this.sourceRegistry.register(this.doeAdapter); this.sourceRegistry.register(this.netServerAdapter); this.sourceRegistry.register(this.cosinexAdapter); this.sourceRegistry.register(this.rssAdapter); + this.sourceRegistry.register(this.emailAlertAdapter); this.logger.log( `Registered source adapters: ${this.sourceRegistry .activeAdapters() @@ -276,5 +300,30 @@ export class TendersModule implements OnModuleInit { } catch (error) { this.logger.error('Failed to seed service.bund.de RSS feed', error); } + + try { + // Phase 14, Plan 03 (INGEST-05, D-15): seed the email-alert poll + // config with pollGranularity: 'tick' (same tick-driven mechanism as + // rss — no lastIngestedDay day-cursor gate) and isActive: false — no + // tenant has configured a mailbox yet, so there is nothing to poll + // until an admin saves a TenderEmailConfig row via the settings UI + // (D-02: framework ready, activation deferred, same stance as + // ai-netserver/cosinex-dtvp). + await this.prisma.tenderSourcePollConfig.upsert({ + where: { sourceType: 'email-alert' }, + update: {}, + create: { + sourceType: 'email-alert', + pollIntervalMin: 60, + isActive: false, + pollGranularity: 'tick', + }, + }); + this.logger.log( + "email-alert poll config seeded (inactive, pollGranularity='tick')", + ); + } catch (error) { + this.logger.error('Failed to seed email-alert poll config', error); + } } }