feat(14-03): add per-tenant encrypted TenderEmailConfig + ownerTenantId write-side (D-13)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<typeof vi.fn>;
|
||||
exchangeFetchMessages?: ReturnType<typeof vi.fn>;
|
||||
} = {}) {
|
||||
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: '<msg-1>',
|
||||
subject: 'Neue Ausschreibung Nr. 42',
|
||||
from: 'alerts@portal.example',
|
||||
date: new Date('2026-07-23T09:00:00.000Z'),
|
||||
bodyHtml: '<a href="https://portal.example/detail/42">Details</a>',
|
||||
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:
|
||||
'<a href="https://portal.example/detail/1">A</a><a href="https://portal.example/detail/2">B</a>',
|
||||
};
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<RawTenderRecord[]> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user