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 { const url = `${this.publicWebUrl}/verify-email?token=${encodeURIComponent(token)}`; 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: `E-Mail-Adresse fuer ${context.branding.productName} bestaetigen`, templateName: MailTemplateName.VERIFICATION, context, }); } 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 } : 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 { const url = `${this.publicWebUrl}/account/email?token=${encodeURIComponent(token)}`; 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: `Neue E-Mail-Adresse fuer ${context.branding.productName} bestaetigen`, templateName: MailTemplateName.EMAIL_CHANGE, context, }); } async sendRegistrationPendingApprovalMail( to: string[], registration: { email: string; displayName: string }, ): Promise { 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: `${context.branding.productName}: Registrierung wartet auf Freigabe`, templateName: MailTemplateName.REGISTRATION_PENDING_APPROVAL, context, }); } async sendAccountCreatedMail(input: SendAccountCreatedMailInput): Promise { 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, }); } async sendInvitationMail(input: SendInvitationMailInput): Promise { 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 { 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 { 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>(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('PUBLIC_WEB_URL') ?? 'http://localhost:4200').replace(/\/+$/, ''); } }