---
phase: 12-tender-notifications
plan: 02
type: execute
wave: 2
depends_on: [12-01]
files_modified:
- apps/api/src/tenders/tender-mail.service.ts
- apps/api/src/tenders/tender-mail.service.spec.ts
- apps/api/src/tenders/tender-digest.scheduler.ts
- apps/api/src/tenders/tender-digest.scheduler.spec.ts
- apps/api/src/tenders/tenders.module.ts
autonomous: true
requirements: [NOTIFY-01, NOTIFY-04, NOTIFY-03]
must_haves:
truths:
- "Ein Nutzer mit digestInterval=daily (oder ohne Pref-Zeile — Default daily, D-01) und mindestens einem notifiedAt=NULL-Match erhält beim Digest-Lauf genau eine E-Mail, nach Suchprofil gegliedert (D-02)"
- "Zwei Nutzer in zwei Mandanten mit daily erhalten je eine eigene E-Mail über ihre jeweilige mandanten-SMTP-Konfiguration (findMany, kein findFirst)"
- "Nach erfolgreichem Digest-Versand tragen alle einbezogenen Matches notifiedAt=now und notifiedChannel='digest' — beim nächsten Lauf werden sie nicht erneut versendet (D-06)"
- "Ein Mandant ohne SmtpConfig führt zu Skip+Log für dessen Nutzer, notifiedAt bleibt NULL, der Lauf bricht für andere Nutzer NICHT ab"
artifacts:
- "apps/api/src/tenders/tender-mail.service.ts (sendDigest/sendInstant, DkvMailService-Klon)"
- "apps/api/src/tenders/tender-digest.scheduler.ts (ein globaler Cron)"
- "apps/api/src/tenders/tender-mail.service.spec.ts"
- "apps/api/src/tenders/tender-digest.scheduler.spec.ts"
key_links:
- "TenderMailService ruft SettingsService.getDecryptedSmtpConfig(tenantId) je Send und erzeugt einen frischen nodemailer-Transport, transport.close() im finally (D-08)"
- "Der Digest-Cron selektiert fällige Nutzer via findMany und je Nutzer TenderMatch WHERE userId=? AND notifiedAt IS NULL"
- "TendersModule importiert SettingsModule, damit TenderMailService SettingsService injizieren kann"
---
Der Zustell-Kanal „periodischer Digest" plus der mandanten-SMTP-Versandpfad. Baut TenderMailService (1:1-Klon des DkvMailService-Musters: frischer nodemailer-Transport pro Send über die mandantenspezifische SmtpConfig, transport.close() im finally) und einen EINZIGEN globalen Digest-Cron, der über alle fälligen Nutzer aller Mandanten iteriert, je Nutzer die un-benachrichtigten Matches nach Suchprofil gruppiert, genau eine sektionierte Mail sendet und die Matches als benachrichtigt stempelt.
Purpose: NOTIFY-01 (konfigurierbarer Digest) + NOTIFY-04 (mandanten-SMTP) + der Digest-Hälfte von NOTIFY-03 (kein Doppelversand durch das notifiedAt-IS-NULL-Gate). Nach diesem Plan bekommt ein Nutzer proaktiv E-Mails über neue Treffer.
Output: TenderMailService, TenderDigestScheduler (global), registrierte Provider, SettingsModule-Import.
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
@.planning/phases/12-tender-notifications/12-CONTEXT.md
@.planning/phases/12-tender-notifications/12-RESEARCH.md
@apps/api/src/dkv/dkv-mail.service.ts
@apps/api/src/settings/settings.service.ts
@apps/api/src/settings/settings.module.ts
@apps/api/src/tenders/tender-scheduler.service.ts
@apps/api/src/tenders/tenders.module.ts
@apps/api/prisma/schema.prisma
Task 1: TenderMailService — mandanten-SMTP-Versand (DkvMailService-Klon)
apps/api/src/tenders/tender-mail.service.ts, apps/api/src/tenders/tender-mail.service.spec.ts
- Test 1 (D-08 SMTP-Auflösung): sendDigest ruft settingsService.getDecryptedSmtpConfig(tenantId) mit exakt der übergebenen tenantId; der nodemailer-Transport wird mit host/port/secure(ssl-tls)/requireTLS(starttls)/auth aus dieser Config gebaut.
- Test 2 (frischer Transport + close): nodemailer.createTransport wird pro Send genau einmal aufgerufen; transport.close() wird im finally aufgerufen (auch bei sendMail-Fehler) — WR-01 Socket-Leck-Schutz.
- Test 3 (fehlende Config): getDecryptedSmtpConfig=null → sendDigest/sendInstant sendet nichts, wirft NICHT, gibt einen falsy/„skipped"-Indikator zurück (Aufrufer setzt dann notifiedAt nicht).
- Test 4 (Betreff/Body): sendDigest baut EINE Mail an user.email, sektioniert nach Profilname; sendInstant baut EINE Mail für ein Profil mit seiner Tender-Liste. estimatedValue (String, meist null) wird nie ungeprüft Number()-coerct.
Schreibe ZUERST tender-mail.service.spec.ts (RED) — nodemailer wird gemockt wie im DkvMailService-Vorbild (vi.mock('nodemailer') mit createTransport → { sendMail, close }). Dann implementiere tender-mail.service.ts (GREEN) als strukturellen Klon von dkv-mail.service.ts.
TenderMailService (@Injectable) mit constructor(settingsService: SettingsService). Private Helper buildTransport-über-tenantId, der getDecryptedSmtpConfig(tenantId) lädt; bei null → generisches Log + return „skipped" (Signal an den Aufrufer, notifiedAt NICHT zu setzen → Retry beim nächsten Lauf, RESEARCH Pitfall 6). Transport exakt nach DkvMailService bauen: secure = encryption==='ssl-tls', requireTLS = encryption==='starttls', auth nur wenn username gesetzt, pass = decryptedPassword ?? ''. sendMail im try, transport.close() im finally.
Öffentliche Methoden:
- sendDigest(user: { email; ... }, tenantId, sections): sections ist die nach Profil gruppierte Trefferstruktur (Profilname → Tender[]). Baut EINE Mail (D-02): Betreff z.B. „Ausschreibungs-Radar: neue Treffer", Body je Profil eine Überschrift + Liste (Titel, buyerName, deadlineAt, estimatedValue nur wenn nicht null, sourceUrl-Link). Schlichtes hardcodiertes Deutsch, Text + optional HTML (i18n = Phase 14).
- sendInstant(search: { name; ... }, tenders: Tender[]): EINE Sammel-Mail für ein Profil (D-05) mit derselben Zeilenformatierung. (Von 12-03 aufgerufen; hier bereits implementieren, damit 12-03 nur noch verdrahtet.)
Security: entschlüsseltes Passwort nur im Method-Scope, nie loggen (T-07-10-Muster); generische Fehlermeldungen. Body als escaped/Text — Tender-Titel/Profilname nie ungeprüft in HTML interpolieren (E-Mail-Injection-Schutz).
pnpm --filter api test -- tender-mail.service
tender-mail.service.spec.ts grün: getDecryptedSmtpConfig je Send, frischer Transport + close() im finally, Skip bei fehlender Config ohne Throw, sektionierter Digest-Body; kein globaler Mailer verwendet.
Task 2: TenderDigestScheduler — ein globaler Cron, findMany über fällige Nutzer
apps/api/src/tenders/tender-digest.scheduler.ts, apps/api/src/tenders/tender-digest.scheduler.spec.ts
- Test 1 (Fälligkeit D-01): daily-Nutzer sind an jedem Lauf fällig; weekly-Nutzer nur an einem festen Wochentag (Montag Europe/Berlin); off-Nutzer nie. Ein Nutzer OHNE Pref-Zeile wird wie daily behandelt (Default D-01).
- Test 2 (Multi-Tenant, findMany): Zwei Nutzer in zwei verschiedenen Mandanten, beide daily, beide mit un-benachrichtigten Matches → beide erhalten je einen sendDigest-Aufruf mit ihrer jeweiligen tenantId. Es wird NIE nur der erste Nutzer bedient.
- Test 3 (Gruppierung + eine Mail, D-02): Ein Nutzer mit Matches aus 2 Profilen → genau ein sendDigest-Aufruf, dessen sections beide Profil-Abschnitte enthält.
- Test 4 (kein Doppelversand, D-06): Nur Matches mit notifiedAt=NULL werden selektiert; nach erfolgreichem Send updateMany notifiedAt=now, notifiedChannel='digest' auf genau diese Match-IDs. Ein bereits per instant benachrichtigter Match (notifiedAt gesetzt) wird nie einbezogen.
- Test 5 (Robustheit): sendDigest-Fehler / fehlende SMTP für einen Nutzer → dessen Matches bleiben notifiedAt=NULL, der Lauf fährt mit den übrigen Nutzern fort (kein Cron-Crash).
Schreibe ZUERST tender-digest.scheduler.spec.ts (RED), dann implementiere tender-digest.scheduler.ts (GREEN). Registrierungs- und CronJob-Mechanik verbatim aus tender-scheduler.service.ts übernehmen (require('cron').CronJob-Workaround, SchedulerRegistry.addCronJob, JOB_NAME='tender-digest', OnModuleInit).
TenderDigestScheduler (@Injectable, implements OnModuleInit) mit constructor(schedulerRegistry: SchedulerRegistry, prisma: PrismaService, mail: TenderMailService). onModuleInit registriert EINEN globalen Cron (z.B. '0 7 * * *', täglich 07:00 — ein einziger platform-weiter Job, KEINE Tenant-Dimension, exakt das TenderSchedulerService-Muster, NICHT das DkvSchedulerService-Einzelmandanten-Muster).
Kern-Methode runDigest(): Ermittle die Kandidaten-Nutzer als distinct userId aus prisma.tenderMatch.findMany/groupBy where { notifiedAt: null } — nur Nutzer mit offenen Treffern. Lade je Kandidat dessen Pref via prisma.tenderNotificationPref.findUnique({ where: { userId } }); fehlt die Zeile → als 'daily' behandeln (Default D-01). Bestimme Fälligkeit: daily=immer, weekly=nur wenn Europe/Berlin-Wochentag Montag, off=skip. WICHTIG: über ALLE fälligen Nutzer iterieren (findMany-Semantik) — den Einzelmandanten-Einstieg (findFirst) NICHT verwenden; das ist der dokumentierte DkvScheduler-v1-Gap (RESEARCH Pitfall 1). Je fälligem Nutzer: matches = prisma.tenderMatch.findMany({ where: { userId, notifiedAt: null }, include: { tender: true, savedSearch: true }, orderBy }); bei leer weiter. Lade user = prisma.user.findUnique({ where: { id: userId } }) für email + tenantId (bevorzugt user.tenantId für die SMTP-Auflösung). Gruppiere nach savedSearch (D-02), rufe mail.sendDigest(user, user.tenantId, sections). NUR bei erfolgreichem (nicht-„skipped") Send: prisma.tenderMatch.updateMany({ where: { id: { in: [...] } }, data: { notifiedAt: new Date(), notifiedChannel: 'digest' } }). Pro Nutzer try/catch-and-log — ein Fehler bricht den Gesamtlauf nicht ab (RESEARCH Pitfall 6).
pnpm --filter api test -- tender-digest.scheduler
tender-digest.scheduler.spec.ts grün: Fälligkeit daily/weekly/off + Default-daily, Multi-Tenant über findMany, eine sektionierte Mail je Nutzer, notifiedAt-Stempelung nur nach Erfolg, Robustheit bei SMTP-Fehler.
Task 3: Provider-Registrierung + SettingsModule-Import
apps/api/src/tenders/tenders.module.ts
Erweitere tenders.module.ts: importiere SettingsModule (aus ../settings/settings.module — es exportiert SettingsService, siehe DkvModule-Vorbild), damit TenderMailService SettingsService injizieren kann. Füge TenderMailService und TenderDigestScheduler dem providers-Array hinzu (neben den bestehenden inkl. dem in 12-01 hinzugefügten TenderMatchingService). Aktualisiere den Modul-Doc-Kommentar um den Plan-12-02-Beitrag (Digest-Kanal + mandanten-SMTP). ScheduleModule.forRoot() ist bereits global in AppModule registriert — nicht erneut importieren.
Verifiziere per Test-Build, dass die DI-Graph auflösbar ist (TenderDigestScheduler → TenderMailService → SettingsService).
cd apps/api && npx tsc --noEmit -p tsconfig.json && grep -q "SettingsModule" src/tenders/tenders.module.ts && grep -q "TenderDigestScheduler" src/tenders/tenders.module.ts
TendersModule importiert SettingsModule und registriert TenderMailService + TenderDigestScheduler; tsc --noEmit ist fehlerfrei (DI-Graph auflösbar).
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Digest-Cron (Server) → mandanten-SMTP-Relay | Entschlüsselte SMTP-Credentials + Empfängeradressen verlassen den Prozess Richtung externem Mailserver |
| DB (SmtpConfig, verschlüsselt) → TenderMailService | AES-256-GCM-Passwort wird nur im Send-Scope entschlüsselt |
| TenderMatch (per-user) → Digest-Mail | Trefferdaten eines Nutzers dürfen nur an genau diesen Nutzer gehen |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-12-05 | Information Disclosure | Cross-Tenant-Digest | high | mitigate | Digest-Query strikt where:{userId}; SMTP je Nutzer über dessen tenantId; findMany über alle Nutzer statt findFirst (Pitfall 1) — kein Cross-User/Cross-Tenant-Leak |
| T-12-06 | Information Disclosure | SMTP-Credential-Leak in Logs | high | mitigate | decryptedPassword nur im Method-Scope, nie geloggt; generische Fehlermeldungen (DkvMailService T-07-10-Muster) |
| T-12-07 | Denial of Service | Cron-Crash durch einen defekten Mandanten | high | mitigate | Pro-Nutzer try/catch-and-log; fehlende SmtpConfig → Skip+notifiedAt bleibt NULL; nie den ganzen Lauf abbrechen (Pitfall 6) |
| T-12-08 | Tampering | E-Mail-Injection über Tender-Titel/Profilname | medium | mitigate | nodemailer escaped Header; Body als Text/escaped HTML, keine ungeprüfte HTML-Interpolation von Tender-Feldern |
| T-12-09 | Elevation of Privilege | Doppelversand-Umgehung des notifiedAt-Gates | high | mitigate | Digest selektiert ausschließlich notifiedAt IS NULL; Stempelung nur nach erfolgreichem Send → strukturell kein Doppelversand (D-06) |
| T-12-SC | Tampering | npm/pip/cargo installs | high | accept | Keine Paketinstallation (RESEARCH Package Legitimacy Audit: keine Neuinstallation) |
- `pnpm --filter api test -- tender-mail.service` grün
- `pnpm --filter api test -- tender-digest.scheduler` grün
- `npx tsc --noEmit` fehlerfrei (DI-Graph auflösbar)
- Manuell/UAT (end-of-phase): Digest-Testlauf gegen lokales Mailhog (localhost:1025) zeigt genau eine sektionierte Mail je Nutzer
- Nutzer mit offenen Matches und daily/Default erhält beim Digest-Lauf genau eine nach Profil gegliederte Mail über seine mandanten-SMTP
- Zwei Nutzer/zwei Mandanten daily → beide erhalten je eine Mail (findMany, kein findFirst)
- Nach Versand tragen die Matches notifiedAt + channel='digest'; kein erneuter Versand
- Fehlende SMTP eines Mandanten bricht den Lauf nicht ab