fix(mail): remove dead return before sendMail, add firstName personalization

- Remove I18n dependency injection from MailService constructor
- Hardcode German email subject/body strings directly in the service
- Add optional firstName field to userSignUp and forgotPassword methods
- Wire firstName from user object through auth.service.ts call sites
- Add comprehensive unit tests for MailService

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Bastian Wagner
2026-07-31 22:33:34 +02:00
parent fb3ed10b41
commit 247aeefd4c
3 changed files with 93 additions and 34 deletions

View File

@@ -192,6 +192,7 @@ export class AuthService {
to: user.email,
data: {
hash,
firstName: user.firstName,
},
});
}
@@ -247,6 +248,7 @@ export class AuthService {
to: email,
data: {
hash,
firstName: user.firstName,
},
});
}

View File

@@ -0,0 +1,61 @@
import { ConfigService } from '@nestjs/config';
import { MailerService } from '@nestjs-modules/mailer';
import { MailService } from './mail.service';
describe('MailService', () => {
let service: MailService;
let sendMail: jest.Mock;
let configGet: jest.Mock;
beforeEach(() => {
sendMail = jest.fn().mockResolvedValue(undefined);
configGet = jest.fn().mockReturnValue('https://app.example.com');
service = new MailService(
{ sendMail } as unknown as MailerService,
{ get: configGet } as unknown as ConfigService,
);
});
it('sends the activation mail with the confirm-email link', async () => {
await service.userSignUp({
to: 'user@example.com',
data: { hash: 'abc123', firstName: 'Max' },
});
expect(sendMail).toHaveBeenCalledTimes(1);
const call = sendMail.mock.calls[0][0];
expect(call.to).toBe('user@example.com');
expect(call.template).toBe('activation');
expect(call.context.url).toBe(
'https://app.example.com/confirm-email/abc123',
);
expect(call.context.firstName).toBe('Max');
});
it('sends the reset-password mail with the password-change link', async () => {
await service.forgotPassword({
to: 'user@example.com',
data: { hash: 'xyz789', firstName: 'Erika' },
});
expect(sendMail).toHaveBeenCalledTimes(1);
const call = sendMail.mock.calls[0][0];
expect(call.to).toBe('user@example.com');
expect(call.template).toBe('reset-password');
expect(call.context.url).toBe(
'https://app.example.com/password-change/xyz789',
);
expect(call.context.firstName).toBe('Erika');
});
it('works without a firstName (optional personalization)', async () => {
await service.userSignUp({
to: 'user@example.com',
data: { hash: 'abc123' },
});
const call = sendMail.mock.calls[0][0];
expect(call.context.firstName).toBeUndefined();
});
});

View File

@@ -1,61 +1,57 @@
import { MailerService } from '@nestjs-modules/mailer';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { I18n, I18nRequestScopeService } from 'nestjs-i18n';
import { MailData } from './interfaces/mail-data.interface';
@Injectable()
export class MailService {
constructor(
@I18n()
private i18n: I18nRequestScopeService,
private mailerService: MailerService,
private configService: ConfigService,
) {}
async userSignUp(mailData: MailData<{ hash: string }>) {
return;
async userSignUp(
mailData: MailData<{ hash: string; firstName?: string | null }>,
) {
const actionTitle = 'E-Mail bestätigen';
const url = `${this.configService.get('app.frontendDomain')}/confirm-email/${
mailData.data.hash
}`;
await this.mailerService.sendMail({
to: mailData.to,
subject: await this.i18n.t('common.confirmEmail'),
text: `${this.configService.get('app.frontendDomain')}/confirm-email/${
mailData.data.hash
} ${await this.i18n.t('common.confirmEmail')}`,
subject: 'Bestätige deine E-Mail-Adresse',
text: `${url} ${actionTitle}`,
template: 'activation',
context: {
title: await this.i18n.t('common.confirmEmail'),
url: `${this.configService.get('app.frontendDomain')}/confirm-email/${
mailData.data.hash
}`,
actionTitle: await this.i18n.t('common.confirmEmail'),
app_name: this.configService.get('app.name'),
text1: await this.i18n.t('confirm-email.text1'),
text2: await this.i18n.t('confirm-email.text2'),
text3: await this.i18n.t('confirm-email.text3'),
title: 'Bestätige deine E-Mail-Adresse',
year: new Date().getFullYear(),
firstName: mailData.data.firstName,
url,
actionTitle,
},
});
}
async forgotPassword(mailData: MailData<{ hash: string }>) {
return;
async forgotPassword(
mailData: MailData<{ hash: string; firstName?: string | null }>,
) {
const actionTitle = 'Passwort zurücksetzen';
const url = `${this.configService.get('app.frontendDomain')}/password-change/${
mailData.data.hash
}`;
await this.mailerService.sendMail({
to: mailData.to,
subject: await this.i18n.t('common.resetPassword'),
text: `${this.configService.get('app.frontendDomain')}/password-change/${
mailData.data.hash
} ${await this.i18n.t('common.resetPassword')}`,
subject: actionTitle,
text: `${url} ${actionTitle}`,
template: 'reset-password',
context: {
title: await this.i18n.t('common.resetPassword'),
url: `${this.configService.get('app.frontendDomain')}/password-change/${
mailData.data.hash
}`,
actionTitle: await this.i18n.t('common.resetPassword'),
app_name: this.configService.get('app.name'),
text1: await this.i18n.t('reset-password.text1'),
text2: await this.i18n.t('reset-password.text2'),
text3: await this.i18n.t('reset-password.text3'),
text4: await this.i18n.t('reset-password.text4'),
title: actionTitle,
year: new Date().getFullYear(),
firstName: mailData.data.firstName,
url,
actionTitle,
},
});
}