import { Injectable } from '@nestjs/common'; import { CryptoService } from '../crypto/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, 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. * * 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 { constructor( private readonly prisma: PrismaService, private readonly crypto: CryptoService, ) {} /** * 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) { const safe = await this.prisma.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 this.prisma.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. */ async saveConfig(ctx: { userId: string; tenantId: string }, dto: TenderEmailConfigDto) { const { userId, tenantId } = ctx; 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: { 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. return this.prisma.tenderEmailConfig.upsert({ where: { userId }, create: { userId, tenantId, ...data }, update: { tenantId, ...data }, select: EMAIL_CONFIG_SAFE_SELECT, }); } }