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

@@ -4,6 +4,7 @@ export enum ApplicationErrorCategory {
EMAIL = 'EMAIL',
EXTERNAL_API = 'EXTERNAL_API',
FILE = 'FILE',
OIDC = 'OIDC',
UNHANDLED = 'UNHANDLED',
}
@@ -14,6 +15,8 @@ export enum ApplicationErrorCode {
EMAIL_PROVIDER_UNAVAILABLE = 'EMAIL_PROVIDER_UNAVAILABLE',
EXTERNAL_API_REQUEST_FAILED = 'EXTERNAL_API_REQUEST_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',
REGISTRATION_APPROVAL_NOTIFICATION_FAILED = 'REGISTRATION_APPROVAL_NOTIFICATION_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 { ConfigService } from '@nestjs/config';
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';
@Module({
@@ -23,10 +27,25 @@ import { PortalMailService } from './portal-mail.service';
defaults: {
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],
})
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 { ConfigService } from '@nestjs/config';
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()
export class PortalMailService {
constructor(
private readonly mailer: MailerService,
private readonly config: ConfigService,
private readonly templateRenderer: MailTemplateRendererService,
) {}
async sendVerificationMail(to: string, token: string): Promise<void> {
const url = `${this.publicWebUrl}/verify-email?token=${encodeURIComponent(token)}`;
await this.mailer.sendMail({
await this.sendTemplateMail({
to,
subject: 'LDAP Portal: E-Mail bestaetigen',
html: `<p>Bitte bestaetige deine Registrierung:</p><p><a href="${url}">${url}</a></p>`,
text: `Bitte bestaetige deine Registrierung: ${url}`,
subject: `E-Mail-Adresse fuer ${this.branding.productName} bestaetigen`,
templateName: MailTemplateName.VERIFICATION,
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> {
const url = `${this.publicWebUrl}/reset-password?token=${encodeURIComponent(token)}`;
await this.mailer.sendMail({
to,
subject: 'LDAP Portal: Passwort zuruecksetzen',
html: `<p>Du kannst dein Passwort ueber diesen Link zuruecksetzen:</p><p><a href="${url}">${url}</a></p>`,
text: `Du kannst dein Passwort ueber diesen Link zuruecksetzen: ${url}`,
async sendPasswordResetMail(input: SendPasswordResetMailInput): Promise<void>;
async sendPasswordResetMail(to: string, token: string): Promise<void>;
async sendPasswordResetMail(inputOrTo: SendPasswordResetMailInput | string, token?: string): Promise<void> {
const input =
typeof inputOrTo === 'string'
? { recipient: inputOrTo, token: token ?? '', expiresAt: new Date(Date.now() + 60 * 60_000) }
: 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> {
const url = `${this.publicWebUrl}/account/email?token=${encodeURIComponent(token)}`;
await this.mailer.sendMail({
await this.sendTemplateMail({
to,
subject: 'LDAP Portal: neue E-Mail bestaetigen',
html: `<p>Bitte bestaetige deine neue E-Mail-Adresse:</p><p><a href="${url}">${url}</a></p>`,
text: `Bitte bestaetige deine neue E-Mail-Adresse: ${url}`,
subject: `Neue E-Mail-Adresse fuer ${this.branding.productName} bestaetigen`,
templateName: MailTemplateName.EMAIL_CHANGE,
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 },
): Promise<void> {
const url = `${this.publicWebUrl}/admin/registrations`;
await this.mailer.sendMail({
await this.sendTemplateMail({
to,
subject: 'LDAP Portal: Registrierung wartet auf Freigabe',
html: `
<p>Eine Registrierung wurde per E-Mail bestaetigt und wartet jetzt auf Freigabe.</p>
<p><strong>Name:</strong> ${this.escapeHtml(registration.displayName)}<br>
<strong>E-Mail:</strong> ${this.escapeHtml(registration.email)}</p>
<p><a href="${url}">${url}</a></p>
`,
text: [
'Eine Registrierung wurde per E-Mail bestaetigt und wartet jetzt auf Freigabe.',
`Name: ${registration.displayName}`,
`E-Mail: ${registration.email}`,
`Admin-Bereich: ${url}`,
].join('\n'),
subject: `Registrierung wartet auf Freigabe in ${this.branding.productName}`,
templateName: MailTemplateName.REGISTRATION_PENDING_APPROVAL,
context: {
...this.baseContext(
'Eine Registrierung wartet auf Freigabe.',
'Registrierung wartet auf Freigabe',
'Eine Registrierung wurde per E-Mail bestaetigt und wartet jetzt auf administrative Freigabe.',
),
action: { label: 'Registrierungen oeffnen', url },
alternateUrlLabel: 'Direkter Link zum Admin-Bereich:',
keyValues: [
{ key: 'Name', value: registration.displayName },
{ 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';
}
private escapeHtml(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
private get branding() {
return mailBrandingFromConfig(this.config);
}
private baseContext(preheader: string, title: string, subtitle?: string, locale?: string) {
return {
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 { AccountClaims, Adapter, Configuration, Interaction } from 'oidc-provider';
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 { RequestContextService } from '../common/request-context.service';
import { LdapAuthService } from '../lldap/ldap-auth.service';
import { LldapService } from '../lldap/lldap.service';
import { OidcProviderStorageEntity } from './entities/oidc-provider-storage.entity';
@@ -35,6 +38,8 @@ export class OidcProviderService implements OnModuleInit {
private readonly ldapAuth: LdapAuthService,
private readonly lldap: LldapService,
private readonly audit: AuditService,
private readonly applicationErrorLogger: ApplicationErrorLoggerService,
private readonly requestContext: RequestContextService,
) {}
async onModuleInit(): Promise<void> {
@@ -45,6 +50,7 @@ export class OidcProviderService implements OnModuleInit {
this.provider = new oidc.default(issuer, this.buildConfiguration(jwks));
this.provider.proxy = this.config.get('OIDC_TRUST_PROXY') === 'true';
this.registerAuditEvents(this.provider);
this.registerErrorEvents(this.provider);
const expressApp = this.httpAdapterHost.httpAdapter.getInstance();
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 {
if (!this.provider) {
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 mail = {
sendPasswordResetMail: jest.fn<(email: string, token: string) => Promise<void>>().mockRejectedValue(mailError),
sendPasswordResetMail: jest.fn<(input: unknown) => Promise<void>>().mockRejectedValue(mailError),
};
const audit = {
record: jest.fn<(input: unknown) => Promise<void>>().mockResolvedValue(undefined),

View File

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