mail und logging
This commit is contained in:
@@ -23,6 +23,13 @@ SMTP_SECURE=false
|
||||
SMTP_USER=portal@example.com
|
||||
SMTP_PASS=change-me
|
||||
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_COOKIE_SECRET=change-me-long-random-oidc-cookie-secret
|
||||
|
||||
@@ -143,6 +143,10 @@ Die wichtigsten Variablen aus `.env.example`:
|
||||
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURE` | SMTP-Verbindung. |
|
||||
| `SMTP_USER`, `SMTP_PASS` | Optionale SMTP-Authentifizierung. |
|
||||
| `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_COOKIE_SECRET` | Cookie-Secret fuer OIDC Sessions; Fallback ist `TOKEN_SECRET`. |
|
||||
| `OIDC_ADMIN_GROUP` | Gruppe fuer OIDC-Clientverwaltung, Standard `client_manager`. |
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
"deleteOutDir": true,
|
||||
"assets": [
|
||||
{
|
||||
"include": "mail/templates/**/*",
|
||||
"outDir": "dist"
|
||||
}
|
||||
],
|
||||
"watchAssets": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"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:dev": "nest start --watch",
|
||||
"preview:mails": "ts-node scripts/render-mail-previews.ts",
|
||||
"test": "jest --passWithNoTests",
|
||||
"lint": "eslint \"src/**/*.ts\""
|
||||
},
|
||||
|
||||
13
apps/api/scripts/copy-mail-assets.js
Normal file
13
apps/api/scripts/copy-mail-assets.js
Normal 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 });
|
||||
117
apps/api/scripts/render-mail-previews.ts
Normal file
117
apps/api/scripts/render-mail-previews.ts
Normal 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}`);
|
||||
@@ -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',
|
||||
|
||||
33
apps/api/src/mail/mail-branding.ts
Normal file
33
apps/api/src/mail/mail-branding.ts
Normal 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('"', '');
|
||||
}
|
||||
25
apps/api/src/mail/mail-errors.ts
Normal file
25
apps/api/src/mail/mail-errors.ts
Normal 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';
|
||||
}
|
||||
60
apps/api/src/mail/mail-template-renderer.service.ts
Normal file
60
apps/api/src/mail/mail-template-renderer.service.ts
Normal 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] : [];
|
||||
});
|
||||
}
|
||||
}
|
||||
14
apps/api/src/mail/mail-template.constants.ts
Normal file
14
apps/api/src/mail/mail-template.constants.ts
Normal 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',
|
||||
}
|
||||
49
apps/api/src/mail/mail-template.types.ts
Normal file
49
apps/api/src/mail/mail-template.types.ts
Normal 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[];
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
181
apps/api/src/mail/portal-mail.service.spec.ts
Normal file
181
apps/api/src/mail/portal-mail.service.spec.ts
Normal 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=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('<script>alert(1)</script>');
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
17
apps/api/src/mail/templates/account-created.hbs
Normal file
17
apps/api/src/mail/templates/account-created.hbs
Normal 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}}
|
||||
12
apps/api/src/mail/templates/account-created.text.hbs
Normal file
12
apps/api/src/mail/templates/account-created.text.hbs
Normal 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}}
|
||||
11
apps/api/src/mail/templates/email-change.hbs
Normal file
11
apps/api/src/mail/templates/email-change.hbs
Normal 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}}
|
||||
8
apps/api/src/mail/templates/email-change.text.hbs
Normal file
8
apps/api/src/mail/templates/email-change.text.hbs
Normal 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}}
|
||||
18
apps/api/src/mail/templates/generic-notification.hbs
Normal file
18
apps/api/src/mail/templates/generic-notification.hbs
Normal 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}}
|
||||
23
apps/api/src/mail/templates/generic-notification.text.hbs
Normal file
23
apps/api/src/mail/templates/generic-notification.text.hbs
Normal 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}}
|
||||
18
apps/api/src/mail/templates/invitation.hbs
Normal file
18
apps/api/src/mail/templates/invitation.hbs
Normal 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}}
|
||||
14
apps/api/src/mail/templates/invitation.text.hbs
Normal file
14
apps/api/src/mail/templates/invitation.text.hbs
Normal 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}}
|
||||
125
apps/api/src/mail/templates/layouts/base.hbs
Normal file
125
apps/api/src/mail/templates/layouts/base.hbs
Normal 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>
|
||||
9
apps/api/src/mail/templates/partials/button.hbs
Normal file
9
apps/api/src/mail/templates/partials/button.hbs
Normal 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>
|
||||
5
apps/api/src/mail/templates/partials/divider.hbs
Normal file
5
apps/api/src/mail/templates/partials/divider.hbs
Normal 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;"> </td>
|
||||
</tr>
|
||||
</table>
|
||||
31
apps/api/src/mail/templates/partials/footer.hbs
Normal file
31
apps/api/src/mail/templates/partials/footer.hbs
Normal 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> | </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>
|
||||
17
apps/api/src/mail/templates/partials/header.hbs
Normal file
17
apps/api/src/mail/templates/partials/header.hbs
Normal 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>
|
||||
10
apps/api/src/mail/templates/partials/info-box.hbs
Normal file
10
apps/api/src/mail/templates/partials/info-box.hbs
Normal 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>
|
||||
8
apps/api/src/mail/templates/partials/key-value.hbs
Normal file
8
apps/api/src/mail/templates/partials/key-value.hbs
Normal 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>
|
||||
3
apps/api/src/mail/templates/partials/secondary-link.hbs
Normal file
3
apps/api/src/mail/templates/partials/secondary-link.hbs
Normal 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>
|
||||
10
apps/api/src/mail/templates/partials/warning-box.hbs
Normal file
10
apps/api/src/mail/templates/partials/warning-box.hbs
Normal 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>
|
||||
21
apps/api/src/mail/templates/password-reset.hbs
Normal file
21
apps/api/src/mail/templates/password-reset.hbs
Normal 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>
|
||||
15
apps/api/src/mail/templates/password-reset.text.hbs
Normal file
15
apps/api/src/mail/templates/password-reset.text.hbs
Normal 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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
11
apps/api/src/mail/templates/verification.hbs
Normal file
11
apps/api/src/mail/templates/verification.hbs
Normal 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}}
|
||||
10
apps/api/src/mail/templates/verification.text.hbs
Normal file
10
apps/api/src/mail/templates/verification.text.hbs
Normal 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}}
|
||||
15
apps/api/src/mail/templates/warning-notification.hbs
Normal file
15
apps/api/src/mail/templates/warning-notification.hbs
Normal 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}}
|
||||
14
apps/api/src/mail/templates/warning-notification.text.hbs
Normal file
14
apps/api/src/mail/templates/warning-notification.text.hbs
Normal 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}}
|
||||
@@ -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');
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -46,7 +46,11 @@ Kontextdaten werden in Tiefe, Array-Laenge, Schluesselanzahl und String-Laenge b
|
||||
|
||||
```typescript
|
||||
try {
|
||||
await this.mail.sendPasswordResetMail(user.email, token);
|
||||
await this.mail.sendPasswordResetMail({
|
||||
recipient: user.email,
|
||||
token,
|
||||
expiresAt: resetToken.expiresAt,
|
||||
});
|
||||
} catch (error) {
|
||||
await this.applicationErrorLogger.log({
|
||||
error,
|
||||
|
||||
80
docs/mail-templates.md
Normal file
80
docs/mail-templates.md
Normal 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.
|
||||
Reference in New Issue
Block a user