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:
@@ -1,102 +1,32 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { MailerModule } from '@nestjs-modules/mailer';
|
||||
import { SettingsModule } from '../settings/settings.module';
|
||||
import { SettingsService } from '../settings/settings.service';
|
||||
import { MailService } from './mail.service';
|
||||
|
||||
/**
|
||||
* MailModule — system email delivery (password reset, welcome emails).
|
||||
*
|
||||
* D-06: SMTP transport is now sourced from the DB SmtpConfig row (priority 1)
|
||||
* with an env-var fallback (priority 2) when no DB row exists.
|
||||
* KEIN STARTPFAD MEHR (Etappe 3c, 260914-eym, WINDOWS #30 GESCHLOSSEN):
|
||||
* die Mailer-Fabrik (`MailerModule.forRootAsync`) und ihr Lesezugriff
|
||||
* `findFirst()` auf SmtpConfig beim Boot sind ersatzlos entfernt.
|
||||
* `MailService` baut je Versand einen nodemailer-Transport nach dem
|
||||
* Mandanten des Empfaengers (siehe dessen Kopfkommentar). Der sechste Fall
|
||||
* der Hintergrunddienst-Falle (docs/mandantentrennung-zugriffsklassifikation.md)
|
||||
* EXISTIERT damit NICHT MEHR — deshalb traegt SmtpConfig keine
|
||||
* `system_read_policy` (Migration 20260914120000).
|
||||
*
|
||||
* Transport priority:
|
||||
* 1. DB SmtpConfig (loadAnySmtpConfigForStartupTransport — bewusst
|
||||
* UNGEBUNDEN, sechster Fall der Hintergrunddienst-Falle, 260911-gwh;
|
||||
* siehe deren Kopfkommentar in settings.service.ts fuer beide
|
||||
* Zustaende: HEUTE zieht sie den Server EINES beliebigen Mandanten fuer
|
||||
* alle Systemmails [T-GWH-03], NACH DEM SCHARFSCHALTEN liefert sie
|
||||
* `null` und diese Rueckfallkette greift — WINDOWS #30)
|
||||
* Transport-Prioritaet JE VERSAND:
|
||||
* 1. SmtpConfig des Empfaenger-Mandanten (gebunden, `getDecryptedSmtpConfig(tenantId)`)
|
||||
* 2. Env vars: MAIL_HOST / MAIL_PORT / MAIL_USER / MAIL_PASS
|
||||
* 3. Legacy env vars: TESSERA_SMTP_HOST / TESSERA_SMTP_PORT / TESSERA_SMTP_USER / TESSERA_SMTP_PASSWORD
|
||||
* 4. Final hardcoded fallback: localhost:1025 (Mailhog / dev default)
|
||||
*
|
||||
* The factory is async because loadAnySmtpConfigForStartupTransport() reads
|
||||
* from the DB. No circular import risk: MailModule → SettingsModule →
|
||||
* CalendarModule (no reverse edges).
|
||||
* No circular import risk: MailModule -> SettingsModule -> CalendarModule
|
||||
* (no reverse edges). `@nestjs-modules/mailer` bleibt als Paket installiert,
|
||||
* wird aber von keinem Modul mehr benutzt.
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
SettingsModule,
|
||||
MailerModule.forRootAsync({
|
||||
imports: [SettingsModule],
|
||||
useFactory: async (settingsService: SettingsService, configService: ConfigService) => {
|
||||
// Priority 1: DB SmtpConfig — loadAnySmtpConfigForStartupTransport()
|
||||
// stays bewusst UNGEBUNDEN (findFirst, no tenant context at boot).
|
||||
const db = await settingsService.loadAnySmtpConfigForStartupTransport();
|
||||
|
||||
if (db) {
|
||||
// T-07-11: DB password used only to build transport; never logged
|
||||
return {
|
||||
transport: {
|
||||
host: db.host,
|
||||
port: db.port,
|
||||
secure: db.secure,
|
||||
requireTLS: db.requireTLS,
|
||||
auth: db.username
|
||||
? { user: db.username, pass: db.password ?? '' }
|
||||
: undefined,
|
||||
},
|
||||
defaults: {
|
||||
from: db.fromAddress,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Priority 2: Env vars (new names first, legacy TESSERA_SMTP_* as secondary fallback)
|
||||
const host =
|
||||
configService.get<string>('MAIL_HOST') ??
|
||||
configService.get<string>('TESSERA_SMTP_HOST') ??
|
||||
'localhost';
|
||||
|
||||
const port =
|
||||
configService.get<number>('MAIL_PORT') ??
|
||||
configService.get<number>('TESSERA_SMTP_PORT') ??
|
||||
1025;
|
||||
|
||||
const user =
|
||||
configService.get<string>('MAIL_USER') ??
|
||||
configService.get<string>('TESSERA_SMTP_USER') ??
|
||||
'';
|
||||
|
||||
const pass =
|
||||
configService.get<string>('MAIL_PASS') ??
|
||||
configService.get<string>('TESSERA_SMTP_PASSWORD') ??
|
||||
'';
|
||||
|
||||
const from =
|
||||
configService.get<string>('TESSERA_SMTP_FROM') ??
|
||||
'Tessera <tessera@tessera.local>';
|
||||
|
||||
const secure =
|
||||
configService.get<string>('TESSERA_SMTP_SECURE', 'false') === 'true';
|
||||
|
||||
return {
|
||||
transport: {
|
||||
host,
|
||||
port,
|
||||
secure,
|
||||
auth: { user, pass },
|
||||
},
|
||||
defaults: { from },
|
||||
};
|
||||
},
|
||||
inject: [SettingsService, ConfigService],
|
||||
}),
|
||||
],
|
||||
imports: [SettingsModule],
|
||||
providers: [MailService],
|
||||
exports: [MailService],
|
||||
})
|
||||
export class MailModule {}
|
||||
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import * as nodemailer from 'nodemailer';
|
||||
import { MailService } from './mail.service';
|
||||
|
||||
/**
|
||||
* MailService.spec — NEU (260914-eym, Etappe 3c, WINDOWS #30). Der Bereich
|
||||
* `mail` hatte VOR diesem Durchlauf KEINE Testdatei. Festgenagelt wird die
|
||||
* Bauform "Transport je Versand nach Mandant des Empfaengers":
|
||||
*
|
||||
* 1. IDENTITAET FUER EINEN MANDANTEN MIT SmtpConfig: Transport aus GENAU
|
||||
* dieser Config, `from` = deren fromAddress, `close()` gerufen.
|
||||
* 2. Mandant OHNE SmtpConfig: die bisherige Umgebungs-Kette (MAIL_* vor
|
||||
* TESSERA_SMTP_* vor localhost:1025), `from` aus TESSERA_SMTP_FROM bzw.
|
||||
* Vorgabe.
|
||||
* 3. ZWEI Mandanten nacheinander -> zwei verschiedene Transporte, keiner
|
||||
* sieht die Zugangsdaten des anderen (T-GWH-03 geschlossen).
|
||||
* 4. `sendMail` wirft -> kein Throw nach aussen (T-02-12), Fehler
|
||||
* protokolliert, `close()` trotzdem gerufen.
|
||||
*
|
||||
* `nodemailer` wird per `vi.mock` ersetzt (wie in settings.service.spec.ts)
|
||||
* — kein echter Transport, lokal gibt es keinen `mailhog`.
|
||||
*/
|
||||
|
||||
let mockSendMail = vi.fn(async (_mail: unknown) => ({}));
|
||||
const mockClose = vi.fn();
|
||||
vi.mock('nodemailer', () => ({
|
||||
createTransport: vi.fn(() => ({
|
||||
sendMail: (...args: unknown[]) => (mockSendMail as any)(...args),
|
||||
close: (...args: unknown[]) => (mockClose as any)(...args),
|
||||
})),
|
||||
}));
|
||||
|
||||
interface FakeDecrypted {
|
||||
host: string;
|
||||
port: number;
|
||||
encryption: string;
|
||||
username: string | null;
|
||||
fromAddress: string;
|
||||
decryptedPassword: string | null;
|
||||
}
|
||||
|
||||
function makeFakeSettings(configsByTenant: Record<string, FakeDecrypted>) {
|
||||
return {
|
||||
getDecryptedSmtpConfig: vi.fn(async (tenantId: string) => configsByTenant[tenantId] ?? null),
|
||||
};
|
||||
}
|
||||
|
||||
function makeFakeConfig(values: Record<string, string | number | undefined>) {
|
||||
return {
|
||||
get: vi.fn((key: string, fallback?: unknown) => (values[key] !== undefined ? values[key] : fallback)),
|
||||
};
|
||||
}
|
||||
|
||||
const configA: FakeDecrypted = {
|
||||
host: 'smtp-a.example.invalid',
|
||||
port: 465,
|
||||
encryption: 'ssl-tls',
|
||||
username: 'user-a',
|
||||
fromAddress: 'noreply@a.example.invalid',
|
||||
decryptedPassword: 'geheim-a',
|
||||
};
|
||||
|
||||
const configB: FakeDecrypted = {
|
||||
host: 'smtp-b.example.invalid',
|
||||
port: 587,
|
||||
encryption: 'starttls',
|
||||
username: 'user-b',
|
||||
fromAddress: 'noreply@b.example.invalid',
|
||||
decryptedPassword: 'geheim-b',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockSendMail = vi.fn(async (_mail: unknown) => ({}));
|
||||
});
|
||||
|
||||
describe('MailService — Transport je Versand nach Mandant des Empfaengers (260914-eym, WINDOWS #30)', () => {
|
||||
it('Test 1: Mandant MIT SmtpConfig -> getDecryptedSmtpConfig genau einmal mit dieser tenantId, createTransport mit deren host/port/secure/requireTLS/auth, from = deren fromAddress, close() gerufen (Identitaet zu heute)', async () => {
|
||||
const settings = makeFakeSettings({ t1: configA });
|
||||
const config = makeFakeConfig({ MAIL_HOST: 'env-darf-nicht-greifen' });
|
||||
const service = new MailService(settings as any, config as any);
|
||||
|
||||
await service.sendPasswordResetEmail('alice@a.example.invalid', 'tok-1', 't1');
|
||||
|
||||
expect(settings.getDecryptedSmtpConfig).toHaveBeenCalledTimes(1);
|
||||
expect(settings.getDecryptedSmtpConfig).toHaveBeenCalledWith('t1');
|
||||
expect(vi.mocked(nodemailer.createTransport)).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(nodemailer.createTransport)).toHaveBeenCalledWith({
|
||||
host: 'smtp-a.example.invalid',
|
||||
port: 465,
|
||||
secure: true,
|
||||
requireTLS: false,
|
||||
auth: { user: 'user-a', pass: 'geheim-a' },
|
||||
});
|
||||
expect(mockSendMail).toHaveBeenCalledTimes(1);
|
||||
const sent = mockSendMail.mock.calls[0][0] as any;
|
||||
expect(sent.from).toBe('noreply@a.example.invalid');
|
||||
expect(sent.to).toBe('alice@a.example.invalid');
|
||||
expect(sent.text).toContain('/reset-password/tok-1');
|
||||
expect(mockClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Test 2: Mandant OHNE SmtpConfig -> Umgebungs-Kette: MAIL_* vor TESSERA_SMTP_* vor localhost:1025, from aus TESSERA_SMTP_FROM bzw. Vorgabe', async () => {
|
||||
// (a) MAIL_* gesetzt -> gewinnt vor TESSERA_SMTP_*
|
||||
const svcA = new MailService(
|
||||
makeFakeSettings({}) as any,
|
||||
makeFakeConfig({
|
||||
MAIL_HOST: 'mail.example.invalid',
|
||||
MAIL_PORT: 2525,
|
||||
MAIL_USER: 'mail-user',
|
||||
MAIL_PASS: 'mail-pass',
|
||||
TESSERA_SMTP_HOST: 'legacy.example.invalid',
|
||||
TESSERA_SMTP_FROM: 'Tessera <from@example.invalid>',
|
||||
}) as any,
|
||||
);
|
||||
await svcA.sendPasswordResetEmail('x@example.invalid', 'tok', 't-ohne');
|
||||
expect(vi.mocked(nodemailer.createTransport)).toHaveBeenLastCalledWith({
|
||||
host: 'mail.example.invalid',
|
||||
port: 2525,
|
||||
secure: false,
|
||||
auth: { user: 'mail-user', pass: 'mail-pass' },
|
||||
});
|
||||
expect((mockSendMail.mock.calls.at(-1)![0] as any).from).toBe('Tessera <from@example.invalid>');
|
||||
|
||||
// (b) nur TESSERA_SMTP_* gesetzt -> zweite Stufe
|
||||
const svcB = new MailService(
|
||||
makeFakeSettings({}) as any,
|
||||
makeFakeConfig({
|
||||
TESSERA_SMTP_HOST: 'legacy.example.invalid',
|
||||
TESSERA_SMTP_PORT: 587,
|
||||
TESSERA_SMTP_USER: 'legacy-user',
|
||||
TESSERA_SMTP_PASSWORD: 'legacy-pass',
|
||||
TESSERA_SMTP_SECURE: 'true',
|
||||
}) as any,
|
||||
);
|
||||
await svcB.sendPasswordResetEmail('x@example.invalid', 'tok', 't-ohne');
|
||||
expect(vi.mocked(nodemailer.createTransport)).toHaveBeenLastCalledWith({
|
||||
host: 'legacy.example.invalid',
|
||||
port: 587,
|
||||
secure: true,
|
||||
auth: { user: 'legacy-user', pass: 'legacy-pass' },
|
||||
});
|
||||
expect((mockSendMail.mock.calls.at(-1)![0] as any).from).toBe('Tessera <tessera@tessera.local>');
|
||||
|
||||
// (c) nichts gesetzt -> localhost:1025
|
||||
const svcC = new MailService(makeFakeSettings({}) as any, makeFakeConfig({}) as any);
|
||||
await svcC.sendPasswordResetEmail('x@example.invalid', 'tok', 't-ohne');
|
||||
expect(vi.mocked(nodemailer.createTransport)).toHaveBeenLastCalledWith({
|
||||
host: 'localhost',
|
||||
port: 1025,
|
||||
secure: false,
|
||||
auth: { user: '', pass: '' },
|
||||
});
|
||||
expect(mockClose).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('Test 3: zwei Mandanten nacheinander -> zwei verschiedene Transporte, keiner sieht die Zugangsdaten des anderen (T-GWH-03 geschlossen)', async () => {
|
||||
const settings = makeFakeSettings({ t1: configA, t2: configB });
|
||||
const service = new MailService(settings as any, makeFakeConfig({}) as any);
|
||||
|
||||
await service.sendPasswordResetEmail('alice@a.example.invalid', 'tok-a', 't1');
|
||||
await service.sendWelcomeEmail('bob@b.example.invalid', 'bob', 't2');
|
||||
|
||||
expect(settings.getDecryptedSmtpConfig.mock.calls.map((c) => c[0])).toEqual(['t1', 't2']);
|
||||
const transports = vi.mocked(nodemailer.createTransport).mock.calls.map((c) => c[0] as any);
|
||||
expect(transports).toHaveLength(2);
|
||||
expect(transports[0].host).toBe('smtp-a.example.invalid');
|
||||
expect(transports[0].auth).toEqual({ user: 'user-a', pass: 'geheim-a' });
|
||||
expect(transports[1].host).toBe('smtp-b.example.invalid');
|
||||
expect(transports[1].requireTLS).toBe(true);
|
||||
expect(transports[1].auth).toEqual({ user: 'user-b', pass: 'geheim-b' });
|
||||
expect(JSON.stringify(transports[0])).not.toContain('geheim-b');
|
||||
expect(JSON.stringify(transports[1])).not.toContain('geheim-a');
|
||||
|
||||
const sentMails = mockSendMail.mock.calls.map((c) => c[0] as any);
|
||||
expect(sentMails[0].from).toBe('noreply@a.example.invalid');
|
||||
expect(sentMails[1].from).toBe('noreply@b.example.invalid');
|
||||
expect(sentMails[1].text).toContain('bob');
|
||||
expect(mockClose).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('Test 4: sendMail wirft -> kein Throw nach aussen (T-02-12), Fehler protokolliert ohne Kennwort, close() trotzdem gerufen', async () => {
|
||||
mockSendMail = vi.fn(async () => {
|
||||
throw new Error('ECONNREFUSED smtp-a.example.invalid');
|
||||
});
|
||||
const settings = makeFakeSettings({ t1: configA });
|
||||
const service = new MailService(settings as any, makeFakeConfig({}) as any);
|
||||
const errorSpy = vi.spyOn((service as any).logger, 'error').mockImplementation(() => undefined);
|
||||
|
||||
await expect(
|
||||
service.sendPasswordResetEmail('alice@a.example.invalid', 'tok-1', 't1'),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledTimes(1);
|
||||
expect(String(errorSpy.mock.calls[0][0])).toContain('Failed to send Password reset email to alice@a.example.invalid');
|
||||
expect(JSON.stringify(errorSpy.mock.calls[0])).not.toContain('geheim-a');
|
||||
expect(mockClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user