diff --git a/.env.example b/.env.example index 1da0198..ed2c231 100644 --- a/.env.example +++ b/.env.example @@ -23,6 +23,13 @@ SMTP_SECURE=false SMTP_USER=portal@example.com SMTP_PASS=change-me SMTP_FROM="LDAP Portal " +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 diff --git a/README.md b/README.md index c835e47..25cbc8f 100644 --- a/README.md +++ b/README.md @@ -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`. | diff --git a/apps/api/nest-cli.json b/apps/api/nest-cli.json index 9b3d2ab..09b6871 100644 --- a/apps/api/nest-cli.json +++ b/apps/api/nest-cli.json @@ -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 } } diff --git a/apps/api/package.json b/apps/api/package.json index 2ffb077..2b87071 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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\"" }, diff --git a/apps/api/scripts/copy-mail-assets.js b/apps/api/scripts/copy-mail-assets.js new file mode 100644 index 0000000..f969326 --- /dev/null +++ b/apps/api/scripts/copy-mail-assets.js @@ -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 }); diff --git a/apps/api/scripts/render-mail-previews.ts b/apps/api/scripts/render-mail-previews.ts new file mode 100644 index 0000000..d67c772 --- /dev/null +++ b/apps/api/scripts/render-mail-previews.ts @@ -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 }> = [ + { + 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}`); diff --git a/apps/api/src/application-error-log/application-error-codes.ts b/apps/api/src/application-error-log/application-error-codes.ts index ea157fb..cdb3847 100644 --- a/apps/api/src/application-error-log/application-error-codes.ts +++ b/apps/api/src/application-error-log/application-error-codes.ts @@ -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', diff --git a/apps/api/src/mail/mail-branding.ts b/apps/api/src/mail/mail-branding.ts new file mode 100644 index 0000000..5bf2165 --- /dev/null +++ b/apps/api/src/mail/mail-branding.ts @@ -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('PUBLIC_WEB_URL') ?? 'http://localhost:4200'; + const smtpFrom = config.get('SMTP_FROM') ?? 'LDAP Portal '; + + return { + productName: config.get('MAIL_PRODUCT_NAME') ?? 'LDAP Portal', + companyName: config.get('MAIL_COMPANY_NAME') ?? 'LDAP Portal', + primaryColor: config.get('MAIL_PRIMARY_COLOR') ?? '#2563eb', + supportEmail: config.get('MAIL_SUPPORT_EMAIL') ?? extractEmailAddress(smtpFrom), + logoUrl: config.get('MAIL_LOGO_URL') || undefined, + imprintUrl: config.get('MAIL_IMPRINT_URL') || undefined, + privacyUrl: config.get('MAIL_PRIVACY_URL') || undefined, + publicWebUrl, + }; +} + +function extractEmailAddress(value: string): string { + const match = value.match(/<([^>]+)>/); + return match?.[1] ?? value.replaceAll('"', ''); +} diff --git a/apps/api/src/mail/mail-errors.ts b/apps/api/src/mail/mail-errors.ts new file mode 100644 index 0000000..38156a7 --- /dev/null +++ b/apps/api/src/mail/mail-errors.ts @@ -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'; +} diff --git a/apps/api/src/mail/mail-template-renderer.service.ts b/apps/api/src/mail/mail-template-renderer.service.ts new file mode 100644 index 0000000..1163433 --- /dev/null +++ b/apps/api/src/mail/mail-template-renderer.service.ts @@ -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 { + 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 { + 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] : []; + }); + } +} diff --git a/apps/api/src/mail/mail-template.constants.ts b/apps/api/src/mail/mail-template.constants.ts new file mode 100644 index 0000000..34d1283 --- /dev/null +++ b/apps/api/src/mail/mail-template.constants.ts @@ -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', +} diff --git a/apps/api/src/mail/mail-template.types.ts b/apps/api/src/mail/mail-template.types.ts new file mode 100644 index 0000000..d0fdeff --- /dev/null +++ b/apps/api/src/mail/mail-template.types.ts @@ -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 { + 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[]; +} diff --git a/apps/api/src/mail/mail.module.ts b/apps/api/src/mail/mail.module.ts index 99d263c..244788a 100644 --- a/apps/api/src/mail/mail.module.ts +++ b/apps/api/src/mail/mail.module.ts @@ -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('SMTP_FROM') ?? 'LDAP Portal ', }, + 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 {} diff --git a/apps/api/src/mail/portal-mail.service.spec.ts b/apps/api/src/mail/portal-mail.service.spec.ts new file mode 100644 index 0000000..3db3b8e --- /dev/null +++ b/apps/api/src/mail/portal-mail.service.spec.ts @@ -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 = {}) { + const values: Record = { + PUBLIC_WEB_URL: 'https://portal.example.com', + SMTP_FROM: 'LDAP Portal ', + 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 Promise>>; + renderer?: MailTemplateRendererService; + config?: ReturnType; + } = {}) { + const sendMail = options.sendMail ?? jest.fn<(input: unknown) => Promise>().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; + text: string; + }; + const context = mail.context as Record; + + 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).productName).toBe('Identity Portal'); + expect((context.action as Record).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 }; + 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 ,', + 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(''); + }); + + 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>() + .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); + }); +}); diff --git a/apps/api/src/mail/portal-mail.service.ts b/apps/api/src/mail/portal-mail.service.ts index a9d1145..50f4140 100644 --- a/apps/api/src/mail/portal-mail.service.ts +++ b/apps/api/src/mail/portal-mail.service.ts @@ -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 { const url = `${this.publicWebUrl}/verify-email?token=${encodeURIComponent(token)}`; - await this.mailer.sendMail({ + await this.sendTemplateMail({ to, - subject: 'LDAP Portal: E-Mail bestaetigen', - html: `

Bitte bestaetige deine Registrierung:

${url}

`, - 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 { - const url = `${this.publicWebUrl}/reset-password?token=${encodeURIComponent(token)}`; - await this.mailer.sendMail({ - to, - subject: 'LDAP Portal: Passwort zuruecksetzen', - html: `

Du kannst dein Passwort ueber diesen Link zuruecksetzen:

${url}

`, - text: `Du kannst dein Passwort ueber diesen Link zuruecksetzen: ${url}`, + async sendPasswordResetMail(input: SendPasswordResetMailInput): Promise; + async sendPasswordResetMail(to: string, token: string): Promise; + async sendPasswordResetMail(inputOrTo: SendPasswordResetMailInput | string, token?: string): Promise { + 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 { 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: `

Bitte bestaetige deine neue E-Mail-Adresse:

${url}

`, - 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 { const url = `${this.publicWebUrl}/admin/registrations`; - await this.mailer.sendMail({ + await this.sendTemplateMail({ to, - subject: 'LDAP Portal: Registrierung wartet auf Freigabe', - html: ` -

Eine Registrierung wurde per E-Mail bestaetigt und wartet jetzt auf Freigabe.

-

Name: ${this.escapeHtml(registration.displayName)}
- E-Mail: ${this.escapeHtml(registration.email)}

-

${url}

- `, - 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 { + 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 { + 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 { + 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('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; + }): Promise { + 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); } } diff --git a/apps/api/src/mail/templates/account-created.hbs b/apps/api/src/mail/templates/account-created.hbs new file mode 100644 index 0000000..40a0465 --- /dev/null +++ b/apps/api/src/mail/templates/account-created.hbs @@ -0,0 +1,17 @@ +{{#if greeting}} +

{{greeting}}

+{{/if}} +

+ Ihr Benutzerkonto ist angelegt und kann fuer {{branding.productName}} verwendet werden. +

+{{#if username}} + {{> key-value keyValues}} +{{/if}} +{{#if action}} + {{> button label=action.label url=action.url primaryColor=branding.primaryColor}} +

{{alternateUrlLabel}}

+ +{{/if}} +{{#if infoBox}} + {{> info-box infoBox}} +{{/if}} diff --git a/apps/api/src/mail/templates/account-created.text.hbs b/apps/api/src/mail/templates/account-created.text.hbs new file mode 100644 index 0000000..c354fc1 --- /dev/null +++ b/apps/api/src/mail/templates/account-created.text.hbs @@ -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}} diff --git a/apps/api/src/mail/templates/email-change.hbs b/apps/api/src/mail/templates/email-change.hbs new file mode 100644 index 0000000..8487081 --- /dev/null +++ b/apps/api/src/mail/templates/email-change.hbs @@ -0,0 +1,11 @@ +

+ Bitte bestaetigen Sie diese Aenderung, damit die neue E-Mail-Adresse fuer Ihr Benutzerkonto uebernommen wird. +

+{{#if action}} + {{> button label=action.label url=action.url primaryColor=branding.primaryColor}} +

{{alternateUrlLabel}}

+ +{{/if}} +{{#if infoBox}} + {{> info-box infoBox}} +{{/if}} diff --git a/apps/api/src/mail/templates/email-change.text.hbs b/apps/api/src/mail/templates/email-change.text.hbs new file mode 100644 index 0000000..59617a5 --- /dev/null +++ b/apps/api/src/mail/templates/email-change.text.hbs @@ -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}} diff --git a/apps/api/src/mail/templates/generic-notification.hbs b/apps/api/src/mail/templates/generic-notification.hbs new file mode 100644 index 0000000..37b8864 --- /dev/null +++ b/apps/api/src/mail/templates/generic-notification.hbs @@ -0,0 +1,18 @@ +{{#each paragraphs}} +

{{this}}

+{{/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}} diff --git a/apps/api/src/mail/templates/generic-notification.text.hbs b/apps/api/src/mail/templates/generic-notification.text.hbs new file mode 100644 index 0000000..0989a7a --- /dev/null +++ b/apps/api/src/mail/templates/generic-notification.text.hbs @@ -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}} diff --git a/apps/api/src/mail/templates/invitation.hbs b/apps/api/src/mail/templates/invitation.hbs new file mode 100644 index 0000000..a464b0b --- /dev/null +++ b/apps/api/src/mail/templates/invitation.hbs @@ -0,0 +1,18 @@ +{{#if greeting}} +

{{greeting}}

+{{/if}} +

+ {{#if inviter}}{{inviter}} hat Sie eingeladen.{{else}}Sie wurden eingeladen.{{/if}} + Bitte nehmen Sie die Einladung nur an, wenn Sie diese Nachricht erwartet haben. +

+{{#if action}} + {{> button label=action.label url=action.url primaryColor=branding.primaryColor}} +

{{alternateUrlLabel}}

+ +{{/if}} +{{#if expiresAtLabel}} +

Die Einladung ist gueltig bis {{expiresAtLabel}}.

+{{/if}} +{{#if warningBox}} + {{> warning-box warningBox}} +{{/if}} diff --git a/apps/api/src/mail/templates/invitation.text.hbs b/apps/api/src/mail/templates/invitation.text.hbs new file mode 100644 index 0000000..9c85cf8 --- /dev/null +++ b/apps/api/src/mail/templates/invitation.text.hbs @@ -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}} diff --git a/apps/api/src/mail/templates/layouts/base.hbs b/apps/api/src/mail/templates/layouts/base.hbs new file mode 100644 index 0000000..8d1c4e3 --- /dev/null +++ b/apps/api/src/mail/templates/layouts/base.hbs @@ -0,0 +1,125 @@ + + + + + + + {{title}} + + + +
+ {{preheader}} +
+ + + + + + + diff --git a/apps/api/src/mail/templates/partials/button.hbs b/apps/api/src/mail/templates/partials/button.hbs new file mode 100644 index 0000000..62277c7 --- /dev/null +++ b/apps/api/src/mail/templates/partials/button.hbs @@ -0,0 +1,9 @@ + + + + + diff --git a/apps/api/src/mail/templates/partials/divider.hbs b/apps/api/src/mail/templates/partials/divider.hbs new file mode 100644 index 0000000..3986c44 --- /dev/null +++ b/apps/api/src/mail/templates/partials/divider.hbs @@ -0,0 +1,5 @@ + + + + +
 
diff --git a/apps/api/src/mail/templates/partials/footer.hbs b/apps/api/src/mail/templates/partials/footer.hbs new file mode 100644 index 0000000..7fee72b --- /dev/null +++ b/apps/api/src/mail/templates/partials/footer.hbs @@ -0,0 +1,31 @@ + + + + +
+ {{#if footerNote}} +

{{footerNote}}

+ {{/if}} +

+ Diese E-Mail wurde automatisch von {{branding.productName}} erstellt. Bitte antworten Sie nicht direkt auf diese Nachricht. +

+

+ Bei Fragen wenden Sie sich an + {{branding.supportEmail}}. +

+ {{#if branding.imprintUrl}} +

+ Impressum + {{#if branding.privacyUrl}} +  |  + Datenschutz + {{/if}} +

+ {{else}} + {{#if branding.privacyUrl}} +

+ Datenschutz +

+ {{/if}} + {{/if}} +
diff --git a/apps/api/src/mail/templates/partials/header.hbs b/apps/api/src/mail/templates/partials/header.hbs new file mode 100644 index 0000000..91222bf --- /dev/null +++ b/apps/api/src/mail/templates/partials/header.hbs @@ -0,0 +1,17 @@ + + + + +
+ + + + +
+ {{#if branding.logoUrl}} + {{branding.productName}} + {{else}} + {{branding.productName}} + {{/if}} +
+
diff --git a/apps/api/src/mail/templates/partials/info-box.hbs b/apps/api/src/mail/templates/partials/info-box.hbs new file mode 100644 index 0000000..fce1fc1 --- /dev/null +++ b/apps/api/src/mail/templates/partials/info-box.hbs @@ -0,0 +1,10 @@ + + + + +
+ {{#if title}} +

{{title}}

+ {{/if}} +

{{text}}

+
diff --git a/apps/api/src/mail/templates/partials/key-value.hbs b/apps/api/src/mail/templates/partials/key-value.hbs new file mode 100644 index 0000000..006678d --- /dev/null +++ b/apps/api/src/mail/templates/partials/key-value.hbs @@ -0,0 +1,8 @@ + + {{#each this}} + + + + + {{/each}} +
{{key}}{{value}}
diff --git a/apps/api/src/mail/templates/partials/secondary-link.hbs b/apps/api/src/mail/templates/partials/secondary-link.hbs new file mode 100644 index 0000000..8b6c115 --- /dev/null +++ b/apps/api/src/mail/templates/partials/secondary-link.hbs @@ -0,0 +1,3 @@ +

+ {{label}} +

diff --git a/apps/api/src/mail/templates/partials/warning-box.hbs b/apps/api/src/mail/templates/partials/warning-box.hbs new file mode 100644 index 0000000..83844cc --- /dev/null +++ b/apps/api/src/mail/templates/partials/warning-box.hbs @@ -0,0 +1,10 @@ + + + + +
+ {{#if title}} +

{{title}}

+ {{/if}} +

{{text}}

+
diff --git a/apps/api/src/mail/templates/password-reset.hbs b/apps/api/src/mail/templates/password-reset.hbs new file mode 100644 index 0000000..a0fe393 --- /dev/null +++ b/apps/api/src/mail/templates/password-reset.hbs @@ -0,0 +1,21 @@ +{{#if greeting}} +

{{greeting}}

+{{/if}} +

+ ueber die folgende Schaltflaeche koennen Sie ein neues Passwort vergeben. +

+{{#if action}} + {{> button label=action.label url=action.url primaryColor=branding.primaryColor}} +

{{alternateUrlLabel}}

+ +{{/if}} +

+ Der Link ist gueltig bis {{expiresAtLabel}}. +

+{{#if warningBox}} + {{> warning-box warningBox}} +{{/if}} +

+ Wenn Sie Unterstuetzung benoetigen, wenden Sie sich an + {{branding.supportEmail}}. +

diff --git a/apps/api/src/mail/templates/password-reset.text.hbs b/apps/api/src/mail/templates/password-reset.text.hbs new file mode 100644 index 0000000..0fc6173 --- /dev/null +++ b/apps/api/src/mail/templates/password-reset.text.hbs @@ -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}} diff --git a/apps/api/src/mail/templates/registration-pending-approval.hbs b/apps/api/src/mail/templates/registration-pending-approval.hbs new file mode 100644 index 0000000..9689ead --- /dev/null +++ b/apps/api/src/mail/templates/registration-pending-approval.hbs @@ -0,0 +1,11 @@ +

+ Bitte pruefen Sie die Registrierung im Admin-Bereich und geben Sie sie frei oder lehnen Sie sie ab. +

+{{#if keyValues}} + {{> key-value keyValues}} +{{/if}} +{{#if action}} + {{> button label=action.label url=action.url primaryColor=branding.primaryColor}} +

{{alternateUrlLabel}}

+ +{{/if}} diff --git a/apps/api/src/mail/templates/registration-pending-approval.text.hbs b/apps/api/src/mail/templates/registration-pending-approval.text.hbs new file mode 100644 index 0000000..ed3f696 --- /dev/null +++ b/apps/api/src/mail/templates/registration-pending-approval.text.hbs @@ -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}} diff --git a/apps/api/src/mail/templates/verification.hbs b/apps/api/src/mail/templates/verification.hbs new file mode 100644 index 0000000..1e47292 --- /dev/null +++ b/apps/api/src/mail/templates/verification.hbs @@ -0,0 +1,11 @@ +

+ Bitte bestaetigen Sie Ihre E-Mail-Adresse, um die Registrierung abzuschliessen. +

+{{#if action}} + {{> button label=action.label url=action.url primaryColor=branding.primaryColor}} +

{{alternateUrlLabel}}

+ +{{/if}} +{{#if infoBox}} + {{> info-box infoBox}} +{{/if}} diff --git a/apps/api/src/mail/templates/verification.text.hbs b/apps/api/src/mail/templates/verification.text.hbs new file mode 100644 index 0000000..13b9a06 --- /dev/null +++ b/apps/api/src/mail/templates/verification.text.hbs @@ -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}} diff --git a/apps/api/src/mail/templates/warning-notification.hbs b/apps/api/src/mail/templates/warning-notification.hbs new file mode 100644 index 0000000..8100092 --- /dev/null +++ b/apps/api/src/mail/templates/warning-notification.hbs @@ -0,0 +1,15 @@ +{{#each paragraphs}} +

{{this}}

+{{/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}} diff --git a/apps/api/src/mail/templates/warning-notification.text.hbs b/apps/api/src/mail/templates/warning-notification.text.hbs new file mode 100644 index 0000000..0e7cf7e --- /dev/null +++ b/apps/api/src/mail/templates/warning-notification.text.hbs @@ -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}} diff --git a/apps/api/src/oidc/oidc-provider.service.ts b/apps/api/src/oidc/oidc-provider.service.ts index ee740e7..64a6d6f 100644 --- a/apps/api/src/oidc/oidc-provider.service.ts +++ b/apps/api/src/oidc/oidc-provider.service.ts @@ -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 { @@ -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 { + const oidcContext = ctx as { + method?: string; + path?: string; + status?: number; + query?: Record; + req?: { headers?: Record }; + oidc?: { + route?: string; + client?: { clientId?: string }; + params?: Record; + body?: Record; + }; + }; + 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)[property]; + } + private getProvider(): Provider { if (!this.provider) { throw new InternalServerErrorException('OIDC provider is not initialized'); diff --git a/apps/api/src/password/password.service.spec.ts b/apps/api/src/password/password.service.spec.ts index bbd7821..38648bc 100644 --- a/apps/api/src/password/password.service.spec.ts +++ b/apps/api/src/password/password.service.spec.ts @@ -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>().mockRejectedValue(mailError), + sendPasswordResetMail: jest.fn<(input: unknown) => Promise>().mockRejectedValue(mailError), }; const audit = { record: jest.fn<(input: unknown) => Promise>().mockResolvedValue(undefined), diff --git a/apps/api/src/password/password.service.ts b/apps/api/src/password/password.service.ts index 900bdca..a7c392a 100644 --- a/apps/api/src/password/password.service.ts +++ b/apps/api/src/password/password.service.ts @@ -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(); diff --git a/docs/application-error-logging.md b/docs/application-error-logging.md index 2d07df7..6039f69 100644 --- a/docs/application-error-logging.md +++ b/docs/application-error-logging.md @@ -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, diff --git a/docs/mail-templates.md b/docs/mail-templates.md new file mode 100644 index 0000000..6673c38 --- /dev/null +++ b/docs/mail-templates.md @@ -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.