features
This commit is contained in:
@@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
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(/\/+$/, '');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user