feat(admin): eigene Vorlage fuer die Willkommensmail mit Platzhaltern, Vorschau und Testmail
Tessera CI/CD / Lint & Type Check (push) Successful in 50s
Tessera CI/CD / Tests (push) Successful in 1m22s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 19s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m10s

Administrator -> Willkommensmail: Betreff, Ueberschrift, Einleitung, Abschluss je
Mandant (Tabelle WelcomeMailTemplate, RLS je Mandant, Migration 20260930150000);
Platzhalter {{name}} {{vorname}} {{benutzername}} {{email}} {{adresse}} {{firma}},
unbekannte -> 400 bzw. Hinweis beim Tippen; Werte escaped, Vorlage reiner Text.
Live-Vorschau per API gerendert, Testmail an die eigene Adresse ohne Token,
Zuruecksetzen auf Standard. Feste Bausteine (Kopf, Zugangsdaten, Anmeldehinweis,
Knoepfe, Fusszeile) bleiben immer drin.
Kopf: Wellenzelle dunkel statt weiss, Streifen 600x40, Inhalt 24 px naeher –
keine weisse Luecke, wenn OWA das CID-Bild nicht zeigt.
Lokal nachgewiesen: Hinweis/Sperre bei {{xyz}}, Speichern, Testmail (Link nur
/login), echte Mail mit eigener Vorlage und 7-Tage-Link.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-30 16:49:17 +02:00
parent 31d514b7ca
commit e10da76259
27 changed files with 2471 additions and 82 deletions
@@ -0,0 +1,83 @@
import {
findUnknownWelcomeMailPlaceholders,
WELCOME_MAIL_LIMITS,
type WelcomeMailTexts,
} from '@tessera/shared';
import {
IsIn,
IsOptional,
IsString,
Matches,
MaxLength,
Validate,
type ValidationArguments,
ValidatorConstraint,
type ValidatorConstraintInterface,
} from 'class-validator';
/** Feldnamen fuer die Fehlermeldung (Oberflaeche zeigt sie unveraendert). */
const FIELD_LABELS: Record<keyof WelcomeMailTexts, string> = {
subject: 'Betreff',
heading: 'Überschrift',
intro: 'Einleitungstext',
closing: 'Abschlusstext',
};
/**
* Nur bekannte Platzhalter (`{{name}}`, `{{vorname}}`, `{{benutzername}}`,
* `{{email}}`, `{{adresse}}`, `{{firma}}`). Ein unbekannter wird mit 400
* abgelehnt und in der Meldung genannt ("Unbekannter Platzhalter {{xyz}}"),
* statt spaeter woertlich in der Mail zu stehen.
*/
@ValidatorConstraint({ name: 'nurBekanntePlatzhalter', async: false })
class NurBekanntePlatzhalterConstraint implements ValidatorConstraintInterface {
validate(value: unknown): boolean {
return typeof value !== 'string' || findUnknownWelcomeMailPlaceholders(value).length === 0;
}
defaultMessage(args: ValidationArguments): string {
const unknown =
typeof args.value === 'string' ? findUnknownWelcomeMailPlaceholders(args.value) : [];
const label = FIELD_LABELS[args.property as keyof WelcomeMailTexts] ?? args.property;
const noun = unknown.length > 1 ? 'Unbekannte Platzhalter' : 'Unbekannter Platzhalter';
return `${label}: ${noun} ${unknown.join(', ')}`;
}
}
/**
* Inhalt der eigenen Vorlage (PUT /welcome-mail-template, auch Grundlage fuer
* Vorschau und Testmail). Reiner Text: HTML wird beim Rendern escaped, nicht
* hier abgewiesen — ein "<" im Text ist erlaubt und erscheint als Zeichen.
* Betreff und Ueberschrift sind Pflicht, Einleitung und Abschluss duerfen
* leer sein (der Abschnitt faellt dann weg).
*/
export class WelcomeMailTemplateDto implements WelcomeMailTexts {
@IsString()
@Matches(/\S/, { message: 'Bitte geben Sie einen Betreff ein.' })
@MaxLength(WELCOME_MAIL_LIMITS.subject)
@Validate(NurBekanntePlatzhalterConstraint)
subject!: string;
@IsString()
@Matches(/\S/, { message: 'Bitte geben Sie eine Überschrift ein.' })
@MaxLength(WELCOME_MAIL_LIMITS.heading)
@Validate(NurBekanntePlatzhalterConstraint)
heading!: string;
@IsString()
@MaxLength(WELCOME_MAIL_LIMITS.intro)
@Validate(NurBekanntePlatzhalterConstraint)
intro!: string;
@IsString()
@MaxLength(WELCOME_MAIL_LIMITS.closing)
@Validate(NurBekanntePlatzhalterConstraint)
closing!: string;
}
/** Vorschau: zusaetzlich die Kontoart, fuer die der Anmeldehinweis gezeigt wird. */
export class WelcomeMailPreviewDto extends WelcomeMailTemplateDto {
@IsOptional()
@IsIn(['directory', 'local'])
account?: 'directory' | 'local';
}
+10 -2
View File
@@ -5,6 +5,8 @@ import { AdminSeedService } from './admin-seed.service';
import { UserController } from './user.controller';
import { UserService } from './user.service';
import { WelcomeMailService } from './welcome-mail.service';
import { WelcomeMailTemplateController } from './welcome-mail-template.controller';
import { WelcomeMailTemplateService } from './welcome-mail-template.service';
/**
* Importiert GroupsModule für UserService.create's Standardgruppen-
@@ -15,11 +17,17 @@ import { WelcomeMailService } from './welcome-mail.service';
* Importiert MailModule für die Willkommensmail (`WelcomeMailService`).
* Zyklusfrei: MailModule -> SettingsModule, keiner von beiden importiert
* UserModule.
*
* Eigene Vorlage der Willkommensmail (Administrator → Willkommensmail):
* `WelcomeMailTemplateService` (Speicherung je Mandant) und
* `WelcomeMailTemplateController` (`/welcome-mail-template`) liegen hier,
* weil Vorschau und Testmail denselben Weg wie der Versand nutzen
* (`WelcomeMailService`: Adresse, Kopfbild, SMTP des Mandanten).
*/
@Module({
imports: [GroupsModule, MailModule],
controllers: [UserController],
providers: [UserService, AdminSeedService, WelcomeMailService],
controllers: [UserController, WelcomeMailTemplateController],
providers: [UserService, AdminSeedService, WelcomeMailService, WelcomeMailTemplateService],
exports: [UserService],
})
export class UserModule {}
@@ -0,0 +1,151 @@
import 'reflect-metadata';
import { BadRequestException, ValidationPipe } from '@nestjs/common';
import { DEFAULT_WELCOME_MAIL_TEXTS } from '@tessera/shared';
import { describe, expect, it, vi } from 'vitest';
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
import { WelcomeMailPreviewDto, WelcomeMailTemplateDto } from './dto/welcome-mail-template.dto';
import { WelcomeMailTemplateController } from './welcome-mail-template.controller';
/**
* Administrator → Willkommensmail (eigene Vorlage).
*
* Festgenagelt: alle Routen nur ADMIN/SUPER_ADMIN; Mandant IMMER aus dem
* Token (nie aus dem Body); unbekannte Platzhalter und zu lange Texte → 400
* mit Nennung des Platzhalters; die Testmail geht an den Aufrufer selbst.
*/
function makeTemplates() {
return {
getState: vi.fn(async (..._args: unknown[]) => ({ custom: false })),
save: vi.fn(async (..._args: unknown[]) => ({ custom: true })),
reset: vi.fn(async (..._args: unknown[]) => ({ custom: false })),
};
}
function makeWelcome() {
return {
preview: vi.fn(async (..._args: unknown[]) => ({ subject: 's', html: '<html>', text: 't' })),
sendTest: vi.fn(async (..._args: unknown[]) => ({ to: 'ada@example.invalid' })),
};
}
const admin = { id: 'u-admin', username: 'ada', role: 'ADMIN', tenantId: 't1' } as any;
const proto = WelcomeMailTemplateController.prototype as any;
const pipe = new ValidationPipe({ whitelist: true, transform: true });
const valid = { ...DEFAULT_WELCOME_MAIL_TEXTS };
async function validateBody(body: unknown, metatype: any = WelcomeMailTemplateDto) {
return pipe.transform(body, { type: 'body', metatype });
}
async function messagesOf(body: unknown): Promise<string[]> {
try {
await validateBody(body);
} catch (error) {
expect(error).toBeInstanceOf(BadRequestException);
return ((error as BadRequestException).getResponse() as { message: string[] }).message;
}
throw new Error('erwartete 400');
}
describe('WelcomeMailTemplateController — Rechte', () => {
it.each([
'get',
'save',
'reset',
'preview',
'sendTest',
])('%s nur fuer ADMIN und SUPER_ADMIN', (name) => {
expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toEqual(['ADMIN', 'SUPER_ADMIN']);
});
it('haengt an Pfad welcome-mail-template', () => {
expect(Reflect.getMetadata('path', WelcomeMailTemplateController)).toBe(
'welcome-mail-template',
);
});
});
describe('WelcomeMailTemplateController — Mandant aus dem Token', () => {
it('reicht currentUser.tenantId (und Benutzername/-kennung) weiter, nie Werte aus dem Body', async () => {
const templates = makeTemplates();
const welcome = makeWelcome();
const controller = new WelcomeMailTemplateController(templates as any, welcome as any);
await controller.get(admin);
await controller.save(admin, { ...valid, tenantId: 'evil' } as any);
await controller.reset(admin);
await controller.preview(admin, { ...valid, account: 'local' }, 'https://o.example.invalid');
const result = await controller.sendTest(admin, valid as any, undefined);
expect(templates.getState).toHaveBeenCalledWith('t1');
expect(templates.save).toHaveBeenCalledWith('t1', valid, 'ada');
expect(templates.reset).toHaveBeenCalledWith('t1');
expect(welcome.preview).toHaveBeenCalledWith('t1', valid, 'local', 'https://o.example.invalid');
expect(welcome.sendTest).toHaveBeenCalledWith('t1', 'u-admin', valid, undefined);
expect(result).toEqual({ success: true, to: 'ada@example.invalid' });
});
it('Vorschau ohne Kontoart zeigt die Verzeichniskonto-Variante', async () => {
const welcome = makeWelcome();
const controller = new WelcomeMailTemplateController(makeTemplates() as any, welcome as any);
await controller.preview(admin, { ...valid });
expect(welcome.preview.mock.calls[0][2]).toBe('directory');
});
});
describe('WelcomeMailTemplateDto (globale ValidationPipe)', () => {
it('Standardtexte und alle bekannten Platzhalter (auch mit Leerraum/Grossschreibung) sind gueltig', async () => {
const out: any = await validateBody({
...valid,
intro: '{{name}} {{vorname}} {{benutzername}} {{email}} {{adresse}} {{firma}} {{ Name }}',
tenantId: 'evil',
});
expect(out).not.toHaveProperty('tenantId');
});
it('unbekannter Platzhalter → 400 mit Feld und Platzhalter', async () => {
const messages = await messagesOf({ ...valid, heading: 'Hallo {{xyz}} und {{abc}}' });
expect(messages).toContain('Überschrift: Unbekannte Platzhalter {{xyz}}, {{abc}}');
});
it('ein unbekannter Platzhalter im Betreff → "Unbekannter Platzhalter {{xyz}}"', async () => {
const messages = await messagesOf({ ...valid, subject: 'Hi {{xyz}}' });
expect(messages).toContain('Betreff: Unbekannter Platzhalter {{xyz}}');
});
it('Laengen: Betreff/Ueberschrift 200, Texte 4000', async () => {
await expect(
validateBody({ ...valid, subject: 'a'.repeat(200), intro: 'b'.repeat(4000) }),
).resolves.toBeTruthy();
await expect(messagesOf({ ...valid, subject: 'a'.repeat(201) })).resolves.toHaveLength(1);
await expect(messagesOf({ ...valid, heading: 'a'.repeat(201) })).resolves.toHaveLength(1);
await expect(messagesOf({ ...valid, intro: 'a'.repeat(4001) })).resolves.toHaveLength(1);
await expect(messagesOf({ ...valid, closing: 'a'.repeat(4001) })).resolves.toHaveLength(1);
});
it('leerer Betreff/leere Ueberschrift → 400; leere Einleitung/Abschluss erlaubt', async () => {
expect(await messagesOf({ ...valid, subject: ' ' })).toContain(
'Bitte geben Sie einen Betreff ein.',
);
expect(await messagesOf({ ...valid, heading: '' })).toContain(
'Bitte geben Sie eine Überschrift ein.',
);
await expect(validateBody({ ...valid, intro: '', closing: '' })).resolves.toBeTruthy();
});
it('fehlendes Feld → 400', async () => {
const { closing: _closing, ...rest } = valid;
await expect(messagesOf(rest)).resolves.not.toHaveLength(0);
});
it('Vorschau: nur directory/local als Kontoart', async () => {
await expect(
validateBody({ ...valid, account: 'local' }, WelcomeMailPreviewDto),
).resolves.toBeTruthy();
await expect(
validateBody({ ...valid, account: 'root' }, WelcomeMailPreviewDto),
).rejects.toBeInstanceOf(BadRequestException);
});
});
@@ -0,0 +1,105 @@
import {
Body,
Controller,
Delete,
Get,
Headers,
HttpCode,
HttpStatus,
Post,
Put,
} from '@nestjs/common';
import { Role } from '@prisma/client';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { Roles } from '../auth/decorators/roles.decorator';
import type { AuthUser } from '../auth/types/auth-user';
import { WelcomeMailPreviewDto, WelcomeMailTemplateDto } from './dto/welcome-mail-template.dto';
import { WelcomeMailService } from './welcome-mail.service';
import {
WelcomeMailTemplateService,
type WelcomeMailTemplateState,
} from './welcome-mail-template.service';
/** Nur die vier Texte aus dem DTO — nie weitere Felder in die Vorlage. */
function textsOf(dto: WelcomeMailTemplateDto) {
return { subject: dto.subject, heading: dto.heading, intro: dto.intro, closing: dto.closing };
}
/**
* Administrator → Willkommensmail: eigene Vorlage je Mandant.
*
* Alle Routen nur fuer ADMIN und SUPER_ADMIN (`@Roles`), immer fuer den
* Mandanten des angemeldeten Administrators (`currentUser.tenantId`, aus dem
* Token) — ein Mandant aus der Anfrage wird nie uebernommen. Unbekannte
* Platzhalter und zu lange Texte weist das DTO mit 400 ab.
*
* - GET /welcome-mail-template wirksame Texte + Standardtexte
* - PUT /welcome-mail-template eigene Vorlage speichern
* - DELETE /welcome-mail-template auf Standard zuruecksetzen
* - POST /welcome-mail-template/preview gerendertes HTML (Beispielwerte)
* - POST /welcome-mail-template/test Testmail an die eigene Adresse
*
* Keine Kennungs-Route (`:id`) — eine Vorlage je Mandant —, deshalb keine
* Reihenfolge-Falle.
*/
@Controller('welcome-mail-template')
export class WelcomeMailTemplateController {
constructor(
private readonly templateService: WelcomeMailTemplateService,
private readonly welcomeMailService: WelcomeMailService,
) {}
@Get()
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async get(@CurrentUser() currentUser: AuthUser): Promise<WelcomeMailTemplateState> {
return this.templateService.getState(currentUser.tenantId);
}
@Put()
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async save(
@CurrentUser() currentUser: AuthUser,
@Body() dto: WelcomeMailTemplateDto,
): Promise<WelcomeMailTemplateState> {
return this.templateService.save(currentUser.tenantId, textsOf(dto), currentUser.username);
}
@Delete()
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async reset(@CurrentUser() currentUser: AuthUser): Promise<WelcomeMailTemplateState> {
return this.templateService.reset(currentUser.tenantId);
}
@Post('preview')
@HttpCode(HttpStatus.OK)
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async preview(
@CurrentUser() currentUser: AuthUser,
@Body() dto: WelcomeMailPreviewDto,
@Headers('origin') origin?: string,
): Promise<{ subject: string; html: string; text: string }> {
return this.welcomeMailService.preview(
currentUser.tenantId,
textsOf(dto),
dto.account ?? 'directory',
origin,
);
}
@Post('test')
@HttpCode(HttpStatus.OK)
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async sendTest(
@CurrentUser() currentUser: AuthUser,
@Body() dto: WelcomeMailTemplateDto,
@Headers('origin') origin?: string,
): Promise<{ success: true; to: string }> {
const result = await this.welcomeMailService.sendTest(
currentUser.tenantId,
currentUser.id,
textsOf(dto),
origin,
);
return { success: true, ...result };
}
}
@@ -0,0 +1,125 @@
import { DEFAULT_WELCOME_MAIL_TEXTS } from '@tessera/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { WelcomeMailTemplateService } from './welcome-mail-template.service';
/**
* WelcomeMailTemplateService — Speicherung der eigenen Vorlage je Mandant.
*
* Festgenagelt: jeder Zugriff gebunden an den uebergebenen Mandanten
* (`forTenant`), Standardtexte ohne Vorlage, Speichern als upsert auf
* `tenantId`, Zuruecksetzen loescht die Vorlage.
*/
vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__bound(tenantId)),
}));
type Row = {
tenantId: string;
subject: string;
heading: string;
intro: string;
closing: string;
updatedBy: string | null;
updatedAt: Date;
};
function makePrisma(rows: Row[] = []) {
const log: { tenantId: string; model: string; method: string; args: any }[] = [];
return {
__log: log,
__rows: rows,
__bound(tenantId: string) {
return {
welcomeMailTemplate: {
findUnique: vi.fn(async (args: any) => {
log.push({ tenantId, model: 'welcomeMailTemplate', method: 'findUnique', args });
// RLS-Nachbildung: nur Zeilen des gebundenen Mandanten sichtbar
return (
rows.find((r) => r.tenantId === args.where.tenantId && r.tenantId === tenantId) ??
null
);
}),
upsert: vi.fn(async (args: any) => {
log.push({ tenantId, model: 'welcomeMailTemplate', method: 'upsert', args });
const existing = rows.find((r) => r.tenantId === args.where.tenantId);
if (existing) Object.assign(existing, args.update, { updatedAt: new Date() });
else rows.push({ ...args.create, updatedAt: new Date() });
return {};
}),
deleteMany: vi.fn(async (args: any) => {
log.push({ tenantId, model: 'welcomeMailTemplate', method: 'deleteMany', args });
const before = rows.length;
for (let i = rows.length - 1; i >= 0; i--) {
if (rows[i].tenantId === args.where.tenantId) rows.splice(i, 1);
}
return { count: before - rows.length };
}),
},
tenant: {
findUnique: vi.fn(async (args: any) => {
log.push({ tenantId, model: 'tenant', method: 'findUnique', args });
return args.where.id === 't1' ? { name: 'Beispiel GmbH' } : null;
}),
},
};
},
};
}
const texts = { subject: 'S {{name}}', heading: 'H', intro: 'I', closing: 'C' };
let prisma: ReturnType<typeof makePrisma>;
let service: WelcomeMailTemplateService;
beforeEach(() => {
prisma = makePrisma();
service = new WelcomeMailTemplateService(prisma as any);
});
describe('WelcomeMailTemplateService', () => {
it('ohne Vorlage: Standardtexte, custom=false, getCustomTexts=null', async () => {
const state = await service.getState('t1');
expect(state.custom).toBe(false);
expect(state.texts).toEqual(DEFAULT_WELCOME_MAIL_TEXTS);
expect(state.defaults).toEqual(DEFAULT_WELCOME_MAIL_TEXTS);
expect(await service.getCustomTexts('t1')).toBeNull();
expect(prisma.__log.every((c) => c.tenantId === 't1')).toBe(true);
});
it('speichern: upsert auf tenantId, gebunden, mit Bearbeiter; danach custom=true', async () => {
const state = await service.save('t1', texts, 'ada');
const upsert = prisma.__log.find((c) => c.method === 'upsert');
expect(upsert?.tenantId).toBe('t1');
expect(upsert?.args.where).toEqual({ tenantId: 't1' });
expect(upsert?.args.create).toEqual({ tenantId: 't1', ...texts, updatedBy: 'ada' });
expect(state.custom).toBe(true);
expect(state.texts).toEqual(texts);
expect(state.updatedBy).toBe('ada');
expect(await service.getCustomTexts('t1')).toEqual(texts);
});
it('Mandantenbindung: Vorlage von t1 ist fuer t2 nicht sichtbar', async () => {
await service.save('t1', texts, 'ada');
expect(await service.getCustomTexts('t2')).toBeNull();
expect(prisma.__log.filter((c) => c.method === 'findUnique').at(-1)?.tenantId).toBe('t2');
});
it('zuruecksetzen: loescht gebunden, danach wieder Standard', async () => {
await service.save('t1', texts, 'ada');
const state = await service.reset('t1');
const del = prisma.__log.find((c) => c.method === 'deleteMany');
expect(del?.tenantId).toBe('t1');
expect(del?.args.where).toEqual({ tenantId: 't1' });
expect(state.custom).toBe(false);
expect(state.texts).toEqual(DEFAULT_WELCOME_MAIL_TEXTS);
});
it('Firmenname gebunden gelesen; unbekannter Mandant → leer', async () => {
expect(await service.getTenantName('t1')).toBe('Beispiel GmbH');
expect(await service.getTenantName('tx')).toBe('');
expect(prisma.__log.filter((c) => c.model === 'tenant').map((c) => c.tenantId)).toEqual([
't1',
'tx',
]);
});
});
@@ -0,0 +1,110 @@
import { Injectable } from '@nestjs/common';
import { DEFAULT_WELCOME_MAIL_TEXTS, type WelcomeMailTexts } from '@tessera/shared';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
/**
* WelcomeMailTemplateService — eigene Vorlage der Willkommensmail
* (Administrator → Willkommensmail).
*
* Hoechstens eine Vorlage je Mandant (Tabelle "WelcomeMailTemplate",
* `tenantId` eindeutig). Gespeichert werden nur die vier Texte; Kopf,
* Zugangsdaten, Anmeldehinweis, Knoepfe und Fusszeile bleiben fest in
* `welcome-mail.template.ts`. Fehlt die Vorlage, gelten
* `DEFAULT_WELCOME_MAIL_TEXTS` aus `@tessera/shared`.
*
* Mandantenbindung: jeder Zugriff laeuft ueber `forTenant(prisma, tenantId)`.
* Beim Versand ist `tenantId` der Mandant des ZIEL-Benutzers
* (`WelcomeMailService.send`), in der Verwaltung der Mandant des
* angemeldeten Administrators (`WelcomeMailTemplateController`). Die
* Platzhalter-Pruefung liegt im DTO (400 bei unbekanntem Platzhalter).
*/
/** Antwort von GET /welcome-mail-template. */
export interface WelcomeMailTemplateState {
/** `true` = eigene Vorlage gespeichert, `false` = Standardtexte. */
custom: boolean;
/** Wirksame Texte (eigene Vorlage oder Standard) — Vorbelegung im Formular. */
texts: WelcomeMailTexts;
/** Standardtexte fuer "Auf Standard zuruecksetzen". */
defaults: WelcomeMailTexts;
updatedAt: Date | null;
updatedBy: string | null;
}
const TEXT_SELECT = {
subject: true,
heading: true,
intro: true,
closing: true,
updatedAt: true,
updatedBy: true,
} as const;
@Injectable()
export class WelcomeMailTemplateService {
constructor(private readonly prisma: PrismaService) {}
async getState(tenantId: string): Promise<WelcomeMailTemplateState> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const row = await tenantPrisma.welcomeMailTemplate.findUnique({
where: { tenantId },
select: TEXT_SELECT,
});
const defaults = { ...DEFAULT_WELCOME_MAIL_TEXTS };
if (!row) {
return { custom: false, texts: defaults, defaults, updatedAt: null, updatedBy: null };
}
return {
custom: true,
texts: { subject: row.subject, heading: row.heading, intro: row.intro, closing: row.closing },
defaults,
updatedAt: row.updatedAt,
updatedBy: row.updatedBy,
};
}
/** Texte fuer den Versand: eigene Vorlage des Mandanten, sonst `null` (= Standard). */
async getCustomTexts(tenantId: string): Promise<WelcomeMailTexts | null> {
const state = await this.getState(tenantId);
return state.custom ? state.texts : null;
}
/** Name des Mandanten fuer `{{firma}}`; leer, wenn der Mandant fehlt. */
async getTenantName(tenantId: string): Promise<string> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const tenant = await tenantPrisma.tenant.findUnique({
where: { id: tenantId },
select: { name: true },
});
return tenant?.name ?? '';
}
async save(
tenantId: string,
texts: WelcomeMailTexts,
updatedBy: string,
): Promise<WelcomeMailTemplateState> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const data = {
subject: texts.subject,
heading: texts.heading,
intro: texts.intro,
closing: texts.closing,
updatedBy,
};
await tenantPrisma.welcomeMailTemplate.upsert({
where: { tenantId },
create: { tenantId, ...data },
update: data,
});
return this.getState(tenantId);
}
/** "Auf Standard zuruecksetzen": loescht die eigene Vorlage (idempotent). */
async reset(tenantId: string): Promise<WelcomeMailTemplateState> {
const tenantPrisma = forTenant(this.prisma, tenantId);
await tenantPrisma.welcomeMailTemplate.deleteMany({ where: { tenantId } });
return this.getState(tenantId);
}
}
+209 -11
View File
@@ -1,6 +1,11 @@
import { BadGatewayException, BadRequestException, ConflictException } from '@nestjs/common';
import type { WelcomeMailTexts } from '@tessera/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { normalizeOrigin, type WelcomeMailTarget, WelcomeMailService } from './welcome-mail.service';
import {
normalizeOrigin,
WelcomeMailService,
type WelcomeMailTarget,
} from './welcome-mail.service';
/**
* WelcomeMailService — Willkommensmail aus der Benutzerverwaltung.
@@ -34,6 +39,10 @@ function makePrisma() {
log.push({ tenantId, model: 'user', method: 'update', args });
return { welcomeMailSentAt: args.data.welcomeMailSentAt };
}),
findFirst: vi.fn(async (args: any) => {
log.push({ tenantId, model: 'user', method: 'findFirst', args });
return selfRow;
}),
},
};
},
@@ -73,36 +82,74 @@ const ldapUser: WelcomeMailTarget = {
};
let prisma: ReturnType<typeof makePrisma>;
/** Eigenes Konto des Administrators fuer die Testmail (`user.findFirst`). */
let selfRow: {
username: string;
displayName: string | null;
email: string | null;
ldapDn: string | null;
} | null;
beforeEach(() => {
prisma = makePrisma();
selfRow = {
username: 'admin.lokal',
displayName: 'Ada Admin',
email: 'ada@example.invalid',
ldapDn: null,
};
});
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);
/** Vorlagen-Dienst: eigene Vorlage je Mandant (Map), Firmenname je Mandant. */
function makeTemplates(custom: Record<string, WelcomeMailTexts> = {}) {
return {
getCustomTexts: vi.fn(async (tenantId: string) => custom[tenantId] ?? null),
getTenantName: vi.fn(async (tenantId: string) => `Firma ${tenantId}`),
};
}
function make(
mail = makeMail(),
config = makeConfig({ TESSERA_APP_URL: 'https://tessera.example.invalid' }),
templates = makeTemplates(),
) {
const service = new WelcomeMailService(
prisma as any,
mail as any,
config as any,
templates as any,
);
vi.spyOn((service as any).logger, 'log').mockImplementation(() => undefined);
vi.spyOn((service as any).logger, 'error').mockImplementation(() => undefined);
return { service, mail };
return { service, mail, templates };
}
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);
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);
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);
await expect(service.send({ ...localUser, email: null })).rejects.toBeInstanceOf(
BadRequestException,
);
expect(mail.sendWelcomeMail).not.toHaveBeenCalled();
});
@@ -182,18 +229,27 @@ describe('WelcomeMailService.send — Versand', () => {
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');
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');
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('https://alpha.example.invalid')).toBe(
'https://alpha.example.invalid',
);
expect(service.resolveAppUrl(undefined)).toBe('http://localhost:3000');
});
@@ -203,3 +259,145 @@ describe('WelcomeMailService.resolveAppUrl', () => {
expect(normalizeOrigin('https://a.example.invalid/pfad')).toBe('https://a.example.invalid');
});
});
describe('WelcomeMailService.send — eigene Vorlage', () => {
const custom: WelcomeMailTexts = {
subject: 'Hallo {{vorname}} bei {{firma}}',
heading: 'Schön, dass Sie da sind, {{name}}!',
intro: 'Erste Zeile\nzweite Zeile\n\nIhr Konto: {{benutzername}} / {{email}} / {{adresse}}',
closing: 'Grüße vom <b>IT-Team</b>',
};
it('ohne eigene Vorlage: Standardtexte; Vorlage und Firmenname werden fuer den Mandanten des ZIELS gelesen', async () => {
const { service, mail, templates } = make();
await service.send({ ...ldapUser, tenantId: 't9' });
expect(templates.getCustomTexts).toHaveBeenCalledWith('t9');
expect(templates.getTenantName).toHaveBeenCalledWith('t9');
const rendered = (mail.sendWelcomeMail.mock.calls[0] as any[])[2];
expect(rendered.subject).toBe('Willkommen bei Tessera');
expect(rendered.html).toContain('Tessera gibt es auch als Desktop-App');
});
it('mit eigener Vorlage des Ziel-Mandanten: Platzhalter ersetzt, Absaetze/Umbrueche, Markup aus der Vorlage escaped, feste Bausteine bleiben', async () => {
const { service, mail } = make(undefined, undefined, makeTemplates({ t1: custom }));
await service.send(localUser);
const rendered = (mail.sendWelcomeMail.mock.calls[0] as any[])[2];
expect(rendered.subject).toBe('Hallo Max bei Firma t1');
expect(rendered.html).toContain('Schön, dass Sie da sind, Max Muster!');
expect(rendered.html).toContain('Erste Zeile<br>zweite Zeile');
expect(rendered.html).toContain(
'Ihr Konto: max.muster / max@example.invalid / https://tessera.example.invalid',
);
expect(rendered.html).toContain('Grüße vom &lt;b&gt;IT-Team&lt;/b&gt;');
expect(rendered.html).not.toContain('<b>IT-Team</b>');
expect(rendered.html).not.toContain('Desktop-App');
// feste Bausteine
expect(rendered.html).toContain('Passwort festlegen');
expect(rendered.html).toContain('/reset-password/');
expect(rendered.html).toContain('Zu Tessera');
expect(rendered.html).toContain('Benutzername');
expect(rendered.text).toContain('Erste Zeile\nzweite Zeile');
});
it('Vorlage eines ANDEREN Mandanten wirkt nicht', async () => {
const { service, mail } = make(undefined, undefined, makeTemplates({ t2: custom }));
await service.send(localUser);
const rendered = (mail.sendWelcomeMail.mock.calls[0] as any[])[2];
expect(rendered.subject).toBe('Willkommen bei Tessera');
});
it('Platzhalterwerte mit Markup werden escaped (Anzeigename in {{name}}/{{vorname}})', async () => {
const { service, mail } = make(undefined, undefined, makeTemplates({ t1: custom }));
await service.send({ ...ldapUser, displayName: '<img src=x onerror=alert(1)> Böse' });
const rendered = (mail.sendWelcomeMail.mock.calls[0] as any[])[2];
expect(rendered.html).not.toContain('<img src=x');
expect(rendered.html).toContain('&lt;img src=x onerror=alert(1)&gt; Böse');
});
});
describe('WelcomeMailService.preview', () => {
const texts: WelcomeMailTexts = {
subject: 'Betreff {{name}}',
heading: 'Hallo {{vorname}}',
intro: 'Bei {{firma}}',
closing: '',
};
it('Beispielwerte, Firmenname des eigenen Mandanten, kein Token, kein Versand, kein cid:', async () => {
const { service, mail, templates } = make();
const rendered = await service.preview('t1', texts, 'local');
expect(templates.getTenantName).toHaveBeenCalledWith('t1');
expect(rendered.subject).toBe('Betreff Max Mustermann');
expect(rendered.html).toContain('Hallo Max');
expect(rendered.html).toContain('Bei Firma t1');
expect(rendered.html).toContain('/reset-password/beispiel');
expect(rendered.html).not.toContain('cid:');
expect(prisma.__log).toHaveLength(0);
expect(mail.sendWelcomeMail).not.toHaveBeenCalled();
});
it('Verzeichniskonto-Variante: Windows-Hinweis statt Passwort-Knopf', async () => {
const { service } = make();
const rendered = await service.preview('t1', texts, 'directory');
expect(rendered.html).toContain('gewohnten Windows-Passwort');
expect(rendered.html).not.toContain('Passwort festlegen');
});
});
describe('WelcomeMailService.sendTest', () => {
const texts: WelcomeMailTexts = {
subject: 'Test {{name}}',
heading: 'Hallo {{vorname}}',
intro: 'Text',
closing: 'Gruß',
};
it('geht an die eigene Adresse, liest das eigene Konto gebunden, erzeugt KEIN Token und setzt KEIN welcomeMailSentAt', async () => {
const { service, mail } = make();
const result = await service.sendTest('t1', 'u-self', texts);
expect(result.to).toBe('ada@example.invalid');
const find = prisma.__log.find((c) => c.method === 'findFirst');
expect(find?.tenantId).toBe('t1');
expect(find?.args.where).toEqual({ id: 'u-self', tenantId: 't1' });
expect(prisma.__log.some((c) => c.model === 'passwordResetToken')).toBe(false);
expect(prisma.__log.some((c) => c.method === 'update')).toBe(false);
const [tenantId, to, rendered] = mail.sendWelcomeMail.mock.calls[0] as any[];
expect(tenantId).toBe('t1');
expect(to).toBe('ada@example.invalid');
expect(rendered.subject).toBe('Test Ada Admin');
expect(rendered.html).toContain('Testmail');
// lokales Konto: Beispiel-Link zur Anmeldeseite, kein reset-password
expect(rendered.html).toContain('Passwort festlegen');
expect(rendered.html).toContain('Beispiel-Link');
expect(rendered.html).not.toContain('reset-password');
expect(rendered.text).not.toContain('reset-password');
});
it('verzeichnisgefuehrtes eigenes Konto: Windows-Hinweis', async () => {
selfRow = { ...(selfRow as NonNullable<typeof selfRow>), ldapDn: 'CN=Ada' };
const { service, mail } = make();
await service.sendTest('t1', 'u-self', texts);
const rendered = (mail.sendWelcomeMail.mock.calls[0] as any[])[2];
expect(rendered.html).toContain('gewohnten Windows-Passwort');
expect(rendered.html).not.toContain('Passwort festlegen');
});
it('ohne eigene Adresse → BadRequestException, kein Versand', async () => {
selfRow = { ...(selfRow as NonNullable<typeof selfRow>), email: null };
const { service, mail } = make();
await expect(service.sendTest('t1', 'u-self', texts)).rejects.toBeInstanceOf(
BadRequestException,
);
expect(mail.sendWelcomeMail).not.toHaveBeenCalled();
});
it('ohne Versandweg → ConflictException; Versandfehler → BadGatewayException', async () => {
await expect(
make(makeMail({ available: false })).service.sendTest('t1', 'u-self', texts),
).rejects.toBeInstanceOf(ConflictException);
await expect(
make(makeMail({ fail: true })).service.sendTest('t1', 'u-self', texts),
).rejects.toBeInstanceOf(BadGatewayException);
});
});
+128 -10
View File
@@ -1,3 +1,4 @@
import { randomUUID } from 'node:crypto';
import {
BadGatewayException,
BadRequestException,
@@ -7,12 +8,17 @@ import {
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import type { User } from '@prisma/client';
import { randomUUID } from 'node:crypto';
import type { WelcomeMailTexts } from '@tessera/shared';
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 {
type RenderedWelcomeMail,
renderWelcomeMail,
type WelcomeMailAccount,
} from '../mail/welcome-mail.template';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { WelcomeMailTemplateService } from './welcome-mail-template.service';
/**
* WelcomeMailService — Willkommensmail aus der Benutzerverwaltung
@@ -42,6 +48,15 @@ import { PrismaService } from '../prisma/prisma.service';
* dieselbe Regel wie `UserController.update`. Der Versand laeuft ueber den
* SMTP-Weg genau dieses Mandanten (`MailService.sendWelcomeMail`).
*
* Eigene Vorlage (Administrator → Willkommensmail): `send` nimmt die Texte
* der Vorlage des Mandanten des ZIELS (`WelcomeMailTemplateService`), sonst
* die Standardtexte. `preview` rendert einen (auch ungespeicherten)
* Formularinhalt mit Beispielwerten, `sendTest` schickt ihn an die eigene
* Adresse des Administrators — OHNE Token: bei einem lokalen eigenen Konto
* zeigt die Testmail den Knopf "Passwort festlegen" als Beispiel-Link zur
* Anmeldeseite (Kontoart `local-example`), damit nie ein nutzbarer
* Passwort-Link an eine Testadresse geht.
*
* 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
@@ -52,13 +67,7 @@ import { PrismaService } from '../prisma/prisma.service';
/** Zielbenutzer, so weit dieser Dienst ihn liest. */
export type WelcomeMailTarget = Pick<
User,
| 'id'
| 'tenantId'
| 'username'
| 'displayName'
| 'email'
| 'ldapDn'
| 'isActive'
'id' | 'tenantId' | 'username' | 'displayName' | 'email' | 'ldapDn' | 'isActive'
>;
export interface WelcomeMailResult {
@@ -68,6 +77,13 @@ export interface WelcomeMailResult {
const FALLBACK_APP_URL = 'http://localhost:3000';
/** Beispielwerte der Vorschau (Administrator → Willkommensmail). */
export const PREVIEW_SAMPLE = {
name: 'Max Mustermann',
username: 'max.mustermann',
email: 'max.mustermann@example.com',
} as const;
/** `http(s)://host[:port]` aus einem Origin-Kopf, sonst `null`. */
export function normalizeOrigin(value: string | undefined | null): string | null {
if (!value) return null;
@@ -97,6 +113,7 @@ export class WelcomeMailService {
private readonly prisma: PrismaService,
private readonly mailService: MailService,
private readonly configService: ConfigService,
private readonly templateService: WelcomeMailTemplateService,
) {}
/** Ob fuer den Mandanten ein Versandweg eingerichtet ist (Knopf aktiv/inaktiv). */
@@ -158,12 +175,19 @@ export class WelcomeMailService {
};
}
const [texts, tenantName] = await Promise.all([
this.templateService.getCustomTexts(target.tenantId),
this.templateService.getTenantName(target.tenantId),
]);
const mail = renderWelcomeMail({
name: target.displayName?.trim() || target.username,
username: target.username,
email: to,
tenantName,
appUrl,
account,
headerImageSrc: loadWelcomeHeaderPng() ? `cid:${WELCOME_HEADER_CID}` : null,
texts,
});
try {
@@ -188,4 +212,98 @@ export class WelcomeMailService {
this.logger.log(`Welcome mail sent to user ${target.id} (tenant ${target.tenantId})`);
return { to, welcomeMailSentAt };
}
/**
* Vorschau fuer Administrator → Willkommensmail: rendert `texts` (auch
* ungespeichert) mit festen Beispielwerten und dem Firmennamen des eigenen
* Mandanten. Das Kopfbild steht als `data:`-Adresse im HTML statt als
* `cid:`, damit die Vorschau im Rahmen der Seite der echten Mail gleicht.
* Der Link "Passwort festlegen" ist ein Beispiel ohne Token.
*/
async preview(
tenantId: string,
texts: WelcomeMailTexts,
accountKind: 'directory' | 'local',
requestOrigin?: string | null,
): Promise<RenderedWelcomeMail> {
const appUrl = this.resolveAppUrl(requestOrigin);
const header = loadWelcomeHeaderPng();
const account: WelcomeMailAccount =
accountKind === 'directory'
? { kind: 'directory' }
: {
kind: 'local',
setPasswordUrl: `${appUrl}/reset-password/beispiel`,
validHours: Math.round(WELCOME_TOKEN_TTL_MS / (60 * 60 * 1000)),
};
return renderWelcomeMail({
name: PREVIEW_SAMPLE.name,
username: PREVIEW_SAMPLE.username,
email: PREVIEW_SAMPLE.email,
tenantName: await this.templateService.getTenantName(tenantId),
appUrl,
account,
headerImageSrc: header ? `data:image/png;base64,${header.toString('base64')}` : null,
texts,
});
}
/**
* Testmail an den angemeldeten Administrator selbst, mit dem aktuellen
* (auch ungespeicherten) Formularinhalt und dem eigenen Konto als
* Beispiel. Es entsteht KEIN Token: ein verzeichnisgefuehrtes eigenes
* Konto bekommt den Windows-Hinweis, ein lokales den Beispiel-Link zur
* Anmeldeseite (`local-example`). `welcomeMailSentAt` bleibt unberuehrt.
* Ohne eigene Adresse 400, ohne Versandweg 409, Versandfehler 502.
*/
async sendTest(
tenantId: string,
userId: string,
texts: WelcomeMailTexts,
requestOrigin?: string | null,
): Promise<{ to: string }> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const self = await tenantPrisma.user.findFirst({
where: { id: userId, tenantId },
select: { username: true, displayName: true, email: true, ldapDn: true },
});
const to = self?.email?.trim();
if (!self || !to) {
throw new BadRequestException(
'Für Ihr eigenes Konto ist keine E-Mail-Adresse hinterlegt. Hinterlegen Sie zuerst eine Adresse (Administrator → Benutzer), dann kann die Testmail an Sie gehen.',
);
}
if (!(await this.mailService.hasConfiguredTransport(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 mail = renderWelcomeMail({
name: self.displayName?.trim() || self.username,
username: self.username,
email: to,
tenantName: await this.templateService.getTenantName(tenantId),
appUrl: this.resolveAppUrl(requestOrigin),
account: self.ldapDn ? { kind: 'directory' } : { kind: 'local-example' },
headerImageSrc: loadWelcomeHeaderPng() ? `cid:${WELCOME_HEADER_CID}` : null,
texts,
notice:
'Testmail aus Administrator → Willkommensmail – so sieht die Willkommensmail mit Ihrer Vorlage aus.',
});
try {
await this.mailService.sendWelcomeMail(tenantId, to, mail);
} catch (error) {
this.logger.error(
`Welcome mail test to user ${userId} failed`,
error instanceof Error ? error.stack : String(error),
);
throw new BadGatewayException(
'Die Testmail konnte nicht gesendet werden. Bitte prüfen Sie die SMTP-Einstellungen oder versuchen Sie es später erneut.',
);
}
this.logger.log(`Welcome mail test sent to user ${userId} (tenant ${tenantId})`);
return { to };
}
}