feat(quick-260914-eym): Mail-Transport je Versand nach Mandant (WINDOWS #30), ldap/digest/matching ueber Systemkontext, vier Tabellen im Werkzeug, Erlaubnisliste vollstaendig

- mail: MailerModule-Fabrik und DB-Startpfad (findFirst beim Boot) ersatzlos
  entfernt; MailService baut je Versand einen nodemailer-Transport aus
  getDecryptedSmtpConfig(tenantId) des Empfaenger-Mandanten, Umgebungs-Kette
  (MAIL_* -> TESSERA_SMTP_* -> localhost:1025) nur als Rueckfall; Fehler
  weiter verschluckt (T-02-12), close() im finally; neue mail.service.spec.ts
  (4 Tests, T-GWH-03 geschlossen)
- settings: Startpfad-Methode samt vier Spec-Tests geloescht;
  auth: requestPasswordReset reicht user.tenantId durch (Spec-Zusicherung)
- ldap: getAllActiveConfigs und Nachverschluesselung lesen ueber forSystem
  (zwei Zuweisungen), Schreibzeile je Altzeile ueber forTenant(config.tenantId);
  Tests 301/306 umgedreht, neuer Altzeilen-Test
- tender-digest: Kandidatenabfrage ueber forSystem, Schleife gebunden (+1 Test)
- tender-matching: Profilabfrage ueber forSystem, Katalog (D-03) ungebunden (+1 Test)
- tender-notifications.integration.spec: Mock um forSystem
- Werkzeug: LdapConfig (15 Spalten), LdapFieldMapping (6), TenderMatch (8),
  TenderSavedSearch (8) je neun Kennungen plus Relations-Kennung
  ldapconfig-systemkontext-include-fieldmappings-beider-mandanten
  -> Alle 253 Pruefungen bestanden
- Detektor: FORSYSTEM_ALLOWED_CALL_SITES auf 4 Dateien / 5 Aufrufe;
  Proben-Empfaenger sysPrisma (Gate-Zaehlung, Name nicht hartkodiert)
- Klassifikation: 6 Zeilen system-gebunden, settings/smtpConfig gebunden
- Falsifizierung durch Rueckbau ausgefuehrt und zurueckgenommen:
  (a) FOR SELECT bei TenderMatch entfernt -> 5 von 253 rot (Insert gelingt,
  cmd ALL); (b) Regel TenderSavedSearch aus der Datei entfernt -> 1 von 245
  rot (Extraktion), lebende DB bleibt bei 34; (c) local=false -> gruen, plus
  Reset entfernt -> 5 rot (Erben sichtbar); (d) Zahl 0 -> 2 rot, Fremddatei
  admin-seed -> 3 rot
- Baseline: 64 Dateien / 1054 Tests, tsc 0, Werkzeug 253

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY
This commit is contained in:
2026-09-14 11:46:08 +02:00
parent 3d645674f0
commit 6e2a641d76
18 changed files with 828 additions and 336 deletions
+156 -31
View File
@@ -1,6 +1,52 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { MailerService } from '@nestjs-modules/mailer';
import * as nodemailer from 'nodemailer';
import { SettingsService } from '../settings/settings.service';
/**
* MailService — Systemmails (Kennwort-Zuruecksetzung, Willkommensmail).
*
* TRANSPORT JE VERSAND NACH MANDANT DES EMPFAENGERS (Etappe 3c, 260914-eym,
* WINDOWS #30 GESCHLOSSEN):
*
* Vorher baute `mail.module.ts` beim Start EINEN Transport aus einer
* beliebigen SmtpConfig (`findFirst()` ohne Bedingung) und alle
* Systemmails aller Mandanten liefen ueber den SMTP-Server und die
* Absenderadresse DIESES einen Mandanten (T-GWH-03). Zwei Gruende, warum
* der Transport jetzt JE VERSAND entsteht:
*
* 1. Pitfall 3 (Research): ein Start-Transport kann nicht wechseln — eine
* Aenderung der SMTP-Einstellungen im UI griff erst nach einem Neustart.
* 2. Mandantentrennung: der Mandant des EMPFAENGERS entscheidet, welche
* Zugangsdaten benutzt werden — nie ein beliebiger. Der Mandant ist an
* der einzigen produktiven Versandstelle bekannt
* (`AuthService.requestPasswordReset`: `user.tenantId` steht eine Zeile
* vor dem Versand). Vorlage: `DkvMailService`/`TenderMailService`
* (`getDecryptedSmtpConfig(tenantId)`, gebunden, `nodemailer.createTransport`,
* `transport.close()` im `finally`).
*
* Die Umgebungs-Kette (MAIL_* -> TESSERA_SMTP_* -> localhost:1025) ist NUR
* noch der Rueckfall fuer Mandanten OHNE eigene SmtpConfig — nicht mehr
* der Ersatz fuer einen verstummten Startpfad. Es gibt keinen Startpfad
* mehr, deshalb braucht `SmtpConfig` auch keine `system_read_policy`.
*
* Was mit EINEM Mandanten identisch bleibt (mail.service.spec.ts): Mandant
* MIT SmtpConfig -> Transport aus GENAU dieser Config, `from` = deren
* fromAddress; Mandant OHNE -> dieselbe Umgebungs-Kette wie bisher;
* Transportfehler werden weiter verschluckt und protokolliert (T-02-12 —
* der Anmeldeweg antwortet weiter 200, keine E-Mail-Enumeration).
*
* Sicherheit: das entschluesselte Kennwort existiert nur im Rumpf von
* `resolveTransport`/`sendViaTenantTransport` und wird nie protokolliert
* (T-07-10/T-07-11); Protokollzeilen nennen nur Quelle (tenant/env) und
* Empfaenger.
*/
interface ResolvedTransport {
source: 'tenant' | 'env';
options: nodemailer.TransportOptions & Record<string, unknown>;
from: string;
}
@Injectable()
export class MailService {
@@ -8,8 +54,8 @@ export class MailService {
private readonly appUrl: string;
constructor(
private mailerService: MailerService,
private configService: ConfigService,
private readonly settingsService: SettingsService,
private readonly configService: ConfigService,
) {
this.appUrl = this.configService.get<string>(
'TESSERA_APP_URL',
@@ -17,14 +63,113 @@ export class MailService {
);
}
/**
* Transport-Optionen fuer den Mandanten des Empfaengers: die SmtpConfig
* des Mandanten (gebunden ueber `getDecryptedSmtpConfig(tenantId)`),
* sonst die bisherige Umgebungs-Kette aus `mail.module.ts` unveraendert.
*/
private async resolveTransport(tenantId: string): Promise<ResolvedTransport> {
const smtpConfig = await this.settingsService.getDecryptedSmtpConfig(tenantId);
if (smtpConfig) {
return {
source: 'tenant',
options: {
host: smtpConfig.host,
port: smtpConfig.port,
secure: smtpConfig.encryption === 'ssl-tls',
requireTLS: smtpConfig.encryption === 'starttls',
auth: smtpConfig.username
? {
user: smtpConfig.username,
// T-07-10/T-07-11: entschluesseltes Kennwort nur hier, nie protokolliert
pass: smtpConfig.decryptedPassword ?? '',
}
: undefined,
},
from: smtpConfig.fromAddress,
};
}
// Rueckfall: Umgebungsvariablen (neue Namen zuerst, TESSERA_SMTP_* als
// zweite Stufe, zuletzt localhost:1025 — Mailhog / dev default).
const host =
this.configService.get<string>('MAIL_HOST') ??
this.configService.get<string>('TESSERA_SMTP_HOST') ??
'localhost';
const port =
this.configService.get<number>('MAIL_PORT') ??
this.configService.get<number>('TESSERA_SMTP_PORT') ??
1025;
const user =
this.configService.get<string>('MAIL_USER') ??
this.configService.get<string>('TESSERA_SMTP_USER') ??
'';
const pass =
this.configService.get<string>('MAIL_PASS') ??
this.configService.get<string>('TESSERA_SMTP_PASSWORD') ??
'';
const from =
this.configService.get<string>('TESSERA_SMTP_FROM') ??
'Tessera <tessera@tessera.local>';
const secure =
this.configService.get<string>('TESSERA_SMTP_SECURE', 'false') === 'true';
return {
source: 'env',
options: { host, port, secure, auth: { user, pass } },
from,
};
}
/**
* Der eine Versandpfad: Transport je Versand aus `resolveTransport`,
* Fehler verschluckt und protokolliert (T-02-12), `close()` im `finally`
* (WR-01 — keine offenen Verbindungen).
*/
private async sendViaTenantTransport(
tenantId: string,
mail: { to: string; subject: string; text: string },
kind: string,
): Promise<void> {
let transport: nodemailer.Transporter | null = null;
try {
const resolved = await this.resolveTransport(tenantId);
transport = nodemailer.createTransport(resolved.options as any);
await transport.sendMail({
from: resolved.from,
to: mail.to,
subject: mail.subject,
text: mail.text,
});
this.logger.log(`${kind} email sent to ${mail.to} (transport: ${resolved.source})`);
} catch (error) {
// Log but don't throw -- caller returns 200 regardless (T-02-12)
this.logger.error(
`Failed to send ${kind} email to ${mail.to}`,
error instanceof Error ? error.stack : String(error),
);
} finally {
transport?.close();
}
}
/**
* Send a password reset email with a time-limited token link.
* T-02-12: The caller always returns 200 regardless of whether this succeeds
* (no email enumeration).
*
* @param tenantId - Mandant des Empfaengers (entscheidet ueber den SMTP-Transport)
*/
async sendPasswordResetEmail(
email: string,
token: string,
tenantId: string,
locale: string = 'de',
): Promise<void> {
const resetLink = `${this.appUrl}/reset-password/${token}`;
@@ -66,28 +211,20 @@ export class MailService {
'The Tessera Team',
].join('\n');
try {
await this.mailerService.sendMail({
to: email,
subject,
text,
});
this.logger.log(`Password reset email sent to ${email}`);
} catch (error) {
// Log but don't throw -- caller returns 200 regardless (T-02-12)
this.logger.error(
`Failed to send password reset email to ${email}`,
error instanceof Error ? error.stack : String(error),
);
}
await this.sendViaTenantTransport(tenantId, { to: email, subject, text }, 'Password reset');
}
/**
* Send a welcome email to a newly created user (optional).
* Send a welcome email to a newly created user (optional — derzeit ohne
* Aufrufer, gemessen 260914-eym; bleibt als Pfad ueber denselben
* Transport je Versand erhalten).
*
* @param tenantId - Mandant des Empfaengers (entscheidet ueber den SMTP-Transport)
*/
async sendWelcomeEmail(
email: string,
username: string,
tenantId: string,
locale: string = 'de',
): Promise<void> {
const isGerman = locale === 'de';
@@ -117,18 +254,6 @@ export class MailService {
'The Tessera Team',
].join('\n');
try {
await this.mailerService.sendMail({
to: email,
subject,
text,
});
this.logger.log(`Welcome email sent to ${email}`);
} catch (error) {
this.logger.error(
`Failed to send welcome email to ${email}`,
error instanceof Error ? error.stack : String(error),
);
}
await this.sendViaTenantTransport(tenantId, { to: email, subject, text }, 'Welcome');
}
}