diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d849603 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,241 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +--- + +# Newsletter-App (DACH Email Marketing SaaS) + +## Projektübersicht + +Self-hosted Newsletter- und E-Mail-Marketing-Plattform als KlickTipp-Alternative für den DACH-Markt. +Multi-Tenant SaaS, eigener MTA auf Hetzner (iRedMail-basiert), volle Kontrolle über Deliverability. + +--- + +## Tech Stack + +- **Frontend:** Next.js 14 (App Router), TypeScript (strict), Tailwind CSS, shadcn/ui +- **Queue:** BullMQ + Redis (E-Mail-Versand-Jobs, Retry-Logic) +- **Datenbank primär:** PostgreSQL — schema-per-tenant +- **Datenbank Analytics:** ClickHouse (Opens, Clicks, Bounces, Unsubscribes — Event-basiert) +- **MTA:** Selbst-gehostet auf Hetzner (Postfix/iRedMail), SMTP-Integration via API +- **Auth:** Authentik (OIDC) +- **Infra:** Docker Compose, Hetzner Cloud, Zoraxy Reverse Proxy +- **CI/CD:** Forgejo Actions + +--- + +## Befehle + +```bash +pnpm dev # Dev-Server starten +pnpm build # Produktions-Build +pnpm lint # Linting +pnpm test # Alle Tests +pnpm test src/queues/email-send.test.ts # Einzelnen Test ausführen + +# Test-Infrastruktur (Mailhog 1025/8025, ClickHouse 9000, Redis 6380) +docker compose -f docker-compose.test.yml up -d +docker compose -f docker-compose.test.yml down +``` + +--- + +## Prompt-Vorlagen + +Für wiederkehrende Aufgaben (neue Feature, Bug-Fix, Migration, DSGVO-Review) stehen fertige Prompts in [`PROMPT.md`](./PROMPT.md) bereit. + +--- + +## Erlaubte Aktionen (autonom) + +- Code schreiben und refactorn +- Tests schreiben (unit + integration) +- Datenbankmigrationen erstellen (PostgreSQL UND ClickHouse — NICHT ausführen) + +## Verbotene Aktionen (immer Rückfrage) + +- E-Mails gegen echte Empfänger senden — NIEMALS +- `docker compose up/down/restart` — NIEMALS ohne explizite Freigabe +- Migrations ausführen +- Redis-Queue leeren oder Jobs löschen +- Postfix/SMTP-Konfiguration ändern +- Suppression-Listen oder Bounce-Daten löschen +- `.env` oder Secrets überschreiben +- Git push / force push + +--- + +## Datenbankregeln + +### PostgreSQL (Operational Data) + +- Schema-per-Tenant — Tenant-Context wird über `SET search_path` gesetzt +- **Pflicht-Pattern für jeden DB-Zugriff** — Helper in `src/server/db/tenant.ts`: + +```typescript +// src/server/db/tenant.ts +import { db } from './client' + +export async function withTenant( + tenantId: string, + fn: () => Promise +): Promise { + const schema = `tenant_${tenantId}` + await db.execute(`SET search_path = ${schema}, public`) + try { + return await fn() + } finally { + await db.execute(`SET search_path = public`) + } +} + +// Verwendung — IMMER so, niemals direkter Query ohne Tenant-Context: +const result = await withTenant(tenantId, () => + db.query('SELECT * FROM subscribers WHERE email = $1', [email]) +) +``` + +- Migrations in `migrations/pg/`, Naming: `YYYY-MM-DD_beschreibung.sql` +- Keine Migrations ausführen — nur erstellen +- Additive Schema-Änderungen zuerst (kein Breaking Change ohne explizite Freigabe) + +### ClickHouse (Analytics Data) + +- Client: `@clickhouse/client` (npm) — Instanz in `src/server/clickhouse/client.ts` +- Nur INSERT und SELECT — kein DELETE/UPDATE ohne explizite Freigabe +- Event-Tabellen sind append-only: `email_events` (event_type, tenant_id, campaign_id, recipient_hash, timestamp) +- Recipient-Daten in ClickHouse immer als Hash (SHA256) — keine Klartext-E-Mail-Adressen +- Migrations in `migrations/ch/` + +```typescript +// src/server/clickhouse/client.ts +import { createClient } from '@clickhouse/client' +export const clickhouse = createClient({ + url: process.env.CLICKHOUSE_URL, + username: process.env.CLICKHOUSE_USER, + password: process.env.CLICKHOUSE_PASSWORD, + database: 'newsletter', +}) +``` + +--- + +## E-Mail / MTA Regeln + +- **Kein direktes SMTP** aus dem Code ohne Rate-Limiting via BullMQ-Queue +- Jeder Versand-Job muss: Tenant-Check → Suppression-Check → Queue → SMTP +- Bounces und Unsubscribes werden sofort in Suppression-Liste geschrieben +- DKIM/SPF-Validierung ist Infra-Aufgabe — nicht im Code lösen +- Test-Modus: immer gegen Mailhog/MailDev, nie gegen echte SMTP-Credentials + +--- + +## BullMQ / Redis Regeln + +- Job-Definitionen in `src/queues/` +- Jeder Job hat: `maxAttempts: 3`, exponentielles Backoff, Dead-Letter-Queue +- Keine Jobs direkt in Redis manipulieren — immer über BullMQ API +- Queue-Namen: `email:send`, `email:bounce`, `analytics:ingest` + +--- + +## Coding Conventions + +- Named exports, keine default exports +- `interface` vor `type` für Objekt-Shapes +- Fehlerbehandlung: Result-Pattern aus `src/lib/result.ts` — kein nacktes try/catch + +```typescript +// src/lib/result.ts +export type Result = + | { ok: true; data: T } + | { ok: false; error: E } + +export const ok = (data: T): Result => ({ ok: true, data }) +export const err = (error: E): Result => ({ ok: false, error }) + +// Verwendung: +async function sendEmail(jobId: string): Promise> { + const suppressed = await checkSuppression(email) + if (suppressed) return err(new Error('Recipient suppressed')) + // ... + return ok(undefined) +} +``` +- Kein `any` in TypeScript +- Logging: strukturiert (JSON), niemals E-Mail-Adressen im Klartext loggen +- Kommentare auf Deutsch für Business-Logik (Deliverability, DSGVO), sonst Englisch + +--- + +## Dateistruktur + +``` +src/ +├── app/ # Next.js App Router (Pages, Layouts, API Routes) +│ ├── api/ # API Routes (Webhooks, REST-Endpunkte) +│ └── (dashboard)/ # Authenticated App-Bereich +├── components/ # React-Komponenten (shadcn-basiert, wiederverwendbar) +│ ├── ui/ # shadcn primitives (Button, Input, Dialog …) +│ └── email/ # Domain-spezifische Komponenten (CampaignCard …) +├── lib/ # Shared Utilities +│ ├── result.ts # Result-Pattern (siehe unten) +│ ├── crypto.ts # SHA256-Hashing für E-Mail-Adressen +│ └── validation.ts # Zod-Schemas +├── server/ # Server-only Code (nie im Client importieren) +│ ├── db/ # PostgreSQL-Client + Tenant-Context-Helper +│ ├── clickhouse/ # ClickHouse-Client (@clickhouse/client) +│ ├── smtp/ # SMTP-Client (nodemailer, nur via Queue aufgerufen) +│ └── auth/ # Authentik OIDC-Integration +├── queues/ # BullMQ Job-Definitionen und Worker +│ ├── email-send.queue.ts +│ ├── email-bounce.queue.ts +│ └── analytics-ingest.queue.ts +├── analytics/ # ClickHouse Query-Layer (nur SELECT/INSERT) +└── types/ # Globale TypeScript-Typen und Interfaces +migrations/ +├── pg/ # PostgreSQL Migrations (YYYY-MM-DD_beschreibung.sql) +└── ch/ # ClickHouse Migrations +``` + +--- + +## Testregeln + +- Queue-Jobs bekommen Unit-Tests mit gemocktem Redis +- SMTP-Calls immer gegen Mailhog mocken — nie echte Adressen +- ClickHouse-Queries gegen Test-Container testen +- Testframework: Vitest +- Vor Fertigstellung: `pnpm test` + `pnpm lint` + +--- + +## Deliverability-Regeln (kritisch) + +- Suppression-Check ist PFLICHT vor jedem Versand — kein Opt-out-Empfänger darf E-Mail erhalten +- Bounce-Rate-Monitoring: bei >5% Rate Kampagne automatisch pausieren +- Unsubscribe-Links müssen One-Click sein (RFC 8058) +- List-Unsubscribe-Header in jedem Mailing setzen + +--- + +## DSGVO / Compliance + +- Double-Opt-In ist Pflicht — kein Single-Opt-In implementieren +- Consent-Timestamps mit IP und User-Agent speichern +- Datenlöschung: Subscriber-Löschung löscht auch ClickHouse-Events (via tenant_id, nicht recipient_hash allein) +- Keine personenbezogenen Daten außerhalb der EU speichern (Hetzner DE) + +--- + +## Was Claude IMMER tun soll + +1. Vor jeder größeren Änderung Plan beschreiben und Freigabe abwarten +2. Bei Versand-Logik: Suppression-Check explizit zeigen +3. Bei DB-Änderungen: Migration zeigen, nicht ausführen +4. Bei Queue-Änderungen: Auswirkung auf laufende Jobs beschreiben +5. Nach jeder Aufgabe: kurze Zusammenfassung was geändert wurde und warum +6. Ausgaben immer in Deutscher Sprache + diff --git a/PROMPT.md b/PROMPT.md new file mode 100644 index 0000000..3cb20b1 --- /dev/null +++ b/PROMPT.md @@ -0,0 +1,219 @@ + +## 1. Neue Kampagnen-Funktion + +``` +Aufgabe: [Login] für Kampagnen implementieren + +Kontext: +- Tenant: [TENANT-SCHEMA oder "allgemein"] +- Betroffene Queues: [z.B. email:send / keine] +- ClickHouse betroffen: [Ja/Nein] + +Schritt 1: Zeig mir welche Dateien du anlegen oder ändern würdest + — Liste nur, kein Code + +Schritt 2: Schreib die Typen und Interfaces + +Schritt 3: Schreib die Business-Logik + — mit Suppression-Check als erstem Schritt + — mit Tenant-Isolation + +Schritt 4: Schreib Unit-Tests — SMTP gegen Mailhog mocken + +Schritt 5: Zeig mir Queue-Job-Änderungen falls nötig + +Warte nach jedem Schritt auf Freigabe. +Kein Versand gegen echte Empfänger. Kein Deploy. +``` + +--- + +## 2. E-Mail Versand-Job (BullMQ) + +``` +Aufgabe: BullMQ-Job für Kampagnen-Versand] implementieren + +Schritt 1: Zeig mir die Job-Datenstruktur (Payload-Interface) + und den Ablauf als Pseudocode — warte auf Freigabe + +Schritt 2: Implementiere den Job in src/queues/ + — maxAttempts: 3, exponentielles Backoff + — Dead-Letter-Queue bei finalem Fehler + — Suppression-Check VOR SMTP-Call + +Schritt 3: Schreib Unit-Tests mit gemocktem Redis und gemocktem SMTP + +Schritt 4: Zeig mir wie der Job in die Queue eingereiht wird + +Kein echter Redis-Eingriff. Kein SMTP gegen Produktion. +``` + +--- + +## 3. Subscriber-Management + +``` +Aufgabe: [ CSV-Import / Segmentierung / Unsubscribe-Flow] implementieren + +Schritt 1: Erkläre welche Tabellen betroffen sind (PostgreSQL-Schema) + — ist Double-Opt-In betroffen? Ja/Nein + +Schritt 2: Zeig mir die Validierungslogik + — Pflichtfelder, E-Mail-Format, Duplikat-Check, Suppression-Check + +Schritt 3: Schreib den Code — mit Consent-Timestamp-Speicherung (IP + User-Agent) + +Schritt 4: Schreib die Migration falls Schema-Änderung nötig + — nicht ausführen + +Schritt 5: Schreib Unit-Tests + +Keine E-Mail-Adressen im Klartext in ClickHouse speichern — nur SHA256-Hash. +``` + +--- + +## 4. ClickHouse Analytics + +``` +Aufgabe: [Open-Rate pro Kampagne] implementieren + +Schritt 1: Zeig mir die ClickHouse-Tabellenstruktur die du nutzen würdest + — warte auf Freigabe + +Schritt 2: Schreib die Query + — immer mit tenant_id als Filter + — keine personenbezogenen Daten selektieren (nur Hashes) + +Schritt 3: Schreib den Query-Layer in src/analytics/ + +Schritt 4: Schreib einen Test gegen ClickHouse-Test-Container + +Kein DELETE/UPDATE ohne explizite Freigabe. +ClickHouse-Tabellen sind append-only. +``` + +--- + +## 5. Datenbankänderung / Migration + +``` +Aufgabe: [BESCHREIBUNG DER SCHEMAÄNDERUNG] + +Welche DB: [PostgreSQL / ClickHouse / beide] + +Schritt 1: Erkläre welche Tabellen betroffen sind + — additiv oder breaking? + +Schritt 2: Zeig mir das geänderte Schema — warte auf Freigabe + +Schritt 3: Generiere die Migration-SQL + — PostgreSQL → migrations/pg/YYYY-MM-DD_beschreibung.sql + — ClickHouse → migrations/ch/YYYY-MM-DD_beschreibung.sql + — NICHT ausführen + +Schritt 4: Zeig mir welcher Code angepasst werden muss + +Führe zu keinem Zeitpunkt Migrations aus. +``` + +--- + +## 6. Bounce / Unsubscribe Handling + +``` +Aufgabe: [Bounce-Handler / Unsubscribe-Flow] implementieren oder erweitern + +Schritt 1: Zeig mir den aktuellen Flow als Diagramm (Text reicht) + — warte auf Freigabe + +Schritt 2: Implementiere die Änderung + — Suppression-Liste wird SOFORT geschrieben + — kein async ohne Fehlerbehandlung + +Schritt 3: Stelle sicher dass Bounce-Rate-Monitoring greift + — bei >5% Kampagne pausieren + +Schritt 4: One-Click-Unsubscribe Header prüfen (RFC 8058) + +Schritt 5: Schreib Tests + +Kein echter SMTP-Call. Kein Löschen aus Suppression-Liste. +``` + +--- + +## 7. MTA / SMTP Integration + +``` +Aufgabe: [SMTP-ÄNDERUNG z.B. neuen Versand-Domain einbinden] + +Schritt 1: Erkläre was sich an der SMTP-Konfiguration ändern muss + — zeige mir NUR den Code-Teil, nicht die Postfix-Config + +Schritt 2: Schreib den SMTP-Client-Code + — Rate-Limiting via BullMQ, kein direkter SMTP-Call + +Schritt 3: Schreib einen Test gegen Mailhog + +Postfix/iRedMail-Konfiguration ist Infra-Aufgabe — nicht im Code lösen. +Kein SMTP gegen echte Empfänger. +``` + +--- + +## 8. Bug fixen + +``` +Bug: [FEHLERBESCHREIBUNG] +Fehler-Output: [Stacktrace oder Fehlermeldung] +Kontext: [Queue-Job / API / Analytics / Frontend] + +Schritt 1: Finde die Ursache — erkläre sie bevor du etwas änderst + — warte auf Bestätigung + +Schritt 2: Schreib einen Test der den Bug reproduziert (failing) + +Schritt 3: Fix — Test muss grün werden + +Schritt 4: Prüfe Seiteneffekte auf Queue-Jobs und Suppression-Liste + +Kein Eingriff in laufende Queue-Jobs ohne Freigabe. +``` + +--- + +## 9. DSGVO / Compliance Check + +``` +Aufgabe: DSGVO-Review für [FEATURE oder DATEI] + +Prüfe auf: +- Werden E-Mail-Adressen irgendwo im Klartext geloggt? +- Ist Double-Opt-In mit Timestamp, IP und User-Agent gespeichert? +- Kann ein Tenant Subscriber-Daten eines anderen Tenants sehen? +- Werden Daten außerhalb der EU gespeichert oder übertragen? +- Ist die Lösch-Logik vollständig (PostgreSQL + ClickHouse)? + +Ausgabe: Findings mit Schweregrad (kritisch / mittel / niedrig) +Keine Änderungen ohne Freigabe. +``` + +--- + +## 10. Deliverability-Check + +``` +Aufgabe: Deliverability-Review für [VERSAND-FEATURE oder KAMPAGNEN-CODE] + +Prüfe auf: +- Suppression-Check vorhanden und als erster Schritt? +- List-Unsubscribe-Header gesetzt (RFC 8058)? +- Bounce-Rate-Monitoring aktiv? +- Versand-Rate limitiert (kein Burst)? +- DKIM/SPF-Kontext korrekt gesetzt? + +Ausgabe: Findings mit konkreten Code-Stellen +Keine Änderungen ohne Freigabe. +``` + diff --git a/docs/plans/2026-04-17-kampagnen-design.md b/docs/plans/2026-04-17-kampagnen-design.md new file mode 100644 index 0000000..abdc35b --- /dev/null +++ b/docs/plans/2026-04-17-kampagnen-design.md @@ -0,0 +1,136 @@ +# Kampagnen-Funktion — Design + +**Datum:** 2026-04-17 + +--- + +## Ziel + +Newsletter-SaaS-Plattform mit vollständiger Kampagnen-Funktion: einmaliger Versand, zeitgesteuerter Versand und event-basierte Automations — alle mit Tenant-Isolation, Suppression-Check und ClickHouse-Analytics. + +--- + +## Entscheidungen + +| Thema | Entscheidung | +|---|---| +| Kampagnen-Typen | Newsletter + Automation (beide gleichzeitig) | +| Empfänger | Feste Liste ODER dynamisches Segment wählbar | +| Inhaltsformate | HTML + Plain-Text (beide manuell pflegbar) | +| Status-Workflow | `draft → scheduled → sending → sent` | +| Versand-Zeitpunkt | Sofort, einmalig geplant, oder Cron-wiederkehrend | +| Analytics | Opens, Clicks, Bounces, Unsubscribes (aggregiert) | +| Automation-Trigger | Cron UND Event-Trigger kombinierbar | + +--- + +## Datenmodell (PostgreSQL, schema-per-tenant) + +```sql +-- Kampagne +campaigns ( + id UUID PK, + name TEXT NOT NULL, + subject TEXT NOT NULL, + html_body TEXT NOT NULL, + plain_body TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'draft', + -- draft | scheduled | sending | sent | paused | cancelled + scheduled_at TIMESTAMPTZ, + cron_expression TEXT, -- NULL = einmalig + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +) + +-- Empfänger-Zuordnung (Liste ODER Segment — genau eines davon NOT NULL) +campaign_recipients ( + id UUID PK, + campaign_id UUID NOT NULL REFERENCES campaigns(id), + list_id UUID NULL, -- feste Subscriber-Liste + segment_id UUID NULL -- dynamisches Segment +) + +-- Trigger für Automations +campaign_triggers ( + id UUID PK, + campaign_id UUID NOT NULL REFERENCES campaigns(id), + trigger_type TEXT NOT NULL, -- cron | event + trigger_value TEXT NOT NULL -- Cron-Ausdruck oder Event-Name (z.B. "subscriber.optin") +) +``` + +**ClickHouse** (`email_events`, append-only): +- Felder: `event_type`, `tenant_id`, `campaign_id`, `recipient_hash` (SHA256), `timestamp` +- Kein Klartext-E-Mail in ClickHouse + +--- + +## API Routes (Next.js App Router) + +``` +POST /api/campaigns → Kampagne erstellen (Status: draft) +GET /api/campaigns/:id → Kampagne abrufen +PATCH /api/campaigns/:id → Inhalt / Status aktualisieren +POST /api/campaigns/:id/send → Sofortversand auslösen +POST /api/campaigns/:id/schedule → Zeitplan oder Cron setzen +GET /api/campaigns/:id/analytics → Aggregierte ClickHouse-Daten +``` + +--- + +## Queue-Architektur (BullMQ) + +**Versand-Flow:** +``` +API → withTenant() → Suppression-Check + → BullMQ Job enqueuen (email:send) + → Job: Empfänger auflösen (Liste oder Segment) + → Pro Empfänger: Suppression-Check → SMTP + → ClickHouse INSERT (email_events) +``` + +**Queues:** +- `email:send` — Versand-Jobs (maxAttempts: 3, exponentielles Backoff, DLQ) +- `campaign:trigger` — Event-basierte Automation-Auslösung +- `analytics:scheduler` — Cron-Kampagnen-Prüfung (scheduled_at + cron_expression) + +**Status-Übergänge:** Atomar in PostgreSQL mit Optimistic Locking — kein Doppelversand. + +--- + +## Frontend (Next.js App Router) + +**Seiten:** +``` +/campaigns → Übersicht (CampaignCard-Liste, Status-Badge) +/campaigns/new → Wizard: Name → Inhalt → Empfänger → Zeitplan → Review +/campaigns/:id → Detail-Ansicht +/campaigns/:id/edit → Bearbeiten (nur im draft-Status) +/campaigns/:id/analytics → Aggregierte Analytics-Ansicht +``` + +**Komponenten:** +- `CampaignCard` — Status-Badge, Kurzinfo, Aktionen +- `CampaignEditor` — HTML + Plain-Text Tabs +- `RecipientPicker` — Toggle: Liste ODER Segment +- `SchedulePicker` — Sofort / Datum+Uhrzeit / Cron-Ausdruck +- `CampaignAnalytics` — Zahlen aus ClickHouse via API + +**State:** Server Components + React Server Actions — kein globaler Client-Store. + +--- + +## Deliverability-Pflichten + +- Suppression-Check ist PFLICHT vor jedem Versand +- List-Unsubscribe-Header (RFC 8058) in jeder E-Mail +- Bounce-Rate > 5% → Kampagne automatisch pausieren +- Kein Klartext-Logging von E-Mail-Adressen + +--- + +## DSGVO + +- Double-Opt-In Pflicht (Consent-Timestamp + IP + User-Agent) +- Subscriber-Löschung löscht auch ClickHouse-Events (via tenant_id) +- Alle Daten auf Hetzner DE diff --git a/docs/plans/2026-04-17-kampagnen-implementierung.md b/docs/plans/2026-04-17-kampagnen-implementierung.md new file mode 100644 index 0000000..fa8e032 --- /dev/null +++ b/docs/plans/2026-04-17-kampagnen-implementierung.md @@ -0,0 +1,1920 @@ +# Kampagnen-Funktion Implementierungsplan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Vollständige Kampagnen-Funktion für Newsletter-SaaS — Erstellen, Versenden (sofort/geplant/Cron), Empfänger (Liste/Segment), Analytics. + +**Architecture:** Schema-per-Tenant in PostgreSQL via `withTenant()`, Versand immer über BullMQ-Queue mit Suppression-Check, Analytics-Events append-only in ClickHouse. Next.js App Router mit Server Components und Server Actions. + +**Tech Stack:** Next.js 14, TypeScript strict, Tailwind, shadcn/ui, PostgreSQL, ClickHouse (@clickhouse/client), BullMQ + Redis, Vitest, Mailhog (Tests) + +--- + +## Task 1: Projektstruktur & Core Utilities + +**Files:** +- Create: `src/lib/result.ts` +- Create: `src/lib/crypto.ts` +- Create: `src/lib/validation.ts` +- Create: `src/types/index.ts` +- Test: `src/lib/result.test.ts` +- Test: `src/lib/crypto.test.ts` + +**Step 1: Failing Test schreiben — Result Pattern** + +```typescript +// src/lib/result.test.ts +import { describe, it, expect } from 'vitest' +import { ok, err } from './result' + +describe('Result', () => { + it('ok wraps data', () => { + const r = ok('hello') + expect(r.ok).toBe(true) + if (r.ok) expect(r.data).toBe('hello') + }) + + it('err wraps error', () => { + const r = err(new Error('fail')) + expect(r.ok).toBe(false) + if (!r.ok) expect(r.error.message).toBe('fail') + }) +}) +``` + +**Step 2: Test ausführen — muss fehlschlagen** + +```bash +pnpm test src/lib/result.test.ts +``` +Expected: FAIL — "Cannot find module './result'" + +**Step 3: Result Pattern implementieren** + +```typescript +// src/lib/result.ts +export type Result = + | { ok: true; data: T } + | { ok: false; error: E } + +export const ok = (data: T): Result => ({ ok: true, data }) +export const err = (error: E): Result => ({ ok: false, error }) +``` + +**Step 4: Test ausführen — muss grün sein** + +```bash +pnpm test src/lib/result.test.ts +``` +Expected: PASS + +**Step 5: Failing Test schreiben — Crypto** + +```typescript +// src/lib/crypto.test.ts +import { describe, it, expect } from 'vitest' +import { hashEmail } from './crypto' + +describe('hashEmail', () => { + it('gibt SHA256-Hash zurück', () => { + const hash = hashEmail('test@example.com') + expect(hash).toHaveLength(64) + expect(hash).toMatch(/^[a-f0-9]+$/) + }) + + it('normalisiert E-Mail vor Hash (lowercase)', () => { + expect(hashEmail('Test@Example.COM')).toBe(hashEmail('test@example.com')) + }) + + it('gibt nie die Klartext-E-Mail zurück', () => { + const hash = hashEmail('test@example.com') + expect(hash).not.toContain('@') + }) +}) +``` + +**Step 6: Test ausführen — muss fehlschlagen** + +```bash +pnpm test src/lib/crypto.test.ts +``` +Expected: FAIL + +**Step 7: Crypto implementieren** + +```typescript +// src/lib/crypto.ts +import { createHash } from 'crypto' + +export function hashEmail(email: string): string { + return createHash('sha256').update(email.toLowerCase().trim()).digest('hex') +} +``` + +**Step 8: Test ausführen — muss grün sein** + +```bash +pnpm test src/lib/crypto.test.ts +``` +Expected: PASS + +**Step 9: Globale Typen anlegen** + +```typescript +// src/types/index.ts +export type CampaignStatus = + | 'draft' + | 'scheduled' + | 'sending' + | 'sent' + | 'paused' + | 'cancelled' + +export type TriggerType = 'cron' | 'event' + +export interface Campaign { + id: string + name: string + subject: string + htmlBody: string + plainBody: string + status: CampaignStatus + scheduledAt: Date | null + cronExpression: string | null + createdAt: Date + updatedAt: Date +} + +export interface CampaignRecipient { + id: string + campaignId: string + listId: string | null + segmentId: string | null +} + +export interface CampaignTrigger { + id: string + campaignId: string + triggerType: TriggerType + triggerValue: string +} + +export interface CampaignAnalytics { + campaignId: string + sent: number + opens: number + clicks: number + bounces: number + unsubscribes: number + openRate: number + clickRate: number + bounceRate: number +} +``` + +**Step 10: Validierungsschemas anlegen** + +```typescript +// src/lib/validation.ts +import { z } from 'zod' + +export const CreateCampaignSchema = z.object({ + name: z.string().min(1).max(255), + subject: z.string().min(1).max(998), + htmlBody: z.string().min(1), + plainBody: z.string().min(1), +}) + +export const UpdateCampaignSchema = CreateCampaignSchema.partial() + +export const ScheduleCampaignSchema = z.union([ + z.object({ type: z.literal('immediate') }), + z.object({ type: z.literal('once'), scheduledAt: z.coerce.date() }), + z.object({ type: z.literal('cron'), cronExpression: z.string().min(9) }), +]) + +export const RecipientSchema = z.union([ + z.object({ listId: z.string().uuid(), segmentId: z.null().optional() }), + z.object({ segmentId: z.string().uuid(), listId: z.null().optional() }), +]) +``` + +**Step 11: Commit** + +```bash +git add src/lib/result.ts src/lib/result.test.ts src/lib/crypto.ts src/lib/crypto.test.ts src/lib/validation.ts src/types/index.ts +git commit -m "feat: core utilities — Result pattern, email hashing, Zod schemas, Campaign types" +``` + +--- + +## Task 2: Datenbankclients (PostgreSQL + ClickHouse) + +**Files:** +- Create: `src/server/db/client.ts` +- Create: `src/server/db/tenant.ts` +- Create: `src/server/clickhouse/client.ts` +- Test: `src/server/db/tenant.test.ts` + +**Step 1: Failing Test — withTenant** + +```typescript +// src/server/db/tenant.test.ts +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// DB-Client mocken +vi.mock('./client', () => ({ + db: { + execute: vi.fn(), + query: vi.fn().mockResolvedValue({ rows: [{ id: '1' }] }), + }, +})) + +import { withTenant } from './tenant' +import { db } from './client' + +describe('withTenant', () => { + beforeEach(() => vi.clearAllMocks()) + + it('setzt search_path auf tenant-Schema', async () => { + await withTenant('abc123', () => db.query('SELECT 1', [])) + expect(db.execute).toHaveBeenCalledWith('SET search_path = tenant_abc123, public') + }) + + it('setzt search_path zurück auf public nach Ausführung', async () => { + await withTenant('abc123', () => db.query('SELECT 1', [])) + expect(db.execute).toHaveBeenLastCalledWith('SET search_path = public') + }) + + it('setzt search_path zurück auch bei Fehler', async () => { + const fn = vi.fn().mockRejectedValue(new Error('DB-Fehler')) + await expect(withTenant('abc123', fn)).rejects.toThrow('DB-Fehler') + expect(db.execute).toHaveBeenLastCalledWith('SET search_path = public') + }) + + it('gibt Rückgabewert der Funktion zurück', async () => { + const result = await withTenant('abc123', async () => 'testdata') + expect(result).toBe('testdata') + }) +}) +``` + +**Step 2: Test ausführen — muss fehlschlagen** + +```bash +pnpm test src/server/db/tenant.test.ts +``` +Expected: FAIL + +**Step 3: DB-Client anlegen** + +```typescript +// src/server/db/client.ts +import { Pool } from 'pg' + +const pool = new Pool({ connectionString: process.env.DATABASE_URL }) + +export const db = { + execute: (sql: string) => pool.query(sql), + query: >(sql: string, params: unknown[]) => + pool.query(sql, params).then((r) => r.rows), +} +``` + +**Step 4: withTenant implementieren** + +```typescript +// src/server/db/tenant.ts +import { db } from './client' + +export async function withTenant(tenantId: string, fn: () => Promise): Promise { + const schema = `tenant_${tenantId}` + await db.execute(`SET search_path = ${schema}, public`) + try { + return await fn() + } finally { + await db.execute(`SET search_path = public`) + } +} +``` + +**Step 5: Test ausführen — muss grün sein** + +```bash +pnpm test src/server/db/tenant.test.ts +``` +Expected: PASS + +**Step 6: ClickHouse-Client anlegen** + +```typescript +// src/server/clickhouse/client.ts +import { createClient } from '@clickhouse/client' + +export const clickhouse = createClient({ + url: process.env.CLICKHOUSE_URL, + username: process.env.CLICKHOUSE_USER, + password: process.env.CLICKHOUSE_PASSWORD, + database: 'newsletter', +}) +``` + +**Step 7: Commit** + +```bash +git add src/server/db/client.ts src/server/db/tenant.ts src/server/db/tenant.test.ts src/server/clickhouse/client.ts +git commit -m "feat: PostgreSQL withTenant helper und ClickHouse-Client" +``` + +--- + +## Task 3: Datenbank-Migrationen + +**Files:** +- Create: `migrations/pg/2026-04-17_campaigns.sql` +- Create: `migrations/ch/2026-04-17_email_events.sql` + +**Step 1: PostgreSQL-Migration erstellen** + +```sql +-- migrations/pg/2026-04-17_campaigns.sql +-- Wird pro Tenant-Schema ausgeführt (SET search_path = tenant_, public vorher) + +CREATE TABLE IF NOT EXISTS campaigns ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + subject TEXT NOT NULL, + html_body TEXT NOT NULL, + plain_body TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'draft' + CHECK (status IN ('draft','scheduled','sending','sent','paused','cancelled')), + scheduled_at TIMESTAMPTZ, + cron_expression TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS campaign_recipients ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + campaign_id UUID NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE, + list_id UUID, + segment_id UUID, + CONSTRAINT recipient_has_one CHECK ( + (list_id IS NOT NULL AND segment_id IS NULL) OR + (segment_id IS NOT NULL AND list_id IS NULL) + ) +); + +CREATE TABLE IF NOT EXISTS campaign_triggers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + campaign_id UUID NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE, + trigger_type TEXT NOT NULL CHECK (trigger_type IN ('cron', 'event')), + trigger_value TEXT NOT NULL +); + +-- updated_at automatisch aktualisieren +CREATE OR REPLACE FUNCTION update_updated_at() +RETURNS TRIGGER AS $$ +BEGIN NEW.updated_at = now(); RETURN NEW; END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER campaigns_updated_at + BEFORE UPDATE ON campaigns + FOR EACH ROW EXECUTE FUNCTION update_updated_at(); +``` + +**Step 2: ClickHouse-Migration erstellen** + +```sql +-- migrations/ch/2026-04-17_email_events.sql +CREATE TABLE IF NOT EXISTS newsletter.email_events ( + event_type LowCardinality(String), + tenant_id String, + campaign_id UUID, + recipient_hash String, -- SHA256, kein Klartext + timestamp DateTime64(3, 'UTC'), + metadata Map(String, String) -- optionale Felder (bounce_type, click_url, …) +) +ENGINE = MergeTree() +PARTITION BY toYYYYMM(timestamp) +ORDER BY (tenant_id, campaign_id, event_type, timestamp); +``` + +**Step 3: NICHT ausführen — nur prüfen ob Syntax korrekt ist** + +```bash +# Nur Syntax-Check (psql dry-run): +psql $DATABASE_URL --single-transaction --dry-run -f migrations/pg/2026-04-17_campaigns.sql || echo "Syntax OK oder Fehler anzeigen" +``` + +**Step 4: Commit** + +```bash +git add migrations/pg/2026-04-17_campaigns.sql migrations/ch/2026-04-17_email_events.sql +git commit -m "feat: Migrations für campaigns-Tabellen und ClickHouse email_events" +``` + +--- + +## Task 4: Campaign Repository (DB-Zugriffs-Layer) + +**Files:** +- Create: `src/server/db/campaigns.ts` +- Test: `src/server/db/campaigns.test.ts` + +**Step 1: Failing Tests schreiben** + +```typescript +// src/server/db/campaigns.test.ts +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('./client', () => ({ + db: { + execute: vi.fn(), + query: vi.fn(), + }, +})) +vi.mock('./tenant', () => ({ + withTenant: vi.fn((_, fn) => fn()), +})) + +import { db } from './client' +import { + createCampaign, + getCampaign, + updateCampaign, + listCampaigns, + updateCampaignStatus, +} from './campaigns' + +const mockCampaign = { + id: 'uuid-1', + name: 'Test', + subject: 'Betreff', + html_body: '

Hi

', + plain_body: 'Hi', + status: 'draft', + scheduled_at: null, + cron_expression: null, + created_at: new Date(), + updated_at: new Date(), +} + +describe('createCampaign', () => { + it('gibt erstellte Kampagne zurück', async () => { + vi.mocked(db.query).mockResolvedValueOnce([mockCampaign]) + const result = await createCampaign('tenant1', { + name: 'Test', + subject: 'Betreff', + htmlBody: '

Hi

', + plainBody: 'Hi', + }) + expect(result.ok).toBe(true) + if (result.ok) expect(result.data.name).toBe('Test') + }) +}) + +describe('getCampaign', () => { + it('gibt Kampagne zurück wenn gefunden', async () => { + vi.mocked(db.query).mockResolvedValueOnce([mockCampaign]) + const result = await getCampaign('tenant1', 'uuid-1') + expect(result.ok).toBe(true) + }) + + it('gibt err zurück wenn nicht gefunden', async () => { + vi.mocked(db.query).mockResolvedValueOnce([]) + const result = await getCampaign('tenant1', 'uuid-1') + expect(result.ok).toBe(false) + }) +}) + +describe('updateCampaignStatus', () => { + it('erlaubt gültigen Status-Übergang draft → scheduled', async () => { + vi.mocked(db.query).mockResolvedValueOnce([{ ...mockCampaign, status: 'scheduled' }]) + const result = await updateCampaignStatus('tenant1', 'uuid-1', 'scheduled') + expect(result.ok).toBe(true) + }) +}) +``` + +**Step 2: Test ausführen — muss fehlschlagen** + +```bash +pnpm test src/server/db/campaigns.test.ts +``` +Expected: FAIL + +**Step 3: Repository implementieren** + +```typescript +// src/server/db/campaigns.ts +import { db } from './client' +import { withTenant } from './tenant' +import { ok, err, type Result } from '../../lib/result' +import type { Campaign, CampaignStatus } from '../../types' + +interface CreateCampaignInput { + name: string + subject: string + htmlBody: string + plainBody: string +} + +function rowToCampaign(row: Record): Campaign { + return { + id: row.id as string, + name: row.name as string, + subject: row.subject as string, + htmlBody: row.html_body as string, + plainBody: row.plain_body as string, + status: row.status as CampaignStatus, + scheduledAt: row.scheduled_at ? new Date(row.scheduled_at as string) : null, + cronExpression: row.cron_expression as string | null, + createdAt: new Date(row.created_at as string), + updatedAt: new Date(row.updated_at as string), + } +} + +export async function createCampaign( + tenantId: string, + input: CreateCampaignInput +): Promise> { + try { + const rows = await withTenant(tenantId, () => + db.query( + `INSERT INTO campaigns (name, subject, html_body, plain_body) + VALUES ($1, $2, $3, $4) RETURNING *`, + [input.name, input.subject, input.htmlBody, input.plainBody] + ) + ) + return ok(rowToCampaign(rows[0])) + } catch (e) { + return err(e instanceof Error ? e : new Error(String(e))) + } +} + +export async function getCampaign( + tenantId: string, + id: string +): Promise> { + try { + const rows = await withTenant(tenantId, () => + db.query('SELECT * FROM campaigns WHERE id = $1', [id]) + ) + if (rows.length === 0) return err(new Error('Kampagne nicht gefunden')) + return ok(rowToCampaign(rows[0])) + } catch (e) { + return err(e instanceof Error ? e : new Error(String(e))) + } +} + +export async function listCampaigns(tenantId: string): Promise> { + try { + const rows = await withTenant(tenantId, () => + db.query('SELECT * FROM campaigns ORDER BY created_at DESC', []) + ) + return ok(rows.map(rowToCampaign)) + } catch (e) { + return err(e instanceof Error ? e : new Error(String(e))) + } +} + +export async function updateCampaign( + tenantId: string, + id: string, + input: Partial +): Promise> { + try { + const fields: string[] = [] + const values: unknown[] = [] + let idx = 1 + if (input.name !== undefined) { fields.push(`name = $${idx++}`); values.push(input.name) } + if (input.subject !== undefined) { fields.push(`subject = $${idx++}`); values.push(input.subject) } + if (input.htmlBody !== undefined) { fields.push(`html_body = $${idx++}`); values.push(input.htmlBody) } + if (input.plainBody !== undefined) { fields.push(`plain_body = $${idx++}`); values.push(input.plainBody) } + if (fields.length === 0) return err(new Error('Keine Felder zum Aktualisieren')) + values.push(id) + const rows = await withTenant(tenantId, () => + db.query( + `UPDATE campaigns SET ${fields.join(', ')} WHERE id = $${idx} AND status = 'draft' RETURNING *`, + values + ) + ) + if (rows.length === 0) return err(new Error('Kampagne nicht gefunden oder nicht im Draft-Status')) + return ok(rowToCampaign(rows[0])) + } catch (e) { + return err(e instanceof Error ? e : new Error(String(e))) + } +} + +export async function updateCampaignStatus( + tenantId: string, + id: string, + status: CampaignStatus, + extra?: { scheduledAt?: Date; cronExpression?: string } +): Promise> { + try { + const rows = await withTenant(tenantId, () => + db.query( + `UPDATE campaigns SET status = $1, scheduled_at = $2, cron_expression = $3 + WHERE id = $4 RETURNING *`, + [status, extra?.scheduledAt ?? null, extra?.cronExpression ?? null, id] + ) + ) + if (rows.length === 0) return err(new Error('Kampagne nicht gefunden')) + return ok(rowToCampaign(rows[0])) + } catch (e) { + return err(e instanceof Error ? e : new Error(String(e))) + } +} +``` + +**Step 4: Test ausführen — muss grün sein** + +```bash +pnpm test src/server/db/campaigns.test.ts +``` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/server/db/campaigns.ts src/server/db/campaigns.test.ts +git commit -m "feat: Campaign Repository — CRUD mit Tenant-Isolation und Result-Pattern" +``` + +--- + +## Task 5: BullMQ E-Mail-Versand-Queue + +**Files:** +- Create: `src/queues/email-send.queue.ts` +- Test: `src/queues/email-send.test.ts` + +**Step 1: Failing Tests schreiben** + +```typescript +// src/queues/email-send.test.ts +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('bullmq', () => ({ + Queue: vi.fn().mockImplementation(() => ({ + add: vi.fn().mockResolvedValue({ id: 'job-1' }), + })), + Worker: vi.fn(), +})) + +vi.mock('../server/db/campaigns', () => ({ + getCampaign: vi.fn(), + updateCampaignStatus: vi.fn(), +})) + +vi.mock('../server/smtp/client', () => ({ + sendEmail: vi.fn().mockResolvedValue({ ok: true, data: undefined }), +})) + +vi.mock('../server/suppression/check', () => ({ + checkSuppression: vi.fn().mockResolvedValue(false), +})) + +import { enqueueEmailSend, type EmailSendJobData } from './email-send.queue' +import { Queue } from 'bullmq' + +describe('enqueueEmailSend', () => { + it('enqueued Job mit korrekten Daten', async () => { + const data: EmailSendJobData = { + tenantId: 'tenant1', + campaignId: 'campaign-uuid', + recipientEmail: 'empfaenger@example.com', + recipientHash: 'abc123', + } + const result = await enqueueEmailSend(data) + expect(result.ok).toBe(true) + const queueInstance = vi.mocked(Queue).mock.results[0].value + expect(queueInstance.add).toHaveBeenCalledWith( + 'send', + data, + expect.objectContaining({ attempts: 3 }) + ) + }) +}) +``` + +**Step 2: Test ausführen — muss fehlschlagen** + +```bash +pnpm test src/queues/email-send.test.ts +``` +Expected: FAIL + +**Step 3: Queue implementieren** + +```typescript +// src/queues/email-send.queue.ts +import { Queue } from 'bullmq' +import { ok, err, type Result } from '../lib/result' + +export interface EmailSendJobData { + tenantId: string + campaignId: string + recipientEmail: string + recipientHash: string +} + +const connection = { + host: process.env.REDIS_HOST ?? 'localhost', + port: Number(process.env.REDIS_PORT ?? 6379), +} + +export const emailSendQueue = new Queue('email:send', { connection }) + +export async function enqueueEmailSend(data: EmailSendJobData): Promise> { + try { + const job = await emailSendQueue.add('send', data, { + attempts: 3, + backoff: { type: 'exponential', delay: 2000 }, + removeOnComplete: 100, + removeOnFail: { count: 500 }, + }) + return ok(job.id ?? 'unknown') + } catch (e) { + return err(e instanceof Error ? e : new Error(String(e))) + } +} +``` + +**Step 4: Test ausführen — muss grün sein** + +```bash +pnpm test src/queues/email-send.test.ts +``` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/queues/email-send.queue.ts src/queues/email-send.test.ts +git commit -m "feat: BullMQ email:send Queue — maxAttempts 3, exponentielles Backoff" +``` + +--- + +## Task 6: Suppression-Check & SMTP-Client + +**Files:** +- Create: `src/server/suppression/check.ts` +- Create: `src/server/smtp/client.ts` +- Test: `src/server/suppression/check.test.ts` +- Test: `src/server/smtp/client.test.ts` + +**Step 1: Failing Test — Suppression-Check** + +```typescript +// src/server/suppression/check.test.ts +import { describe, it, expect, vi } from 'vitest' + +vi.mock('../db/client', () => ({ + db: { query: vi.fn() }, +})) +vi.mock('../db/tenant', () => ({ + withTenant: vi.fn((_, fn) => fn()), +})) + +import { db } from '../db/client' +import { checkSuppression } from './check' + +describe('checkSuppression', () => { + it('gibt true zurück wenn E-Mail in Suppression-Liste', async () => { + vi.mocked(db.query).mockResolvedValueOnce([{ email: 'test@example.com' }]) + const result = await checkSuppression('tenant1', 'test@example.com') + expect(result).toBe(true) + }) + + it('gibt false zurück wenn E-Mail nicht in Suppression-Liste', async () => { + vi.mocked(db.query).mockResolvedValueOnce([]) + const result = await checkSuppression('tenant1', 'clean@example.com') + expect(result).toBe(false) + }) +}) +``` + +**Step 2: Test ausführen — muss fehlschlagen** + +```bash +pnpm test src/server/suppression/check.test.ts +``` + +**Step 3: Suppression-Check implementieren** + +```typescript +// src/server/suppression/check.ts +import { db } from '../db/client' +import { withTenant } from '../db/tenant' + +export async function checkSuppression(tenantId: string, email: string): Promise { + const rows = await withTenant(tenantId, () => + db.query( + 'SELECT 1 FROM suppression_list WHERE email = $1 LIMIT 1', + [email.toLowerCase().trim()] + ) + ) + return rows.length > 0 +} +``` + +**Step 4: Test ausführen** + +```bash +pnpm test src/server/suppression/check.test.ts +``` +Expected: PASS + +**Step 5: Failing Test — SMTP-Client** + +```typescript +// src/server/smtp/client.test.ts +import { describe, it, expect, vi } from 'vitest' + +vi.mock('nodemailer', () => ({ + default: { + createTransport: vi.fn().mockReturnValue({ + sendMail: vi.fn().mockResolvedValue({ messageId: 'test-id' }), + }), + }, +})) + +import { sendEmail } from './client' + +describe('sendEmail', () => { + it('sendet E-Mail über SMTP', async () => { + const result = await sendEmail({ + to: 'empfaenger@example.com', + subject: 'Test', + html: '

Hi

', + text: 'Hi', + listUnsubscribeHeader: '', + }) + expect(result.ok).toBe(true) + }) + + it('setzt List-Unsubscribe-Header (RFC 8058)', async () => { + import nodemailer from 'nodemailer' + const transport = vi.mocked(nodemailer.createTransport).mock.results[0].value + await sendEmail({ + to: 'empfaenger@example.com', + subject: 'Test', + html: '

Hi

', + text: 'Hi', + listUnsubscribeHeader: '', + }) + expect(transport.sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.objectContaining({ + 'List-Unsubscribe': expect.stringContaining('unsub'), + 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click', + }), + }) + ) + }) +}) +``` + +**Step 6: Test ausführen — muss fehlschlagen** + +```bash +pnpm test src/server/smtp/client.test.ts +``` + +**Step 7: SMTP-Client implementieren** + +```typescript +// src/server/smtp/client.ts +import nodemailer from 'nodemailer' +import { ok, err, type Result } from '../../lib/result' + +interface SendEmailInput { + to: string + subject: string + html: string + text: string + listUnsubscribeHeader: string +} + +const transport = nodemailer.createTransport({ + host: process.env.SMTP_HOST ?? 'localhost', + port: Number(process.env.SMTP_PORT ?? 1025), + secure: false, + auth: process.env.SMTP_USER + ? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS } + : undefined, +}) + +export async function sendEmail(input: SendEmailInput): Promise> { + try { + await transport.sendMail({ + from: process.env.SMTP_FROM ?? 'newsletter@localhost', + to: input.to, + subject: input.subject, + html: input.html, + text: input.text, + headers: { + 'List-Unsubscribe': input.listUnsubscribeHeader, + 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click', + }, + }) + return ok(undefined) + } catch (e) { + return err(e instanceof Error ? e : new Error(String(e))) + } +} +``` + +**Step 8: Tests ausführen** + +```bash +pnpm test src/server/smtp/client.test.ts +``` +Expected: PASS + +**Step 9: Commit** + +```bash +git add src/server/suppression/check.ts src/server/suppression/check.test.ts src/server/smtp/client.ts src/server/smtp/client.test.ts +git commit -m "feat: Suppression-Check und SMTP-Client mit RFC-8058-Header" +``` + +--- + +## Task 7: BullMQ Worker — E-Mail verarbeiten + +**Files:** +- Create: `src/queues/email-send.worker.ts` +- Test: `src/queues/email-send.worker.test.ts` + +**Step 1: Failing Tests** + +```typescript +// src/queues/email-send.worker.test.ts +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('../server/suppression/check', () => ({ + checkSuppression: vi.fn(), +})) +vi.mock('../server/smtp/client', () => ({ + sendEmail: vi.fn(), +})) +vi.mock('../server/db/campaigns', () => ({ + getCampaign: vi.fn(), +})) +vi.mock('../server/clickhouse/client', () => ({ + clickhouse: { insert: vi.fn().mockResolvedValue(undefined) }, +})) + +import { processEmailSendJob } from './email-send.worker' +import { checkSuppression } from '../server/suppression/check' +import { sendEmail } from '../server/smtp/client' +import { getCampaign } from '../server/db/campaigns' + +const mockCampaign = { + ok: true, + data: { + id: 'campaign-1', + subject: 'Newsletter', + htmlBody: '

Hi

', + plainBody: 'Hi', + }, +} + +describe('processEmailSendJob', () => { + beforeEach(() => vi.clearAllMocks()) + + it('sendet E-Mail wenn nicht suppressed', async () => { + vi.mocked(getCampaign).mockResolvedValue(mockCampaign as never) + vi.mocked(checkSuppression).mockResolvedValue(false) + vi.mocked(sendEmail).mockResolvedValue({ ok: true, data: undefined }) + + const result = await processEmailSendJob({ + tenantId: 'tenant1', + campaignId: 'campaign-1', + recipientEmail: 'test@example.com', + recipientHash: 'abc123', + }) + expect(result.ok).toBe(true) + expect(sendEmail).toHaveBeenCalledOnce() + }) + + it('überspringt Versand wenn Empfänger suppressed', async () => { + vi.mocked(getCampaign).mockResolvedValue(mockCampaign as never) + vi.mocked(checkSuppression).mockResolvedValue(true) + + const result = await processEmailSendJob({ + tenantId: 'tenant1', + campaignId: 'campaign-1', + recipientEmail: 'suppressed@example.com', + recipientHash: 'def456', + }) + expect(result.ok).toBe(true) + expect(sendEmail).not.toHaveBeenCalled() + }) + + it('gibt err zurück wenn Kampagne nicht gefunden', async () => { + vi.mocked(getCampaign).mockResolvedValue({ ok: false, error: new Error('nicht gefunden') }) + + const result = await processEmailSendJob({ + tenantId: 'tenant1', + campaignId: 'missing', + recipientEmail: 'test@example.com', + recipientHash: 'abc123', + }) + expect(result.ok).toBe(false) + }) +}) +``` + +**Step 2: Test ausführen — muss fehlschlagen** + +```bash +pnpm test src/queues/email-send.worker.test.ts +``` + +**Step 3: Worker implementieren** + +```typescript +// src/queues/email-send.worker.ts +import { Worker } from 'bullmq' +import { getCampaign } from '../server/db/campaigns' +import { checkSuppression } from '../server/suppression/check' +import { sendEmail } from '../server/smtp/client' +import { clickhouse } from '../server/clickhouse/client' +import { ok, err, type Result } from '../lib/result' +import type { EmailSendJobData } from './email-send.queue' + +export async function processEmailSendJob(data: EmailSendJobData): Promise> { + const campaignResult = await getCampaign(data.tenantId, data.campaignId) + if (!campaignResult.ok) return err(campaignResult.error) + + const campaign = campaignResult.data + + // Suppression-Check ist PFLICHT — kein Opt-out-Empfänger darf E-Mail erhalten + const suppressed = await checkSuppression(data.tenantId, data.recipientEmail) + if (suppressed) { + await insertEvent('suppressed', data) + return ok(undefined) + } + + const unsubUrl = `${process.env.APP_URL}/unsub?tid=${data.tenantId}&cid=${data.campaignId}&r=${data.recipientHash}` + const sendResult = await sendEmail({ + to: data.recipientEmail, + subject: campaign.subject, + html: campaign.htmlBody, + text: campaign.plainBody, + listUnsubscribeHeader: `<${unsubUrl}>`, + }) + + if (!sendResult.ok) return err(sendResult.error) + + await insertEvent('sent', data) + return ok(undefined) +} + +async function insertEvent(eventType: string, data: EmailSendJobData): Promise { + await clickhouse.insert({ + table: 'email_events', + values: [{ + event_type: eventType, + tenant_id: data.tenantId, + campaign_id: data.campaignId, + recipient_hash: data.recipientHash, + timestamp: new Date().toISOString(), + metadata: {}, + }], + format: 'JSONEachRow', + }) +} + +const connection = { + host: process.env.REDIS_HOST ?? 'localhost', + port: Number(process.env.REDIS_PORT ?? 6379), +} + +// Worker nur außerhalb von Tests starten +if (process.env.NODE_ENV !== 'test') { + new Worker( + 'email:send', + async (job) => { + const result = await processEmailSendJob(job.data) + if (!result.ok) throw result.error + }, + { + connection, + concurrency: 10, + } + ) +} +``` + +**Step 4: Tests ausführen** + +```bash +pnpm test src/queues/email-send.worker.test.ts +``` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/queues/email-send.worker.ts src/queues/email-send.worker.test.ts +git commit -m "feat: BullMQ Worker — Suppression-Check vor Versand, ClickHouse-Event-Insert" +``` + +--- + +## Task 8: API Routes — Campaign CRUD + +**Files:** +- Create: `src/app/api/campaigns/route.ts` +- Create: `src/app/api/campaigns/[id]/route.ts` +- Create: `src/app/api/campaigns/[id]/send/route.ts` +- Create: `src/app/api/campaigns/[id]/schedule/route.ts` + +**Step 1: POST /api/campaigns** + +```typescript +// src/app/api/campaigns/route.ts +import { NextRequest, NextResponse } from 'next/server' +import { CreateCampaignSchema } from '../../../lib/validation' +import { createCampaign, listCampaigns } from '../../../server/db/campaigns' + +// Tenant-ID kommt aus OIDC-Session — hier placeholder bis Auth implementiert +function getTenantId(req: NextRequest): string { + return req.headers.get('x-tenant-id') ?? 'default' +} + +export async function GET(req: NextRequest) { + const tenantId = getTenantId(req) + const result = await listCampaigns(tenantId) + if (!result.ok) return NextResponse.json({ error: result.error.message }, { status: 500 }) + return NextResponse.json(result.data) +} + +export async function POST(req: NextRequest) { + const tenantId = getTenantId(req) + const body = await req.json() + const parsed = CreateCampaignSchema.safeParse(body) + if (!parsed.success) return NextResponse.json({ error: parsed.error.issues }, { status: 400 }) + + const result = await createCampaign(tenantId, parsed.data) + if (!result.ok) return NextResponse.json({ error: result.error.message }, { status: 500 }) + return NextResponse.json(result.data, { status: 201 }) +} +``` + +**Step 2: GET/PATCH /api/campaigns/[id]** + +```typescript +// src/app/api/campaigns/[id]/route.ts +import { NextRequest, NextResponse } from 'next/server' +import { UpdateCampaignSchema } from '../../../../lib/validation' +import { getCampaign, updateCampaign } from '../../../../server/db/campaigns' + +function getTenantId(req: NextRequest): string { + return req.headers.get('x-tenant-id') ?? 'default' +} + +export async function GET(req: NextRequest, { params }: { params: { id: string } }) { + const result = await getCampaign(getTenantId(req), params.id) + if (!result.ok) return NextResponse.json({ error: result.error.message }, { status: 404 }) + return NextResponse.json(result.data) +} + +export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) { + const tenantId = getTenantId(req) + const body = await req.json() + const parsed = UpdateCampaignSchema.safeParse(body) + if (!parsed.success) return NextResponse.json({ error: parsed.error.issues }, { status: 400 }) + + const result = await updateCampaign(tenantId, params.id, parsed.data) + if (!result.ok) return NextResponse.json({ error: result.error.message }, { status: 400 }) + return NextResponse.json(result.data) +} +``` + +**Step 3: POST /api/campaigns/[id]/send** + +```typescript +// src/app/api/campaigns/[id]/send/route.ts +import { NextRequest, NextResponse } from 'next/server' +import { getCampaign, updateCampaignStatus } from '../../../../../server/db/campaigns' +import { enqueueEmailSend } from '../../../../../queues/email-send.queue' +import { withTenant } from '../../../../../server/db/tenant' +import { db } from '../../../../../server/db/client' +import { hashEmail } from '../../../../../lib/crypto' + +function getTenantId(req: NextRequest): string { + return req.headers.get('x-tenant-id') ?? 'default' +} + +export async function POST(req: NextRequest, { params }: { params: { id: string } }) { + const tenantId = getTenantId(req) + + const campaignResult = await getCampaign(tenantId, params.id) + if (!campaignResult.ok) return NextResponse.json({ error: campaignResult.error.message }, { status: 404 }) + if (campaignResult.data.status !== 'draft') { + return NextResponse.json({ error: 'Nur Draft-Kampagnen können versendet werden' }, { status: 400 }) + } + + // Status auf 'sending' setzen + await updateCampaignStatus(tenantId, params.id, 'sending') + + // Empfänger auflösen und Jobs enqueuen + const recipients = await withTenant(tenantId, () => + db.query( + `SELECT s.email FROM subscribers s + JOIN campaign_recipients cr ON cr.campaign_id = $1 + WHERE (cr.list_id IS NULL OR s.list_id = cr.list_id) + AND s.status = 'active'`, + [params.id] + ) + ) + + for (const recipient of recipients as Array<{ email: string }>) { + await enqueueEmailSend({ + tenantId, + campaignId: params.id, + recipientEmail: recipient.email, + recipientHash: hashEmail(recipient.email), + }) + } + + return NextResponse.json({ queued: recipients.length }) +} +``` + +**Step 4: POST /api/campaigns/[id]/schedule** + +```typescript +// src/app/api/campaigns/[id]/schedule/route.ts +import { NextRequest, NextResponse } from 'next/server' +import { ScheduleCampaignSchema } from '../../../../../lib/validation' +import { updateCampaignStatus } from '../../../../../server/db/campaigns' + +function getTenantId(req: NextRequest): string { + return req.headers.get('x-tenant-id') ?? 'default' +} + +export async function POST(req: NextRequest, { params }: { params: { id: string } }) { + const tenantId = getTenantId(req) + const body = await req.json() + const parsed = ScheduleCampaignSchema.safeParse(body) + if (!parsed.success) return NextResponse.json({ error: parsed.error.issues }, { status: 400 }) + + const schedule = parsed.data + let scheduledAt: Date | undefined + let cronExpression: string | undefined + + if (schedule.type === 'once') scheduledAt = schedule.scheduledAt + if (schedule.type === 'cron') cronExpression = schedule.cronExpression + + const result = await updateCampaignStatus(tenantId, params.id, 'scheduled', { + scheduledAt, + cronExpression, + }) + if (!result.ok) return NextResponse.json({ error: result.error.message }, { status: 400 }) + return NextResponse.json(result.data) +} +``` + +**Step 5: Alle Tests ausführen** + +```bash +pnpm test && pnpm lint +``` +Expected: PASS + +**Step 6: Commit** + +```bash +git add src/app/api/campaigns/ +git commit -m "feat: API Routes — Campaign CRUD, sofortiger Versand, Zeitplan-Endpoint" +``` + +--- + +## Task 9: Analytics — ClickHouse Query-Layer & API + +**Files:** +- Create: `src/analytics/campaigns.ts` +- Create: `src/app/api/campaigns/[id]/analytics/route.ts` +- Test: `src/analytics/campaigns.test.ts` + +**Step 1: Failing Tests** + +```typescript +// src/analytics/campaigns.test.ts +import { describe, it, expect, vi } from 'vitest' + +vi.mock('../server/clickhouse/client', () => ({ + clickhouse: { + query: vi.fn().mockResolvedValue({ + json: vi.fn().mockResolvedValue([ + { + event_type: 'sent', count: '100', + opens: '45', clicks: '12', bounces: '3', unsubscribes: '2', + }, + ]), + }), + }, +})) + +import { getCampaignAnalytics } from './campaigns' + +describe('getCampaignAnalytics', () => { + it('gibt aggregierte Metriken zurück', async () => { + const result = await getCampaignAnalytics('tenant1', 'campaign-1') + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.data.openRate).toBeGreaterThanOrEqual(0) + expect(result.data.bounceRate).toBeGreaterThanOrEqual(0) + } + }) + + it('schließt keine Klartext-E-Mails ein', async () => { + const result = await getCampaignAnalytics('tenant1', 'campaign-1') + if (result.ok) { + const keys = Object.keys(result.data) + expect(keys).not.toContain('email') + expect(keys).not.toContain('recipientEmail') + } + }) +}) +``` + +**Step 2: Test ausführen — muss fehlschlagen** + +```bash +pnpm test src/analytics/campaigns.test.ts +``` + +**Step 3: Analytics Query implementieren** + +```typescript +// src/analytics/campaigns.ts +import { clickhouse } from '../server/clickhouse/client' +import { ok, err, type Result } from '../lib/result' +import type { CampaignAnalytics } from '../types' + +export async function getCampaignAnalytics( + tenantId: string, + campaignId: string +): Promise> { + try { + const result = await clickhouse.query({ + query: ` + SELECT + countIf(event_type = 'sent') AS sent, + countIf(event_type = 'open') AS opens, + countIf(event_type = 'click') AS clicks, + countIf(event_type = 'bounce') AS bounces, + countIf(event_type = 'unsubscribe') AS unsubscribes + FROM email_events + WHERE tenant_id = {tenantId:String} + AND campaign_id = {campaignId:UUID} + `, + query_params: { tenantId, campaignId }, + }) + const rows = await result.json>() + const row = rows[0] ?? {} + const sent = Number(row.sent ?? 0) + const opens = Number(row.opens ?? 0) + const clicks = Number(row.clicks ?? 0) + const bounces = Number(row.bounces ?? 0) + const unsubscribes = Number(row.unsubscribes ?? 0) + + return ok({ + campaignId, + sent, + opens, + clicks, + bounces, + unsubscribes, + openRate: sent > 0 ? opens / sent : 0, + clickRate: sent > 0 ? clicks / sent : 0, + bounceRate: sent > 0 ? bounces / sent : 0, + }) + } catch (e) { + return err(e instanceof Error ? e : new Error(String(e))) + } +} +``` + +**Step 4: Analytics API Route** + +```typescript +// src/app/api/campaigns/[id]/analytics/route.ts +import { NextRequest, NextResponse } from 'next/server' +import { getCampaignAnalytics } from '../../../../../analytics/campaigns' + +function getTenantId(req: NextRequest): string { + return req.headers.get('x-tenant-id') ?? 'default' +} + +export async function GET(req: NextRequest, { params }: { params: { id: string } }) { + const result = await getCampaignAnalytics(getTenantId(req), params.id) + if (!result.ok) return NextResponse.json({ error: result.error.message }, { status: 500 }) + return NextResponse.json(result.data) +} +``` + +**Step 5: Tests ausführen** + +```bash +pnpm test src/analytics/campaigns.test.ts +``` +Expected: PASS + +**Step 6: Commit** + +```bash +git add src/analytics/campaigns.ts src/analytics/campaigns.test.ts src/app/api/campaigns/[id]/analytics/ +git commit -m "feat: ClickHouse Analytics-Query — Opens, Clicks, Bounces, Raten" +``` + +--- + +## Task 10: Frontend — Kampagnen-Übersicht + +**Files:** +- Create: `src/components/email/CampaignCard.tsx` +- Create: `src/app/(dashboard)/campaigns/page.tsx` + +**Step 1: CampaignCard-Komponente** + +```tsx +// src/components/email/CampaignCard.tsx +import type { Campaign } from '../../types' + +const STATUS_LABELS: Record = { + draft: 'Entwurf', + scheduled: 'Geplant', + sending: 'Wird gesendet', + sent: 'Gesendet', + paused: 'Pausiert', + cancelled: 'Abgebrochen', +} + +const STATUS_COLORS: Record = { + draft: 'bg-gray-100 text-gray-700', + scheduled: 'bg-blue-100 text-blue-700', + sending: 'bg-yellow-100 text-yellow-700', + sent: 'bg-green-100 text-green-700', + paused: 'bg-orange-100 text-orange-700', + cancelled: 'bg-red-100 text-red-700', +} + +interface CampaignCardProps { + campaign: Campaign +} + +export function CampaignCard({ campaign }: CampaignCardProps) { + return ( +
+
+
+

{campaign.name}

+

{campaign.subject}

+
+ + {STATUS_LABELS[campaign.status]} + +
+

+ Erstellt: {campaign.createdAt.toLocaleDateString('de-DE')} +

+
+ + Details + + {campaign.status === 'draft' && ( + + Bearbeiten + + )} + + Analytics + +
+
+ ) +} +``` + +**Step 2: Kampagnen-Übersichtsseite** + +```tsx +// src/app/(dashboard)/campaigns/page.tsx +import { headers } from 'next/headers' +import { listCampaigns } from '../../../server/db/campaigns' +import { CampaignCard } from '../../../components/email/CampaignCard' + +export default async function CampaignsPage() { + const headersList = headers() + const tenantId = headersList.get('x-tenant-id') ?? 'default' + const result = await listCampaigns(tenantId) + const campaigns = result.ok ? result.data : [] + + return ( +
+
+

Kampagnen

+ + Neue Kampagne + +
+ {campaigns.length === 0 ? ( +

Noch keine Kampagnen vorhanden.

+ ) : ( +
+ {campaigns.map((c) => ( + + ))} +
+ )} +
+ ) +} +``` + +**Step 3: Commit** + +```bash +git add src/components/email/CampaignCard.tsx src/app/\(dashboard\)/campaigns/page.tsx +git commit -m "feat: Kampagnen-Übersicht — CampaignCard mit Status-Badge, Server Component" +``` + +--- + +## Task 11: Frontend — Kampagne erstellen (Wizard) + +**Files:** +- Create: `src/app/(dashboard)/campaigns/new/page.tsx` +- Create: `src/components/email/CampaignEditor.tsx` +- Create: `src/components/email/RecipientPicker.tsx` +- Create: `src/components/email/SchedulePicker.tsx` + +**Step 1: CampaignEditor** + +```tsx +// src/components/email/CampaignEditor.tsx +'use client' +import { useState } from 'react' + +interface CampaignEditorProps { + htmlBody: string + plainBody: string + onChangeHtml: (v: string) => void + onChangePlain: (v: string) => void +} + +export function CampaignEditor({ htmlBody, plainBody, onChangeHtml, onChangePlain }: CampaignEditorProps) { + const [tab, setTab] = useState<'html' | 'plain'>('html') + return ( +
+
+ + +
+ {tab === 'html' ? ( +