feat(users): Willkommensmail mit Wellen-Kopf und Logo, Spalte Letzte Anmeldung
Tessera CI/CD / Lint & Type Check (push) Successful in 48s
Tessera CI/CD / Tests (push) Successful in 1m18s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 18s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m11s

POST /users/:id/welcome-mail (gleiche Rechte wie Bearbeiten, jederzeit sendbar),
GET /users/welcome-mail/status; HTML-Mail (Tabellenlayout, Inline-Stile,
Kopfbild als CID-PNG aus assets/mail/welcome-header.svg, erzeugt mit
scripts/render-mail-header.mjs) plus Textfassung. Verzeichniskonten: Hinweis
auf Windows-Passwort; lokale Konten: Link Passwort festlegen (7 Tage, einmalig).
Neue Spalte User.welcomeMailSentAt (Migration 20260930120000). Benutzerliste:
Spalte Letzte Anmeldung, Zeilenaktionen als Symbole. Dockerfile kopiert
apps/api/assets. Lokal per MailHog nachgewiesen.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-30 15:28:52 +02:00
parent 0b34e82b21
commit 32441d77c7
24 changed files with 1540 additions and 115 deletions
+80 -2
View File
@@ -1,4 +1,4 @@
import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
import { BadRequestException, ConflictException, ForbiddenException, NotFoundException } from '@nestjs/common';
import { Role } from '@prisma/client';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
@@ -124,11 +124,13 @@ describe('UserController', () => {
let prisma: any;
let userService: any;
let controller: UserController;
let welcomeMail: { isAvailable: ReturnType<typeof vi.fn>; send: ReturnType<typeof vi.fn> };
beforeEach(() => {
prisma = makeFakePrisma();
userService = makeUserServiceMock();
controller = new UserController(userService as any, prisma);
welcomeMail = { isAvailable: vi.fn(), send: vi.fn() };
controller = new UserController(userService as any, prisma, welcomeMail as any);
});
describe('findAll', () => {
@@ -663,4 +665,80 @@ describe('UserController', () => {
expect(await storedFor('t2', 'u-other')).toBeNull();
});
});
describe('Willkommensmail (POST :id/welcome-mail, GET welcome-mail/status)', () => {
const admin = { role: Role.ADMIN, tenantId: 't1', id: 'admin1', username: 'admin1', mustChangePassword: false };
const superAdmin = { role: Role.SUPER_ADMIN, tenantId: 't9', id: 'super1', username: 'super1', mustChangePassword: false };
const sentAt = new Date('2026-09-30T10:00:00.000Z');
it('ADMIN: Ziel gebunden an den eigenen Mandanten aufgeloest, Dienst mit Ziel und Origin gerufen, Antwort traegt Empfaenger und Zeitpunkt', async () => {
const target = { id: 'u-x', username: 'x', tenantId: 't1', role: 'USER', lastLoginAt: null };
userService.findById.mockResolvedValue(target);
welcomeMail.send.mockResolvedValue({ to: 'x@x.invalid', welcomeMailSentAt: sentAt });
const result = await controller.sendWelcomeMail('u-x', admin, 'https://tessera.example.invalid');
expect(userService.findById).toHaveBeenCalledWith('t1', 'u-x');
expect(welcomeMail.send).toHaveBeenCalledWith(target, 'https://tessera.example.invalid');
expect(result).toEqual({ success: true, to: 'x@x.invalid', welcomeMailSentAt: sentAt });
});
it('ADMIN: unbekannter oder fremdmandantiger Benutzer (gebundene Aufloesung null) → NotFoundException, kein Versand', async () => {
userService.findById.mockResolvedValue(null);
await expect(controller.sendWelcomeMail('u-fremd', admin)).rejects.toBeInstanceOf(NotFoundException);
expect(welcomeMail.send).not.toHaveBeenCalled();
});
it('ADMIN: Ziel eines fremden Mandanten (zweite Schicht) → ForbiddenException mit Mandanten-Meldung, kein Versand', async () => {
userService.findById.mockResolvedValue({ id: 'u-y', tenantId: 't2', role: 'SUPER_ADMIN' });
await expect(controller.sendWelcomeMail('u-y', admin)).rejects.toThrow('Cannot modify users from other tenants');
expect(welcomeMail.send).not.toHaveBeenCalled();
});
it('ADMIN: Ziel ist SUPER_ADMIN des eigenen Mandanten → ForbiddenException (Zielrollen-Riegel), kein Versand', async () => {
userService.findById.mockResolvedValue({ id: 'u-s', tenantId: 't1', role: 'SUPER_ADMIN' });
await expect(controller.sendWelcomeMail('u-s', admin)).rejects.toThrow('Cannot modify a SUPER_ADMIN user');
expect(welcomeMail.send).not.toHaveBeenCalled();
});
it('SUPER_ADMIN: Ziel ueber die uebergreifende Aufloesung, auch in fremdem Mandanten und mit oberster Rolle', async () => {
const target = { id: 'u-z', tenantId: 't2', role: 'SUPER_ADMIN', lastLoginAt: null };
userService.findByIdForPlatformAdmin.mockResolvedValue(target);
welcomeMail.send.mockResolvedValue({ to: 'z@x.invalid', welcomeMailSentAt: sentAt });
await controller.sendWelcomeMail('u-z', superAdmin);
expect(userService.findByIdForPlatformAdmin).toHaveBeenCalledWith('u-z');
expect(welcomeMail.send).toHaveBeenCalledWith(target, undefined);
});
it('Fehler des Dienstes (z. B. 409 Konto deaktiviert) gehen unveraendert nach aussen', async () => {
userService.findById.mockResolvedValue({ id: 'u-x', tenantId: 't1', role: 'USER' });
welcomeMail.send.mockRejectedValue(new ConflictException('deaktiviert'));
await expect(controller.sendWelcomeMail('u-x', admin)).rejects.toBeInstanceOf(ConflictException);
});
it('GET welcome-mail/status fragt den Versandweg fuer den Mandanten des Aufrufers ab', async () => {
welcomeMail.isAvailable.mockResolvedValue(true);
await expect(controller.getWelcomeMailStatus(admin)).resolves.toEqual({ available: true });
expect(welcomeMail.isAvailable).toHaveBeenCalledWith('t1');
});
it('findAll (ADMIN) liefert welcomeMailSentAt mit', async () => {
prisma.__seedUser({
id: 'u-a',
username: 'alice',
tenantId: 't1',
email: 'alice@x.invalid',
displayName: null,
role: 'USER',
isActive: true,
createdAt: new Date(),
lastLoginAt: null,
welcomeMailSentAt: sentAt,
});
const result = await controller.findAll(admin);
expect((result as any[])[0].welcomeMailSentAt).toBe(sentAt);
});
});
});
+66
View File
@@ -6,6 +6,7 @@ import {
ForbiddenException,
Get,
HttpCode,
Headers,
HttpStatus,
NotFoundException,
Param,
@@ -39,6 +40,7 @@ import { CreateUserDto } from './dto/create-user.dto';
import { ReleaseSeenDto } from './dto/release-seen.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { UserService } from './user.service';
import { WelcomeMailService } from './welcome-mail.service';
/** Map accepted MIME types to file extensions (T-gbh-01). */
const AVATAR_MIME_TO_EXT: Record<string, string> = {
@@ -68,6 +70,10 @@ function resolveAvatarsDir(): string {
* quick-260928-ujj: dazu kommt ein gebundener Zugriff des
* Selbstbedienungswegs `PATCH me/dashboard-background`, ebenfalls
* `forTenant()` mit `where: { id: currentUser.id }`.
*
* Willkommensmail: `POST :id/welcome-mail` loest den Zielbenutzer ueber
* `resolveTargetUser` auf; die beiden Schreibzugriffe (Token, Versandzeit)
* liegen gebunden in `WelcomeMailService`, nicht in diesem Controller.
*/
@Controller('users')
@UseGuards(RolesGuard)
@@ -75,6 +81,7 @@ export class UserController {
constructor(
private readonly userService: UserService,
private readonly prisma: PrismaService,
private readonly welcomeMailService: WelcomeMailService,
) {}
/**
@@ -126,6 +133,7 @@ export class UserController {
tenantId: true,
createdAt: true,
lastLoginAt: true,
welcomeMailSentAt: true,
},
orderBy: { username: 'asc' },
});
@@ -218,6 +226,23 @@ export class UserController {
return { success: true, lastSeenReleaseVersion: version };
}
/**
* GET /users/welcome-mail/status (Willkommensmail)
*
* Ob fuer den Mandanten des Aufrufers ein Versandweg eingerichtet ist —
* die Oberflaeche deaktiviert damit den Knopf "Willkommensmail senden"
* und nennt den Grund. Die wirksame Pruefung bleibt beim Versand selbst
* (`POST :id/welcome-mail`, dort je Mandant des ZIELS). Statische Route
* steht VOR der Kennungs-Route (GET :id).
*/
@Get('welcome-mail/status')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async getWelcomeMailStatus(
@CurrentUser() currentUser: AuthUser,
): Promise<{ available: boolean }> {
return { available: await this.welcomeMailService.isAvailable(currentUser.tenantId) };
}
/**
* GET /users/:id
*/
@@ -330,6 +355,47 @@ export class UserController {
return result;
}
/**
* POST /users/:id/welcome-mail (Willkommensmail)
*
* Schickt einem Benutzer (egal ob schon angemeldet) eine
* Willkommensmail (Adresse, Benutzername, Anmeldehinweis je Kontoart) und
* merkt den Zeitpunkt in `welcomeMailSentAt`. Dieselben Regeln wie beim
* Bearbeiten: ADMIN nur im eigenen Mandanten, SUPER_ADMIN uebergreifend;
* ein Nicht-SUPER_ADMIN darf einem SUPER_ADMIN nichts schicken
* (Zielrollen-Riegel wie in update()). Deaktiviert -> 409, ohne
* Adresse -> 400, ohne Versandweg -> 409, Versandfehler -> 502 (alles in
* `WelcomeMailService`). Der Origin-Kopf dient nur als Rueckfall fuer die
* Adresse in der Mail, wenn `TESSERA_APP_URL` fehlt.
*/
@Post(':id/welcome-mail')
@HttpCode(HttpStatus.OK)
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async sendWelcomeMail(
@Param('id') id: string,
@CurrentUser() currentUser: AuthUser,
@Headers('origin') origin?: string,
): Promise<{ success: true; to: string; welcomeMailSentAt: Date }> {
const user = await this.resolveTargetUser(currentUser, id);
if (!user) {
throw new NotFoundException('User not found');
}
if (
currentUser.role !== Role.SUPER_ADMIN &&
user.tenantId !== currentUser.tenantId
) {
throw new ForbiddenException('Cannot modify users from other tenants');
}
if (user.role === Role.SUPER_ADMIN && currentUser.role !== Role.SUPER_ADMIN) {
throw new ForbiddenException('Cannot modify a SUPER_ADMIN user');
}
const result = await this.welcomeMailService.send(user, origin);
return { success: true, ...result };
}
/**
* DELETE /users/:id
* ADMIN cannot delete self or users from other tenants.
+8 -2
View File
@@ -1,19 +1,25 @@
import { Module } from '@nestjs/common';
import { GroupsModule } from '../groups/groups.module';
import { MailModule } from '../mail/mail.module';
import { AdminSeedService } from './admin-seed.service';
import { UserController } from './user.controller';
import { UserService } from './user.service';
import { WelcomeMailService } from './welcome-mail.service';
/**
* Importiert GroupsModule für UserService.create's Standardgruppen-
* Mitgliedschaft (D-11/D-12). GroupsModule importiert seinerseits
* UserModule nicht — GroupsService greift für Benutzerprüfungen direkt
* auf PrismaService zu, keine Zirkularität.
*
* Importiert MailModule für die Willkommensmail (`WelcomeMailService`).
* Zyklusfrei: MailModule -> SettingsModule, keiner von beiden importiert
* UserModule.
*/
@Module({
imports: [GroupsModule],
imports: [GroupsModule, MailModule],
controllers: [UserController],
providers: [UserService, AdminSeedService],
providers: [UserService, AdminSeedService, WelcomeMailService],
exports: [UserService],
})
export class UserModule {}
+1
View File
@@ -37,6 +37,7 @@ const PLATFORM_USER_SELECT = {
tenantId: true,
createdAt: true,
lastLoginAt: true,
welcomeMailSentAt: true,
} as const;
/**
@@ -0,0 +1,205 @@
import { BadGatewayException, BadRequestException, ConflictException } from '@nestjs/common';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { normalizeOrigin, type WelcomeMailTarget, WelcomeMailService } from './welcome-mail.service';
/**
* WelcomeMailService — Willkommensmail aus der Benutzerverwaltung.
*
* Festgenagelt: Versand auch an bereits angemeldete Benutzer; 409 fuer
* deaktivierte Benutzer,
* 400 ohne Adresse, 409 ohne Versandweg, 502 bei Versandfehler (und dann
* KEIN `welcomeMailSentAt`); beide Schreibzugriffe gebunden an den
* Mandanten des ZIELS; Inhalt je Kontoart (verzeichnisgefuehrt: Windows-
* Passwort, kein Token; lokal: Token-Link mit der Frist des
* "Passwort vergessen"-Wegs); Adresse aus Konfiguration bzw. Origin.
*/
vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__bound(tenantId)),
}));
function makePrisma() {
const log: { tenantId: string; model: string; method: string; args: any }[] = [];
return {
__log: log,
__bound(tenantId: string) {
return {
passwordResetToken: {
create: vi.fn(async (args: any) => {
log.push({ tenantId, model: 'passwordResetToken', method: 'create', args });
return { id: 'tok-row', ...args.data };
}),
},
user: {
update: vi.fn(async (args: any) => {
log.push({ tenantId, model: 'user', method: 'update', args });
return { welcomeMailSentAt: args.data.welcomeMailSentAt };
}),
},
};
},
};
}
function makeMail(opts: { available?: boolean; fail?: boolean } = {}) {
return {
hasConfiguredTransport: vi.fn(async () => opts.available ?? true),
sendWelcomeMail: vi.fn(async () => {
if (opts.fail) throw new Error('ECONNREFUSED');
}),
};
}
function makeConfig(values: Record<string, string | undefined>) {
return { get: vi.fn((key: string) => values[key]) };
}
const localUser: WelcomeMailTarget = {
id: 'u-local',
tenantId: 't1',
username: 'max.muster',
displayName: 'Max Muster',
email: 'max@example.invalid',
ldapDn: null,
isActive: true,
};
const ldapUser: WelcomeMailTarget = {
...localUser,
id: 'u-ldap',
username: 'erika',
displayName: null,
email: 'erika@example.invalid',
ldapDn: 'CN=Erika,OU=Users,DC=example,DC=invalid',
};
let prisma: ReturnType<typeof makePrisma>;
beforeEach(() => {
prisma = makePrisma();
});
function make(mail = makeMail(), config = makeConfig({ TESSERA_APP_URL: 'https://tessera.example.invalid' })) {
const service = new WelcomeMailService(prisma as any, mail as any, config as any);
vi.spyOn((service as any).logger, 'log').mockImplementation(() => undefined);
vi.spyOn((service as any).logger, 'error').mockImplementation(() => undefined);
return { service, mail };
}
describe('WelcomeMailService.send — Vorbedingungen', () => {
it('bereits angemeldete Benutzer jeder Rolle → Versand erlaubt, welcomeMailSentAt gesetzt', async () => {
const { service, mail } = make();
await service.send({ ...ldapUser, lastLoginAt: new Date() } as WelcomeMailTarget);
await service.send({ ...localUser, lastLoginAt: new Date(), role: 'SUPER_ADMIN' } as WelcomeMailTarget);
expect(mail.sendWelcomeMail).toHaveBeenCalledTimes(2);
expect(prisma.__log.filter((c) => c.model === 'user')).toHaveLength(2);
});
it('deaktiviertes Konto → ConflictException', async () => {
const { service, mail } = make();
await expect(service.send({ ...localUser, isActive: false })).rejects.toBeInstanceOf(ConflictException);
expect(mail.sendWelcomeMail).not.toHaveBeenCalled();
});
it('ohne E-Mail-Adresse → BadRequestException', async () => {
const { service, mail } = make();
await expect(service.send({ ...localUser, email: null })).rejects.toBeInstanceOf(BadRequestException);
expect(mail.sendWelcomeMail).not.toHaveBeenCalled();
});
it('ohne Versandweg → ConflictException mit Hinweis auf Administrator → SMTP; Pruefung im Mandanten des Ziels', async () => {
const mail = makeMail({ available: false });
const { service } = make(mail);
await expect(service.send(localUser)).rejects.toThrow('Administrator → SMTP');
expect(mail.hasConfiguredTransport).toHaveBeenCalledWith('t1');
expect(mail.sendWelcomeMail).not.toHaveBeenCalled();
expect(prisma.__log).toHaveLength(0);
});
});
describe('WelcomeMailService.send — Versand', () => {
it('lokales Konto: Token gebunden an den Mandanten des Ziels, Frist 7 Tage, Link in Text und HTML, kein Kennwort; welcomeMailSentAt gebunden gesetzt', async () => {
const { service, mail } = make();
const before = Date.now();
const result = await service.send(localUser);
const tokenCall = prisma.__log.find((c) => c.model === 'passwordResetToken');
expect(tokenCall?.tenantId).toBe('t1');
expect(tokenCall?.args.data.userId).toBe('u-local');
const ttl = (tokenCall as { args: any }).args.data.expiresAt.getTime() - before;
expect(ttl).toBeGreaterThan(7 * 24 * 60 * 60 * 1000 - 60 * 1000);
expect(ttl).toBeLessThanOrEqual(7 * 24 * 60 * 60 * 1000 + 1000);
expect(mail.sendWelcomeMail).toHaveBeenCalledTimes(1);
const [tenantId, to, rendered] = mail.sendWelcomeMail.mock.calls[0] as any[];
expect(tenantId).toBe('t1');
expect(to).toBe('max@example.invalid');
const token = (tokenCall as { args: any }).args.data.token;
expect(rendered.text).toContain(`https://tessera.example.invalid/reset-password/${token}`);
expect(rendered.html).toContain(`https://tessera.example.invalid/reset-password/${token}`);
expect(rendered.html).toContain('Passwort festlegen');
expect(rendered.html).toContain('Willkommen bei Tessera, Max Muster!');
expect(rendered.text).toContain('Benutzername: max.muster');
expect(rendered.html).toContain('https://tessera.example.invalid/login');
expect(rendered.text).not.toContain('Windows-Passwort');
const updateCall = prisma.__log.find((c) => c.model === 'user' && c.method === 'update');
expect(updateCall?.tenantId).toBe('t1');
expect(updateCall?.args.where).toEqual({ id: 'u-local' });
expect(updateCall?.args.data.welcomeMailSentAt).toBeInstanceOf(Date);
expect(result.to).toBe('max@example.invalid');
expect(result.welcomeMailSentAt).toBe(updateCall?.args.data.welcomeMailSentAt);
});
it('verzeichnisgefuehrtes Konto: kein Token, Hinweis auf das Windows-Passwort, Anrede mit Benutzername ohne Anzeigenamen', async () => {
const { service, mail } = make();
await service.send(ldapUser);
expect(prisma.__log.some((c) => c.model === 'passwordResetToken')).toBe(false);
const rendered = (mail.sendWelcomeMail.mock.calls[0] as any[])[2];
expect(rendered.text).toContain('gewohnten Windows-Passwort');
expect(rendered.html).toContain('gewohnten Windows-Passwort');
expect(rendered.html).not.toContain('reset-password');
expect(rendered.html).not.toContain('Passwort festlegen');
expect(rendered.html).toContain('Willkommen bei Tessera, erika!');
});
it('Versandfehler → BadGatewayException, welcomeMailSentAt wird NICHT gesetzt', async () => {
const { service } = make(makeMail({ fail: true }));
await expect(service.send(ldapUser)).rejects.toBeInstanceOf(BadGatewayException);
expect(prisma.__log.some((c) => c.model === 'user')).toBe(false);
});
it('Anzeigename mit Markup wird im HTML maskiert', async () => {
const { service, mail } = make();
await service.send({ ...ldapUser, displayName: '<b>Böse</b>' });
const rendered = (mail.sendWelcomeMail.mock.calls[0] as any[])[2];
expect(rendered.html).not.toContain('<b>Böse</b>');
expect(rendered.html).toContain('&lt;b&gt;Böse&lt;/b&gt;');
});
});
describe('WelcomeMailService.resolveAppUrl', () => {
it('Konfiguration gewinnt vor dem Origin; abschliessender Schraegstrich faellt weg', () => {
const { service } = make(makeMail(), makeConfig({ TESSERA_APP_URL: 'https://tessera.example.invalid/' }));
expect(service.resolveAppUrl('https://anders.example.invalid')).toBe('https://tessera.example.invalid');
});
it('ohne Konfiguration: Origin der Anfrage', () => {
const { service } = make(makeMail(), makeConfig({}));
expect(service.resolveAppUrl('https://alpha.example.invalid')).toBe('https://alpha.example.invalid');
});
it('Konfiguration zeigt nur auf localhost (Compose-Vorgabe) → Origin gewinnt; ohne Origin bleibt die Konfiguration', () => {
const { service } = make(makeMail(), makeConfig({ TESSERA_APP_URL: 'http://localhost:3000' }));
expect(service.resolveAppUrl('https://alpha.example.invalid')).toBe('https://alpha.example.invalid');
expect(service.resolveAppUrl(undefined)).toBe('http://localhost:3000');
});
it('ungueltiger Origin wird ignoriert', () => {
expect(normalizeOrigin('javascript:alert(1)')).toBeNull();
expect(normalizeOrigin('kein origin')).toBeNull();
expect(normalizeOrigin('https://a.example.invalid/pfad')).toBe('https://a.example.invalid');
});
});
+191
View File
@@ -0,0 +1,191 @@
import {
BadGatewayException,
BadRequestException,
ConflictException,
Injectable,
Logger,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import type { User } from '@prisma/client';
import { randomUUID } from 'node:crypto';
import { WELCOME_TOKEN_TTL_MS } from '../auth/password-reset-token';
import { loadWelcomeHeaderPng, MailService, WELCOME_HEADER_CID } from '../mail/mail.service';
import { renderWelcomeMail, type WelcomeMailAccount } from '../mail/welcome-mail.template';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { PrismaService } from '../prisma/prisma.service';
/**
* WelcomeMailService — Willkommensmail aus der Benutzerverwaltung
* (Administrator → Benutzer, "Willkommensmail senden").
*
* Kann an JEDEN Benutzer gehen, unabhaengig davon, ob und wann er sich
* schon angemeldet hat (Wunsch des Betreibers: auch zum Erneuern des
* Zugangs und zum Ausprobieren). Wer wem senden darf (nur eigener
* Mandant, ADMIN nie an SUPER_ADMIN), prueft der Controller wie beim
* Bearbeiten. Erneutes Senden ist erlaubt und ueberschreibt
* `welcomeMailSentAt`. Abgelehnt wird nur ein deaktiviertes Konto (409),
* ein Konto ohne Adresse (400) und ein Mandant ohne Versandweg (409).
*
* Anmeldehinweis je Kontoart:
* - verzeichnisgefuehrt (`ldapDn` gesetzt): Hinweis auf das gewohnte
* Windows-Passwort — Tessera kennt und setzt dieses Kennwort nicht (das
* Verzeichnis bleibt nur lesend angebunden);
* - lokal: Link "Passwort festlegen" mit einem frischen `PasswordResetToken`
* — derselbe Token, dieselbe Seite `/reset-password/<token>` und dieselbe
* Frist `WELCOME_TOKEN_TTL_MS` (7 Tage statt 1 Stunde wie beim Weg "Passwort
* vergessen"). Ein Kennwort steht NIE in der Mail.
*
* Mandantenbindung: der Aufrufer (`UserController`) hat den Zielbenutzer
* bereits rollenabhaengig aufgeloest und die Mandanten-/Rollenregeln
* geprueft; beide Zugriffe hier (`passwordResetToken.create`,
* `user.update`) binden ueber `forTenant()` an den Mandanten des ZIELS —
* dieselbe Regel wie `UserController.update`. Der Versand laeuft ueber den
* SMTP-Weg genau dieses Mandanten (`MailService.sendWelcomeMail`).
*
* Adresse in der Mail: `TESSERA_APP_URL` (in Compose aus `APP_URL`), sonst
* der Origin der Admin-Anfrage. Zeigt die Konfiguration nur auf
* `localhost` (Compose-Vorgabe, nie gesetzt), gewinnt ein vorhandener
* Origin — sonst bekaeme der Empfaenger einen Link, der auf seinem
* Rechner ins Leere fuehrt.
*/
/** Zielbenutzer, so weit dieser Dienst ihn liest. */
export type WelcomeMailTarget = Pick<
User,
| 'id'
| 'tenantId'
| 'username'
| 'displayName'
| 'email'
| 'ldapDn'
| 'isActive'
>;
export interface WelcomeMailResult {
to: string;
welcomeMailSentAt: Date;
}
const FALLBACK_APP_URL = 'http://localhost:3000';
/** `http(s)://host[:port]` aus einem Origin-Kopf, sonst `null`. */
export function normalizeOrigin(value: string | undefined | null): string | null {
if (!value) return null;
try {
const url = new URL(value);
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
return url.origin;
} catch {
return null;
}
}
function isLocalhostUrl(value: string): boolean {
try {
const host = new URL(value).hostname;
return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]';
} catch {
return false;
}
}
@Injectable()
export class WelcomeMailService {
private readonly logger = new Logger(WelcomeMailService.name);
constructor(
private readonly prisma: PrismaService,
private readonly mailService: MailService,
private readonly configService: ConfigService,
) {}
/** Ob fuer den Mandanten ein Versandweg eingerichtet ist (Knopf aktiv/inaktiv). */
async isAvailable(tenantId: string): Promise<boolean> {
return this.mailService.hasConfiguredTransport(tenantId);
}
/**
* Oeffentliche Adresse fuer Links in der Mail: Konfiguration, sonst Origin
* der Anfrage, sonst die Entwicklungsvorgabe (siehe Kopfkommentar).
*/
resolveAppUrl(requestOrigin?: string | null): string {
const raw = (this.configService.get<string>('TESSERA_APP_URL') ?? '').trim();
const configured = /^https?:\/\//i.test(raw) ? raw.replace(/\/+$/, '') : '';
const origin = normalizeOrigin(requestOrigin);
if (configured && !(isLocalhostUrl(configured) && origin)) {
return configured;
}
return origin ?? (configured || FALLBACK_APP_URL);
}
async send(target: WelcomeMailTarget, requestOrigin?: string | null): Promise<WelcomeMailResult> {
if (!target.isActive) {
throw new ConflictException(
'Dieses Konto ist deaktiviert. Aktivieren Sie es zuerst, bevor Sie eine Willkommensmail senden.',
);
}
const to = target.email?.trim();
if (!to) {
throw new BadRequestException('Für diesen Benutzer ist keine E-Mail-Adresse hinterlegt.');
}
if (!(await this.mailService.hasConfiguredTransport(target.tenantId))) {
throw new ConflictException(
'Für den E-Mail-Versand ist noch kein SMTP-Server eingerichtet. Ein Administrator legt ihn unter Administrator → SMTP fest.',
);
}
const appUrl = this.resolveAppUrl(requestOrigin);
const tenantPrisma = forTenant(this.prisma, target.tenantId);
let account: WelcomeMailAccount;
if (target.ldapDn) {
account = { kind: 'directory' };
} else {
// Derselbe Token wie beim Weg "Passwort vergessen" (T-02-13): zufaellig,
// einmal verwendbar, gleiche Frist.
const token = randomUUID();
await tenantPrisma.passwordResetToken.create({
data: {
token,
userId: target.id,
expiresAt: new Date(Date.now() + WELCOME_TOKEN_TTL_MS),
},
});
account = {
kind: 'local',
setPasswordUrl: `${appUrl}/reset-password/${token}`,
validHours: Math.round(WELCOME_TOKEN_TTL_MS / (60 * 60 * 1000)),
};
}
const mail = renderWelcomeMail({
name: target.displayName?.trim() || target.username,
username: target.username,
appUrl,
account,
headerImageSrc: loadWelcomeHeaderPng() ? `cid:${WELCOME_HEADER_CID}` : null,
});
try {
await this.mailService.sendWelcomeMail(target.tenantId, to, mail);
} catch (error) {
this.logger.error(
`Welcome mail to user ${target.id} failed`,
error instanceof Error ? error.stack : String(error),
);
throw new BadGatewayException(
'Die Willkommensmail konnte nicht gesendet werden. Bitte prüfen Sie die SMTP-Einstellungen oder versuchen Sie es später erneut.',
);
}
const updated = await tenantPrisma.user.update({
where: { id: target.id },
data: { welcomeMailSentAt: new Date() },
select: { welcomeMailSentAt: true },
});
const welcomeMailSentAt = updated.welcomeMailSentAt ?? new Date();
this.logger.log(`Welcome mail sent to user ${target.id} (tenant ${target.tenantId})`);
return { to, welcomeMailSentAt };
}
}