6e2a641d76
- 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
196 lines
6.2 KiB
TypeScript
196 lines
6.2 KiB
TypeScript
import { Injectable, Logger } from '@nestjs/common';
|
|
import { CryptoService } from '../crypto/crypto.service';
|
|
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import { forTenant } from '../prisma/prisma-tenant.extension';
|
|
import { SmtpConfigDto } from './dto/smtp-config.dto';
|
|
import * as nodemailer from 'nodemailer';
|
|
|
|
/**
|
|
* Safe select for SmtpConfig rows — never returns the encrypted password to API callers.
|
|
* T-07-07: encryptedPassword is excluded from all GET responses.
|
|
*/
|
|
const SMTP_SAFE_SELECT = {
|
|
id: true,
|
|
tenantId: true,
|
|
host: true,
|
|
port: true,
|
|
encryption: true,
|
|
username: true,
|
|
// encryptedPassword: NEVER included — T-07-07
|
|
fromAddress: true,
|
|
createdAt: true,
|
|
updatedAt: true,
|
|
} as const;
|
|
|
|
@Injectable()
|
|
export class SettingsService {
|
|
private readonly logger = new Logger(SettingsService.name);
|
|
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly crypto: CryptoService,
|
|
) {}
|
|
|
|
/**
|
|
* Get the SMTP config for a tenant — safe (no password field).
|
|
* Returns null when no config row exists for the tenant.
|
|
*
|
|
* Mandantengebunden (260911-gwh): EIN Klient `tenantPrisma` fuer diese
|
|
* Methode, wie die restlichen Anfragewege dieser Datei.
|
|
*/
|
|
async getSmtpConfig(tenantId: string) {
|
|
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
|
return tenantPrisma.smtpConfig.findUnique({
|
|
where: { tenantId },
|
|
select: {
|
|
...SMTP_SAFE_SELECT,
|
|
// Include encryptedPassword presence for hasPassword boolean only
|
|
encryptedPassword: true,
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Upsert the SMTP config for a tenant.
|
|
* Encrypts the password with AES-256-GCM when a new password is provided.
|
|
* When `dto.password` is empty or absent, the existing encrypted password is preserved.
|
|
*
|
|
* T-07-08: Encryption via CryptoService. Never logs the plaintext password.
|
|
* Mandantengebunden (260911-gwh): EIN Klient `tenantPrisma`.
|
|
*/
|
|
async saveSmtpConfig(tenantId: string, dto: SmtpConfigDto) {
|
|
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
|
|
|
// Determine the encrypted password to store
|
|
let encryptedPassword: string | undefined;
|
|
|
|
if (dto.password && dto.password.length > 0) {
|
|
encryptedPassword = this.crypto.encrypt(dto.password);
|
|
// T-07-10: Never log the plaintext password
|
|
}
|
|
|
|
const data = {
|
|
host: dto.host,
|
|
port: dto.port,
|
|
encryption: dto.encryption,
|
|
username: dto.username ?? null,
|
|
fromAddress: dto.fromAddress,
|
|
...(encryptedPassword !== undefined ? { encryptedPassword } : {}),
|
|
};
|
|
|
|
const result = await tenantPrisma.smtpConfig.upsert({
|
|
where: { tenantId },
|
|
create: { tenantId, ...data },
|
|
update: data,
|
|
select: SMTP_SAFE_SELECT,
|
|
});
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Internal: Get the decrypted SMTP config for a tenant.
|
|
* Used by DkvMailService/TenderMailService — and seit 260914-eym auch von
|
|
* MailService (Systemmails, Transport je Versand nach Mandant des
|
|
* Empfaengers, WINDOWS #30) — to build a nodemailer transport at send
|
|
* time — the ONLY send path (Befund K, 260909-laa/260909-mir).
|
|
* NEVER log the decrypted password (T-07-10 / T-05-13).
|
|
*
|
|
* Mandantengebunden seit 260911-gwh (Aufgabe 2): EIN Klient
|
|
* `tenantPrisma`. Vorher lief diese Methode ungebunden — nach dem
|
|
* Scharfschalten waere fuer NIEMANDEN mehr eine Mail rausgegangen
|
|
* (tender: warn+skip, dkv: throw). Die Reihenfolgebedingung aus (t4)
|
|
* Befund K und (d4) ist mit dieser Bindung erfuellt.
|
|
*/
|
|
async getDecryptedSmtpConfig(tenantId: string): Promise<{
|
|
host: string;
|
|
port: number;
|
|
encryption: string;
|
|
username: string | null;
|
|
fromAddress: string;
|
|
decryptedPassword: string | null;
|
|
} | null> {
|
|
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
|
const config = await tenantPrisma.smtpConfig.findUnique({
|
|
where: { tenantId },
|
|
});
|
|
|
|
if (!config) return null;
|
|
|
|
let decryptedPassword: string | null = null;
|
|
if (config.encryptedPassword) {
|
|
// T-05-13: Never log this value
|
|
decryptedPassword = this.crypto.decrypt(config.encryptedPassword);
|
|
}
|
|
|
|
return {
|
|
host: config.host,
|
|
port: config.port,
|
|
encryption: config.encryption,
|
|
username: config.username,
|
|
fromAddress: config.fromAddress,
|
|
decryptedPassword,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Test an SMTP connection using the submitted DTO.
|
|
* When `dto.password` is empty, uses the stored decrypted password instead.
|
|
* Returns true on success, false on failure.
|
|
*
|
|
* T-07-16: Returns only a boolean — no credentials or transport details in the response.
|
|
* Kein eigener Datenbankzugriff — greift ueber `getDecryptedSmtpConfig`
|
|
* (bereits gebunden) auf gespeicherte Zugangsdaten zurueck.
|
|
*/
|
|
async testSmtpConfig(
|
|
tenantId: string,
|
|
dto: SmtpConfigDto,
|
|
): Promise<{ success: boolean; warning?: string }> {
|
|
let password: string | undefined = dto.password;
|
|
let username: string | undefined = dto.username;
|
|
|
|
// Fall back to stored credentials (form never pre-fills password — T-07-17)
|
|
if (!password || !username) {
|
|
const stored = await this.getDecryptedSmtpConfig(tenantId);
|
|
if (stored) {
|
|
if (!password) password = stored.decryptedPassword ?? undefined;
|
|
if (!username) username = stored.username ?? undefined;
|
|
}
|
|
}
|
|
|
|
try {
|
|
const transport = nodemailer.createTransport({
|
|
host: dto.host,
|
|
port: dto.port,
|
|
secure: dto.encryption === 'ssl-tls',
|
|
requireTLS: dto.encryption === 'starttls',
|
|
connectionTimeout: 10_000,
|
|
greetingTimeout: 10_000,
|
|
socketTimeout: 10_000,
|
|
auth: username
|
|
? { user: username, pass: password ?? '' }
|
|
: undefined,
|
|
});
|
|
|
|
if (dto.testTo) {
|
|
await transport.sendMail({
|
|
from: dto.fromAddress,
|
|
to: dto.testTo,
|
|
subject: 'Tessera SMTP-Test',
|
|
text: 'Diese E-Mail bestätigt, dass die SMTP-Konfiguration in Tessera funktioniert.',
|
|
});
|
|
} else {
|
|
await transport.verify();
|
|
}
|
|
|
|
return { success: true };
|
|
} catch (error) {
|
|
this.logger.warn(
|
|
`SMTP connection test failed for tenant ${tenantId}: ${(error as Error).message}`,
|
|
);
|
|
return { success: false };
|
|
}
|
|
}
|
|
}
|