From da76f8dc96090598cef60459d16c7753c6f17f0f Mon Sep 17 00:00:00 2001 From: linus Date: Thu, 10 Sep 2026 08:17:53 +0200 Subject: [PATCH] feat(backend): mail module + send personal Gemeinde-Teamer invites New global mail/ module mirroring the files/storage/ provider pattern: - MailProvider abstraction; default LogMailProvider only logs (no delivery), MAIL_PROVIDER=smtp switches to a nodemailer SMTP transport (SMTP_*, MAIL_FROM). - MailService.sendTeamerInvite() composes the invite email with a link built from APP_BASE_URL. TeamerService.createInvite() now mails personal invites (those with an email) best-effort and returns `emailSent`; group links are unchanged. Delivery failures are logged and swallowed, never blocking invite creation. New env: APP_BASE_URL, MAIL_PROVIDER, MAIL_FROM, SMTP_HOST/PORT/SECURE/ USER/PASS. Tests: teamer spec covers mail-on-personal-invite, no-mail-on-group-link, and transport-drop; npm test green at 56. Docs updated. Co-Authored-By: Claude Sonnet 5 --- .env.example | 14 ++++++++++ README.md | 22 ++++++++++----- package-lock.json | 21 +++++++++++++++ package.json | 2 ++ src/app.module.ts | 2 ++ src/mail/log-mail.provider.ts | 15 +++++++++++ src/mail/mail-provider.ts | 18 +++++++++++++ src/mail/mail.module.ts | 25 +++++++++++++++++ src/mail/mail.service.ts | 43 +++++++++++++++++++++++++++++ src/mail/smtp-mail.provider.ts | 45 +++++++++++++++++++++++++++++++ src/teamer/teamer.service.spec.ts | 28 ++++++++++++++----- src/teamer/teamer.service.ts | 21 ++++++++++++++- 12 files changed, 243 insertions(+), 13 deletions(-) create mode 100644 src/mail/log-mail.provider.ts create mode 100644 src/mail/mail-provider.ts create mode 100644 src/mail/mail.module.ts create mode 100644 src/mail/mail.service.ts create mode 100644 src/mail/smtp-mail.provider.ts diff --git a/.env.example b/.env.example index 668d845..de0a625 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,20 @@ TEAM_JWT_SECRET="change-me-too" PORT=3000 +# Public base URL of the app, used to build links in outgoing emails. +APP_BASE_URL="http://localhost:3000" + +# Email: defaults to "log" (writes what it would send to the log, no +# delivery). Set MAIL_PROVIDER=smtp plus the SMTP_* vars + MAIL_FROM to +# actually send Gemeinde-Teamer invite emails. +MAIL_PROVIDER="log" +MAIL_FROM="KC-App " +SMTP_HOST="smtp.example.org" +SMTP_PORT=587 +SMTP_SECURE="false" +SMTP_USER="" +SMTP_PASS="" + # File storage: defaults to Nextcloud via WebDAV; set STORAGE_PROVIDER=s3 to # use an S3-compatible bucket instead (see S3_* vars below). STORAGE_PROVIDER="webdav" diff --git a/README.md b/README.md index 64a605f..a81bbe0 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,8 @@ architecture context). ```bash npm install -cp .env.example .env # DATABASE_URL / AUTHENTIK_ISSUER_URL / AUTHENTIK_LEITUNGSTEAM_GROUP / GUEST_JWT_SECRET / TEAM_JWT_SECRET +cp .env.example .env # DATABASE_URL / AUTHENTIK_ISSUER_URL / AUTHENTIK_LEITUNGSTEAM_GROUP / + # GUEST_JWT_SECRET / TEAM_JWT_SECRET / APP_BASE_URL (+ MAIL_* for real email) npx prisma generate npx prisma migrate dev --name init # requires a running PostgreSQL instance npm run start:dev @@ -64,7 +65,9 @@ client's host - no separate web server is needed. `DELETE teamer-invites/:inviteId`. Callable by Leitungsteam (any Gemeinde) or a Verantwortliche/r for their own Gemeinde (enforced in `TeamerService`, since `RolesGuard` only scopes by `kcId`). Files/chat read endpoints accept - `'team'` tokens too, so Teamer see non-Konfi files and chat. + `'team'` tokens too, so Teamer see non-Konfi files and chat. A personal + invite (with `email`) is mailed via `MailService`; the response carries + `emailSent`. Group-link invites (no `email`) are shared by hand. - `onboarding/` — self-registration for Gemeinde Verantwortliche. `GET /onboarding/kc/:inviteCode` (public) returns the KC name + its Gemeinden to pick from. `POST /onboarding/verantwortliche` takes the @@ -74,6 +77,12 @@ client's host - no separate web server is needed. `GET /onboarding/requests?kcId=` and `POST /onboarding/requests/:id/approve` or `.../reject`. Auth strategies only load `ACTIVE` memberships, so a pending request grants nothing until approved. +- `mail/` — global `MailProvider` abstraction (mirrors `files/storage/`): + default `log` provider only logs what it would send; `MAIL_PROVIDER=smtp` + uses a real `nodemailer` SMTP transport (`SMTP_*`, `MAIL_FROM`). + `MailService.sendTeamerInvite()` composes the personal-invite email with a + link built from `APP_BASE_URL`. Delivery is best-effort — failures are + logged and swallowed, never blocking the invite. - `wahl/` — Wahl/Workshop administration (Leitungsteam-only), guest Teilnehmer submission, Force-Zuteilung overrides, and `ZuteilungService`: a faithful port of the WP plugin's `kc_run_zuteilung` (force-assignments → @@ -113,7 +122,8 @@ client's host - no separate web server is needed. All planned backend phases are implemented. `npm test` runs Jest unit tests (`ZuteilungService`, `TeamAuthService`, `TeamerService`, `OnboardingService`, `resolveOrProvisionAuthentikUser` / `toAuthenticatedUser`; Prisma mocked). -Remaining work: the Flutter clients (see repo root README), invite email -delivery, push notifications, and the first real Prisma migration (only -`schema.prisma` exists so far). Ops note: the Authentik provider must emit a -`groups` claim in the access token for the LT check to work. +Remaining work: the Flutter clients (see repo root README), push +notifications, and the first real Prisma migration (only `schema.prisma` +exists so far). Ops notes: the Authentik provider must emit a `groups` claim +for the LT check, and `MAIL_PROVIDER=smtp` + `SMTP_*` must be set for invite +emails to actually leave the box. diff --git a/package-lock.json b/package-lock.json index 5b3c298..25d18a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "jsonwebtoken": "^9.0.2", "jwks-rsa": "^3.1.0", "multer": "^2.0.1", + "nodemailer": "^7.0.13", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "reflect-metadata": "^0.2.2", @@ -44,6 +45,7 @@ "@types/jsonwebtoken": "^9.0.7", "@types/multer": "^1.4.12", "@types/node": "^20.17.9", + "@types/nodemailer": "^6.4.24", "@types/passport": "^1.0.17", "@types/passport-jwt": "^4.0.1", "@types/supertest": "^6.0.2", @@ -2985,6 +2987,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/nodemailer": { + "version": "6.4.24", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.24.tgz", + "integrity": "sha512-Ww4u0rT9wQNXh4JiQaIwx3QWdcOFXzOjQA2zc+jtFYNmQiT4mIUqcDin51bDFdkzKubFnQCZNK7FIHlPKQ/q9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/passport": { "version": "1.0.17", "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz", @@ -8374,6 +8386,15 @@ "node": ">=18" } }, + "node_modules/nodemailer": { + "version": "7.0.13", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz", + "integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", diff --git a/package.json b/package.json index 61c95b8..0f9caf5 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "jsonwebtoken": "^9.0.2", "jwks-rsa": "^3.1.0", "multer": "^2.0.1", + "nodemailer": "^7.0.13", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "reflect-metadata": "^0.2.2", @@ -56,6 +57,7 @@ "@types/jsonwebtoken": "^9.0.7", "@types/multer": "^1.4.12", "@types/node": "^20.17.9", + "@types/nodemailer": "^6.4.24", "@types/passport": "^1.0.17", "@types/passport-jwt": "^4.0.1", "@types/supertest": "^6.0.2", diff --git a/src/app.module.ts b/src/app.module.ts index ffffde1..2cf76fb 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -3,6 +3,7 @@ import { ConfigModule } from '@nestjs/config'; import { ServeStaticModule } from '@nestjs/serve-static'; import { join } from 'path'; import { PrismaModule } from './prisma/prisma.module'; +import { MailModule } from './mail/mail.module'; import { AuthModule } from './auth/auth.module'; import { KcModule } from './kc/kc.module'; import { GemeindeModule } from './gemeinde/gemeinde.module'; @@ -23,6 +24,7 @@ import { SyncModule } from './sync/sync.module'; exclude: ['/api*'], }), PrismaModule, + MailModule, SyncModule, AuthModule, KcModule, diff --git a/src/mail/log-mail.provider.ts b/src/mail/log-mail.provider.ts new file mode 100644 index 0000000..312100c --- /dev/null +++ b/src/mail/log-mail.provider.ts @@ -0,0 +1,15 @@ +import { Logger } from '@nestjs/common'; +import { MailMessage, MailProvider } from './mail-provider'; + +/// Default provider: doesn't send anything, just logs that it would have. +/// Keeps the invite flow working before SMTP is configured. +export class LogMailProvider implements MailProvider { + private readonly logger = new Logger('MailProvider'); + + async send(message: MailMessage): Promise { + this.logger.log( + `[log-only] would send "${message.subject}" to ${message.to}: ${message.text}`, + ); + return false; + } +} diff --git a/src/mail/mail-provider.ts b/src/mail/mail-provider.ts new file mode 100644 index 0000000..7cdf3b9 --- /dev/null +++ b/src/mail/mail-provider.ts @@ -0,0 +1,18 @@ +/// Abstraction over the outbound email backend. Default is a no-send provider +/// that only logs (fine for dev and for deployments that don't do email yet); +/// MAIL_PROVIDER=smtp switches to a real SMTP transport. +export interface MailMessage { + to: string; + subject: string; + text: string; + html?: string; +} + +export interface MailProvider { + /// Resolves true if the message was handed off to the transport, false if + /// it was dropped (e.g. the log provider). Never throws for delivery + /// problems — callers treat email as best-effort. + send(message: MailMessage): Promise; +} + +export const MAIL_PROVIDER = Symbol('MAIL_PROVIDER'); diff --git a/src/mail/mail.module.ts b/src/mail/mail.module.ts new file mode 100644 index 0000000..7691ccd --- /dev/null +++ b/src/mail/mail.module.ts @@ -0,0 +1,25 @@ +import { Global, Module } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { MAIL_PROVIDER } from './mail-provider'; +import { LogMailProvider } from './log-mail.provider'; +import { SmtpMailProvider } from './smtp-mail.provider'; +import { MailService } from './mail.service'; + +/// Global so any feature module can inject MailService. Provider defaults to +/// log-only; MAIL_PROVIDER=smtp switches to a real SMTP transport. +@Global() +@Module({ + providers: [ + MailService, + { + provide: MAIL_PROVIDER, + inject: [ConfigService], + useFactory: (config: ConfigService) => + config.get('MAIL_PROVIDER') === 'smtp' + ? new SmtpMailProvider(config) + : new LogMailProvider(), + }, + ], + exports: [MailService], +}) +export class MailModule {} diff --git a/src/mail/mail.service.ts b/src/mail/mail.service.ts new file mode 100644 index 0000000..e587c11 --- /dev/null +++ b/src/mail/mail.service.ts @@ -0,0 +1,43 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { MAIL_PROVIDER, MailProvider } from './mail-provider'; + +@Injectable() +export class MailService { + private readonly appBaseUrl: string; + + constructor( + @Inject(MAIL_PROVIDER) private readonly provider: MailProvider, + config: ConfigService, + ) { + this.appBaseUrl = (config.get('APP_BASE_URL') ?? 'http://localhost:3000').replace( + /\/$/, + '', + ); + } + + /// Sends a personal Gemeinde-Teamer invite. Returns whether it was handed + /// to the transport (false for the log-only provider or on failure). + sendTeamerInvite(opts: { + to: string; + kcName: string; + gemeindeName: string; + token: string; + expiresAt: Date | null; + }): Promise { + const link = `${this.appBaseUrl}/?teamerInviteToken=${encodeURIComponent(opts.token)}`; + const expiry = opts.expiresAt + ? `\n\nDer Link gilt bis ${opts.expiresAt.toISOString()}.` + : ''; + return this.provider.send({ + to: opts.to, + subject: `Einladung als Teamer:in – ${opts.gemeindeName} (${opts.kcName})`, + text: + `Hallo,\n\ndu wurdest als Teamer:in für die Gemeinde "${opts.gemeindeName}" ` + + `beim ${opts.kcName} eingeladen.\n\n` + + `Konto anlegen: ${link}\n\n` + + `Falls der Link nicht funktioniert, nutze diesen Einladungscode: ${opts.token}` + + `${expiry}\n`, + }); + } +} diff --git a/src/mail/smtp-mail.provider.ts b/src/mail/smtp-mail.provider.ts new file mode 100644 index 0000000..b9cc7a5 --- /dev/null +++ b/src/mail/smtp-mail.provider.ts @@ -0,0 +1,45 @@ +import { Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as nodemailer from 'nodemailer'; +import { MailMessage, MailProvider } from './mail-provider'; + +/// SMTP transport (MAIL_PROVIDER=smtp). Delivery failures are logged and +/// swallowed — callers treat email as best-effort. +export class SmtpMailProvider implements MailProvider { + private readonly logger = new Logger('MailProvider'); + private readonly from: string; + private readonly transport: nodemailer.Transporter; + + constructor(config: ConfigService) { + this.from = config.getOrThrow('MAIL_FROM'); + this.transport = nodemailer.createTransport({ + host: config.getOrThrow('SMTP_HOST'), + port: Number(config.get('SMTP_PORT') ?? 587), + secure: config.get('SMTP_SECURE') === 'true', + auth: config.get('SMTP_USER') + ? { + user: config.getOrThrow('SMTP_USER'), + pass: config.getOrThrow('SMTP_PASS'), + } + : undefined, + }); + } + + async send(message: MailMessage): Promise { + try { + await this.transport.sendMail({ + from: this.from, + to: message.to, + subject: message.subject, + text: message.text, + html: message.html, + }); + return true; + } catch (err) { + this.logger.error( + `Failed to send "${message.subject}" to ${message.to}: ${(err as Error).message}`, + ); + return false; + } + } +} diff --git a/src/teamer/teamer.service.spec.ts b/src/teamer/teamer.service.spec.ts index 28a71d9..498d229 100644 --- a/src/teamer/teamer.service.spec.ts +++ b/src/teamer/teamer.service.spec.ts @@ -31,6 +31,7 @@ function makeService(opts: { gemeinde?: typeof GEMEINDE | null; existingEmails?: const prisma = { gemeinde: { findUnique: jest.fn().mockResolvedValue(gemeinde) }, + kc: { findUnique: jest.fn().mockResolvedValue({ name: 'KC 2026' }) }, user: { findUnique: jest.fn(({ where }: { where: { email: string } }) => Promise.resolve(emails.has(where.email) ? { id: 'dup', email: where.email } : null), @@ -56,8 +57,9 @@ function makeService(opts: { gemeinde?: typeof GEMEINDE | null; existingEmails?: }, }; const sync = { capture: jest.fn().mockResolvedValue(undefined) }; - const service = new TeamerService(prisma as never, sync as never); - return { service, prisma, sync, created }; + const mail = { sendTeamerInvite: jest.fn().mockResolvedValue(true) }; + const service = new TeamerService(prisma as never, sync as never, mail as never); + return { service, prisma, sync, mail, created }; } describe('TeamerService scope check', () => { @@ -120,20 +122,34 @@ describe('TeamerService.createTeamer', () => { }); describe('TeamerService.createInvite', () => { - it('defaults a group link to unlimited uses and no expiry', async () => { - const { service } = makeService(); + it('defaults a group link to unlimited uses, no expiry, and sends no email', async () => { + const { service, mail } = makeService(); const inv = await service.createInvite(LT, 'gem-1', {}); expect(inv.email).toBeNull(); expect(inv.maxUses).toBeNull(); expect(inv.expiresAt).toBeNull(); expect(inv.token).toEqual(expect.any(String)); + expect(inv.emailSent).toBe(false); + expect(mail.sendTeamerInvite).not.toHaveBeenCalled(); }); - it('defaults a personal invite to a single use and lowercases the email', async () => { - const { service } = makeService(); + it('defaults a personal invite to a single use, lowercases the email, and mails it', async () => { + const { service, mail } = makeService(); const inv = await service.createInvite(LT, 'gem-1', { email: 'New@Example.org' }); expect(inv.email).toBe('new@example.org'); expect(inv.maxUses).toBe(1); + expect(inv.emailSent).toBe(true); + expect(mail.sendTeamerInvite).toHaveBeenCalledWith( + expect.objectContaining({ to: 'new@example.org', gemeindeName: 'Nord', kcName: 'KC 2026' }), + ); + }); + + it('still returns the invite when the mail transport drops it', async () => { + const { service, mail } = makeService(); + mail.sendTeamerInvite.mockResolvedValueOnce(false); + const inv = await service.createInvite(LT, 'gem-1', { email: 'x@example.org' }); + expect(inv.emailSent).toBe(false); + expect(inv.token).toEqual(expect.any(String)); }); it('turns expiresInHours into a concrete expiry', async () => { diff --git a/src/teamer/teamer.service.ts b/src/teamer/teamer.service.ts index e397106..0eac3d2 100644 --- a/src/teamer/teamer.service.ts +++ b/src/teamer/teamer.service.ts @@ -9,6 +9,7 @@ import { Role, SyncOperation } from '@prisma/client'; import * as bcrypt from 'bcryptjs'; import { PrismaClient } from '../prisma/prisma.module'; import { SyncService } from '../sync/sync.service'; +import { MailService } from '../mail/mail.service'; import { AuthenticatedUser } from '../auth/authenticated-request'; import { CreateTeamerInviteDto } from './dto/create-teamer-invite.dto'; @@ -30,6 +31,7 @@ export class TeamerService { constructor( private readonly prisma: PrismaClient, private readonly sync: SyncService, + private readonly mail: MailService, ) {} async createTeamer( @@ -118,7 +120,24 @@ export class TeamerService { }, }); await this.sync.capture('TeamerInvite', SyncOperation.CREATE, invite.id, invite); - return invite; + + // Personal invites go out by email (best-effort); group links are shared + // by the Verantwortliche/r directly. + let emailSent = false; + if (email) { + const kc = await this.prisma.kc.findUnique({ + where: { id: gemeinde.kcId }, + select: { name: true }, + }); + emailSent = await this.mail.sendTeamerInvite({ + to: email, + kcName: kc?.name ?? '', + gemeindeName: gemeinde.name, + token: invite.token, + expiresAt: invite.expiresAt, + }); + } + return { ...invite, emailSent }; } async listInvites(caller: AuthenticatedUser, gemeindeId: string) {