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:
Binary file not shown.
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 8.7 KiB |
@@ -1,7 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="112" viewBox="0 0 1200 112">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="80" viewBox="0 0 1200 80">
|
||||
<!--
|
||||
Wellenstreifen unter dem Kopf der Tessera-Systemmails (Willkommensmail).
|
||||
Quelle des PNG daneben (welcome-header.png, 1200x112, angezeigt 600x56);
|
||||
Quelle des PNG daneben (welcome-header.png, 1200x80, angezeigt 600x40);
|
||||
erzeugt mit `node apps/api/scripts/render-mail-header.mjs`.
|
||||
|
||||
Seit quick-260930 (Rueckmeldung des Nutzers: in Outlook "ein riesiger
|
||||
@@ -11,14 +11,17 @@
|
||||
Duenen-Wellen des Dashboard-Hintergrunds (dunkle Fassung,
|
||||
apps/web/src/lib/dashboard-background.ts) mit der feinen gelben Linie,
|
||||
unten laeuft es ins Weiss der Karte aus. Zeigt ein Mailprogramm das Bild
|
||||
nicht, bleibt dort nur weisser Abstand.
|
||||
nicht (Outlook mit gesperrten Bildern), bleibt dort die dunkle Kopffarbe
|
||||
der Zelle stehen — der Kopf wirkt nur etwas hoeher, keine weisse Luecke.
|
||||
Seit quick-260930 (zweite Rueckmeldung) 80 statt 112 px hoch (y-Werte
|
||||
x 80/112), in der Mail 40 statt 56 px.
|
||||
-->
|
||||
<rect width="1200" height="112" fill="#ffffff"/>
|
||||
<rect width="1200" height="80" fill="#ffffff"/>
|
||||
<!-- Flaechen von unten nach oben uebereinander, jede von oben bis zu ihrer
|
||||
Wellenlinie: so teilen sich benachbarte Baender dieselbe Kante, ohne
|
||||
Luecken dazwischen. -->
|
||||
<path d="M0 0 V92 C220 78 420 102 600 96 S1000 76 1200 86 V0 Z" fill="#d9dce0"/>
|
||||
<path d="M0 0 V70 C250 50 420 86 640 76 S1040 50 1200 64 V0 Z" fill="#2c3036"/>
|
||||
<path d="M0 0 V44 C330 18 520 62 700 52 S1060 22 1200 40 V0 Z" fill="#1a1c20"/>
|
||||
<path d="M0 44 C330 18 520 62 700 52 S1060 22 1200 40" fill="none" stroke="#ffed00" stroke-opacity="0.75" stroke-width="3"/>
|
||||
<path d="M0 0 V65.7 C220 55.7 420 72.9 600 68.6 S1000 54.3 1200 61.4 V0 Z" fill="#d9dce0"/>
|
||||
<path d="M0 0 V50 C250 35.7 420 61.4 640 54.3 S1040 35.7 1200 45.7 V0 Z" fill="#2c3036"/>
|
||||
<path d="M0 0 V31.4 C330 12.9 520 44.3 700 37.1 S1060 15.7 1200 28.6 V0 Z" fill="#1a1c20"/>
|
||||
<path d="M0 31.4 C330 12.9 520 44.3 700 37.1 S1060 15.7 1200 28.6" fill="none" stroke="#ffed00" stroke-opacity="0.75" stroke-width="3"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,52 @@
|
||||
-- Willkommensmail: eigene Vorlage je Mandant (Administrator → Willkommensmail).
|
||||
--
|
||||
-- Zweck: neue Tabelle "WelcomeMailTemplate". Ein Administrator kann Betreff,
|
||||
-- Ueberschrift, Einleitung und Abschlusstext der Willkommensmail anpassen
|
||||
-- (mit Platzhaltern wie {{name}}). Hoechstens EINE Zeile je Mandant
|
||||
-- ("tenantId" eindeutig); "Auf Standard zuruecksetzen" loescht die Zeile, dann
|
||||
-- gelten wieder die Standardtexte aus dem Code. Die festen Bausteine der Mail
|
||||
-- (Kopf, Zugangsdaten, Anmeldehinweis, Knoepfe, Fusszeile) stehen NICHT in
|
||||
-- der Tabelle. "updatedBy" haelt den Benutzernamen des letzten Bearbeiters
|
||||
-- als reinen Anzeigetext (keine Relation).
|
||||
--
|
||||
-- Von Hand geschrieben (Vorbild 20260929120000_custom_module).
|
||||
--
|
||||
-- Zeilenschutz (Pflicht — sonst schlaegt rls-coverage.spec.ts fehl): die
|
||||
-- Tabelle traegt `tenantId` und `tenant_isolation_policy` OHNE
|
||||
-- Benutzerdimension (`USING ("tenantId" = current_tenant_id())`) — die
|
||||
-- Vorlage ist Verwaltungsdatum des Mandanten, nicht persoenliches Datum eines
|
||||
-- einzelnen Benutzers.
|
||||
--
|
||||
-- BEWUSST KEINE `system_read_policy`: gelesen wird nur beim Versand einer
|
||||
-- Willkommensmail, und zwar gebunden an den Mandanten des Zielbenutzers
|
||||
-- (`forTenant(prisma, tenantId)`); es gibt keinen Hintergrunddienst, der die
|
||||
-- Vorlagen ueber alle Mandanten liest.
|
||||
--
|
||||
-- Rechte fuer die Anwendungsrolle tessera_app kommen automatisch ueber
|
||||
-- ALTER DEFAULT PRIVILEGES aus 20260909130000_rls_app_role — hier nichts zu
|
||||
-- tun.
|
||||
--
|
||||
-- WICHTIG: wie alle bisherigen RLS-Migrationen wirken diese Regeln erst,
|
||||
-- wenn die Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter
|
||||
-- heute AUS, siehe docs/mandantentrennung-datenbankrolle.md).
|
||||
|
||||
CREATE TABLE "WelcomeMailTemplate" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tenantId" TEXT NOT NULL,
|
||||
"subject" TEXT NOT NULL,
|
||||
"heading" TEXT NOT NULL,
|
||||
"intro" TEXT NOT NULL,
|
||||
"closing" TEXT NOT NULL,
|
||||
"updatedBy" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "WelcomeMailTemplate_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "WelcomeMailTemplate_tenantId_key" ON "WelcomeMailTemplate"("tenantId");
|
||||
|
||||
ALTER TABLE "WelcomeMailTemplate" ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "WelcomeMailTemplate" FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tenant_isolation_policy ON "WelcomeMailTemplate"
|
||||
USING ("tenantId" = current_tenant_id());
|
||||
@@ -767,3 +767,20 @@ model Reminder {
|
||||
@@index([tenantId, userId, dueAt])
|
||||
@@index([dueAt])
|
||||
}
|
||||
|
||||
// Eigene Vorlage der Willkommensmail (Administrator → Willkommensmail):
|
||||
// hoechstens eine je Mandant; fehlt sie, gelten die Standardtexte aus
|
||||
// @tessera/shared (DEFAULT_WELCOME_MAIL_TEXTS). Nur die vier Texte —
|
||||
// Kopf, Zugangsdaten, Anmeldehinweis und Knoepfe bleiben fest im Code.
|
||||
model WelcomeMailTemplate {
|
||||
id String @id @default(uuid())
|
||||
tenantId String @unique
|
||||
subject String
|
||||
heading String
|
||||
intro String
|
||||
closing String
|
||||
// Benutzername des Administrators, der zuletzt gespeichert hat (Anzeige)
|
||||
updatedBy String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* render-mail-header.mjs — erzeugt das Kopfbild der Willkommensmail
|
||||
* (apps/api/assets/mail/welcome-header.png, 1200x112 fuer hochaufloesende
|
||||
* Bildschirme, in der Mail 600x56 angezeigt) aus der daneben liegenden
|
||||
* (apps/api/assets/mail/welcome-header.png, 1200x80 fuer hochaufloesende
|
||||
* Bildschirme, in der Mail 600x40 angezeigt) aus der daneben liegenden
|
||||
* Quelle welcome-header.svg.
|
||||
*
|
||||
* Warum ein PNG statt Inline-SVG oder CSS-Hintergrund: Outlook (Word-
|
||||
@@ -33,7 +33,7 @@ const sharp = createRequire(nextPkg)('sharp');
|
||||
|
||||
const svg = readFileSync(join(assetDir, 'welcome-header.svg'));
|
||||
const png = await sharp(svg, { density: 72 })
|
||||
.resize(1200, 112)
|
||||
.resize(1200, 80)
|
||||
.png({ compressionLevel: 9, palette: false })
|
||||
.toBuffer();
|
||||
writeFileSync(join(assetDir, 'welcome-header.png'), png);
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { DEFAULT_WELCOME_MAIL_TEXTS, findUnknownWelcomeMailPlaceholders } from '@tessera/shared';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
applyWelcomeMailPlaceholders,
|
||||
firstNameOf,
|
||||
renderWelcomeMail,
|
||||
type WelcomeMailInput,
|
||||
} from './welcome-mail.template';
|
||||
|
||||
/**
|
||||
* Willkommensmail mit eigener Vorlage: Platzhalter, Escaping, Absaetze,
|
||||
* feste Bausteine.
|
||||
*/
|
||||
|
||||
const base: WelcomeMailInput = {
|
||||
name: 'Max Muster',
|
||||
username: 'max.muster',
|
||||
email: 'max@example.invalid',
|
||||
tenantName: 'Muster & Co',
|
||||
appUrl: 'https://tessera.example.invalid/',
|
||||
account: { kind: 'directory' },
|
||||
headerImageSrc: null,
|
||||
};
|
||||
|
||||
const values = {
|
||||
name: 'Max Muster',
|
||||
vorname: 'Max',
|
||||
benutzername: 'max.muster',
|
||||
email: 'max@example.invalid',
|
||||
adresse: 'https://t.example.invalid',
|
||||
firma: 'Muster & Co',
|
||||
};
|
||||
|
||||
describe('Platzhalter', () => {
|
||||
it('ersetzt alle sechs, Gross-/Kleinschreibung und Leerraum egal; unbekannte bleiben stehen', () => {
|
||||
expect(
|
||||
applyWelcomeMailPlaceholders(
|
||||
'{{name}}|{{vorname}}|{{benutzername}}|{{email}}|{{adresse}}|{{firma}}|{{ NAME }}|{{xyz}}',
|
||||
values,
|
||||
),
|
||||
).toBe(
|
||||
'Max Muster|Max|max.muster|max@example.invalid|https://t.example.invalid|Muster & Co|Max Muster|{{xyz}}',
|
||||
);
|
||||
});
|
||||
|
||||
it('findUnknownWelcomeMailPlaceholders nennt jeden unbekannten einmal', () => {
|
||||
expect(findUnknownWelcomeMailPlaceholders('{{a}} {{name}} {{a}} {{ b }} {{}}')).toEqual([
|
||||
'{{a}}',
|
||||
'{{b}}',
|
||||
'{{}}',
|
||||
]);
|
||||
expect(findUnknownWelcomeMailPlaceholders(DEFAULT_WELCOME_MAIL_TEXTS.heading)).toEqual([]);
|
||||
});
|
||||
|
||||
it('vorname: erstes Wort des Anzeigenamens, sonst Benutzername', () => {
|
||||
expect(firstNameOf(' Erika Mustermann ', 'erika')).toBe('Erika');
|
||||
expect(firstNameOf(null, 'erika')).toBe('erika');
|
||||
expect(firstNameOf(' ', 'erika')).toBe('erika');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderWelcomeMail mit Vorlage', () => {
|
||||
it('ohne Vorlage: Standard-Betreff und -Ueberschrift', () => {
|
||||
const mail = renderWelcomeMail(base);
|
||||
expect(mail.subject).toBe('Willkommen bei Tessera');
|
||||
expect(mail.html).toContain('Willkommen bei Tessera, Max Muster!');
|
||||
expect(mail.html).toContain('Viel Erfolg mit Tessera!');
|
||||
});
|
||||
|
||||
it('Text ist reiner Text: HTML aus Vorlage und Werten wird escaped; Firma mit & korrekt', () => {
|
||||
const mail = renderWelcomeMail({
|
||||
...base,
|
||||
name: '<script>x</script>',
|
||||
texts: {
|
||||
subject: 'Hallo\r\n{{firma}}',
|
||||
heading: '<h2>{{name}}</h2>',
|
||||
intro: '<a href="javascript:alert(1)">klick</a>',
|
||||
closing: '',
|
||||
},
|
||||
});
|
||||
expect(mail.subject).toBe('Hallo Muster & Co');
|
||||
expect(mail.html).not.toContain('<script>x</script>');
|
||||
expect(mail.html).not.toContain('<h2>');
|
||||
expect(mail.html).not.toContain('href="javascript:');
|
||||
expect(mail.html).toContain('<h2><script>x</script></h2>');
|
||||
expect(mail.html).toContain('<title>Hallo Muster & Co</title>');
|
||||
});
|
||||
|
||||
it('Leerzeile = neuer Absatz, einfacher Umbruch = <br>; leerer Abschluss faellt weg', () => {
|
||||
const mail = renderWelcomeMail({
|
||||
...base,
|
||||
texts: { subject: 's', heading: 'h', intro: 'A1\nA2\r\n\r\n\nB', closing: ' ' },
|
||||
});
|
||||
expect(mail.html).toContain('>A1<br>A2</p>');
|
||||
expect(mail.html).toContain('>B</p>');
|
||||
expect(mail.html).not.toContain('Viel Erfolg');
|
||||
expect(mail.text).toContain('A1\nA2\n\nB');
|
||||
});
|
||||
|
||||
it('feste Bausteine bleiben bei jeder Vorlage: Adresse, Benutzername, Anmeldehinweis, Zu Tessera, Fusszeile', () => {
|
||||
const mail = renderWelcomeMail({
|
||||
...base,
|
||||
account: {
|
||||
kind: 'local',
|
||||
setPasswordUrl: 'https://tessera.example.invalid/reset-password/abc',
|
||||
validHours: 168,
|
||||
},
|
||||
texts: { subject: 'x', heading: 'y', intro: '', closing: '' },
|
||||
});
|
||||
expect(mail.html).toContain('https://tessera.example.invalid/reset-password/abc');
|
||||
expect(mail.html).toContain('Passwort festlegen');
|
||||
expect(mail.html).toContain('7 Tage');
|
||||
expect(mail.html).toContain('Zu Tessera');
|
||||
expect(mail.html).toContain('max.muster');
|
||||
expect(mail.html).toContain('im Auftrag Ihres Administrators');
|
||||
});
|
||||
|
||||
it('local-example: Knopf zur Anmeldeseite mit Beispiel-Hinweis, kein reset-password', () => {
|
||||
const mail = renderWelcomeMail({
|
||||
...base,
|
||||
account: { kind: 'local-example' },
|
||||
notice: 'Testmail',
|
||||
});
|
||||
expect(mail.html).toContain('Beispiel-Link');
|
||||
expect(mail.html).not.toContain('reset-password');
|
||||
expect(mail.html).toContain('Testmail');
|
||||
expect(mail.text.startsWith('[Testmail]')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Kopf (Outlook ohne CID-Bild)', () => {
|
||||
it('Wellenstreifen 600x40 in einer Zelle mit dunkler Kopffarbe, Inhalt beginnt mit 24px Abstand', () => {
|
||||
const mail = renderWelcomeMail({ ...base, headerImageSrc: 'cid:welcome-header@tessera' });
|
||||
expect(mail.html).toMatch(
|
||||
/<td bgcolor="#1a1c20" style="background-color:#1a1c20;line-height:0;font-size:0;">\n<img src="cid:welcome-header@tessera" width="600" height="40"/,
|
||||
);
|
||||
expect(mail.html).toContain('padding:24px 40px 12px;');
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,14 @@
|
||||
* `MailService.sendWelcomeMail`, die Entscheidung "wer bekommt welchen
|
||||
* Anmeldehinweis" `WelcomeMailService`.
|
||||
*
|
||||
* Eigene Vorlage (Administrator → Willkommensmail): Betreff, Ueberschrift,
|
||||
* Einleitung und Abschluss kommen als `texts` herein (Vorlage des Mandanten
|
||||
* des ZIEL-Benutzers, sonst `DEFAULT_WELCOME_MAIL_TEXTS` aus
|
||||
* `@tessera/shared`). Platzhalter (`{{name}}`, `{{vorname}}`,
|
||||
* `{{benutzername}}`, `{{email}}`, `{{adresse}}`, `{{firma}}`) werden auf dem
|
||||
* REINEN Text ersetzt und erst danach escaped. Leerzeile = neuer Absatz,
|
||||
* einfacher Umbruch = `<br>`. Alles andere bleibt fest.
|
||||
*
|
||||
* E-Mail-tauglich gebaut, weil Outlook (Word-Darstellung), Gmail und Apple
|
||||
* Mail sehr unterschiedlich darstellen:
|
||||
* - Tabellenlayout, alle Stile inline, hoechstens 600 px breit;
|
||||
@@ -25,6 +33,14 @@
|
||||
* Windows-Passwort.
|
||||
*/
|
||||
|
||||
import {
|
||||
DEFAULT_WELCOME_MAIL_TEXTS,
|
||||
isWelcomeMailPlaceholder,
|
||||
WELCOME_MAIL_PLACEHOLDER_RE,
|
||||
type WelcomeMailPlaceholder,
|
||||
type WelcomeMailTexts,
|
||||
} from '@tessera/shared';
|
||||
|
||||
/** Farben aus dem Design "Mosaik" (globals.css / brand.ts). */
|
||||
const C = {
|
||||
page: '#eceef1',
|
||||
@@ -46,17 +62,31 @@ const FONT =
|
||||
export type WelcomeMailAccount =
|
||||
| { kind: 'directory' }
|
||||
| { kind: 'local'; setPasswordUrl: string; validHours: number }
|
||||
| { kind: 'local-no-link' };
|
||||
| { kind: 'local-no-link' }
|
||||
/**
|
||||
* Nur Testmail aus Administrator → Willkommensmail: derselbe Baustein wie
|
||||
* `local`, aber OHNE Token — der Knopf fuehrt zur Anmeldeseite und ein
|
||||
* Hinweis sagt, was er in der echten Mail tut.
|
||||
*/
|
||||
| { kind: 'local-example' };
|
||||
|
||||
export interface WelcomeMailInput {
|
||||
/** Anzeigename, sonst Benutzername. */
|
||||
name: string;
|
||||
username: string;
|
||||
/** E-Mail-Adresse des Empfaengers (Platzhalter `{{email}}`). */
|
||||
email: string;
|
||||
/** Name des Mandanten (Platzhalter `{{firma}}`). */
|
||||
tenantName: string;
|
||||
/** Oeffentliche Basisadresse der Web-Oberflaeche, ohne abschliessenden Schraegstrich. */
|
||||
appUrl: string;
|
||||
account: WelcomeMailAccount;
|
||||
/** `cid:...` im Versand, relativer Pfad in der Vorschau, `null` = Textkopf. */
|
||||
/** `cid:...` im Versand, `data:` in der Vorschau, `null` = Textkopf. */
|
||||
headerImageSrc: string | null;
|
||||
/** Eigene Vorlage des Mandanten; fehlt sie, gelten die Standardtexte. */
|
||||
texts?: WelcomeMailTexts | null;
|
||||
/** Hinweisleiste ganz oben (nur Testmail), reiner Text. */
|
||||
notice?: string | null;
|
||||
}
|
||||
|
||||
export interface RenderedWelcomeMail {
|
||||
@@ -65,13 +95,55 @@ export interface RenderedWelcomeMail {
|
||||
html: string;
|
||||
}
|
||||
|
||||
export const WELCOME_MAIL_SUBJECT = 'Willkommen bei Tessera';
|
||||
export const WELCOME_MAIL_SUBJECT = DEFAULT_WELCOME_MAIL_TEXTS.subject;
|
||||
|
||||
const FOOTER = 'Diese E-Mail wurde von Tessera im Auftrag Ihres Administrators versendet.';
|
||||
const INTRO =
|
||||
'für Sie wurde ein Zugang zu Tessera eingerichtet – Ihrer zentralen Plattform für Werkzeuge und Abläufe im Unternehmen. Alles, was Sie für Ihre tägliche Arbeit brauchen, finden Sie dort an einem Ort.';
|
||||
const DESKTOP_HINT =
|
||||
'Tessera gibt es auch als Desktop-App – den Download finden Sie auf der Anmeldeseite.';
|
||||
const EXAMPLE_LINK_NOTE =
|
||||
'Beispiel-Link: In der echten Willkommensmail führt dieser Knopf zu einem persönlichen Link, mit dem der neue Benutzer sein Passwort festlegt. In dieser Testmail öffnet er nur die Anmeldeseite.';
|
||||
|
||||
/** Werte der Platzhalter, noch NICHT escaped (Ersetzung laeuft auf reinem Text). */
|
||||
export type WelcomeMailPlaceholderValues = Record<WelcomeMailPlaceholder, string>;
|
||||
|
||||
/** Erstes Wort des Anzeigenamens, sonst der Benutzername. */
|
||||
export function firstNameOf(displayName: string | null | undefined, username: string): string {
|
||||
const first = (displayName ?? '').trim().split(/\s+/)[0];
|
||||
return first || username;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt die Platzhalter in einen REINEN Text ein (Gross-/Kleinschreibung
|
||||
* egal). Unbekannte bleiben stehen — gespeicherte Vorlagen enthalten keine,
|
||||
* das prueft die API beim Speichern. Das Ergebnis ist weiter reiner Text und
|
||||
* wird erst beim Einbau ins HTML escaped.
|
||||
*/
|
||||
export function applyWelcomeMailPlaceholders(
|
||||
text: string,
|
||||
values: WelcomeMailPlaceholderValues,
|
||||
): string {
|
||||
return text.replace(WELCOME_MAIL_PLACEHOLDER_RE, (whole, name: string) =>
|
||||
isWelcomeMailPlaceholder(name) ? values[name.toLowerCase() as WelcomeMailPlaceholder] : whole,
|
||||
);
|
||||
}
|
||||
|
||||
/** Zeilenenden vereinheitlichen, Leerraum an den Raendern entfernen. */
|
||||
function normalizeText(value: string): string {
|
||||
return value.replace(/\r\n?/g, '\n').trim();
|
||||
}
|
||||
|
||||
/** Einzeilig (Betreff, Ueberschrift): jeder Umbruch/Leerraum-Lauf wird ein Leerzeichen. */
|
||||
function singleLine(value: string): string {
|
||||
return value.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
/** Absaetze: Leerzeile trennt, einfacher Umbruch bleibt im Absatz. */
|
||||
function paragraphsOf(value: string): string[] {
|
||||
const text = normalizeText(value);
|
||||
if (!text) return [];
|
||||
return text
|
||||
.split(/\n[ \t]*\n+/)
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function escapeHtml(value: string): string {
|
||||
return value
|
||||
@@ -92,6 +164,7 @@ function loginHintText(account: WelcomeMailAccount): string {
|
||||
case 'directory':
|
||||
return 'Melden Sie sich mit Ihrem Benutzernamen und Ihrem gewohnten Windows-Passwort an.';
|
||||
case 'local':
|
||||
case 'local-example':
|
||||
return 'Bevor Sie sich zum ersten Mal anmelden, legen Sie bitte Ihr persönliches Passwort fest.';
|
||||
case 'local-no-link':
|
||||
return 'Ihr Startpasswort erhalten Sie von Ihrem Administrator.';
|
||||
@@ -154,8 +227,10 @@ ${row([olive, olive, C.yellow])}${spacer}${row([null, olive, null])}${spacer}${r
|
||||
* - Bildmarke (HTML-Kacheln) und Schriftzug "Tessera" (echter Text) in einer
|
||||
* niedrigen dunklen Leiste — erscheinen immer;
|
||||
* - darunter die Duenen-Welle als schmaler Bildstreifen (`src`, im Versand
|
||||
* `cid:`), der ins Weiss der Karte auslaeuft. Fehlt er, bleibt nur weisser
|
||||
* Abstand, kein dunkler Block.
|
||||
* `cid:`, 600x40), der ins Weiss der Karte auslaeuft. Die Zelle um das
|
||||
* Bild traegt die dunkle Kopffarbe (zweite Rueckmeldung des Nutzers: sein
|
||||
* Outlook zeigt das CID-Bild nicht, vorher stand dort eine "riesengrosse
|
||||
* weisse Luecke"): fehlt das Bild, wirkt der Kopf nur etwas hoeher.
|
||||
*/
|
||||
function headerRow(src: string | null): string {
|
||||
const bar = `<tr><td bgcolor="${C.header}" style="background-color:${C.header};border-radius:12px 12px 0 0;padding:22px 32px 14px;">
|
||||
@@ -165,30 +240,56 @@ function headerRow(src: string | null): string {
|
||||
</tr></table>
|
||||
</td></tr>`;
|
||||
const wave = src
|
||||
? `<tr><td bgcolor="${C.card}" style="background-color:${C.card};line-height:0;font-size:0;">
|
||||
<img src="${escapeHtml(src)}" width="600" height="56" alt="" style="display:block;width:100%;max-width:600px;height:auto;border:0;outline:none;text-decoration:none;">
|
||||
? `<tr><td bgcolor="${C.header}" style="background-color:${C.header};line-height:0;font-size:0;">
|
||||
<img src="${escapeHtml(src)}" width="600" height="40" alt="" style="display:block;width:100%;max-width:600px;height:auto;border:0;outline:none;text-decoration:none;">
|
||||
</td></tr>`
|
||||
: `<tr><td bgcolor="${C.header}" height="4" style="background-color:${C.header};height:4px;font-size:1px;line-height:4px;border-bottom:3px solid ${C.yellow};"> </td></tr>`;
|
||||
return bar + wave;
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut die Willkommensmail. Die vier Texte (Betreff, Ueberschrift,
|
||||
* Einleitung, Abschluss) kommen aus der eigenen Vorlage des Mandanten, sonst
|
||||
* aus `DEFAULT_WELCOME_MAIL_TEXTS`. Sie sind REINER Text: Platzhalter werden
|
||||
* auf dem Text ersetzt, erst danach wird alles escaped — HTML aus der
|
||||
* Vorlage oder aus einem Benutzerwert erscheint als Text, nie als Markup.
|
||||
* Kopf, Kasten Adresse/Benutzername, Anmeldehinweis, Knopf "Zu Tessera" und
|
||||
* Fusszeile sind feste Bausteine und nicht Teil der Vorlage.
|
||||
*/
|
||||
export function renderWelcomeMail(input: WelcomeMailInput): RenderedWelcomeMail {
|
||||
const base = safeUrl(input.appUrl.replace(/\/+$/, ''));
|
||||
const loginUrl = `${base}/login`;
|
||||
const hint = loginHintText(input.account);
|
||||
const texts = input.texts ?? DEFAULT_WELCOME_MAIL_TEXTS;
|
||||
|
||||
const values: WelcomeMailPlaceholderValues = {
|
||||
name: input.name,
|
||||
vorname: firstNameOf(input.name, input.username),
|
||||
benutzername: input.username,
|
||||
email: input.email,
|
||||
adresse: base,
|
||||
firma: input.tenantName,
|
||||
};
|
||||
const fill = (value: string) => applyWelcomeMailPlaceholders(value, values);
|
||||
|
||||
const subject = singleLine(fill(texts.subject)) || WELCOME_MAIL_SUBJECT;
|
||||
const heading = singleLine(fill(texts.heading));
|
||||
const introParagraphs = paragraphsOf(fill(texts.intro));
|
||||
const closingParagraphs = paragraphsOf(fill(texts.closing));
|
||||
const notice = input.notice ? singleLine(input.notice) : '';
|
||||
|
||||
// ── Text-Alternative ───────────────────────────────────────────────────
|
||||
const textLines = [
|
||||
`Guten Tag ${input.name},`,
|
||||
'',
|
||||
INTRO,
|
||||
'',
|
||||
const textLines: string[] = [];
|
||||
if (notice) textLines.push(`[${notice}]`, '');
|
||||
if (heading) textLines.push(heading, '');
|
||||
for (const para of introParagraphs) textLines.push(para, '');
|
||||
textLines.push(
|
||||
'Ihre Zugangsdaten',
|
||||
`Adresse: ${base}`,
|
||||
`Benutzername: ${input.username}`,
|
||||
'',
|
||||
hint,
|
||||
];
|
||||
);
|
||||
if (input.account.kind === 'local') {
|
||||
textLines.push(
|
||||
'',
|
||||
@@ -196,29 +297,24 @@ export function renderWelcomeMail(input: WelcomeMailInput): RenderedWelcomeMail
|
||||
input.account.setPasswordUrl,
|
||||
validityText(input.account.validHours),
|
||||
);
|
||||
} else if (input.account.kind === 'local-example') {
|
||||
textLines.push('', 'Passwort festlegen:', loginUrl, EXAMPLE_LINK_NOTE);
|
||||
}
|
||||
textLines.push(
|
||||
'',
|
||||
'Zu Tessera:',
|
||||
loginUrl,
|
||||
'',
|
||||
DESKTOP_HINT,
|
||||
'',
|
||||
'Viel Erfolg mit Tessera!',
|
||||
'',
|
||||
'--',
|
||||
FOOTER,
|
||||
);
|
||||
textLines.push('', 'Zu Tessera:', loginUrl);
|
||||
for (const para of closingParagraphs) textLines.push('', para);
|
||||
textLines.push('', '--', FOOTER);
|
||||
const text = textLines.join('\n');
|
||||
|
||||
// ── HTML ───────────────────────────────────────────────────────────────
|
||||
const name = escapeHtml(input.name);
|
||||
const username = escapeHtml(input.username);
|
||||
const baseHtml = escapeHtml(base);
|
||||
|
||||
const p = (content: string, extra = '') =>
|
||||
`<p style="margin:0 0 16px;font-family:${FONT};font-size:16px;line-height:25px;color:${C.body};${extra}">${content}</p>`;
|
||||
|
||||
/** Reiner Text -> escaped, einfache Umbrueche als <br>. */
|
||||
const para = (value: string) => escapeHtml(value).replace(/\n/g, '<br>');
|
||||
|
||||
const label = (content: string) =>
|
||||
`<div style="font-family:${FONT};font-size:12px;line-height:16px;font-weight:600;letter-spacing:0.06em;text-transform:uppercase;color:${C.muted};">${content}</div>`;
|
||||
|
||||
@@ -227,8 +323,35 @@ export function renderWelcomeMail(input: WelcomeMailInput): RenderedWelcomeMail
|
||||
const setUrl = safeUrl(input.account.setPasswordUrl);
|
||||
accountBlock += `${button(setUrl, 'Passwort festlegen', C.ink, '#ffffff')}
|
||||
<p style="margin:12px 0 0;font-family:${FONT};font-size:13px;line-height:20px;color:${C.muted};">${escapeHtml(validityText(input.account.validHours))}</p>`;
|
||||
} else if (input.account.kind === 'local-example') {
|
||||
accountBlock += `${button(loginUrl, 'Passwort festlegen', C.ink, '#ffffff')}
|
||||
<p style="margin:12px 0 0;font-family:${FONT};font-size:13px;line-height:20px;color:${C.muted};">${escapeHtml(EXAMPLE_LINK_NOTE)}</p>`;
|
||||
}
|
||||
|
||||
const noticeRow = notice
|
||||
? `<tr><td style="padding:0 0 12px;"><table role="presentation" width="100%" border="0" cellpadding="0" cellspacing="0"><tr><td bgcolor="#fff8c2" style="background-color:#fff8c2;border:1px solid #e6d74c;border-radius:8px;padding:10px 16px;font-family:${FONT};font-size:13px;line-height:20px;color:${C.ink};">${escapeHtml(notice)}</td></tr></table></td></tr>
|
||||
`
|
||||
: '';
|
||||
|
||||
const headingHtml = heading
|
||||
? `<h1 style="margin:0 0 16px;font-family:${FONT};font-size:24px;line-height:32px;font-weight:700;color:${C.ink};">${escapeHtml(heading)}</h1>
|
||||
`
|
||||
: '';
|
||||
const introHtml = introParagraphs.map((part) => p(para(part))).join('\n');
|
||||
|
||||
const closingHtml = closingParagraphs.length
|
||||
? `<table role="presentation" width="100%" border="0" cellpadding="0" cellspacing="0"><tr><td style="border-top:1px solid ${C.line};padding-top:20px;">
|
||||
${closingParagraphs
|
||||
.map((part, i) =>
|
||||
p(
|
||||
para(part),
|
||||
`font-size:14px;line-height:22px;margin:0${i < closingParagraphs.length - 1 ? ' 0 12px' : ''};`,
|
||||
),
|
||||
)
|
||||
.join('\n')}
|
||||
</td></tr></table>`
|
||||
: '';
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="de" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
<head>
|
||||
@@ -237,7 +360,7 @@ export function renderWelcomeMail(input: WelcomeMailInput): RenderedWelcomeMail
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="color-scheme" content="light">
|
||||
<meta name="supported-color-schemes" content="light">
|
||||
<title>${escapeHtml(WELCOME_MAIL_SUBJECT)}</title>
|
||||
<title>${escapeHtml(subject)}</title>
|
||||
<!--[if mso]><noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript><![endif]-->
|
||||
<style>
|
||||
a { color: ${C.link}; }
|
||||
@@ -252,10 +375,9 @@ export function renderWelcomeMail(input: WelcomeMailInput): RenderedWelcomeMail
|
||||
<tr><td align="center" style="padding:32px 12px;">
|
||||
<!--[if mso]><table role="presentation" width="600" border="0" cellpadding="0" cellspacing="0"><tr><td><![endif]-->
|
||||
<table role="presentation" width="100%" border="0" cellpadding="0" cellspacing="0" style="width:100%;max-width:600px;border-collapse:separate;">
|
||||
${headerRow(input.headerImageSrc)}
|
||||
<tr><td class="tsr-pad" bgcolor="${C.card}" style="background-color:${C.card};padding:36px 40px 12px;">
|
||||
<h1 style="margin:0 0 16px;font-family:${FONT};font-size:24px;line-height:32px;font-weight:700;color:${C.ink};">Willkommen bei Tessera, ${name}!</h1>
|
||||
${p(escapeHtml(INTRO.charAt(0).toUpperCase() + INTRO.slice(1)))}
|
||||
${noticeRow}${headerRow(input.headerImageSrc)}
|
||||
<tr><td class="tsr-pad" bgcolor="${C.card}" style="background-color:${C.card};padding:24px 40px 12px;">
|
||||
${headingHtml}${introHtml}
|
||||
<table role="presentation" width="100%" border="0" cellpadding="0" cellspacing="0" style="border-collapse:separate;margin:8px 0 0;">
|
||||
<tr><td bgcolor="${C.well}" style="background-color:${C.well};border:1px solid ${C.line};border-left:4px solid ${C.yellow};border-radius:8px;padding:18px 22px;">
|
||||
${label('Adresse')}
|
||||
@@ -269,10 +391,7 @@ ${accountBlock}
|
||||
${button(loginUrl, 'Zu Tessera', C.yellow, C.ink)}
|
||||
</td></tr>
|
||||
<tr><td class="tsr-pad" bgcolor="${C.card}" style="background-color:${C.card};padding:20px 40px 36px;border-radius:0 0 12px 12px;">
|
||||
<table role="presentation" width="100%" border="0" cellpadding="0" cellspacing="0"><tr><td style="border-top:1px solid ${C.line};padding-top:20px;">
|
||||
${p(`<strong style="color:${C.ink};">Tipp:</strong> ${escapeHtml(DESKTOP_HINT)}`, `font-size:14px;line-height:22px;margin:0 0 12px;`)}
|
||||
${p('Viel Erfolg mit Tessera!', 'font-size:14px;line-height:22px;margin:0;')}
|
||||
</td></tr></table>
|
||||
${closingHtml}
|
||||
</td></tr>
|
||||
<tr><td align="center" style="padding:20px 24px 0;font-family:${FONT};font-size:12px;line-height:18px;color:${C.muted};">${escapeHtml(FOOTER)}</td></tr>
|
||||
</table>
|
||||
@@ -282,5 +401,5 @@ ${p('Viel Erfolg mit Tessera!', 'font-size:14px;line-height:22px;margin:0;')}
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
return { subject: WELCOME_MAIL_SUBJECT, text, html };
|
||||
return { subject, text, html };
|
||||
}
|
||||
|
||||
@@ -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