# 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' ? (