Compare commits
6 Commits
81040efd8b
...
8e318df94f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e318df94f | ||
|
|
c80f78594e | ||
|
|
0a0990c6f4 | ||
|
|
ae9de39ac4 | ||
|
|
247aeefd4c | ||
|
|
fb3ed10b41 |
@@ -2,8 +2,6 @@ NODE_ENV=production
|
|||||||
APP_PORT=3999
|
APP_PORT=3999
|
||||||
APP_NAME="NestJS API"
|
APP_NAME="NestJS API"
|
||||||
API_PREFIX=api
|
API_PREFIX=api
|
||||||
APP_FALLBACK_LANGUAGE=en
|
|
||||||
APP_HEADER_LANGUAGE=x-custom-lang
|
|
||||||
FRONTEND_DOMAIN=http://localhost:3999
|
FRONTEND_DOMAIN=http://localhost:3999
|
||||||
BACKEND_DOMAIN=http://localhost:3999
|
BACKEND_DOMAIN=http://localhost:3999
|
||||||
|
|
||||||
|
|||||||
664
docs/superpowers/plans/2026-07-31-mail-versand.md
Normal file
664
docs/superpowers/plans/2026-07-31-mail-versand.md
Normal file
@@ -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 `<style>`-CSS automatisch
|
||||||
|
via `inline-css`, `inlineCssEnabled` ist standardmäßig `true`).
|
||||||
|
- Kein MJML, kein Build-Pipeline-Zusatz für Templates.
|
||||||
|
- `.env` (in `myteamwallet_backend/.env`) wird **nicht** verändert — existiert bereits mit
|
||||||
|
gültigen Werten.
|
||||||
|
- Kein neuer e2e-/MailDev-Aufbau — Verifikation über Unit-Tests plus einen manuellen echten
|
||||||
|
Testversand (siehe Task 4).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Bugfix `MailService` + Vornamen-Personalisierung
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `myteamwallet_backend/src/mail/mail.service.ts`
|
||||||
|
- Modify: `myteamwallet_backend/src/auth/auth.service.ts:191-196` (register) und `:246-251`
|
||||||
|
(forgotPassword)
|
||||||
|
- Test: `myteamwallet_backend/src/mail/mail.service.spec.ts` (neu)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `MailService.userSignUp(mailData: MailData<{ hash: string; firstName?: string | null }>): Promise<void>`
|
||||||
|
- Produces: `MailService.forgotPassword(mailData: MailData<{ hash: string; firstName?: string | null }>): Promise<void>`
|
||||||
|
- Beide senden über `this.mailerService.sendMail({ to, subject, text, template, context })` mit
|
||||||
|
`context` immer inkl. der Keys `title`, `year`, `firstName`, `url`, `actionTitle` (Task 3
|
||||||
|
Templates lesen genau diese Keys).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Failing Test schreiben**
|
||||||
|
|
||||||
|
Datei `myteamwallet_backend/src/mail/mail.service.spec.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Test laufen lassen, erwartetes Scheitern bestätigen**
|
||||||
|
|
||||||
|
Run: `cd myteamwallet_backend && npx jest mail.service.spec.ts`
|
||||||
|
Expected: FAIL — `MailService` erwartet aktuell drei Constructor-Parameter (`I18n`,
|
||||||
|
`MailerService`, `ConfigService`), der Test übergibt nur zwei, und selbst bei passendem Aufruf
|
||||||
|
würde `sendMail` wegen des toten `return;` nie aufgerufen. Der Test schlägt fehl (z. B.
|
||||||
|
`expect(sendMail).toHaveBeenCalledTimes(1)` erhält `0`, oder ein `TypeError` beim Zugriff auf
|
||||||
|
`this.i18n.t`).
|
||||||
|
|
||||||
|
- [ ] **Step 3: `mail.service.ts` neu implementieren**
|
||||||
|
|
||||||
|
Datei `myteamwallet_backend/src/mail/mail.service.ts` komplett ersetzen:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { MailerService } from '@nestjs-modules/mailer';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { MailData } from './interfaces/mail-data.interface';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MailService {
|
||||||
|
constructor(
|
||||||
|
private mailerService: MailerService,
|
||||||
|
private configService: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
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: 'Bestätige deine E-Mail-Adresse',
|
||||||
|
text: `${url} ${actionTitle}`,
|
||||||
|
template: 'activation',
|
||||||
|
context: {
|
||||||
|
title: 'Bestätige deine E-Mail-Adresse',
|
||||||
|
year: new Date().getFullYear(),
|
||||||
|
firstName: mailData.data.firstName,
|
||||||
|
url,
|
||||||
|
actionTitle,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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: actionTitle,
|
||||||
|
text: `${url} ${actionTitle}`,
|
||||||
|
template: 'reset-password',
|
||||||
|
context: {
|
||||||
|
title: actionTitle,
|
||||||
|
year: new Date().getFullYear(),
|
||||||
|
firstName: mailData.data.firstName,
|
||||||
|
url,
|
||||||
|
actionTitle,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Test laufen lassen, Erfolg bestätigen**
|
||||||
|
|
||||||
|
Run: `cd myteamwallet_backend && npx jest mail.service.spec.ts`
|
||||||
|
Expected: PASS — alle 3 Tests grün.
|
||||||
|
|
||||||
|
- [ ] **Step 5: `auth.service.ts` — Vornamen mitgeben**
|
||||||
|
|
||||||
|
In `myteamwallet_backend/src/auth/auth.service.ts`, die zwei bestehenden Aufrufstellen anpassen
|
||||||
|
(nur die `data`-Objekte erweitern, sonst nichts ändern):
|
||||||
|
|
||||||
|
Zeilen 191-196 (in `register()`, `user` ist zu diesem Zeitpunkt bereits erstellt):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
await this.mailService.userSignUp({
|
||||||
|
to: user.email,
|
||||||
|
data: {
|
||||||
|
hash,
|
||||||
|
firstName: user.firstName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Zeilen 246-251 (in `forgotPassword()`, im `else`-Zweig nach `user` lookup):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
await this.mailService.forgotPassword({
|
||||||
|
to: email,
|
||||||
|
data: {
|
||||||
|
hash,
|
||||||
|
firstName: user.firstName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: TypeScript-Build prüfen**
|
||||||
|
|
||||||
|
Run: `cd myteamwallet_backend && npm run build`
|
||||||
|
Expected: Build erfolgreich, keine Type-Fehler (insbesondere keine Fehler zu `firstName` an den
|
||||||
|
beiden Aufrufstellen).
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add myteamwallet_backend/src/mail/mail.service.ts myteamwallet_backend/src/mail/mail.service.spec.ts myteamwallet_backend/src/auth/auth.service.ts
|
||||||
|
git commit -m "fix(mail): remove dead return before sendMail, add firstName personalization"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: `nestjs-i18n` vollständig entfernen
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `myteamwallet_backend/src/app.module.ts`
|
||||||
|
- Modify: `myteamwallet_backend/src/config/app.config.ts`
|
||||||
|
- Delete: `myteamwallet_backend/src/i18n/` (kompletter Ordner: `en/common.json`,
|
||||||
|
`en/confirm-email.json`, `en/reset-password.json`)
|
||||||
|
- Modify: `myteamwallet_backend/package.json`, `myteamwallet_backend/package-lock.json`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: nichts aus Task 1.
|
||||||
|
- Produces: keine neuen Symbole — reine Entfernung. Spätere Tasks verlassen sich nicht auf
|
||||||
|
`nestjs-i18n`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: `src/i18n/` Ordner löschen**
|
||||||
|
|
||||||
|
Run: `cd myteamwallet_backend && rm -rf src/i18n`
|
||||||
|
|
||||||
|
- [ ] **Step 2: `app.module.ts` bereinigen**
|
||||||
|
|
||||||
|
In `myteamwallet_backend/src/app.module.ts`:
|
||||||
|
|
||||||
|
Import-Block (Zeilen 1-30) — `import * as path from 'path';` (Zeile 9), `import { I18nModule } from 'nestjs-i18n/dist/i18n.module';` (Zeile 13) und `import { HeaderResolver } from 'nestjs-i18n';` (Zeile 14) entfernen; `import { ConfigModule, ConfigService } from '@nestjs/config';` (Zeile 11) zu `import { ConfigModule } from '@nestjs/config';` ändern (kein anderer Verbraucher von `ConfigService` mehr in dieser Datei). Ergebnis:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { UsersModule } from './users/users.module';
|
||||||
|
import { AuthModule } from './auth/auth.module';
|
||||||
|
import databaseConfig from './config/database.config';
|
||||||
|
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 { MailerModule } from '@nestjs-modules/mailer';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { TypeOrmConfigService } from './database/typeorm-config.service';
|
||||||
|
import { MailConfigService } from './mail/mail-config.service';
|
||||||
|
import { ForgotModule } from './forgot/forgot.module';
|
||||||
|
import { MailModule } from './mail/mail.module';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { PlayersModule } from './players/players.module';
|
||||||
|
import { TeamsModule } from './teams/teams.module';
|
||||||
|
import { TransactionsModule } from './transactions/transactions.module';
|
||||||
|
import { TeamSettingsModule } from './team-settings/team-settings.module';
|
||||||
|
import { TeamWalletTransactionsModule } from './team-wallet-transactions/team-wallet-transactions.module';
|
||||||
|
import { ServeStaticModule } from '@nestjs/serve-static';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { LoggingModule } from './database/logging/logging.module';
|
||||||
|
import { TranslateModule } from './translate/translate.module';
|
||||||
|
import { PenaltyModule } from './penalty/penalty.module';
|
||||||
|
```
|
||||||
|
|
||||||
|
Im `@Module({ imports: [...] })` Array den kompletten `I18nModule.forRootAsync({...})` Block
|
||||||
|
(bisher direkt nach `MailerModule.forRootAsync({...})`) entfernen, sodass `MailerModule` direkt
|
||||||
|
von `ServeStaticModule` gefolgt wird:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
MailerModule.forRootAsync({
|
||||||
|
useClass: MailConfigService,
|
||||||
|
}),
|
||||||
|
ServeStaticModule.forRoot({
|
||||||
|
rootPath: join(__dirname, '../client'),
|
||||||
|
exclude: ['*/api*'],
|
||||||
|
}),
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: `app.config.ts` bereinigen**
|
||||||
|
|
||||||
|
`myteamwallet_backend/src/config/app.config.ts` — Zeilen `fallbackLanguage` und
|
||||||
|
`headerLanguage` entfernen (waren ausschließlich für `I18nModule` gedacht, kein anderer
|
||||||
|
Konsument im Code):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { registerAs } from '@nestjs/config';
|
||||||
|
|
||||||
|
export default registerAs('app', () => ({
|
||||||
|
nodeEnv: process.env.NODE_ENV,
|
||||||
|
name: process.env.APP_NAME,
|
||||||
|
workingDirectory: process.env.PWD || process.cwd(),
|
||||||
|
frontendDomain: process.env.FRONTEND_DOMAIN,
|
||||||
|
backendDomain: process.env.BACKEND_DOMAIN,
|
||||||
|
port: parseInt(process.env.APP_PORT || process.env.PORT, 10) || 3000,
|
||||||
|
apiPrefix: process.env.API_PREFIX || 'api',
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Dependency entfernen**
|
||||||
|
|
||||||
|
Run: `cd myteamwallet_backend && npm uninstall nestjs-i18n`
|
||||||
|
Expected: `package.json` und `package-lock.json` werden automatisch aktualisiert (Eintrag
|
||||||
|
`"nestjs-i18n": "9.2.2"` verschwindet aus `dependencies`).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Keine verbleibenden Referenzen prüfen**
|
||||||
|
|
||||||
|
Run: `cd myteamwallet_backend && grep -rn "nestjs-i18n\|I18nModule\|HeaderResolver\|I18nRequestScopeService" src/`
|
||||||
|
Expected: keine Treffer (leere Ausgabe).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Build prüfen**
|
||||||
|
|
||||||
|
Run: `cd myteamwallet_backend && npm run build`
|
||||||
|
Expected: Build erfolgreich, keine Fehler zu fehlenden `nestjs-i18n`-Imports oder unbenutzten
|
||||||
|
Imports.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Bestehende Unit-Tests laufen lassen**
|
||||||
|
|
||||||
|
Run: `cd myteamwallet_backend && npm test`
|
||||||
|
Expected: alle Tests grün, inkl. der in Task 1 hinzugefügten `mail.service.spec.ts`.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add myteamwallet_backend/src/app.module.ts myteamwallet_backend/src/config/app.config.ts myteamwallet_backend/package.json myteamwallet_backend/package-lock.json
|
||||||
|
git status
|
||||||
|
git add myteamwallet_backend/src/i18n
|
||||||
|
git commit -m "chore(mail): remove nestjs-i18n, only ever used for the two mail templates"
|
||||||
|
```
|
||||||
|
|
||||||
|
(Der zweite `git add` erfasst die Löschung von `src/i18n/*` — je nach Git-Version reicht auch
|
||||||
|
ein einzelnes `git add -A myteamwallet_backend/src/i18n myteamwallet_backend/src/app.module.ts myteamwallet_backend/src/config/app.config.ts myteamwallet_backend/package.json myteamwallet_backend/package-lock.json`.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: E-Mail-Templates neu gestalten
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `myteamwallet_backend/src/mail/mail-config.service.ts`
|
||||||
|
- Create: `myteamwallet_backend/src/mail/mail-templates/partials/layout.hbs`
|
||||||
|
- Modify: `myteamwallet_backend/src/mail/mail-templates/activation.hbs`
|
||||||
|
- Modify: `myteamwallet_backend/src/mail/mail-templates/reset-password.hbs`
|
||||||
|
- Test: `myteamwallet_backend/src/mail/mail-templates/mail-templates.spec.ts` (neu)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: Context-Keys aus Task 1 — `title`, `year`, `firstName`, `url`, `actionTitle`.
|
||||||
|
- Produces: registriertes Handlebars-Partial `layout` (Name = Dateiname ohne Endung, da es
|
||||||
|
direkt in `partials/` liegt), eingebunden via `{{#> layout}} ... {{/layout}}` in beiden
|
||||||
|
Content-Templates.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Failing Test schreiben**
|
||||||
|
|
||||||
|
Datei `myteamwallet_backend/src/mail/mail-templates/mail-templates.spec.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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,');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Test laufen lassen, erwartetes Scheitern bestätigen**
|
||||||
|
|
||||||
|
Run: `cd myteamwallet_backend && npx jest mail-templates.spec.ts`
|
||||||
|
Expected: FAIL — `partials/layout.hbs` existiert noch nicht (`ENOENT`), bzw. die bestehenden
|
||||||
|
`activation.hbs`/`reset-password.hbs` enthalten weder „TeamWallet" noch „Hallo Max,".
|
||||||
|
|
||||||
|
- [ ] **Step 3: Layout-Partial erstellen**
|
||||||
|
|
||||||
|
Datei `myteamwallet_backend/src/mail/mail-templates/partials/layout.hbs`:
|
||||||
|
|
||||||
|
```handlebars
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{title}}</title>
|
||||||
|
<style>
|
||||||
|
body { margin: 0; padding: 0; background: #f4f6f4; font-family: Roboto, Helvetica, Arial, sans-serif; }
|
||||||
|
.tw-container { max-width: 600px; margin: 0 auto; padding: 32px 16px; }
|
||||||
|
.tw-card { background: #ffffff; border-radius: 12px; overflow: hidden; box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08); }
|
||||||
|
.tw-header { background: #2e7d32; padding: 28px 32px; text-align: center; }
|
||||||
|
.tw-wordmark { color: #ffffff; font-size: 22px; font-weight: 700; letter-spacing: 0.5px; }
|
||||||
|
.tw-body { padding: 32px; color: #20251f; font-size: 15px; line-height: 1.6; }
|
||||||
|
.tw-body p { margin: 0 0 16px; }
|
||||||
|
.tw-button-row { text-align: center; padding: 8px 0 24px; }
|
||||||
|
.tw-button { display: inline-block; background: #2e7d32; color: #ffffff !important; text-decoration: none; padding: 14px 32px; border-radius: 8px; font-weight: 600; font-size: 15px; }
|
||||||
|
.tw-footer { text-align: center; padding: 20px 16px 0; color: #8a938a; font-size: 12px; line-height: 1.6; }
|
||||||
|
.tw-footer a { color: #8a938a; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="tw-container">
|
||||||
|
<div class="tw-card">
|
||||||
|
<div class="tw-header">
|
||||||
|
<span class="tw-wordmark">TeamWallet</span>
|
||||||
|
</div>
|
||||||
|
<div class="tw-body">
|
||||||
|
{{> @partial-block }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tw-footer">
|
||||||
|
<p>Diese E-Mail wurde automatisch von TeamWallet verschickt.<br>© {{year}} TeamWallet</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: `activation.hbs` neu gestalten**
|
||||||
|
|
||||||
|
Datei `myteamwallet_backend/src/mail/mail-templates/activation.hbs` komplett ersetzen:
|
||||||
|
|
||||||
|
```handlebars
|
||||||
|
{{#> layout}}
|
||||||
|
<p>Hallo{{#if firstName}} {{firstName}}{{/if}},</p>
|
||||||
|
<p>schön, dass du bei TeamWallet dabei bist! Bestätige deine E-Mail-Adresse, um dein Konto zu aktivieren.</p>
|
||||||
|
<div class="tw-button-row">
|
||||||
|
<a class="tw-button" href="{{url}}">{{actionTitle}}</a>
|
||||||
|
</div>
|
||||||
|
<p>Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser:<br><a href="{{url}}">{{url}}</a></p>
|
||||||
|
{{/layout}}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: `reset-password.hbs` neu gestalten**
|
||||||
|
|
||||||
|
Datei `myteamwallet_backend/src/mail/mail-templates/reset-password.hbs` komplett ersetzen:
|
||||||
|
|
||||||
|
```handlebars
|
||||||
|
{{#> layout}}
|
||||||
|
<p>Hallo{{#if firstName}} {{firstName}}{{/if}},</p>
|
||||||
|
<p>du hast angefragt, dein TeamWallet-Passwort zurückzusetzen. Klicke auf den Button, um ein neues Passwort zu vergeben.</p>
|
||||||
|
<div class="tw-button-row">
|
||||||
|
<a class="tw-button" href="{{url}}">{{actionTitle}}</a>
|
||||||
|
</div>
|
||||||
|
<p>Falls du diese Anfrage nicht gestellt hast, kannst du diese E-Mail einfach ignorieren — es wird nichts verändert.</p>
|
||||||
|
<p>Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser:<br><a href="{{url}}">{{url}}</a></p>
|
||||||
|
{{/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).
|
||||||
@@ -2,8 +2,6 @@ NODE_ENV=development
|
|||||||
APP_PORT=3000
|
APP_PORT=3000
|
||||||
APP_NAME="NestJS API"
|
APP_NAME="NestJS API"
|
||||||
API_PREFIX=api
|
API_PREFIX=api
|
||||||
APP_FALLBACK_LANGUAGE=en
|
|
||||||
APP_HEADER_LANGUAGE=x-custom-lang
|
|
||||||
FRONTEND_DOMAIN=http://localhost:3000
|
FRONTEND_DOMAIN=http://localhost:3000
|
||||||
BACKEND_DOMAIN=http://localhost:3000
|
BACKEND_DOMAIN=http://localhost:3000
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ Seeden: npm run seed:run
|
|||||||
- [x] Sign in and sign up via email.
|
- [x] Sign in and sign up via email.
|
||||||
- [x] Social sign in (Apple, Facebook, Google, Twitter).
|
- [x] Social sign in (Apple, Facebook, Google, Twitter).
|
||||||
- [x] Admin and User roles.
|
- [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] File uploads. Support local and Amazon S3 drivers.
|
||||||
- [x] Swagger.
|
- [x] Swagger.
|
||||||
- [x] E2E and units tests.
|
- [x] E2E and units tests.
|
||||||
|
|||||||
84
myteamwallet_backend/package-lock.json
generated
84
myteamwallet_backend/package-lock.json
generated
@@ -30,7 +30,6 @@
|
|||||||
"multer": "1.4.4",
|
"multer": "1.4.4",
|
||||||
"multer-s3": "2.10.0",
|
"multer-s3": "2.10.0",
|
||||||
"mysql2": "^2.3.3",
|
"mysql2": "^2.3.3",
|
||||||
"nestjs-i18n": "9.2.2",
|
|
||||||
"nodemailer": "6.8.0",
|
"nodemailer": "6.8.0",
|
||||||
"passport": "0.6.0",
|
"passport": "0.6.0",
|
||||||
"passport-anonymous": "1.0.1",
|
"passport-anonymous": "1.0.1",
|
||||||
@@ -4587,11 +4586,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
|
||||||
"integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q=="
|
"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": {
|
"node_modules/accepts": {
|
||||||
"version": "1.3.8",
|
"version": "1.3.8",
|
||||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
|
||||||
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="
|
"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": {
|
"node_modules/netmask": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz",
|
||||||
"integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ="
|
"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": {
|
"node_modules/string-length": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
|
||||||
"integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q=="
|
"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": {
|
"accepts": {
|
||||||
"version": "1.3.8",
|
"version": "1.3.8",
|
||||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
|
||||||
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="
|
"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": {
|
"netmask": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz",
|
||||||
"integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ="
|
"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": {
|
"string-length": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz",
|
||||||
|
|||||||
@@ -49,7 +49,6 @@
|
|||||||
"multer": "1.4.4",
|
"multer": "1.4.4",
|
||||||
"multer-s3": "2.10.0",
|
"multer-s3": "2.10.0",
|
||||||
"mysql2": "^2.3.3",
|
"mysql2": "^2.3.3",
|
||||||
"nestjs-i18n": "9.2.2",
|
|
||||||
"nodemailer": "6.8.0",
|
"nodemailer": "6.8.0",
|
||||||
"passport": "0.6.0",
|
"passport": "0.6.0",
|
||||||
"passport-anonymous": "1.0.1",
|
"passport-anonymous": "1.0.1",
|
||||||
|
|||||||
@@ -6,12 +6,9 @@ import authConfig from './config/auth.config';
|
|||||||
import appConfig from './config/app.config';
|
import appConfig from './config/app.config';
|
||||||
import mailConfig from './config/mail.config';
|
import mailConfig from './config/mail.config';
|
||||||
import fileConfig from './config/file.config';
|
import fileConfig from './config/file.config';
|
||||||
import * as path from 'path';
|
|
||||||
import { MailerModule } from '@nestjs-modules/mailer';
|
import { MailerModule } from '@nestjs-modules/mailer';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
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 { TypeOrmConfigService } from './database/typeorm-config.service';
|
||||||
import { MailConfigService } from './mail/mail-config.service';
|
import { MailConfigService } from './mail/mail-config.service';
|
||||||
import { ForgotModule } from './forgot/forgot.module';
|
import { ForgotModule } from './forgot/forgot.module';
|
||||||
@@ -45,23 +42,6 @@ import { PenaltyModule } from './penalty/penalty.module';
|
|||||||
MailerModule.forRootAsync({
|
MailerModule.forRootAsync({
|
||||||
useClass: MailConfigService,
|
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({
|
ServeStaticModule.forRoot({
|
||||||
rootPath: join(__dirname, '../client'),
|
rootPath: join(__dirname, '../client'),
|
||||||
exclude: ['*/api*'],
|
exclude: ['*/api*'],
|
||||||
|
|||||||
@@ -192,6 +192,7 @@ export class AuthService {
|
|||||||
to: user.email,
|
to: user.email,
|
||||||
data: {
|
data: {
|
||||||
hash,
|
hash,
|
||||||
|
firstName: user.firstName,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -247,6 +248,7 @@ export class AuthService {
|
|||||||
to: email,
|
to: email,
|
||||||
data: {
|
data: {
|
||||||
hash,
|
hash,
|
||||||
|
firstName: user.firstName,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,4 @@ export default registerAs('app', () => ({
|
|||||||
backendDomain: process.env.BACKEND_DOMAIN,
|
backendDomain: process.env.BACKEND_DOMAIN,
|
||||||
port: parseInt(process.env.APP_PORT || process.env.PORT, 10) || 3000,
|
port: parseInt(process.env.APP_PORT || process.env.PORT, 10) || 3000,
|
||||||
apiPrefix: process.env.API_PREFIX || 'api',
|
apiPrefix: process.env.API_PREFIX || 'api',
|
||||||
fallbackLanguage: process.env.APP_FALLBACK_LANGUAGE || 'en',
|
|
||||||
headerLanguage: process.env.APP_HEADER_LANGUAGE || 'x-custom-lang',
|
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"confirmEmail": "Confirm email",
|
|
||||||
"resetPassword": "Reset password"
|
|
||||||
}
|
|
||||||
@@ -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."
|
|
||||||
}
|
|
||||||
@@ -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."
|
|
||||||
}
|
|
||||||
56
myteamwallet_backend/src/mail/mail-config.service.spec.ts
Normal file
56
myteamwallet_backend/src/mail/mail-config.service.spec.ts
Normal 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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as handlebars from 'handlebars';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { MailerOptions, MailerOptionsFactory } from '@nestjs-modules/mailer';
|
import { MailerOptions, MailerOptionsFactory } from '@nestjs-modules/mailer';
|
||||||
@@ -9,6 +11,21 @@ export class MailConfigService implements MailerOptionsFactory {
|
|||||||
constructor(private configService: ConfigService) {}
|
constructor(private configService: ConfigService) {}
|
||||||
|
|
||||||
createMailerOptions(): MailerOptions {
|
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 {
|
return {
|
||||||
transport: {
|
transport: {
|
||||||
host: this.configService.get('mail.host'),
|
host: this.configService.get('mail.host'),
|
||||||
@@ -27,12 +44,7 @@ export class MailConfigService implements MailerOptionsFactory {
|
|||||||
)}" <${this.configService.get('mail.defaultEmail')}>`,
|
)}" <${this.configService.get('mail.defaultEmail')}>`,
|
||||||
},
|
},
|
||||||
template: {
|
template: {
|
||||||
dir: path.join(
|
dir: templatesDir,
|
||||||
this.configService.get('app.workingDirectory'),
|
|
||||||
'src',
|
|
||||||
'mail',
|
|
||||||
'mail-templates',
|
|
||||||
),
|
|
||||||
adapter: new HandlebarsAdapter(),
|
adapter: new HandlebarsAdapter(),
|
||||||
options: {
|
options: {
|
||||||
strict: true,
|
strict: true,
|
||||||
|
|||||||
@@ -1,33 +1,8 @@
|
|||||||
<!DOCTYPE html>
|
{{#> layout}}
|
||||||
<html lang="en">
|
<p>Hallo{{#if firstName}} {{firstName}}{{/if}},</p>
|
||||||
|
<p>schön, dass du bei TeamWallet dabei bist! Bestätige deine E-Mail-Adresse, um dein Konto zu aktivieren.</p>
|
||||||
<head>
|
<div class="tw-button-row">
|
||||||
<meta charset="UTF-8">
|
<a class="tw-button" href="{{url}}">{{actionTitle}}</a>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=">
|
</div>
|
||||||
<title>{{title}}</title>
|
<p>Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser:<br><a href="{{url}}">{{url}}</a></p>
|
||||||
</head>
|
{{/layout}}
|
||||||
|
|
||||||
<body style="margin:0;font-family:arial">
|
|
||||||
<table style="border:0;width:100%">
|
|
||||||
<tr style="background:#eeeeee">
|
|
||||||
<td style="padding:20px;color:#808080;text-align:center;font-size:40px;font-weight:600">
|
|
||||||
{{app_name}}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding:20px;color:#808080;font-size:16px;font-weight:100">
|
|
||||||
{{text1}}<br>
|
|
||||||
{{text2}} {{app_name}}.<br>
|
|
||||||
{{text3}}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="text-align:center">
|
|
||||||
<a href="{{url}}"
|
|
||||||
style="display:inline-block;padding:20px;background:#00838f;text-decoration:none;color:#ffffff">{{actionTitle}}</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
|
|||||||
@@ -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,');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{title}}</title>
|
||||||
|
<style>
|
||||||
|
body { margin: 0; padding: 0; background: #f4f6f4; font-family: Roboto, Helvetica, Arial, sans-serif; }
|
||||||
|
.tw-container { max-width: 600px; margin: 0 auto; padding: 32px 16px; }
|
||||||
|
.tw-card { background: #ffffff; border-radius: 12px; overflow: hidden; box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08); }
|
||||||
|
.tw-header { background: #2e7d32; padding: 28px 32px; text-align: center; }
|
||||||
|
.tw-wordmark { color: #ffffff; font-size: 22px; font-weight: 700; letter-spacing: 0.5px; }
|
||||||
|
.tw-body { padding: 32px; color: #20251f; font-size: 15px; line-height: 1.6; }
|
||||||
|
.tw-body p { margin: 0 0 16px; }
|
||||||
|
.tw-button-row { text-align: center; padding: 8px 0 24px; }
|
||||||
|
.tw-button { display: inline-block; background: #2e7d32; color: #ffffff !important; text-decoration: none; padding: 14px 32px; border-radius: 8px; font-weight: 600; font-size: 15px; }
|
||||||
|
.tw-footer { text-align: center; padding: 20px 16px 0; color: #8a938a; font-size: 12px; line-height: 1.6; }
|
||||||
|
.tw-footer a { color: #8a938a; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="tw-container">
|
||||||
|
<div class="tw-card">
|
||||||
|
<div class="tw-header">
|
||||||
|
<span class="tw-wordmark">TeamWallet</span>
|
||||||
|
</div>
|
||||||
|
<div class="tw-body">
|
||||||
|
{{> @partial-block }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tw-footer">
|
||||||
|
<p>Diese E-Mail wurde automatisch von TeamWallet verschickt.<br>© {{year}} TeamWallet</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,38 +1,9 @@
|
|||||||
<!DOCTYPE html>
|
{{#> layout}}
|
||||||
<html lang="en">
|
<p>Hallo{{#if firstName}} {{firstName}}{{/if}},</p>
|
||||||
|
<p>du hast angefragt, dein TeamWallet-Passwort zurückzusetzen. Klicke auf den Button, um ein neues Passwort zu vergeben.</p>
|
||||||
<head>
|
<div class="tw-button-row">
|
||||||
<meta charset="UTF-8">
|
<a class="tw-button" href="{{url}}">{{actionTitle}}</a>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=">
|
</div>
|
||||||
<title>{{title}}</title>
|
<p>Falls du diese Anfrage nicht gestellt hast, kannst du diese E-Mail einfach ignorieren — es wird nichts verändert.</p>
|
||||||
</head>
|
<p>Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser:<br><a href="{{url}}">{{url}}</a></p>
|
||||||
|
{{/layout}}
|
||||||
<body style="margin:0;font-family:arial">
|
|
||||||
<table style="border:0;width:100%">
|
|
||||||
<tr style="background:#eeeeee">
|
|
||||||
<td style="padding:20px;color:#808080;text-align:center;font-size:40px;font-weight:600">
|
|
||||||
{{app_name}}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding:20px;color:#808080;font-size:16px;font-weight:100">
|
|
||||||
{{text1}}<br>
|
|
||||||
{{text2}}<br>
|
|
||||||
{{text3}}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="text-align:center">
|
|
||||||
<a href="{{url}}"
|
|
||||||
style="display:inline-block;padding:20px;background:#00838f;text-decoration:none;color:#ffffff">{{actionTitle}}</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding:20px;color:#808080;font-size:16px;font-weight:100">
|
|
||||||
{{text4}}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
|
|||||||
61
myteamwallet_backend/src/mail/mail.service.spec.ts
Normal file
61
myteamwallet_backend/src/mail/mail.service.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,61 +1,57 @@
|
|||||||
import { MailerService } from '@nestjs-modules/mailer';
|
import { MailerService } from '@nestjs-modules/mailer';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { I18n, I18nRequestScopeService } from 'nestjs-i18n';
|
|
||||||
import { MailData } from './interfaces/mail-data.interface';
|
import { MailData } from './interfaces/mail-data.interface';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MailService {
|
export class MailService {
|
||||||
constructor(
|
constructor(
|
||||||
@I18n()
|
|
||||||
private i18n: I18nRequestScopeService,
|
|
||||||
private mailerService: MailerService,
|
private mailerService: MailerService,
|
||||||
private configService: ConfigService,
|
private configService: ConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async userSignUp(mailData: MailData<{ hash: string }>) {
|
async userSignUp(
|
||||||
return;
|
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({
|
await this.mailerService.sendMail({
|
||||||
to: mailData.to,
|
to: mailData.to,
|
||||||
subject: await this.i18n.t('common.confirmEmail'),
|
subject: 'Bestätige deine E-Mail-Adresse',
|
||||||
text: `${this.configService.get('app.frontendDomain')}/confirm-email/${
|
text: `${url} ${actionTitle}`,
|
||||||
mailData.data.hash
|
|
||||||
} ${await this.i18n.t('common.confirmEmail')}`,
|
|
||||||
template: 'activation',
|
template: 'activation',
|
||||||
context: {
|
context: {
|
||||||
title: await this.i18n.t('common.confirmEmail'),
|
title: 'Bestätige deine E-Mail-Adresse',
|
||||||
url: `${this.configService.get('app.frontendDomain')}/confirm-email/${
|
year: new Date().getFullYear(),
|
||||||
mailData.data.hash
|
firstName: mailData.data.firstName,
|
||||||
}`,
|
url,
|
||||||
actionTitle: await this.i18n.t('common.confirmEmail'),
|
actionTitle,
|
||||||
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'),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async forgotPassword(mailData: MailData<{ hash: string }>) {
|
async forgotPassword(
|
||||||
return;
|
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({
|
await this.mailerService.sendMail({
|
||||||
to: mailData.to,
|
to: mailData.to,
|
||||||
subject: await this.i18n.t('common.resetPassword'),
|
subject: actionTitle,
|
||||||
text: `${this.configService.get('app.frontendDomain')}/password-change/${
|
text: `${url} ${actionTitle}`,
|
||||||
mailData.data.hash
|
|
||||||
} ${await this.i18n.t('common.resetPassword')}`,
|
|
||||||
template: 'reset-password',
|
template: 'reset-password',
|
||||||
context: {
|
context: {
|
||||||
title: await this.i18n.t('common.resetPassword'),
|
title: actionTitle,
|
||||||
url: `${this.configService.get('app.frontendDomain')}/password-change/${
|
year: new Date().getFullYear(),
|
||||||
mailData.data.hash
|
firstName: mailData.data.firstName,
|
||||||
}`,
|
url,
|
||||||
actionTitle: await this.i18n.t('common.resetPassword'),
|
actionTitle,
|
||||||
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'),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user