mail und logging

This commit is contained in:
Bastian Wagner
2026-07-17 11:50:12 +02:00
parent 201c4e03f8
commit edd88acd98
45 changed files with 1413 additions and 42 deletions

View File

@@ -23,6 +23,13 @@ SMTP_SECURE=false
SMTP_USER=portal@example.com SMTP_USER=portal@example.com
SMTP_PASS=change-me SMTP_PASS=change-me
SMTP_FROM="LDAP Portal <portal@example.com>" SMTP_FROM="LDAP Portal <portal@example.com>"
MAIL_PRODUCT_NAME=LDAP Portal
MAIL_COMPANY_NAME=LDAP Portal
MAIL_PRIMARY_COLOR=#2563eb
MAIL_SUPPORT_EMAIL=portal@example.com
MAIL_LOGO_URL=
MAIL_IMPRINT_URL=
MAIL_PRIVACY_URL=
OIDC_ISSUER=http://localhost:8080 OIDC_ISSUER=http://localhost:8080
OIDC_COOKIE_SECRET=change-me-long-random-oidc-cookie-secret OIDC_COOKIE_SECRET=change-me-long-random-oidc-cookie-secret

View File

@@ -143,6 +143,10 @@ Die wichtigsten Variablen aus `.env.example`:
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURE` | SMTP-Verbindung. | | `SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURE` | SMTP-Verbindung. |
| `SMTP_USER`, `SMTP_PASS` | Optionale SMTP-Authentifizierung. | | `SMTP_USER`, `SMTP_PASS` | Optionale SMTP-Authentifizierung. |
| `SMTP_FROM` | Absenderadresse fuer Portal-Mails. | | `SMTP_FROM` | Absenderadresse fuer Portal-Mails. |
| `MAIL_PRODUCT_NAME`, `MAIL_COMPANY_NAME` | Optionale Branding-Namen fuer HTML-Mail-Templates. |
| `MAIL_PRIMARY_COLOR` | Primaerfarbe fuer Mail-Buttons und Links, Standard `#2563eb`. |
| `MAIL_SUPPORT_EMAIL` | Support-Adresse im Mail-Footer, Fallback aus `SMTP_FROM`. |
| `MAIL_LOGO_URL`, `MAIL_IMPRINT_URL`, `MAIL_PRIVACY_URL` | Optionale oeffentlich erreichbare HTTPS-Links fuer Mail-Logo, Impressum und Datenschutz. |
| `OIDC_ISSUER` | Externe Issuer-URL des OIDC Providers. | | `OIDC_ISSUER` | Externe Issuer-URL des OIDC Providers. |
| `OIDC_COOKIE_SECRET` | Cookie-Secret fuer OIDC Sessions; Fallback ist `TOKEN_SECRET`. | | `OIDC_COOKIE_SECRET` | Cookie-Secret fuer OIDC Sessions; Fallback ist `TOKEN_SECRET`. |
| `OIDC_ADMIN_GROUP` | Gruppe fuer OIDC-Clientverwaltung, Standard `client_manager`. | | `OIDC_ADMIN_GROUP` | Gruppe fuer OIDC-Clientverwaltung, Standard `client_manager`. |

View File

@@ -2,6 +2,13 @@
"$schema": "https://json.schemastore.org/nest-cli", "$schema": "https://json.schemastore.org/nest-cli",
"sourceRoot": "src", "sourceRoot": "src",
"compilerOptions": { "compilerOptions": {
"deleteOutDir": true "deleteOutDir": true,
"assets": [
{
"include": "mail/templates/**/*",
"outDir": "dist"
}
],
"watchAssets": true
} }
} }

View File

@@ -3,9 +3,10 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "tsc -p tsconfig.build.json", "build": "tsc -p tsconfig.build.json && node scripts/copy-mail-assets.js",
"start": "node dist/main.js", "start": "node dist/main.js",
"start:dev": "nest start --watch", "start:dev": "nest start --watch",
"preview:mails": "ts-node scripts/render-mail-previews.ts",
"test": "jest --passWithNoTests", "test": "jest --passWithNoTests",
"lint": "eslint \"src/**/*.ts\"" "lint": "eslint \"src/**/*.ts\""
}, },

View File

@@ -0,0 +1,13 @@
const { cpSync, existsSync, mkdirSync, rmSync } = require('node:fs');
const { join } = require('node:path');
const source = join(__dirname, '..', 'src', 'mail', 'templates');
const destination = join(__dirname, '..', 'dist', 'mail', 'templates');
if (!existsSync(source)) {
throw new Error(`Mail template source directory not found: ${source}`);
}
rmSync(destination, { recursive: true, force: true });
mkdirSync(destination, { recursive: true });
cpSync(source, destination, { recursive: true });

View File

@@ -0,0 +1,117 @@
import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { mailBrandingFromConfig } from '../src/mail/mail-branding';
import { MailTemplateName } from '../src/mail/mail-template.constants';
import { MailTemplateRendererService } from '../src/mail/mail-template-renderer.service';
const outputDir = process.env.MAIL_PREVIEW_DIR ?? join(tmpdir(), 'ldap-portal-mail-previews');
const renderer = new MailTemplateRendererService();
const branding = mailBrandingFromConfig({
get: (key: string) =>
({
PUBLIC_WEB_URL: 'https://portal.example.com',
MAIL_PRODUCT_NAME: 'LDAP Portal',
MAIL_COMPANY_NAME: 'Example AG',
MAIL_PRIMARY_COLOR: '#2563eb',
MAIL_SUPPORT_EMAIL: 'support@example.com',
MAIL_IMPRINT_URL: 'https://example.com/impressum',
MAIL_PRIVACY_URL: 'https://example.com/datenschutz',
})[key],
} as never);
const common = {
branding,
locale: 'de-DE',
};
const previews: Array<{ fileName: string; templateName: MailTemplateName; context: Record<string, unknown> }> = [
{
fileName: 'password-reset.html',
templateName: MailTemplateName.PASSWORD_RESET,
context: {
...common,
preheader: 'Passwort zuruecksetzen.',
title: 'Passwort zuruecksetzen',
subtitle: 'Fuer Ihr Benutzerkonto wurde das Zuruecksetzen des Passworts angefordert.',
greeting: 'Guten Tag Max Mustermann,',
action: {
label: 'Passwort zuruecksetzen',
url: 'https://portal.example.com/reset-password?token=preview-token',
},
alternateUrlLabel: 'Falls die Schaltflaeche nicht funktioniert, kopieren Sie diese URL:',
expiresAtLabel: '17.07.2026, 15:30',
warningBox: {
title: 'Sicherheitshinweis',
text: 'Falls Sie diese Anfrage nicht selbst gestellt haben, koennen Sie diese E-Mail ignorieren.',
},
},
},
{
fileName: 'account-created.html',
templateName: MailTemplateName.ACCOUNT_CREATED,
context: {
...common,
preheader: 'Ihr Benutzerkonto wurde angelegt.',
title: 'Benutzerkonto erstellt',
subtitle: 'Ihr Benutzerkonto fuer LDAP Portal ist einsatzbereit.',
greeting: 'Guten Tag Max Mustermann,',
username: 'max.mustermann@example.com',
keyValues: [{ key: 'Benutzername', value: 'max.mustermann@example.com' }],
action: { label: 'Zur Anwendung', url: 'https://portal.example.com' },
alternateUrlLabel: 'Direkter Link zur Anwendung:',
},
},
{
fileName: 'invitation.html',
templateName: MailTemplateName.INVITATION,
context: {
...common,
preheader: 'Einladung zu LDAP Portal.',
title: 'Einladung annehmen',
subtitle: 'Example AG hat Sie zu LDAP Portal eingeladen.',
greeting: 'Guten Tag,',
inviter: 'Example AG',
expiresAtLabel: '24.07.2026, 12:00',
action: { label: 'Einladung annehmen', url: 'https://portal.example.com/invitation/preview' },
alternateUrlLabel: 'Falls die Schaltflaeche nicht funktioniert, kopieren Sie diese URL:',
warningBox: {
title: 'Sicherheitshinweis',
text: 'Leiten Sie diese Einladung nicht weiter. Der Link ist nur fuer die vorgesehene Person bestimmt.',
},
},
},
{
fileName: 'generic-notification.html',
templateName: MailTemplateName.GENERIC_NOTIFICATION,
context: {
...common,
preheader: 'Neue Benachrichtigung in LDAP Portal.',
title: 'Neue Benachrichtigung',
paragraphs: ['Eine fachliche Aktion wurde erfolgreich abgeschlossen.', 'Weitere Details finden Sie in der Anwendung.'],
infoBox: { title: 'Hinweis', text: 'Diese Nachricht dient nur Ihrer Information.' },
action: { label: 'Details ansehen', url: 'https://portal.example.com/notifications/preview' },
},
},
{
fileName: 'warning-notification.html',
templateName: MailTemplateName.WARNING_NOTIFICATION,
context: {
...common,
preheader: 'Warnmeldung in LDAP Portal.',
title: 'Warnmeldung',
paragraphs: ['Eine geplante Verarbeitung konnte nicht vollstaendig abgeschlossen werden.'],
warningBox: { title: 'Pruefung erforderlich', text: 'Bitte pruefen Sie die Details in der Anwendung.' },
action: { label: 'Warnung pruefen', url: 'https://portal.example.com/admin/audit' },
},
},
];
mkdirSync(outputDir, { recursive: true });
for (const preview of previews) {
const html = renderer.renderHtml(preview.templateName, preview.context);
writeFileSync(join(outputDir, preview.fileName), html, 'utf8');
}
console.log(`Mail previews written to ${outputDir}`);

View File

@@ -4,6 +4,7 @@ export enum ApplicationErrorCategory {
EMAIL = 'EMAIL', EMAIL = 'EMAIL',
EXTERNAL_API = 'EXTERNAL_API', EXTERNAL_API = 'EXTERNAL_API',
FILE = 'FILE', FILE = 'FILE',
OIDC = 'OIDC',
UNHANDLED = 'UNHANDLED', UNHANDLED = 'UNHANDLED',
} }
@@ -14,6 +15,8 @@ export enum ApplicationErrorCode {
EMAIL_PROVIDER_UNAVAILABLE = 'EMAIL_PROVIDER_UNAVAILABLE', EMAIL_PROVIDER_UNAVAILABLE = 'EMAIL_PROVIDER_UNAVAILABLE',
EXTERNAL_API_REQUEST_FAILED = 'EXTERNAL_API_REQUEST_FAILED', EXTERNAL_API_REQUEST_FAILED = 'EXTERNAL_API_REQUEST_FAILED',
FILE_GENERATION_FAILED = 'FILE_GENERATION_FAILED', FILE_GENERATION_FAILED = 'FILE_GENERATION_FAILED',
OIDC_AUTHORIZATION_ERROR = 'OIDC_AUTHORIZATION_ERROR',
OIDC_PROVIDER_ERROR = 'OIDC_PROVIDER_ERROR',
PASSWORD_RESET_EMAIL_SEND_FAILED = 'PASSWORD_RESET_EMAIL_SEND_FAILED', PASSWORD_RESET_EMAIL_SEND_FAILED = 'PASSWORD_RESET_EMAIL_SEND_FAILED',
REGISTRATION_APPROVAL_NOTIFICATION_FAILED = 'REGISTRATION_APPROVAL_NOTIFICATION_FAILED', REGISTRATION_APPROVAL_NOTIFICATION_FAILED = 'REGISTRATION_APPROVAL_NOTIFICATION_FAILED',
REGISTRATION_VERIFICATION_EMAIL_SEND_FAILED = 'REGISTRATION_VERIFICATION_EMAIL_SEND_FAILED', REGISTRATION_VERIFICATION_EMAIL_SEND_FAILED = 'REGISTRATION_VERIFICATION_EMAIL_SEND_FAILED',

View File

@@ -0,0 +1,33 @@
import { ConfigService } from '@nestjs/config';
export interface MailBranding {
productName: string;
companyName: string;
primaryColor: string;
supportEmail: string;
logoUrl?: string;
imprintUrl?: string;
privacyUrl?: string;
publicWebUrl: string;
}
export function mailBrandingFromConfig(config: ConfigService): MailBranding {
const publicWebUrl = config.get<string>('PUBLIC_WEB_URL') ?? 'http://localhost:4200';
const smtpFrom = config.get<string>('SMTP_FROM') ?? 'LDAP Portal <no-reply@example.com>';
return {
productName: config.get<string>('MAIL_PRODUCT_NAME') ?? 'LDAP Portal',
companyName: config.get<string>('MAIL_COMPANY_NAME') ?? 'LDAP Portal',
primaryColor: config.get<string>('MAIL_PRIMARY_COLOR') ?? '#2563eb',
supportEmail: config.get<string>('MAIL_SUPPORT_EMAIL') ?? extractEmailAddress(smtpFrom),
logoUrl: config.get<string>('MAIL_LOGO_URL') || undefined,
imprintUrl: config.get<string>('MAIL_IMPRINT_URL') || undefined,
privacyUrl: config.get<string>('MAIL_PRIVACY_URL') || undefined,
publicWebUrl,
};
}
function extractEmailAddress(value: string): string {
const match = value.match(/<([^>]+)>/);
return match?.[1] ?? value.replaceAll('"', '');
}

View File

@@ -0,0 +1,25 @@
export class PortalMailTemplateError extends Error {
constructor(
readonly templateName: string,
cause: unknown,
) {
super(`Mail template rendering failed for "${templateName}": ${errorMessage(cause)}`);
this.name = 'PortalMailTemplateError';
this.cause = cause;
}
}
export class PortalMailDeliveryError extends Error {
constructor(
readonly templateName: string,
cause: unknown,
) {
super(`Mail delivery failed for "${templateName}": ${errorMessage(cause)}`);
this.name = 'PortalMailDeliveryError';
this.cause = cause;
}
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : 'unknown error';
}

View File

@@ -0,0 +1,60 @@
import { Injectable } from '@nestjs/common';
import { readFileSync, readdirSync } from 'node:fs';
import { basename, extname, join, relative } from 'node:path';
import Handlebars from 'handlebars';
import { mailTemplateDir, MailTemplateName } from './mail-template.constants';
@Injectable()
export class MailTemplateRendererService {
private readonly handlebars = Handlebars.create();
private partialsRegistered = false;
renderHtml(templateName: MailTemplateName, context: Record<string, unknown>): string {
this.registerPartials();
const template = this.compile(join(mailTemplateDir, `${templateName}.hbs`));
const body = template(context);
const layout = this.compile(join(mailTemplateDir, 'layouts', 'base.hbs'));
return layout({
...context,
body: new this.handlebars.SafeString(body),
});
}
renderText(templateName: MailTemplateName, context: Record<string, unknown>): string {
this.registerPartials();
return this.compile(join(mailTemplateDir, `${templateName}.text.hbs`))(context);
}
private compile(path: string): Handlebars.TemplateDelegate {
return this.handlebars.compile(readFileSync(path, 'utf8'), {
noEscape: false,
strict: false,
});
}
private registerPartials(): void {
if (this.partialsRegistered) {
return;
}
for (const filePath of this.listTemplateFiles(join(mailTemplateDir, 'partials'))) {
const partialName = relative(join(mailTemplateDir, 'partials'), filePath)
.replace(extname(filePath), '')
.replace(/\\/g, '/');
this.handlebars.registerPartial(partialName || basename(filePath, extname(filePath)), readFileSync(filePath, 'utf8'));
}
this.partialsRegistered = true;
}
private listTemplateFiles(directory: string): string[] {
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const entryPath = join(directory, entry.name);
if (entry.isDirectory()) {
return this.listTemplateFiles(entryPath);
}
return entry.name.endsWith('.hbs') ? [entryPath] : [];
});
}
}

View File

@@ -0,0 +1,14 @@
import { join } from 'node:path';
export const mailTemplateDir = join(__dirname, 'templates');
export enum MailTemplateName {
ACCOUNT_CREATED = 'account-created',
EMAIL_CHANGE = 'email-change',
GENERIC_NOTIFICATION = 'generic-notification',
INVITATION = 'invitation',
PASSWORD_RESET = 'password-reset',
REGISTRATION_PENDING_APPROVAL = 'registration-pending-approval',
VERIFICATION = 'verification',
WARNING_NOTIFICATION = 'warning-notification',
}

View File

@@ -0,0 +1,49 @@
import { MailBranding } from './mail-branding';
export interface MailAction {
label: string;
url: string;
}
export interface MailBox {
title?: string;
text: string;
}
export interface MailKeyValue {
key: string;
value: string;
}
export interface BaseMailTemplateContext extends Record<string, unknown> {
branding: MailBranding;
preheader: string;
title: string;
subtitle?: string;
greeting?: string;
action?: MailAction;
alternateUrlLabel?: string;
infoBox?: MailBox;
warningBox?: MailBox;
footerNote?: string;
locale?: string;
}
export interface PasswordResetTemplateContext extends BaseMailTemplateContext {
expiresAtLabel: string;
}
export interface AccountCreatedTemplateContext extends BaseMailTemplateContext {
username?: string;
}
export interface InvitationTemplateContext extends BaseMailTemplateContext {
inviter?: string;
expiresAtLabel?: string;
}
export interface GenericNotificationTemplateContext extends BaseMailTemplateContext {
paragraphs: string[];
secondaryLink?: MailAction;
keyValues?: MailKeyValue[];
}

View File

@@ -1,6 +1,10 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { MailerModule } from '@nestjs-modules/mailer'; import { MailerModule } from '@nestjs-modules/mailer';
import { HandlebarsAdapter } from '@nestjs-modules/mailer/dist/adapters/handlebars.adapter';
import { join } from 'node:path';
import { mailTemplateDir } from './mail-template.constants';
import { MailTemplateRendererService } from './mail-template-renderer.service';
import { PortalMailService } from './portal-mail.service'; import { PortalMailService } from './portal-mail.service';
@Module({ @Module({
@@ -23,10 +27,25 @@ import { PortalMailService } from './portal-mail.service';
defaults: { defaults: {
from: config.get<string>('SMTP_FROM') ?? 'LDAP Portal <no-reply@example.com>', from: config.get<string>('SMTP_FROM') ?? 'LDAP Portal <no-reply@example.com>',
}, },
template: {
dir: mailTemplateDir,
adapter: new HandlebarsAdapter(undefined, {
inlineCssEnabled: true,
}),
options: {
strict: false,
},
},
options: {
layout: 'layouts/base',
partials: {
dir: join(mailTemplateDir, 'partials'),
},
},
}), }),
}), }),
], ],
providers: [PortalMailService], providers: [PortalMailService, MailTemplateRendererService],
exports: [PortalMailService], exports: [PortalMailService],
}) })
export class MailModule {} export class MailModule {}

View File

@@ -0,0 +1,181 @@
import { describe, expect, it, jest } from '@jest/globals';
import { PortalMailDeliveryError, PortalMailTemplateError } from './mail-errors';
import { MailTemplateName } from './mail-template.constants';
import { MailTemplateRendererService } from './mail-template-renderer.service';
import { PortalMailService } from './portal-mail.service';
describe('PortalMailService templates', () => {
const expiresAt = new Date('2026-07-17T13:30:00.000Z');
function createConfig(overrides: Record<string, string> = {}) {
const values: Record<string, string> = {
PUBLIC_WEB_URL: 'https://portal.example.com',
SMTP_FROM: 'LDAP Portal <portal@example.com>',
MAIL_PRODUCT_NAME: 'Identity Portal',
MAIL_COMPANY_NAME: 'Example AG',
MAIL_PRIMARY_COLOR: '#005ea8',
MAIL_SUPPORT_EMAIL: 'support@example.com',
...overrides,
};
return {
get: jest.fn((key: string) => values[key]),
getOrThrow: jest.fn((key: string) => values[key]),
};
}
function createService(options: {
sendMail?: ReturnType<typeof jest.fn<(input: unknown) => Promise<unknown>>>;
renderer?: MailTemplateRendererService;
config?: ReturnType<typeof createConfig>;
} = {}) {
const sendMail = options.sendMail ?? jest.fn<(input: unknown) => Promise<unknown>>().mockResolvedValue({});
const mailer = { sendMail };
const config = options.config ?? createConfig();
const renderer = options.renderer ?? new MailTemplateRendererService();
return {
service: new PortalMailService(mailer as never, config as never, renderer),
sendMail,
renderer,
config,
};
}
it('renders the password reset template with partials', () => {
const renderer = new MailTemplateRendererService();
const html = renderer.renderHtml(MailTemplateName.PASSWORD_RESET, {
branding: {
productName: 'Identity Portal',
companyName: 'Example AG',
primaryColor: '#005ea8',
supportEmail: 'support@example.com',
publicWebUrl: 'https://portal.example.com',
},
preheader: 'Passwort zuruecksetzen.',
title: 'Passwort zuruecksetzen',
subtitle: 'Fuer Ihr Benutzerkonto wurde das Zuruecksetzen des Passworts angefordert.',
greeting: 'Guten Tag Max Mustermann,',
action: {
label: 'Passwort zuruecksetzen',
url: 'https://portal.example.com/reset-password?token=abc',
},
alternateUrlLabel: 'Alternativer Link:',
expiresAtLabel: '17.07.2026, 15:30',
warningBox: {
title: 'Sicherheitshinweis',
text: 'Ignorieren Sie diese E-Mail, falls Sie die Anfrage nicht gestellt haben.',
},
});
expect(html).toContain('Identity Portal');
expect(html).toContain('Passwort zuruecksetzen');
expect(html).toContain('https://portal.example.com/reset-password?token&#x3D;abc');
expect(html).toContain('Sicherheitshinweis');
expect(html).toContain('support@example.com');
});
it('passes display name, reset URL, expiration, branding, template and subject to the mailer', async () => {
const { service, sendMail } = createService();
await service.sendPasswordResetMail({
recipient: 'max@example.com',
token: 'reset-token',
displayName: 'Max Mustermann',
expiresAt,
});
const mail = sendMail.mock.calls[0][0] as {
subject: string;
template: string;
context: Record<string, unknown>;
text: string;
};
const context = mail.context as Record<string, unknown>;
expect(mail.template).toBe(MailTemplateName.PASSWORD_RESET);
expect(mail.subject).toBe('Passwort fuer Identity Portal zuruecksetzen');
expect(context.greeting).toBe('Guten Tag Max Mustermann,');
expect((context.branding as Record<string, unknown>).productName).toBe('Identity Portal');
expect((context.action as Record<string, unknown>).url).toBe(
'https://portal.example.com/reset-password?token=reset-token',
);
expect(context.expiresAtLabel).toEqual(expect.any(String));
expect(mail.text).toContain('https://portal.example.com/reset-password?token=reset-token');
});
it('uses a neutral greeting when display name is missing', async () => {
const { service, sendMail } = createService();
await service.sendPasswordResetMail({
recipient: 'max@example.com',
token: 'reset-token',
expiresAt,
});
const mail = sendMail.mock.calls[0][0] as { context: Record<string, unknown> };
expect(mail.context.greeting).toBe('Guten Tag,');
});
it('escapes HTML from user-provided display names', () => {
const renderer = new MailTemplateRendererService();
const html = renderer.renderHtml(MailTemplateName.PASSWORD_RESET, {
branding: {
productName: 'Identity Portal',
companyName: 'Example AG',
primaryColor: '#005ea8',
supportEmail: 'support@example.com',
publicWebUrl: 'https://portal.example.com',
},
preheader: 'Passwort zuruecksetzen.',
title: 'Passwort zuruecksetzen',
greeting: 'Guten Tag <script>alert(1)</script>,',
action: { label: 'Passwort zuruecksetzen', url: 'https://portal.example.com/reset-password?token=abc' },
alternateUrlLabel: 'Alternativer Link:',
expiresAtLabel: '17.07.2026, 15:30',
});
expect(html).toContain('&lt;script&gt;alert(1)&lt;/script&gt;');
expect(html).not.toContain('<script>alert(1)</script>');
});
it('allows optional generic notification fields to be omitted', async () => {
const { service, sendMail } = createService();
await service.sendNotificationMail({
recipient: 'user@example.com',
title: 'Neue Benachrichtigung',
paragraphs: ['Die Verarbeitung wurde abgeschlossen.'],
});
const mail = sendMail.mock.calls[0][0] as { template: string; text: string };
expect(mail.template).toBe(MailTemplateName.GENERIC_NOTIFICATION);
expect(mail.text).toContain('Die Verarbeitung wurde abgeschlossen.');
});
it('wraps template rendering errors separately from delivery errors', async () => {
const renderer = {
renderHtml: jest.fn(() => {
throw new Error('broken template');
}),
renderText: jest.fn(() => ''),
};
const { service, sendMail } = createService({ renderer: renderer as never });
await expect(
service.sendPasswordResetMail({ recipient: 'max@example.com', token: 'reset-token', expiresAt }),
).rejects.toBeInstanceOf(PortalMailTemplateError);
expect(sendMail).not.toHaveBeenCalled();
});
it('does not classify delivery failures as template errors', async () => {
const sendMail = jest
.fn<(input: unknown) => Promise<unknown>>()
.mockRejectedValue(new Error('smtp connection refused'));
const { service } = createService({ sendMail });
await expect(
service.sendPasswordResetMail({ recipient: 'max@example.com', token: 'reset-token', expiresAt }),
).rejects.toBeInstanceOf(PortalMailDeliveryError);
});
});

View File

@@ -1,41 +1,135 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { MailerService } from '@nestjs-modules/mailer'; import { MailerService } from '@nestjs-modules/mailer';
import { mailBrandingFromConfig } from './mail-branding';
import { PortalMailDeliveryError, PortalMailTemplateError } from './mail-errors';
import { MailTemplateName } from './mail-template.constants';
import { GenericNotificationTemplateContext } from './mail-template.types';
import { MailTemplateRendererService } from './mail-template-renderer.service';
export interface SendPasswordResetMailInput {
recipient: string;
token: string;
displayName?: string;
expiresAt?: Date;
locale?: string;
}
export interface SendAccountCreatedMailInput {
recipient: string;
displayName?: string;
username?: string;
loginUrl?: string;
locale?: string;
}
export interface SendInvitationMailInput {
recipient: string;
invitationUrl: string;
displayName?: string;
inviter?: string;
expiresAt?: Date;
locale?: string;
}
export interface SendNotificationMailInput {
recipient: string | string[];
title: string;
preheader?: string;
paragraphs: string[];
action?: { label: string; url: string };
secondaryLink?: { label: string; url: string };
infoBox?: { title?: string; text: string };
warningBox?: { title?: string; text: string };
keyValues?: Array<{ key: string; value: string }>;
footerNote?: string;
subject?: string;
locale?: string;
warning?: boolean;
}
@Injectable() @Injectable()
export class PortalMailService { export class PortalMailService {
constructor( constructor(
private readonly mailer: MailerService, private readonly mailer: MailerService,
private readonly config: ConfigService, private readonly config: ConfigService,
private readonly templateRenderer: MailTemplateRendererService,
) {} ) {}
async sendVerificationMail(to: string, token: string): Promise<void> { async sendVerificationMail(to: string, token: string): Promise<void> {
const url = `${this.publicWebUrl}/verify-email?token=${encodeURIComponent(token)}`; const url = `${this.publicWebUrl}/verify-email?token=${encodeURIComponent(token)}`;
await this.mailer.sendMail({ await this.sendTemplateMail({
to, to,
subject: 'LDAP Portal: E-Mail bestaetigen', subject: `E-Mail-Adresse fuer ${this.branding.productName} bestaetigen`,
html: `<p>Bitte bestaetige deine Registrierung:</p><p><a href="${url}">${url}</a></p>`, templateName: MailTemplateName.VERIFICATION,
text: `Bitte bestaetige deine Registrierung: ${url}`, context: {
...this.baseContext(
'Bestaetigen Sie Ihre E-Mail-Adresse.',
'E-Mail-Adresse bestaetigen',
'Schliessen Sie die Registrierung ab, indem Sie Ihre E-Mail-Adresse bestaetigen.',
),
action: { label: 'E-Mail-Adresse bestaetigen', url },
alternateUrlLabel: 'Falls die Schaltflaeche nicht funktioniert, nutzen Sie diesen Link:',
infoBox: {
title: 'Sicherheitshinweis',
text: 'Falls Sie diese Registrierung nicht gestartet haben, koennen Sie diese E-Mail ignorieren.',
},
},
}); });
} }
async sendPasswordResetMail(to: string, token: string): Promise<void> { async sendPasswordResetMail(input: SendPasswordResetMailInput): Promise<void>;
const url = `${this.publicWebUrl}/reset-password?token=${encodeURIComponent(token)}`; async sendPasswordResetMail(to: string, token: string): Promise<void>;
await this.mailer.sendMail({ async sendPasswordResetMail(inputOrTo: SendPasswordResetMailInput | string, token?: string): Promise<void> {
to, const input =
subject: 'LDAP Portal: Passwort zuruecksetzen', typeof inputOrTo === 'string'
html: `<p>Du kannst dein Passwort ueber diesen Link zuruecksetzen:</p><p><a href="${url}">${url}</a></p>`, ? { recipient: inputOrTo, token: token ?? '', expiresAt: new Date(Date.now() + 60 * 60_000) }
text: `Du kannst dein Passwort ueber diesen Link zuruecksetzen: ${url}`, : inputOrTo;
const url = `${this.publicWebUrl}/reset-password?token=${encodeURIComponent(input.token)}`;
const expiresAtLabel = this.formatDateTime(input.expiresAt ?? new Date(Date.now() + 60 * 60_000), input.locale);
await this.sendTemplateMail({
to: input.recipient,
subject: `Passwort fuer ${this.branding.productName} zuruecksetzen`,
templateName: MailTemplateName.PASSWORD_RESET,
context: {
...this.baseContext(
'Passwort zuruecksetzen.',
'Passwort zuruecksetzen',
'Fuer Ihr Benutzerkonto wurde das Zuruecksetzen des Passworts angefordert.',
input.locale,
),
greeting: input.displayName ? `Guten Tag ${input.displayName},` : 'Guten Tag,',
action: { label: 'Passwort zuruecksetzen', url },
alternateUrlLabel: 'Falls die Schaltflaeche nicht funktioniert, kopieren Sie diese URL in Ihren Browser:',
expiresAtLabel,
warningBox: {
title: 'Sicherheitshinweis',
text: 'Falls Sie diese Anfrage nicht selbst gestellt haben, koennen Sie diese E-Mail ignorieren. Ihr bestehendes Passwort bleibt unveraendert.',
},
},
}); });
} }
async sendEmailChangeMail(to: string, token: string): Promise<void> { async sendEmailChangeMail(to: string, token: string): Promise<void> {
const url = `${this.publicWebUrl}/account/email?token=${encodeURIComponent(token)}`; const url = `${this.publicWebUrl}/account/email?token=${encodeURIComponent(token)}`;
await this.mailer.sendMail({ await this.sendTemplateMail({
to, to,
subject: 'LDAP Portal: neue E-Mail bestaetigen', subject: `Neue E-Mail-Adresse fuer ${this.branding.productName} bestaetigen`,
html: `<p>Bitte bestaetige deine neue E-Mail-Adresse:</p><p><a href="${url}">${url}</a></p>`, templateName: MailTemplateName.EMAIL_CHANGE,
text: `Bitte bestaetige deine neue E-Mail-Adresse: ${url}`, context: {
...this.baseContext(
'Bestaetigen Sie Ihre neue E-Mail-Adresse.',
'Neue E-Mail-Adresse bestaetigen',
'Sie haben eine neue E-Mail-Adresse fuer Ihr Benutzerkonto hinterlegt.',
),
action: { label: 'E-Mail-Adresse bestaetigen', url },
alternateUrlLabel: 'Falls die Schaltflaeche nicht funktioniert, nutzen Sie diesen Link:',
infoBox: {
title: 'Hinweis',
text: 'Die neue E-Mail-Adresse wird erst nach der Bestaetigung uebernommen.',
},
},
}); });
} }
@@ -44,21 +138,96 @@ export class PortalMailService {
registration: { email: string; displayName: string }, registration: { email: string; displayName: string },
): Promise<void> { ): Promise<void> {
const url = `${this.publicWebUrl}/admin/registrations`; const url = `${this.publicWebUrl}/admin/registrations`;
await this.mailer.sendMail({ await this.sendTemplateMail({
to, to,
subject: 'LDAP Portal: Registrierung wartet auf Freigabe', subject: `Registrierung wartet auf Freigabe in ${this.branding.productName}`,
html: ` templateName: MailTemplateName.REGISTRATION_PENDING_APPROVAL,
<p>Eine Registrierung wurde per E-Mail bestaetigt und wartet jetzt auf Freigabe.</p> context: {
<p><strong>Name:</strong> ${this.escapeHtml(registration.displayName)}<br> ...this.baseContext(
<strong>E-Mail:</strong> ${this.escapeHtml(registration.email)}</p> 'Eine Registrierung wartet auf Freigabe.',
<p><a href="${url}">${url}</a></p> 'Registrierung wartet auf Freigabe',
`, 'Eine Registrierung wurde per E-Mail bestaetigt und wartet jetzt auf administrative Freigabe.',
text: [ ),
'Eine Registrierung wurde per E-Mail bestaetigt und wartet jetzt auf Freigabe.', action: { label: 'Registrierungen oeffnen', url },
`Name: ${registration.displayName}`, alternateUrlLabel: 'Direkter Link zum Admin-Bereich:',
`E-Mail: ${registration.email}`, keyValues: [
`Admin-Bereich: ${url}`, { key: 'Name', value: registration.displayName },
].join('\n'), { key: 'E-Mail', value: registration.email },
],
},
});
}
async sendAccountCreatedMail(input: SendAccountCreatedMailInput): Promise<void> {
const loginUrl = input.loginUrl ?? this.publicWebUrl;
await this.sendTemplateMail({
to: input.recipient,
subject: `Willkommen bei ${this.branding.productName}`,
templateName: MailTemplateName.ACCOUNT_CREATED,
context: {
...this.baseContext(
`Ihr Benutzerkonto fuer ${this.branding.productName} wurde angelegt.`,
'Benutzerkonto erstellt',
`Ihr Benutzerkonto fuer ${this.branding.productName} ist einsatzbereit.`,
input.locale,
),
greeting: input.displayName ? `Guten Tag ${input.displayName},` : 'Guten Tag,',
username: input.username,
keyValues: input.username ? [{ key: 'Benutzername', value: input.username }] : undefined,
action: { label: 'Zur Anwendung', url: loginUrl },
alternateUrlLabel: 'Direkter Link zur Anwendung:',
},
});
}
async sendInvitationMail(input: SendInvitationMailInput): Promise<void> {
await this.sendTemplateMail({
to: input.recipient,
subject: `Sie wurden zu ${this.branding.productName} eingeladen`,
templateName: MailTemplateName.INVITATION,
context: {
...this.baseContext(
`Einladung zu ${this.branding.productName}.`,
'Einladung annehmen',
`${input.inviter ?? this.branding.companyName} hat Sie zu ${this.branding.productName} eingeladen.`,
input.locale,
),
greeting: input.displayName ? `Guten Tag ${input.displayName},` : 'Guten Tag,',
inviter: input.inviter,
expiresAtLabel: input.expiresAt ? this.formatDateTime(input.expiresAt, input.locale) : undefined,
action: { label: 'Einladung annehmen', url: input.invitationUrl },
alternateUrlLabel: 'Falls die Schaltflaeche nicht funktioniert, kopieren Sie diese URL:',
warningBox: {
title: 'Sicherheitshinweis',
text: 'Leiten Sie diese Einladung nicht weiter. Der Link ist nur fuer die vorgesehene Person bestimmt.',
},
},
});
}
async sendNotificationMail(input: SendNotificationMailInput): Promise<void> {
const templateName = input.warning ? MailTemplateName.WARNING_NOTIFICATION : MailTemplateName.GENERIC_NOTIFICATION;
const context: GenericNotificationTemplateContext = {
...this.baseContext(
input.preheader ?? `Neue Benachrichtigung in ${this.branding.productName}.`,
input.title,
undefined,
input.locale,
),
paragraphs: input.paragraphs,
action: input.action,
secondaryLink: input.secondaryLink,
infoBox: input.infoBox,
warningBox: input.warningBox,
keyValues: input.keyValues,
footerNote: input.footerNote,
};
await this.sendTemplateMail({
to: input.recipient,
subject: input.subject ?? `Neue Benachrichtigung in ${this.branding.productName}`,
templateName,
context,
}); });
} }
@@ -66,12 +235,54 @@ export class PortalMailService {
return this.config.get<string>('PUBLIC_WEB_URL') ?? 'http://localhost:4200'; return this.config.get<string>('PUBLIC_WEB_URL') ?? 'http://localhost:4200';
} }
private escapeHtml(value: string): string { private get branding() {
return value return mailBrandingFromConfig(this.config);
.replaceAll('&', '&amp;') }
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;') private baseContext(preheader: string, title: string, subtitle?: string, locale?: string) {
.replaceAll('"', '&quot;') return {
.replaceAll("'", '&#039;'); branding: this.branding,
preheader,
title,
subtitle,
locale: locale ?? 'de-DE',
};
}
private async sendTemplateMail(input: {
to: string | string[];
subject: string;
templateName: MailTemplateName;
context: Record<string, unknown>;
}): Promise<void> {
let text: string;
try {
this.templateRenderer.renderHtml(input.templateName, input.context);
text = this.templateRenderer.renderText(input.templateName, input.context);
} catch (error) {
throw new PortalMailTemplateError(input.templateName, error);
}
try {
await this.mailer.sendMail({
to: input.to,
subject: input.subject,
template: input.templateName,
context: input.context,
text,
headers: {
'X-Mail-Template': input.templateName,
},
});
} catch (error) {
throw new PortalMailDeliveryError(input.templateName, error);
}
}
private formatDateTime(value: Date, locale = 'de-DE'): string {
return new Intl.DateTimeFormat(locale, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(value);
} }
} }

View File

@@ -0,0 +1,17 @@
{{#if greeting}}
<p class="paragraph">{{greeting}}</p>
{{/if}}
<p class="paragraph">
Ihr Benutzerkonto ist angelegt und kann fuer {{branding.productName}} verwendet werden.
</p>
{{#if username}}
{{> key-value keyValues}}
{{/if}}
{{#if action}}
{{> button label=action.label url=action.url primaryColor=branding.primaryColor}}
<p class="muted">{{alternateUrlLabel}}</p>
<p class="muted link-break"><a href="{{action.url}}">{{action.url}}</a></p>
{{/if}}
{{#if infoBox}}
{{> info-box infoBox}}
{{/if}}

View File

@@ -0,0 +1,12 @@
{{title}}
{{#if greeting}}{{greeting}}
{{/if}}Ihr Benutzerkonto ist angelegt und kann fuer {{branding.productName}} verwendet werden.
{{#if username}}Benutzername: {{username}}
{{/if}}Anwendung:
{{{action.url}}}
Support: {{branding.supportEmail}}

View File

@@ -0,0 +1,11 @@
<p class="paragraph">
Bitte bestaetigen Sie diese Aenderung, damit die neue E-Mail-Adresse fuer Ihr Benutzerkonto uebernommen wird.
</p>
{{#if action}}
{{> button label=action.label url=action.url primaryColor=branding.primaryColor}}
<p class="muted">{{alternateUrlLabel}}</p>
<p class="muted link-break"><a href="{{action.url}}">{{action.url}}</a></p>
{{/if}}
{{#if infoBox}}
{{> info-box infoBox}}
{{/if}}

View File

@@ -0,0 +1,8 @@
{{title}}
Bitte bestaetigen Sie diese Aenderung, damit die neue E-Mail-Adresse fuer Ihr Benutzerkonto uebernommen wird.
Link:
{{{action.url}}}
Support: {{branding.supportEmail}}

View File

@@ -0,0 +1,18 @@
{{#each paragraphs}}
<p class="paragraph">{{this}}</p>
{{/each}}
{{#if keyValues}}
{{> key-value keyValues}}
{{/if}}
{{#if infoBox}}
{{> info-box infoBox}}
{{/if}}
{{#if warningBox}}
{{> warning-box warningBox}}
{{/if}}
{{#if action}}
{{> button label=action.label url=action.url primaryColor=branding.primaryColor}}
{{/if}}
{{#if secondaryLink}}
{{> secondary-link label=secondaryLink.label url=secondaryLink.url primaryColor=branding.primaryColor}}
{{/if}}

View File

@@ -0,0 +1,23 @@
{{title}}
{{#each paragraphs}}
{{this}}
{{/each}}{{#if keyValues}}
{{#each keyValues}}
{{key}}: {{value}}
{{/each}}
{{/if}}{{#if infoBox}}
{{#if infoBox.title}}{{infoBox.title}}
{{/if}}{{infoBox.text}}
{{/if}}{{#if action}}
{{action.label}}:
{{{action.url}}}
{{/if}}{{#if secondaryLink}}
{{secondaryLink.label}}:
{{{secondaryLink.url}}}
{{/if}}Support: {{branding.supportEmail}}

View File

@@ -0,0 +1,18 @@
{{#if greeting}}
<p class="paragraph">{{greeting}}</p>
{{/if}}
<p class="paragraph">
{{#if inviter}}{{inviter}} hat Sie eingeladen.{{else}}Sie wurden eingeladen.{{/if}}
Bitte nehmen Sie die Einladung nur an, wenn Sie diese Nachricht erwartet haben.
</p>
{{#if action}}
{{> button label=action.label url=action.url primaryColor=branding.primaryColor}}
<p class="muted">{{alternateUrlLabel}}</p>
<p class="muted link-break"><a href="{{action.url}}">{{action.url}}</a></p>
{{/if}}
{{#if expiresAtLabel}}
<p class="paragraph">Die Einladung ist gueltig bis <strong>{{expiresAtLabel}}</strong>.</p>
{{/if}}
{{#if warningBox}}
{{> warning-box warningBox}}
{{/if}}

View File

@@ -0,0 +1,14 @@
{{title}}
{{#if greeting}}{{greeting}}
{{/if}}{{#if inviter}}{{inviter}} hat Sie eingeladen.{{else}}Sie wurden eingeladen.{{/if}}
Einladung annehmen:
{{{action.url}}}
{{#if expiresAtLabel}}Die Einladung ist gueltig bis {{expiresAtLabel}}.
{{/if}}Leiten Sie diese Einladung nicht weiter. Der Link ist nur fuer die vorgesehene Person bestimmt.
Support: {{branding.supportEmail}}

View File

@@ -0,0 +1,125 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<title>{{title}}</title>
<style>
body {
margin: 0;
padding: 0;
background: #f3f5f8;
color: #172033;
font-family: Arial, Helvetica, sans-serif;
line-height: 1.5;
}
table {
border-collapse: collapse;
}
img {
border: 0;
line-height: 100%;
outline: none;
text-decoration: none;
}
a {
color: {{branding.primaryColor}};
}
.email-shell {
width: 100%;
background: #f3f5f8;
}
.email-container {
width: 100%;
max-width: 640px;
}
.email-card {
background: #ffffff;
border: 1px solid #e3e8ef;
border-radius: 12px;
overflow: hidden;
}
.content {
padding: 34px 40px 28px;
}
.title {
margin: 0 0 12px;
color: #101828;
font-size: 26px;
font-weight: 700;
line-height: 1.25;
}
.subtitle {
margin: 0 0 26px;
color: #475467;
font-size: 16px;
}
.paragraph {
margin: 0 0 18px;
color: #344054;
font-size: 15px;
}
.muted {
color: #667085;
font-size: 13px;
}
.link-break {
word-break: break-all;
overflow-wrap: anywhere;
}
@media only screen and (max-width: 680px) {
.content {
padding: 26px 22px 24px !important;
}
.email-card {
border-radius: 0 !important;
border-left: 0 !important;
border-right: 0 !important;
}
.title {
font-size: 23px !important;
}
.mobile-full-width {
width: 100% !important;
}
}
</style>
</head>
<body>
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;">
{{preheader}}
</div>
<table role="presentation" class="email-shell" width="100%" cellpadding="0" cellspacing="0">
<tr>
<td align="center" style="padding: 32px 12px;">
<table role="presentation" class="email-container" cellpadding="0" cellspacing="0">
<tr>
<td>
<table role="presentation" class="email-card" width="100%" cellpadding="0" cellspacing="0">
<tr>
<td>
{{> header}}
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
<tr>
<td class="content">
<h1 class="title">{{title}}</h1>
{{#if subtitle}}
<p class="subtitle">{{subtitle}}</p>
{{/if}}
{{{body}}}
</td>
</tr>
</table>
{{> footer}}
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -0,0 +1,9 @@
<table role="presentation" cellpadding="0" cellspacing="0" class="mobile-full-width" style="margin: 26px 0 20px;">
<tr>
<td align="center" bgcolor="{{primaryColor}}" style="border-radius: 8px;">
<a href="{{url}}" style="display:inline-block;padding:14px 22px;border-radius:8px;background:{{primaryColor}};color:#ffffff;font-size:15px;font-weight:700;text-decoration:none;line-height:1.2;">
{{label}}
</a>
</td>
</tr>
</table>

View File

@@ -0,0 +1,5 @@
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin: 24px 0;">
<tr>
<td style="border-top:1px solid #eef2f6;font-size:1px;line-height:1px;">&nbsp;</td>
</tr>
</table>

View File

@@ -0,0 +1,31 @@
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
<tr>
<td style="padding: 24px 40px 30px; border-top: 1px solid #eef2f6; background: #fbfcfe;">
{{#if footerNote}}
<p style="margin:0 0 12px;color:#475467;font-size:13px;line-height:1.5;">{{footerNote}}</p>
{{/if}}
<p style="margin:0 0 10px;color:#667085;font-size:13px;line-height:1.5;">
Diese E-Mail wurde automatisch von {{branding.productName}} erstellt. Bitte antworten Sie nicht direkt auf diese Nachricht.
</p>
<p style="margin:0;color:#667085;font-size:13px;line-height:1.5;">
Bei Fragen wenden Sie sich an
<a href="mailto:{{branding.supportEmail}}" style="color:{{branding.primaryColor}};text-decoration:underline;">{{branding.supportEmail}}</a>.
</p>
{{#if branding.imprintUrl}}
<p style="margin:12px 0 0;color:#667085;font-size:12px;line-height:1.5;">
<a href="{{branding.imprintUrl}}" style="color:#667085;text-decoration:underline;">Impressum</a>
{{#if branding.privacyUrl}}
<span>&nbsp;|&nbsp;</span>
<a href="{{branding.privacyUrl}}" style="color:#667085;text-decoration:underline;">Datenschutz</a>
{{/if}}
</p>
{{else}}
{{#if branding.privacyUrl}}
<p style="margin:12px 0 0;color:#667085;font-size:12px;line-height:1.5;">
<a href="{{branding.privacyUrl}}" style="color:#667085;text-decoration:underline;">Datenschutz</a>
</p>
{{/if}}
{{/if}}
</td>
</tr>
</table>

View File

@@ -0,0 +1,17 @@
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
<tr>
<td style="padding: 28px 40px 22px; border-bottom: 1px solid #eef2f6;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
<tr>
<td style="vertical-align: middle;">
{{#if branding.logoUrl}}
<img src="{{branding.logoUrl}}" width="132" alt="{{branding.productName}}" style="display:block;max-width:132px;height:auto;">
{{else}}
<span style="display:inline-block;color:#101828;font-size:18px;font-weight:700;line-height:1.3;">{{branding.productName}}</span>
{{/if}}
</td>
</tr>
</table>
</td>
</tr>
</table>

View File

@@ -0,0 +1,10 @@
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin: 24px 0; background:#f8fafc; border:1px solid #d9e2ec; border-radius:8px;">
<tr>
<td style="padding:16px 18px;">
{{#if title}}
<p style="margin:0 0 6px;color:#243b53;font-size:14px;font-weight:700;">{{title}}</p>
{{/if}}
<p style="margin:0;color:#344054;font-size:14px;line-height:1.5;">{{text}}</p>
</td>
</tr>
</table>

View File

@@ -0,0 +1,8 @@
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin: 20px 0; border:1px solid #e3e8ef; border-radius:8px;">
{{#each this}}
<tr>
<td style="padding:12px 16px;border-bottom:1px solid #eef2f6;color:#667085;font-size:13px;width:34%;">{{key}}</td>
<td style="padding:12px 16px;border-bottom:1px solid #eef2f6;color:#101828;font-size:14px;font-weight:600;">{{value}}</td>
</tr>
{{/each}}
</table>

View File

@@ -0,0 +1,3 @@
<p style="margin: 14px 0 0; color:#475467; font-size:14px; line-height:1.5;">
<a href="{{url}}" style="color:{{primaryColor}};text-decoration:underline;">{{label}}</a>
</p>

View File

@@ -0,0 +1,10 @@
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin: 24px 0; background:#fff8eb; border:1px solid #fedf89; border-radius:8px;">
<tr>
<td style="padding:16px 18px;">
{{#if title}}
<p style="margin:0 0 6px;color:#7a4b00;font-size:14px;font-weight:700;">{{title}}</p>
{{/if}}
<p style="margin:0;color:#5f4500;font-size:14px;line-height:1.5;">{{text}}</p>
</td>
</tr>
</table>

View File

@@ -0,0 +1,21 @@
{{#if greeting}}
<p class="paragraph">{{greeting}}</p>
{{/if}}
<p class="paragraph">
ueber die folgende Schaltflaeche koennen Sie ein neues Passwort vergeben.
</p>
{{#if action}}
{{> button label=action.label url=action.url primaryColor=branding.primaryColor}}
<p class="muted">{{alternateUrlLabel}}</p>
<p class="muted link-break"><a href="{{action.url}}">{{action.url}}</a></p>
{{/if}}
<p class="paragraph">
Der Link ist gueltig bis <strong>{{expiresAtLabel}}</strong>.
</p>
{{#if warningBox}}
{{> warning-box warningBox}}
{{/if}}
<p class="paragraph">
Wenn Sie Unterstuetzung benoetigen, wenden Sie sich an
<a href="mailto:{{branding.supportEmail}}">{{branding.supportEmail}}</a>.
</p>

View File

@@ -0,0 +1,15 @@
{{title}}
{{#if greeting}}{{greeting}}
{{/if}}Fuer Ihr Benutzerkonto wurde das Zuruecksetzen des Passworts angefordert.
Ueber den folgenden Link koennen Sie ein neues Passwort vergeben:
{{{action.url}}}
Der Link ist gueltig bis {{expiresAtLabel}}.
Falls Sie diese Anfrage nicht selbst gestellt haben, koennen Sie diese E-Mail ignorieren. Ihr bestehendes Passwort bleibt unveraendert.
Support: {{branding.supportEmail}}
{{branding.productName}}

View File

@@ -0,0 +1,11 @@
<p class="paragraph">
Bitte pruefen Sie die Registrierung im Admin-Bereich und geben Sie sie frei oder lehnen Sie sie ab.
</p>
{{#if keyValues}}
{{> key-value keyValues}}
{{/if}}
{{#if action}}
{{> button label=action.label url=action.url primaryColor=branding.primaryColor}}
<p class="muted">{{alternateUrlLabel}}</p>
<p class="muted link-break"><a href="{{action.url}}">{{action.url}}</a></p>
{{/if}}

View File

@@ -0,0 +1,12 @@
{{title}}
Eine Registrierung wurde per E-Mail bestaetigt und wartet jetzt auf administrative Freigabe.
{{#each keyValues}}
{{key}}: {{value}}
{{/each}}
Admin-Bereich:
{{{action.url}}}
Support: {{branding.supportEmail}}

View File

@@ -0,0 +1,11 @@
<p class="paragraph">
Bitte bestaetigen Sie Ihre E-Mail-Adresse, um die Registrierung abzuschliessen.
</p>
{{#if action}}
{{> button label=action.label url=action.url primaryColor=branding.primaryColor}}
<p class="muted">{{alternateUrlLabel}}</p>
<p class="muted link-break"><a href="{{action.url}}">{{action.url}}</a></p>
{{/if}}
{{#if infoBox}}
{{> info-box infoBox}}
{{/if}}

View File

@@ -0,0 +1,10 @@
{{title}}
Bitte bestaetigen Sie Ihre E-Mail-Adresse, um die Registrierung abzuschliessen.
Link:
{{{action.url}}}
Falls Sie diese Registrierung nicht gestartet haben, koennen Sie diese E-Mail ignorieren.
Support: {{branding.supportEmail}}

View File

@@ -0,0 +1,15 @@
{{#each paragraphs}}
<p class="paragraph">{{this}}</p>
{{/each}}
{{#if warningBox}}
{{> warning-box warningBox}}
{{/if}}
{{#if infoBox}}
{{> info-box infoBox}}
{{/if}}
{{#if action}}
{{> button label=action.label url=action.url primaryColor=branding.primaryColor}}
{{/if}}
{{#if secondaryLink}}
{{> secondary-link label=secondaryLink.label url=secondaryLink.url primaryColor=branding.primaryColor}}
{{/if}}

View File

@@ -0,0 +1,14 @@
{{title}}
{{#each paragraphs}}
{{this}}
{{/each}}{{#if warningBox}}
{{#if warningBox.title}}{{warningBox.title}}
{{/if}}{{warningBox.text}}
{{/if}}{{#if action}}
{{action.label}}:
{{{action.url}}}
{{/if}}Support: {{branding.supportEmail}}

View File

@@ -6,7 +6,10 @@ import { Request, Response } from 'express';
import type Provider from 'oidc-provider'; import type Provider from 'oidc-provider';
import type { AccountClaims, Adapter, Configuration, Interaction } from 'oidc-provider'; import type { AccountClaims, Adapter, Configuration, Interaction } from 'oidc-provider';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { ApplicationErrorCategory, ApplicationErrorCode } from '../application-error-log/application-error-codes';
import { ApplicationErrorLoggerService } from '../application-error-log/application-error-logger.service';
import { AuditService } from '../audit/audit.service'; import { AuditService } from '../audit/audit.service';
import { RequestContextService } from '../common/request-context.service';
import { LdapAuthService } from '../lldap/ldap-auth.service'; import { LdapAuthService } from '../lldap/ldap-auth.service';
import { LldapService } from '../lldap/lldap.service'; import { LldapService } from '../lldap/lldap.service';
import { OidcProviderStorageEntity } from './entities/oidc-provider-storage.entity'; import { OidcProviderStorageEntity } from './entities/oidc-provider-storage.entity';
@@ -35,6 +38,8 @@ export class OidcProviderService implements OnModuleInit {
private readonly ldapAuth: LdapAuthService, private readonly ldapAuth: LdapAuthService,
private readonly lldap: LldapService, private readonly lldap: LldapService,
private readonly audit: AuditService, private readonly audit: AuditService,
private readonly applicationErrorLogger: ApplicationErrorLoggerService,
private readonly requestContext: RequestContextService,
) {} ) {}
async onModuleInit(): Promise<void> { async onModuleInit(): Promise<void> {
@@ -45,6 +50,7 @@ export class OidcProviderService implements OnModuleInit {
this.provider = new oidc.default(issuer, this.buildConfiguration(jwks)); this.provider = new oidc.default(issuer, this.buildConfiguration(jwks));
this.provider.proxy = this.config.get('OIDC_TRUST_PROXY') === 'true'; this.provider.proxy = this.config.get('OIDC_TRUST_PROXY') === 'true';
this.registerAuditEvents(this.provider); this.registerAuditEvents(this.provider);
this.registerErrorEvents(this.provider);
const expressApp = this.httpAdapterHost.httpAdapter.getInstance(); const expressApp = this.httpAdapterHost.httpAdapter.getInstance();
expressApp.use(this.provider.callback()); expressApp.use(this.provider.callback());
@@ -279,6 +285,91 @@ export class OidcProviderService implements OnModuleInit {
}); });
} }
private registerErrorEvents(provider: Provider): void {
const eventSource = provider as unknown as {
on(eventName: string, listener: (ctx: unknown, error: unknown) => void): void;
};
const errorEvents = [
'authorization.error',
'server_error',
'grant.error',
'userinfo.error',
'jwks.error',
'discovery.error',
'end_session.error',
'revocation.error',
'introspection.error',
];
for (const eventName of errorEvents) {
eventSource.on(eventName, (ctx, error) => {
void this.logOidcProviderError(eventName, ctx, error);
});
}
}
private async logOidcProviderError(eventName: string, ctx: unknown, error: unknown): Promise<void> {
const oidcContext = ctx as {
method?: string;
path?: string;
status?: number;
query?: Record<string, unknown>;
req?: { headers?: Record<string, string | string[] | undefined> };
oidc?: {
route?: string;
client?: { clientId?: string };
params?: Record<string, unknown>;
body?: Record<string, unknown>;
};
};
const params = oidcContext.oidc?.params ?? oidcContext.query ?? {};
const currentRequestContext = this.requestContext.get();
const correlationHeader = oidcContext.req?.headers?.['x-correlation-id'];
const correlationId =
currentRequestContext.correlationId ??
(Array.isArray(correlationHeader) ? correlationHeader[0] : correlationHeader);
await this.applicationErrorLogger.log({
error,
category: ApplicationErrorCategory.OIDC,
code:
eventName === 'server_error'
? ApplicationErrorCode.OIDC_PROVIDER_ERROR
: ApplicationErrorCode.OIDC_AUTHORIZATION_ERROR,
module: 'OidcModule',
service: OidcProviderService.name,
operation: eventName,
requestContext: {
correlationId,
method: oidcContext.method,
path: oidcContext.path,
statusCode: oidcContext.status,
},
context: {
eventName,
route: oidcContext.oidc?.route,
clientId: oidcContext.oidc?.client?.clientId ?? params.client_id,
redirectUri: params.redirect_uri,
responseType: params.response_type,
responseMode: params.response_mode,
scope: params.scope,
prompt: params.prompt,
error: this.errorProperty(error, 'error'),
errorDescription: this.errorProperty(error, 'error_description'),
errorDetail: this.errorProperty(error, 'error_detail'),
},
handled: true,
});
}
private errorProperty(error: unknown, property: string): unknown {
if (!error || typeof error !== 'object' || !(property in error)) {
return undefined;
}
return (error as Record<string, unknown>)[property];
}
private getProvider(): Provider { private getProvider(): Provider {
if (!this.provider) { if (!this.provider) {
throw new InternalServerErrorException('OIDC provider is not initialized'); throw new InternalServerErrorException('OIDC provider is not initialized');

View File

@@ -21,7 +21,7 @@ describe('PasswordService', () => {
}; };
const mailError = new Error('provider rejected max@example.com'); const mailError = new Error('provider rejected max@example.com');
const mail = { const mail = {
sendPasswordResetMail: jest.fn<(email: string, token: string) => Promise<void>>().mockRejectedValue(mailError), sendPasswordResetMail: jest.fn<(input: unknown) => Promise<void>>().mockRejectedValue(mailError),
}; };
const audit = { const audit = {
record: jest.fn<(input: unknown) => Promise<void>>().mockResolvedValue(undefined), record: jest.fn<(input: unknown) => Promise<void>>().mockResolvedValue(undefined),

View File

@@ -77,7 +77,11 @@ export class PasswordService {
); );
try { try {
await this.mail.sendPasswordResetMail(user.email, token); await this.mail.sendPasswordResetMail({
recipient: user.email,
token,
expiresAt: resetToken.expiresAt,
});
} catch (error) { } catch (error) {
await this.resetTokens.delete({ id: resetToken.id }).catch(() => undefined); await this.resetTokens.delete({ id: resetToken.id }).catch(() => undefined);
const currentRequestContext = this.requestContext.get(); const currentRequestContext = this.requestContext.get();

View File

@@ -46,7 +46,11 @@ Kontextdaten werden in Tiefe, Array-Laenge, Schluesselanzahl und String-Laenge b
```typescript ```typescript
try { try {
await this.mail.sendPasswordResetMail(user.email, token); await this.mail.sendPasswordResetMail({
recipient: user.email,
token,
expiresAt: resetToken.expiresAt,
});
} catch (error) { } catch (error) {
await this.applicationErrorLogger.log({ await this.applicationErrorLogger.log({
error, error,

80
docs/mail-templates.md Normal file
View File

@@ -0,0 +1,80 @@
# Mail Templates
Das Backend nutzt `@nestjs-modules/mailer` mit dem vorhandenen `HandlebarsAdapter`. Templates liegen unter `apps/api/src/mail/templates` und werden beim API-Build nach `dist/mail/templates` kopiert.
## Aufbau
- `layouts/base.hbs`: gemeinsames HTML-Grundlayout mit Preheader, Header, Inhaltsbereich und Footer.
- `partials/`: wiederverwendbare Bausteine fuer Header, Footer, Button, sekundaren Link, Info-Box, Warn-Box, Key-Value-Tabelle und Trennlinie.
- `*.hbs`: HTML-Inhalt je Mailtyp.
- `*.text.hbs`: bewusst gepflegte Plain-Text-Version je Mailtyp.
Aktuelle Templates:
- `password-reset`
- `verification`
- `email-change`
- `registration-pending-approval`
- `account-created`
- `invitation`
- `generic-notification`
- `warning-notification`
## Mail-Service
Neue Mailtypen sollen ueber `PortalMailService` angebunden werden. Der Service setzt Betreff, Template-Namen, Branding-Daten, HTML-Kontext und Plain-Text-Version zentral. Direkte `mailer.sendMail(...)`-Aufrufe ausserhalb dieses Service sollen vermieden werden.
Beispiel:
```typescript
await this.mail.sendPasswordResetMail({
recipient: user.email,
token,
expiresAt: resetToken.expiresAt,
});
```
## Branding
Branding wird zentral aus Environment-Variablen gelesen:
- `MAIL_PRODUCT_NAME`
- `MAIL_COMPANY_NAME`
- `MAIL_PRIMARY_COLOR`
- `MAIL_SUPPORT_EMAIL`
- `MAIL_LOGO_URL`
- `MAIL_IMPRINT_URL`
- `MAIL_PRIVACY_URL`
Falls diese Werte fehlen, verwendet das Backend kompatible Defaults aus der bestehenden Konfiguration, insbesondere `PUBLIC_WEB_URL` und `SMTP_FROM`.
## Sicherheit
Handlebars-Escaping bleibt aktiv. Benutzereingaben wie Anzeigenamen, E-Mail-Adressen und Nachrichtentexte werden mit normalem `{{value}}` gerendert. Unescaped Ausgabe wird nur in Plain-Text-Templates fuer serverseitig erzeugte Aktions-URLs verwendet, damit diese kopierbar bleiben.
Nicht in Templates oder Logs aufnehmen:
- Tokens als sichtbarer Text ausserhalb serverseitig erzeugter URLs
- SMTP-Passwoerter oder Authorization-Daten
- technische Stacktraces
- ungeprueftes HTML aus Benutzereingaben
## Lokale Vorschau
Preview-Dateien koennen ohne Mailversand erzeugt werden:
```bash
npm run preview:mails -w @ldap-portal/api
```
Standardausgabe ist das Betriebssystem-Temp-Verzeichnis unter `ldap-portal-mail-previews`. Alternativ kann `MAIL_PREVIEW_DIR` gesetzt werden.
## Build und Docker
Das Projekt baut die API per `tsc`, nicht per `nest build`. Deshalb kopiert `scripts/copy-mail-assets.js` die Templates nach dem Compile nach `dist/mail/templates`. Die `nest-cli.json` enthaelt zusaetzlich eine Asset-Konfiguration fuer Umgebungen, die spaeter `nest build` nutzen.
Die Dockerfiles kopieren das komplette API-`dist`; dadurch sind die Templates im Runtime-Container verfuegbar.
## Internationalisierung
Es gibt aktuell keine zentrale i18n-Loesung im Projekt. Die Templates verwenden daher die bestehende Standardsprache der Anwendung. Die Service-Inputs akzeptieren optional `locale`, damit spaetere Lokalisierung ohne neue Mail-Ausloeser moeglich bleibt.