This commit is contained in:
Bastian Wagner
2026-07-17 14:07:04 +02:00
parent 8c6ad294b2
commit 45a41c2077
64 changed files with 2032 additions and 83 deletions

View File

@@ -1,41 +1,137 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { MailerService } from '@nestjs-modules/mailer';
import { mailBranding } from './mail-branding';
import { PortalMailDeliveryError, PortalMailTemplateError } from './mail-errors';
import { MailTemplateName } from './mail-template.constants';
import { MailActionContext, MailBaseContext } from './mail-template.types';
import { MailTemplateRendererService } from './mail-template-renderer.service';
export interface SendPasswordResetMailInput {
recipient: string;
displayName?: string;
token?: string;
resetUrl?: string;
expiresAt?: Date;
locale?: string;
correlationId?: string;
}
export interface SendAccountCreatedMailInput {
recipient: string;
displayName?: string;
loginUrl?: string;
locale?: string;
}
export interface SendInvitationMailInput {
recipient: string;
invitedBy?: string;
organizationName?: string;
invitationUrl: string;
expiresAt?: Date;
locale?: string;
}
export interface SendNotificationMailInput {
recipient: string | string[];
title: string;
intro?: string;
paragraphs?: string[];
action?: MailActionContext;
infoText?: string;
warningText?: string;
footerNote?: string;
locale?: string;
}
type MailTemplateContext = MailBaseContext & { [key: string]: unknown };
interface TemplateMailInput {
to: string | string[];
subject: string;
templateName: MailTemplateName;
context: MailTemplateContext;
}
@Injectable()
export class PortalMailService {
constructor(
private readonly mailer: MailerService,
private readonly config: ConfigService,
private readonly renderer: MailTemplateRendererService,
) {}
async sendVerificationMail(to: string, token: string): Promise<void> {
const url = `${this.publicWebUrl}/verify-email?token=${encodeURIComponent(token)}`;
await this.mailer.sendMail({
const context = this.baseContext({
title: 'E-Mail-Adresse bestaetigen',
preheader: 'Bitte bestaetige deine E-Mail-Adresse, um die Registrierung fortzusetzen.',
intro: 'Bitte bestaetige deine Registrierung ueber die folgende Schaltflaeche.',
action: { label: 'E-Mail bestaetigen', url },
infoBox: { text: 'Falls du diese Registrierung nicht gestartet hast, kannst du diese E-Mail ignorieren.' },
verificationUrl: url,
expiresAtText: this.formatDateTime(new Date(Date.now() + 24 * 60 * 60_000)),
});
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 ${context.branding.productName} bestaetigen`,
templateName: MailTemplateName.VERIFICATION,
context,
});
}
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 }
: inputOrTo;
const resetUrl =
input.resetUrl ?? `${this.publicWebUrl}/reset-password?token=${encodeURIComponent(input.token ?? '')}`;
const expiresAt = input.expiresAt ?? new Date(Date.now() + 60 * 60_000);
const context = this.baseContext({
title: 'Passwort zuruecksetzen',
preheader: 'Fuer dein Benutzerkonto wurde das Zuruecksetzen des Passworts angefordert.',
greeting: input.displayName ? `Hallo ${input.displayName},` : 'Hallo,',
intro: 'Fuer dein Benutzerkonto wurde das Zuruecksetzen des Passworts angefordert. Ueber die folgende Schaltflaeche kannst du ein neues Passwort vergeben.',
action: { label: 'Passwort zuruecksetzen', url: resetUrl },
warningBox: {
title: 'Sicherheitshinweis',
text: 'Falls du diese Anfrage nicht selbst gestellt hast, kannst du diese E-Mail ignorieren. Dein bestehendes Passwort bleibt unveraendert.',
},
resetUrl,
expiresAtText: this.formatDateTime(expiresAt, input.locale),
locale: input.locale,
});
await this.sendTemplateMail({
to: input.recipient,
subject: `Passwort fuer ${context.branding.productName} zuruecksetzen`,
templateName: MailTemplateName.PASSWORD_RESET,
context,
});
}
async sendEmailChangeMail(to: string, token: string): Promise<void> {
const url = `${this.publicWebUrl}/account/email?token=${encodeURIComponent(token)}`;
await this.mailer.sendMail({
const context = this.baseContext({
title: 'Neue E-Mail-Adresse bestaetigen',
preheader: 'Bitte bestaetige deine neue E-Mail-Adresse.',
intro: 'Bitte bestaetige deine neue E-Mail-Adresse ueber die folgende Schaltflaeche.',
action: { label: 'E-Mail-Adresse bestaetigen', url },
infoBox: { text: 'Falls du diese Aenderung nicht angefordert hast, kontaktiere bitte den Support.' },
confirmUrl: url,
expiresAtText: this.formatDateTime(new Date(Date.now() + 24 * 60 * 60_000)),
});
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 ${context.branding.productName} bestaetigen`,
templateName: MailTemplateName.EMAIL_CHANGE,
context,
});
}
@@ -43,35 +139,134 @@ export class PortalMailService {
to: string[],
registration: { email: string; displayName: string },
): Promise<void> {
const url = `${this.publicWebUrl}/admin/registrations`;
await this.mailer.sendMail({
const adminUrl = `${this.publicWebUrl}/admin/registrations`;
const context = this.baseContext({
title: 'Registrierung wartet auf Freigabe',
preheader: 'Eine neue Registrierung wurde bestaetigt und wartet auf Freigabe.',
intro: 'Eine Registrierung wurde per E-Mail bestaetigt und wartet jetzt auf administrative Freigabe.',
action: { label: 'Registrierungen pruefen', url: adminUrl },
registration,
adminUrl,
});
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: `${context.branding.productName}: Registrierung wartet auf Freigabe`,
templateName: MailTemplateName.REGISTRATION_PENDING_APPROVAL,
context,
});
}
private get publicWebUrl(): string {
return this.config.get<string>('PUBLIC_WEB_URL') ?? 'http://localhost:4200';
async sendAccountCreatedMail(input: SendAccountCreatedMailInput): Promise<void> {
const loginUrl = input.loginUrl ?? this.publicWebUrl;
const context = this.baseContext({
title: `Willkommen bei ${mailBranding(this.config).productName}`,
preheader: 'Dein Benutzerkonto wurde angelegt.',
greeting: input.displayName ? `Hallo ${input.displayName},` : 'Hallo,',
intro: 'Dein Benutzerkonto wurde angelegt. Du kannst dich jetzt anmelden.',
action: { label: 'Zur Anwendung', url: loginUrl },
locale: input.locale,
});
await this.sendTemplateMail({
to: input.recipient,
subject: `Willkommen bei ${context.branding.productName}`,
templateName: MailTemplateName.ACCOUNT_CREATED,
context,
});
}
private escapeHtml(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
async sendInvitationMail(input: SendInvitationMailInput): Promise<void> {
const context = this.baseContext({
title: `Einladung zu ${mailBranding(this.config).productName}`,
preheader: 'Du wurdest eingeladen.',
intro: input.organizationName
? `Du wurdest zu ${input.organizationName} eingeladen.`
: 'Du wurdest eingeladen, die Anwendung zu nutzen.',
action: { label: 'Einladung annehmen', url: input.invitationUrl },
infoBox: input.expiresAt
? { text: `Diese Einladung ist gueltig bis ${this.formatDateTime(input.expiresAt, input.locale)}.` }
: undefined,
invitedBy: input.invitedBy,
invitationUrl: input.invitationUrl,
expiresAtText: input.expiresAt ? this.formatDateTime(input.expiresAt, input.locale) : undefined,
locale: input.locale,
});
await this.sendTemplateMail({
to: input.recipient,
subject: `Sie wurden zu ${context.branding.productName} eingeladen`,
templateName: MailTemplateName.INVITATION,
context,
});
}
async sendNotificationMail(input: SendNotificationMailInput): Promise<void> {
const templateName = input.warningText ? MailTemplateName.WARNING_NOTIFICATION : MailTemplateName.GENERIC_NOTIFICATION;
const context = this.baseContext({
title: input.title,
preheader: input.intro ?? input.title,
intro: input.intro,
action: input.action,
infoBox: input.infoText ? { text: input.infoText } : undefined,
warningBox: input.warningText ? { text: input.warningText } : undefined,
footerNote: input.footerNote,
paragraphs: input.paragraphs ?? [],
locale: input.locale,
});
await this.sendTemplateMail({
to: input.recipient,
subject: `Neue Benachrichtigung in ${context.branding.productName}`,
templateName,
context,
});
}
private async sendTemplateMail(input: TemplateMailInput): Promise<void> {
let text: string;
try {
await this.renderer.renderHtml(input.templateName, input.context);
text = await this.renderer.renderText(input.templateName, input.context);
} catch (error) {
throw new PortalMailTemplateError(`Mail template rendering failed: ${input.templateName}`, input.templateName, {
cause: 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(`Mail delivery failed: ${input.templateName}`, input.templateName, {
cause: error,
});
}
}
private baseContext<T extends Record<string, unknown>>(context: T): T & MailTemplateContext {
return {
branding: mailBranding(this.config),
...context,
} as T & MailTemplateContext;
}
private formatDateTime(value: Date, locale = 'de-DE'): string {
return new Intl.DateTimeFormat(locale, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(value);
}
private get publicWebUrl(): string {
return (this.config.get<string>('PUBLIC_WEB_URL') ?? 'http://localhost:4200').replace(/\/+$/, '');
}
}