feat(admin): eigene Vorlage fuer die Willkommensmail mit Platzhaltern, Vorschau und Testmail
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:
@@ -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';
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 <b>IT-Team</b>');
|
||||
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('<img src=x onerror=alert(1)> 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user