diff --git a/.env.example b/.env.example index efc18b7..6c00e3a 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/docs/superpowers/plans/2026-07-31-mail-versand.md b/docs/superpowers/plans/2026-07-31-mail-versand.md new file mode 100644 index 0000000..92d75e8 --- /dev/null +++ b/docs/superpowers/plans/2026-07-31-mail-versand.md @@ -0,0 +1,664 @@ +# Mailversand reparieren + Templates neu gestalten — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Mailversand für Registrierung und Passwort-vergessen im NestJS-Backend +(`myteamwallet_backend`) wieder funktionsfähig machen, das mail-only `nestjs-i18n` Setup +entfernen (Texte direkt auf Deutsch), und die zwei Handlebars-Templates in einem zu +"TeamWallet" (Grün `#2e7d32`) passenden, hübschen Design neu bauen. + +**Architecture:** `MailService` (`@nestjs-modules/mailer` + Nodemailer + Handlebars) bleibt die +zentrale Versandstelle. Der Bug ist ein totes `return;` vor `sendMail(...)` in beiden Methoden — +Fix ist ein reiner Code-Fix, keine Config-Änderung nötig (`.env` ist bereits lokal korrekt +befüllt). `nestjs-i18n` wird komplett entfernt, deutsche Texte wandern direkt in die +`.hbs`-Templates bzw. als String-Literale in `mail.service.ts`. Die zwei Templates teilen sich +ein gemeinsames Handlebars-Block-Partial (`partials/layout.hbs`) für Header/Footer, um +Duplikation zu vermeiden. + +**Tech Stack:** NestJS 9, `@nestjs-modules/mailer` 1.8.1 (Nodemailer 6.8.0), Handlebars 4.7.7, +Jest 29 (Unit-Tests), TypeScript 4.8. + +## Global Constraints + +- Nur Deutsch — keine mehrsprachige i18n-Infrastruktur für Mails, keine Sprachdateien. +- `nestjs-i18n` wird vollständig aus dem Backend entfernt (Modul, Dependency, `src/i18n/`). +- Branding: Akzentfarbe `#2e7d32` (Grün), Textwordmark „TeamWallet" (kein Bild-Logo), Roboto mit + Fallback-Stack `Roboto, Helvetica, Arial, sans-serif`, abgerundete Card-Optik (~12px Radius), + `max-width: 600px`, Inline-CSS-safe (die `HandlebarsAdapter` inlined ` + + +
+
+
+ TeamWallet +
+
+ {{> @partial-block }} +
+
+ +
+ + +``` + +- [ ] **Step 4: `activation.hbs` neu gestalten** + +Datei `myteamwallet_backend/src/mail/mail-templates/activation.hbs` komplett ersetzen: + +```handlebars +{{#> layout}} +

Hallo{{#if firstName}} {{firstName}}{{/if}},

+

schön, dass du bei TeamWallet dabei bist! Bestätige deine E-Mail-Adresse, um dein Konto zu aktivieren.

+
+ {{actionTitle}} +
+

Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser:
{{url}}

+{{/layout}} +``` + +- [ ] **Step 5: `reset-password.hbs` neu gestalten** + +Datei `myteamwallet_backend/src/mail/mail-templates/reset-password.hbs` komplett ersetzen: + +```handlebars +{{#> layout}} +

Hallo{{#if firstName}} {{firstName}}{{/if}},

+

du hast angefragt, dein TeamWallet-Passwort zurückzusetzen. Klicke auf den Button, um ein neues Passwort zu vergeben.

+
+ {{actionTitle}} +
+

Falls du diese Anfrage nicht gestellt hast, kannst du diese E-Mail einfach ignorieren — es wird nichts verändert.

+

Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser:
{{url}}

+{{/layout}} +``` + +- [ ] **Step 6: Partials-Verzeichnis in `mail-config.service.ts` registrieren** + +In `myteamwallet_backend/src/mail/mail-config.service.ts` den `template.options` Block +erweitern (`partials.dir` zeigt auf den neuen Ordner, damit `HandlebarsAdapter` die `.hbs` +Dateien darin beim Versand automatisch als Partials lädt): + +```typescript + createMailerOptions(): MailerOptions { + return { + transport: { + host: this.configService.get('mail.host'), + port: this.configService.get('mail.port'), + ignoreTLS: this.configService.get('mail.ignoreTLS'), + secure: this.configService.get('mail.secure'), + requireTLS: this.configService.get('mail.requireTLS'), + auth: { + user: this.configService.get('mail.user'), + pass: this.configService.get('mail.password'), + }, + }, + defaults: { + from: `"${this.configService.get( + 'mail.defaultName', + )}" <${this.configService.get('mail.defaultEmail')}>`, + }, + template: { + dir: path.join( + this.configService.get('app.workingDirectory'), + 'src', + 'mail', + 'mail-templates', + ), + adapter: new HandlebarsAdapter(), + options: { + strict: true, + partials: { + dir: path.join( + this.configService.get('app.workingDirectory'), + 'src', + 'mail', + 'mail-templates', + 'partials', + ), + }, + }, + }, + } as MailerOptions; + } +``` + +- [ ] **Step 7: Test laufen lassen, Erfolg bestätigen** + +Run: `cd myteamwallet_backend && npx jest mail-templates.spec.ts` +Expected: PASS — alle 3 Tests grün. + +- [ ] **Step 8: Alle Unit-Tests + Build** + +Run: `cd myteamwallet_backend && npm test && npm run build` +Expected: alle Tests grün, Build erfolgreich. + +- [ ] **Step 9: Commit** + +```bash +git add myteamwallet_backend/src/mail/mail-config.service.ts myteamwallet_backend/src/mail/mail-templates +git commit -m "feat(mail): redesign email templates with TeamWallet branding and shared layout partial" +``` + +--- + +### Task 4: Manuelle Verifikation mit echtem Versand + +Kein Code-Task — Nachweis, dass Registrierung und Passwort-vergessen tatsächlich E-Mails +verschicken (Strato-SMTP, kein lokaler MailDev vorhanden, siehe Spec). + +**Files:** keine. + +- [ ] **Step 1: Backend lokal starten** + +Voraussetzung: lokale MySQL-Instanz läuft (Container `brave_einstein`, Port 3306, bereits aktiv +laut `docker ps`), `myteamwallet_backend/.env` unverändert vorhanden. + +Run: `cd myteamwallet_backend && npm run start:dev` +Expected: Server startet ohne Fehler auf Port `3999` (kein Absturz durch die entfernten +`nestjs-i18n`-Imports, keine `MailerModule`-Config-Fehler). + +- [ ] **Step 2: Registrierung auslösen (Aktivierungsmail)** + +In einem zweiten Terminal, mit einer echten, von dir kontrollierten Test-Adresse: + +```bash +curl -X POST http://localhost:3999/api/v1/auth/email/register \ + -H "Content-Type: application/json" \ + -d '{"email":"DEINE-TEST-ADRESSE@example.com","password":"Test1234!","firstName":"Max","lastName":"Mustermann"}' +``` + +Expected: HTTP 201, und innerhalb kurzer Zeit trifft eine E-Mail „Bestätige deine +E-Mail-Adresse" mit grünem TeamWallet-Header, Begrüßung „Hallo Max," und funktionierendem +Bestätigungs-Button in der Test-Mailbox ein. + +- [ ] **Step 3: Passwort-vergessen auslösen** + +```bash +curl -X POST http://localhost:3999/api/v1/auth/forgot/password \ + -H "Content-Type: application/json" \ + -d '{"email":"DEINE-TEST-ADRESSE@example.com"}' +``` + +Expected: HTTP 204/200 (je nach Response des Endpoints), und eine E-Mail „Passwort +zurücksetzen" mit gleichem Layout trifft ein. + +- [ ] **Step 4: Server stoppen** + +`npm run start:dev` Prozess beenden (Ctrl+C). + +- [ ] **Step 5: Ergebnis festhalten** + +Kein Commit nötig — dies ist ein manueller Verifikationsschritt. Falls eine der beiden Mails +nicht ankommt, zurück zu systematic-debugging (SMTP-Verbindung, Firewall, Spam-Ordner prüfen) +bevor der Task als abgeschlossen gilt. + +--- + +## Self-Review + +- **Spec coverage:** Bugfix (Task 1), i18n-Entfernung (Task 2), Template-Redesign inkl. + Branding/Partial (Task 3), Personalisierung (Task 1), Testing-Strategie laut korrigiertem Spec + — Unit-Tests statt e2e/MailDev (Task 1 + 3), manueller Realversand (Task 4) — alles abgedeckt. + `.env` explizit als "keine Aktion" markiert (Global Constraints), passend zum korrigierten + Spec. +- **Placeholder-Scan:** keine TBD/TODO, jeder Step enthält vollständigen Code. +- **Typ-Konsistenz:** `MailData<{ hash: string; firstName?: string | null }>` konsistent in + `mail.service.ts` (Task 1) und den Aufrufstellen in `auth.service.ts` (Task 1) verwendet; + Context-Keys `title`/`year`/`firstName`/`url`/`actionTitle` konsistent zwischen `mail.service.ts` + (Task 1) und den Templates (Task 3). diff --git a/env-example b/env-example index 0d4f88f..b5fd249 100644 --- a/env-example +++ b/env-example @@ -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 diff --git a/myteamwallet_backend/README.md b/myteamwallet_backend/README.md index 6979358..d12cc22 100644 --- a/myteamwallet_backend/README.md +++ b/myteamwallet_backend/README.md @@ -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. diff --git a/myteamwallet_backend/package-lock.json b/myteamwallet_backend/package-lock.json index 832e3f6..1055e2a 100644 --- a/myteamwallet_backend/package-lock.json +++ b/myteamwallet_backend/package-lock.json @@ -30,7 +30,6 @@ "multer": "1.4.4", "multer-s3": "2.10.0", "mysql2": "^2.3.3", - "nestjs-i18n": "9.2.2", "nodemailer": "6.8.0", "passport": "0.6.0", "passport-anonymous": "1.0.1", @@ -4587,11 +4586,6 @@ "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==" }, - "node_modules/accept-language-parser": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/accept-language-parser/-/accept-language-parser-1.5.0.tgz", - "integrity": "sha512-QhyTbMLYo0BBGg1aWbeMG4ekWtds/31BrEU+DONOg/7ax23vxpL03Pb7/zBmha2v7vdD3AyzZVWBVGEZxKOXWw==" - }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -14272,41 +14266,6 @@ "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" }, - "node_modules/nestjs-i18n": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/nestjs-i18n/-/nestjs-i18n-9.2.2.tgz", - "integrity": "sha512-GxwDonBnW7MbwuUxF9IHINm0vYhVQUqUnDLxDHPzavs2T2qMDrf/muyqXc9QFIs0v16ElSKz5+aBDZK4nUrgpw==", - "dependencies": { - "accept-language-parser": "^1.5.0", - "chokidar": "^3.5.3", - "cookie": "^0.5.0", - "iterare": "^1.2.1", - "js-yaml": "^4.1.0", - "string-format": "^2.0.0" - }, - "peerDependencies": { - "@nestjs/common": "*", - "@nestjs/core": "*", - "class-validator": "~0.13", - "rxjs": "*" - } - }, - "node_modules/nestjs-i18n/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" - }, - "node_modules/nestjs-i18n/node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/netmask": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", @@ -16339,11 +16298,6 @@ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=" }, - "node_modules/string-format": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/string-format/-/string-format-2.0.0.tgz", - "integrity": "sha512-bbEs3scLeYNXLecRRuk6uJxdXUSj6le/8rNPHChIJTn2V79aXVTR1EH2OH5zLKKoz0V02fOUKZZcw01pLUShZA==" - }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -21936,11 +21890,6 @@ "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==" }, - "accept-language-parser": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/accept-language-parser/-/accept-language-parser-1.5.0.tgz", - "integrity": "sha512-QhyTbMLYo0BBGg1aWbeMG4ekWtds/31BrEU+DONOg/7ax23vxpL03Pb7/zBmha2v7vdD3AyzZVWBVGEZxKOXWw==" - }, "accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -29287,34 +29236,6 @@ "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" }, - "nestjs-i18n": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/nestjs-i18n/-/nestjs-i18n-9.2.2.tgz", - "integrity": "sha512-GxwDonBnW7MbwuUxF9IHINm0vYhVQUqUnDLxDHPzavs2T2qMDrf/muyqXc9QFIs0v16ElSKz5+aBDZK4nUrgpw==", - "requires": { - "accept-language-parser": "^1.5.0", - "chokidar": "^3.5.3", - "cookie": "^0.5.0", - "iterare": "^1.2.1", - "js-yaml": "^4.1.0", - "string-format": "^2.0.0" - }, - "dependencies": { - "argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" - }, - "js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "requires": { - "argparse": "^2.0.1" - } - } - } - }, "netmask": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", @@ -30884,11 +30805,6 @@ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=" }, - "string-format": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/string-format/-/string-format-2.0.0.tgz", - "integrity": "sha512-bbEs3scLeYNXLecRRuk6uJxdXUSj6le/8rNPHChIJTn2V79aXVTR1EH2OH5zLKKoz0V02fOUKZZcw01pLUShZA==" - }, "string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", diff --git a/myteamwallet_backend/package.json b/myteamwallet_backend/package.json index 67dbc89..c7bb6cf 100644 --- a/myteamwallet_backend/package.json +++ b/myteamwallet_backend/package.json @@ -49,7 +49,6 @@ "multer": "1.4.4", "multer-s3": "2.10.0", "mysql2": "^2.3.3", - "nestjs-i18n": "9.2.2", "nodemailer": "6.8.0", "passport": "0.6.0", "passport-anonymous": "1.0.1", diff --git a/myteamwallet_backend/src/app.module.ts b/myteamwallet_backend/src/app.module.ts index 056bed1..53cdcdd 100644 --- a/myteamwallet_backend/src/app.module.ts +++ b/myteamwallet_backend/src/app.module.ts @@ -6,12 +6,9 @@ import authConfig from './config/auth.config'; import appConfig from './config/app.config'; import mailConfig from './config/mail.config'; import fileConfig from './config/file.config'; -import * as path from 'path'; import { MailerModule } from '@nestjs-modules/mailer'; -import { ConfigModule, ConfigService } from '@nestjs/config'; +import { ConfigModule } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { I18nModule } from 'nestjs-i18n/dist/i18n.module'; -import { HeaderResolver } from 'nestjs-i18n'; import { TypeOrmConfigService } from './database/typeorm-config.service'; import { MailConfigService } from './mail/mail-config.service'; import { ForgotModule } from './forgot/forgot.module'; @@ -45,23 +42,6 @@ import { PenaltyModule } from './penalty/penalty.module'; MailerModule.forRootAsync({ useClass: MailConfigService, }), - I18nModule.forRootAsync({ - useFactory: (configService: ConfigService) => ({ - fallbackLanguage: configService.get('app.fallbackLanguage'), - loaderOptions: { path: path.join(__dirname, '/i18n/'), watch: true }, - }), - resolvers: [ - { - use: HeaderResolver, - useFactory: (configService: ConfigService) => { - return [configService.get('app.headerLanguage')]; - }, - inject: [ConfigService], - }, - ], - imports: [ConfigModule], - inject: [ConfigService], - }), ServeStaticModule.forRoot({ rootPath: join(__dirname, '../client'), exclude: ['*/api*'], diff --git a/myteamwallet_backend/src/auth/auth.service.ts b/myteamwallet_backend/src/auth/auth.service.ts index 4a7ccec..6884669 100644 --- a/myteamwallet_backend/src/auth/auth.service.ts +++ b/myteamwallet_backend/src/auth/auth.service.ts @@ -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, }, }); } diff --git a/myteamwallet_backend/src/config/app.config.ts b/myteamwallet_backend/src/config/app.config.ts index a33cba4..b02abf3 100644 --- a/myteamwallet_backend/src/config/app.config.ts +++ b/myteamwallet_backend/src/config/app.config.ts @@ -8,6 +8,4 @@ export default registerAs('app', () => ({ backendDomain: process.env.BACKEND_DOMAIN, port: parseInt(process.env.APP_PORT || process.env.PORT, 10) || 3000, apiPrefix: process.env.API_PREFIX || 'api', - fallbackLanguage: process.env.APP_FALLBACK_LANGUAGE || 'en', - headerLanguage: process.env.APP_HEADER_LANGUAGE || 'x-custom-lang', })); diff --git a/myteamwallet_backend/src/i18n/en/common.json b/myteamwallet_backend/src/i18n/en/common.json deleted file mode 100644 index b7a2419..0000000 --- a/myteamwallet_backend/src/i18n/en/common.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "confirmEmail": "Confirm email", - "resetPassword": "Reset password" -} diff --git a/myteamwallet_backend/src/i18n/en/confirm-email.json b/myteamwallet_backend/src/i18n/en/confirm-email.json deleted file mode 100644 index 0eb981d..0000000 --- a/myteamwallet_backend/src/i18n/en/confirm-email.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "text1": "Hey!", - "text2": "You’re almost ready to start enjoying", - "text3": "Simply click the big green button below to verify your email address." -} diff --git a/myteamwallet_backend/src/i18n/en/reset-password.json b/myteamwallet_backend/src/i18n/en/reset-password.json deleted file mode 100644 index 6cae96a..0000000 --- a/myteamwallet_backend/src/i18n/en/reset-password.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "text1": "Trouble signing in?", - "text2": "Resetting your password is easy.", - "text3": "Just press the button below and follow the instructions. We’ll have you up and running in no time.", - "text4": "If you did not make this request then please ignore this email." -} diff --git a/myteamwallet_backend/src/mail/mail-config.service.spec.ts b/myteamwallet_backend/src/mail/mail-config.service.spec.ts new file mode 100644 index 0000000..e8fc09b --- /dev/null +++ b/myteamwallet_backend/src/mail/mail-config.service.spec.ts @@ -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 = { + '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; + 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, + ); + }); +}); diff --git a/myteamwallet_backend/src/mail/mail-config.service.ts b/myteamwallet_backend/src/mail/mail-config.service.ts index 4a6cba6..acec8db 100644 --- a/myteamwallet_backend/src/mail/mail-config.service.ts +++ b/myteamwallet_backend/src/mail/mail-config.service.ts @@ -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,12 +44,7 @@ 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, diff --git a/myteamwallet_backend/src/mail/mail-templates/activation.hbs b/myteamwallet_backend/src/mail/mail-templates/activation.hbs index edee05e..62850ab 100644 --- a/myteamwallet_backend/src/mail/mail-templates/activation.hbs +++ b/myteamwallet_backend/src/mail/mail-templates/activation.hbs @@ -1,33 +1,8 @@ - - - - - - - {{title}} - - - - - - - - - - - - - -
- {{app_name}} -
- {{text1}}
- {{text2}} {{app_name}}.
- {{text3}} -
- {{actionTitle}} -
- - - \ No newline at end of file +{{#> layout}} +

Hallo{{#if firstName}} {{firstName}}{{/if}},

+

schön, dass du bei TeamWallet dabei bist! Bestätige deine E-Mail-Adresse, um dein Konto zu aktivieren.

+ +

Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser:
{{url}}

+{{/layout}} diff --git a/myteamwallet_backend/src/mail/mail-templates/mail-templates.spec.ts b/myteamwallet_backend/src/mail/mail-templates/mail-templates.spec.ts new file mode 100644 index 0000000..0caa9e1 --- /dev/null +++ b/myteamwallet_backend/src/mail/mail-templates/mail-templates.spec.ts @@ -0,0 +1,63 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as Handlebars from 'handlebars'; + +describe('mail templates rendering', () => { + const templatesDir = __dirname; + + beforeAll(() => { + const layoutSource = fs.readFileSync( + path.join(templatesDir, 'partials', 'layout.hbs'), + 'utf-8', + ); + Handlebars.registerPartial('layout', layoutSource); + }); + + const baseContext = { + title: 'Test-Betreff', + year: 2026, + firstName: 'Max', + url: 'https://app.example.com/confirm-email/abc123', + actionTitle: 'Jetzt bestätigen', + }; + + it('renders activation.hbs with greeting, link and button text', () => { + const source = fs.readFileSync( + path.join(templatesDir, 'activation.hbs'), + 'utf-8', + ); + const html = Handlebars.compile(source, { strict: true })(baseContext); + + expect(html).toContain('TeamWallet'); + expect(html).toContain('Hallo Max,'); + expect(html).toContain(baseContext.url); + expect(html).toContain(baseContext.actionTitle); + }); + + it('renders reset-password.hbs with greeting, link and button text', () => { + const source = fs.readFileSync( + path.join(templatesDir, 'reset-password.hbs'), + 'utf-8', + ); + const html = Handlebars.compile(source, { strict: true })(baseContext); + + expect(html).toContain('TeamWallet'); + expect(html).toContain('Hallo Max,'); + expect(html).toContain(baseContext.url); + expect(html).toContain(baseContext.actionTitle); + }); + + it('falls back to a generic greeting when firstName is missing', () => { + const source = fs.readFileSync( + path.join(templatesDir, 'activation.hbs'), + 'utf-8', + ); + const html = Handlebars.compile(source, { strict: true })({ + ...baseContext, + firstName: undefined, + }); + + expect(html).toContain('Hallo,'); + expect(html).not.toContain('Hallo Max,'); + }); +}); diff --git a/myteamwallet_backend/src/mail/mail-templates/partials/layout.hbs b/myteamwallet_backend/src/mail/mail-templates/partials/layout.hbs new file mode 100644 index 0000000..723775e --- /dev/null +++ b/myteamwallet_backend/src/mail/mail-templates/partials/layout.hbs @@ -0,0 +1,36 @@ + + + + + +{{title}} + + + +
+
+
+ TeamWallet +
+
+ {{> @partial-block }} +
+
+ +
+ + diff --git a/myteamwallet_backend/src/mail/mail-templates/reset-password.hbs b/myteamwallet_backend/src/mail/mail-templates/reset-password.hbs index 3c0e405..ae32299 100644 --- a/myteamwallet_backend/src/mail/mail-templates/reset-password.hbs +++ b/myteamwallet_backend/src/mail/mail-templates/reset-password.hbs @@ -1,38 +1,9 @@ - - - - - - - {{title}} - - - - - - - - - - - - - - - - -
- {{app_name}} -
- {{text1}}
- {{text2}}
- {{text3}} -
- {{actionTitle}} -
- {{text4}} -
- - - \ No newline at end of file +{{#> layout}} +

Hallo{{#if firstName}} {{firstName}}{{/if}},

+

du hast angefragt, dein TeamWallet-Passwort zurückzusetzen. Klicke auf den Button, um ein neues Passwort zu vergeben.

+ +

Falls du diese Anfrage nicht gestellt hast, kannst du diese E-Mail einfach ignorieren — es wird nichts verändert.

+

Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser:
{{url}}

+{{/layout}} diff --git a/myteamwallet_backend/src/mail/mail.service.spec.ts b/myteamwallet_backend/src/mail/mail.service.spec.ts new file mode 100644 index 0000000..9e33ddc --- /dev/null +++ b/myteamwallet_backend/src/mail/mail.service.spec.ts @@ -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(); + }); +}); diff --git a/myteamwallet_backend/src/mail/mail.service.ts b/myteamwallet_backend/src/mail/mail.service.ts index 94ce813..a825da8 100644 --- a/myteamwallet_backend/src/mail/mail.service.ts +++ b/myteamwallet_backend/src/mail/mail.service.ts @@ -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, }, }); }