import { ConflictException, Injectable, Logger, Optional } from '@nestjs/common'; import { CryptoService } from '../crypto/crypto.service'; import { ExchangeInboxProvider } from '../inbox/exchange-inbox.provider'; import { ImapProvider } from '../inbox/imap.provider'; import type { InboxConfig, InboxProvider } from '../inbox/inbox-provider.interface'; import { PrismaService } from '../prisma/prisma.service'; import { forTenant } from '../prisma/prisma-tenant.extension'; import { prismaErrorCode } from '../prisma/prisma-error'; 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, userId: 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-USER CRUD for the portal-alert mailbox * config (Phase 14, Plan 03, INGEST-05/CONFIG-02, D-06/D-07; ownership * moved from tenant to user in Phase 17, Plan 01, D-01). 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. * * Ownership (Phase 17, D-01): a mailbox belongs to exactly one user * (`userId @unique`) — two users at the same tenant each connect their own * inbox independently, neither can read or overwrite the other's config. * `tenantId` stays denormalized on every row (SMTP resolution, same role as * `tenantId` on TenderMatch) and is written on both create and update, * since a user's tenant can in principle change. * * Mandantengebunden (WINDOWS #20 Etappe 2, 260909-laa): every read/write * below runs through `forTenant()`. Because `userId` alone (not * `(userId, tenantId)`) is the row's unique key, a user whose stored * `tenantId` has gone stale sees their OWN row become invisible under the * now-bound context — `getConfigForApi`/`testConnection` then correctly * report "no mailbox configured" (safe direction, no cross-tenant leak), * and a bound `saveConfig` upsert on that stale row hits the platform-wide * uniqueness constraint on `userId` and surfaces as a translated * ConflictException, not a raw 500 (T-LAA-07, Befund F, Aufgabe 1). * * Benutzerdimension seit 20260911120000 (Etappe 3b, 260911-nke): every * `forTenant()` call above also passes `userId` as the third argument, so * the `tenant_isolation_policy` on TenderEmailConfig ALSO enforces * `userId = current_user_id()` — a second net, not a replacement for the * `userId @unique` ownership model above. * * 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. * - T-17-01: userId/tenantId are supplied by the caller from the auth * context (TendersController.extractTriageContext) — this service never * derives ownership from anything the DTO carries. * * This service is used ONLY by the GET/PUT /email-config routes * (TendersController). EmailAlertAdapter's own poll-time fan-out decrypts * credentials independently via a direct CryptoService 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 per-user CRUD. */ @Injectable() export class TenderEmailConfigService { private readonly logger = new Logger(TenderEmailConfigService.name); constructor( private readonly prisma: PrismaService, private readonly crypto: CryptoService, /** * Appended as OPTIONAL trailing constructor params (Quick 260907-let) — * preserves every existing `new TenderEmailConfigService(prisma, * crypto)` 2-arg call site in the spec unchanged. NestJS DI always * resolves and injects both in production (InboxModule is imported in * tenders.module.ts and exports both providers) — same pattern as * TendersController.tenderIngestionService (tenders.controller.ts). */ @Optional() private readonly imapProvider?: ImapProvider, @Optional() private readonly exchangeProvider?: ExchangeInboxProvider, ) {} /** * Load config for API response: safe fields + decrypted username + * hasPassword flag. T-07-12: password is NEVER returned. Scoped strictly * by userId (T-17-01) — a user only ever reads their own mailbox. */ async getConfigForApi(userId: string, tenantId: string) { const tenantPrisma = forTenant(this.prisma, tenantId, userId); const safe = await tenantPrisma.tenderEmailConfig.findUnique({ where: { userId }, select: EMAIL_CONFIG_SAFE_SELECT, }); if (!safe) return null; let username: string | null = null; let hasPassword = false; try { const raw = await tenantPrisma.tenderEmailConfig.findUnique({ where: { userId } }); 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 user. * * 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. * * tenantId is written on both create AND update (Phase 17, D-01): it is * denormalized, so if the resolved tenant for this user ever changes the * stored value must follow. * * T-07-12: Returns safe select (no encryptedInboxCreds). * T-05-13: Never logs decrypted credentials. * * T-LAA-07 (Befund F): the upsert target (`userId`) has no tenant * dimension. A P2002 here means the caller's stale-tenant row already * exists and is now invisible under the bound context — translated into * a German ConflictException instead of a raw error (same pattern as * `tender-saved-search.service.ts`). */ async saveConfig(ctx: { userId: string; tenantId: string }, dto: TenderEmailConfigDto) { const { userId, tenantId } = ctx; const tenantPrisma = forTenant(this.prisma, tenantId, userId); 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 tenantPrisma.tenderEmailConfig.findUnique({ where: { userId } }); 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 }), }; // tenantId is written on BOTH create and update — it is denormalized // (Phase 17, D-01), so a user's resolved tenant must always overwrite // whatever was stored previously, not just be set once on create. try { return await tenantPrisma.tenderEmailConfig.upsert({ where: { userId }, create: { userId, tenantId, ...data }, update: { tenantId, ...data }, select: EMAIL_CONFIG_SAFE_SELECT, }); } catch (error: unknown) { if (prismaErrorCode(error) === 'P2002') { throw new ConflictException( 'Die Postfach-Konfiguration konnte nicht gespeichert werden, weil bereits ein widersprüchlicher Eintrag existiert. Bitte laden Sie die Seite neu und versuchen Sie es erneut.', ); } throw error; } } /** * Tests the inbox connection using credentials from the form DTO, * WITHOUT persisting anything (structural clone of * DkvService.testConnection, dkv.service.ts:201). * * Credential fallback (T-QT16-03): a DTO field left empty falls back to * this SAME user's stored, decrypted credentials — never another user's * row, since `where: { userId }` is the only lookup key. Unlike * saveConfig's credChanged branching, BOTH username and password are * independently backfilled here (saveConfig only ever needs to backfill * the one field the caller didn't touch on a partial re-save; a * connection test with a fully blank form needs both). * * T-05-13: decrypted credentials exist only within this method's local * scope — never returned, never logged beyond the userId itself. */ async testConnection( userId: string, tenantId: string, dto: TenderEmailConfigDto, ): Promise<{ success: boolean; message?: string }> { let username: string | undefined = dto.username; let password: string | undefined = dto.password; if (!username || !password) { try { const tenantPrisma = forTenant(this.prisma, tenantId, userId); const existing = await tenantPrisma.tenderEmailConfig.findUnique({ where: { userId } }); if (existing?.encryptedInboxCreds) { const stored = JSON.parse(this.crypto.decrypt(existing.encryptedInboxCreds)) as { username?: string; password?: string; }; if (!username) username = stored.username; if (!password) password = stored.password; } } catch { // T-05-13: generic — no credential details, only the userId this.logger.error(`testConnection: failed to load stored credentials for user ${userId}`); } } const inboxConfig: InboxConfig = { protocol: dto.protocol, host: dto.host ?? '', port: dto.port ?? 993, username, password, encryption: dto.encryption, folder: dto.folder ?? 'INBOX', senderFilter: dto.senderFilter, domain: dto.domain, }; const provider: InboxProvider | undefined = dto.protocol === 'exchange' ? this.exchangeProvider : this.imapProvider; if (!provider) { return { success: false, message: 'Inbox-Anbieter nicht verfuegbar' }; } return provider.testConnection(inboxConfig); } }