fix: register mail layout partial on handlebars singleton for real mailer wiring

The HandlebarsAdapter reads partials config from a top-level sibling of
`template` (mailerOptions.options.partials), not from
template.options.partials where it was nested. Because the mail templates
use partial blocks ({{#> layout}}...{{/layout}}), the unregistered partial
rendered silently as an unstyled fragment instead of throwing, so this went
unnoticed. A config-only fix also breaks on Windows because the adapter's
glob-based directory loader mishandles backslash path separators.

Fix registers the shared `layout` partial directly on the handlebars module
singleton in MailConfigService, bypassing the broken glob loader entirely.

Also:
- add mail-config.service.spec.ts, an integration test that drives the real
  MailerOptions + HandlebarsAdapter wiring (would have caught this bug,
  unlike the existing template-only spec which registers the partial itself)
- remove stale nestjs-i18n references from .env.example, env-example, and
  the backend README (i18n was already removed from the code)
- add missing trailing newlines to activation.hbs and reset-password.hbs

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Bastian Wagner
2026-07-31 23:29:19 +02:00
parent 0a0990c6f4
commit c80f78594e
7 changed files with 76 additions and 22 deletions

View File

@@ -2,8 +2,6 @@ NODE_ENV=production
APP_PORT=3999
APP_NAME="NestJS API"
API_PREFIX=api
APP_FALLBACK_LANGUAGE=en
APP_HEADER_LANGUAGE=x-custom-lang
FRONTEND_DOMAIN=http://localhost:3999
BACKEND_DOMAIN=http://localhost:3999

View File

@@ -2,8 +2,6 @@ NODE_ENV=development
APP_PORT=3000
APP_NAME="NestJS API"
API_PREFIX=api
APP_FALLBACK_LANGUAGE=en
APP_HEADER_LANGUAGE=x-custom-lang
FRONTEND_DOMAIN=http://localhost:3000
BACKEND_DOMAIN=http://localhost:3000

View File

@@ -26,7 +26,6 @@ Seeden: npm run seed:run
- [x] Sign in and sign up via email.
- [x] Social sign in (Apple, Facebook, Google, Twitter).
- [x] Admin and User roles.
- [x] I18N ([nestjs-i18n](https://www.npmjs.com/package/nestjs-i18n)).
- [x] File uploads. Support local and Amazon S3 drivers.
- [x] Swagger.
- [x] E2E and units tests.

View File

@@ -0,0 +1,56 @@
import * as path from 'path';
import { ConfigService } from '@nestjs/config';
import { MailConfigService } from './mail-config.service';
describe('MailConfigService integration', () => {
it('produces mailer options whose adapter actually renders the shared layout partial', (done) => {
const workingDirectory = path.join(__dirname, '..', '..');
const configValues: Record<string, unknown> = {
'app.workingDirectory': workingDirectory,
'mail.host': 'localhost',
'mail.port': 1025,
'mail.ignoreTLS': true,
'mail.secure': false,
'mail.requireTLS': false,
'mail.user': '',
'mail.password': '',
'mail.defaultName': 'TeamWallet',
'mail.defaultEmail': 'test@example.com',
};
const configService = {
get: (key: string) => configValues[key],
} as unknown as ConfigService;
const options = new MailConfigService(configService).createMailerOptions();
const mail: {
data: {
template: string;
context: Record<string, unknown>;
html?: string;
};
} = {
data: {
template: 'activation',
context: {
title: 'Test',
year: 2026,
firstName: 'Max',
url: 'https://example.com/confirm-email/abc',
actionTitle: 'Jetzt bestätigen',
},
},
};
options.template.adapter.compile(
mail,
(err?: Error) => {
expect(err).toBeUndefined();
expect(mail.data.html).toContain('TeamWallet');
expect(mail.data.html).toContain('tw-wordmark');
done();
},
options,
);
});
});

View File

@@ -1,4 +1,6 @@
import * as path from 'path';
import * as fs from 'fs';
import * as handlebars from 'handlebars';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { MailerOptions, MailerOptionsFactory } from '@nestjs-modules/mailer';
@@ -9,6 +11,21 @@ export class MailConfigService implements MailerOptionsFactory {
constructor(private configService: ConfigService) {}
createMailerOptions(): MailerOptions {
const templatesDir = path.join(
this.configService.get('app.workingDirectory'),
'src',
'mail',
'mail-templates',
);
handlebars.registerPartial(
'layout',
fs.readFileSync(
path.join(templatesDir, 'partials', 'layout.hbs'),
'utf-8',
),
);
return {
transport: {
host: this.configService.get('mail.host'),
@@ -27,24 +44,10 @@ export class MailConfigService implements MailerOptionsFactory {
)}" <${this.configService.get('mail.defaultEmail')}>`,
},
template: {
dir: path.join(
this.configService.get('app.workingDirectory'),
'src',
'mail',
'mail-templates',
),
dir: templatesDir,
adapter: new HandlebarsAdapter(),
options: {
strict: true,
partials: {
dir: path.join(
this.configService.get('app.workingDirectory'),
'src',
'mail',
'mail-templates',
'partials',
),
},
},
},
} as MailerOptions;