From b13eb449b3c39f317f6403bd7ab9b2a5b72524d0 Mon Sep 17 00:00:00 2001 From: Till JS Date: Mon, 20 Apr 2026 19:32:53 +0200 Subject: [PATCH] docs(broadcast): plan newsletter / broadcast module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MVP scope: campaigns CRUD, audience filter from contacts, Tiptap editor, bulk-send via mana-mail extension, per-recipient tracking (open/click/ unsubscribe), DSGVO-compliant footer, DNS-check. Key decisions made up-front: - Tracking endpoints live in mana-mail (public, token-HMAC signed) — not in apps/api, because mana-mail already owns SMTP + auth plumbing - Per-recipient state stays Postgres-only; no Dexie mirror (could be millions of events for big lists, no cross-device benefit) - Tiptap over Unlayer/Lexical: MIT, Svelte wrapper exists, extension- based so bundle stays lean via tree-shaking - juice for CSS-inlining runs server-side — keeps the client bundle light and concentrates email-compat knowledge in one place - Explicitly NOT zero-knowledge compatible; server needs plaintext recipient lists to send. Warning in onboarding. - 10 milestones, ~17 days MVP. M1-M4 builds the core send path, M5-M8 adds tracking + DSGVO + deliverability. Related: docs/reports/clubdesk-vs-mana-comparison.md §7.2 Paket D. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/plans/broadcast-module.md | 505 +++++++++++++++++++++++++++++++++ 1 file changed, 505 insertions(+) create mode 100644 docs/plans/broadcast-module.md diff --git a/docs/plans/broadcast-module.md b/docs/plans/broadcast-module.md new file mode 100644 index 000000000..30af403e5 --- /dev/null +++ b/docs/plans/broadcast-module.md @@ -0,0 +1,505 @@ +# Broadcast / Newsletter — Module Plan + +## Status (2026-04-20) + +**M0 Planning** — dieser Plan. Noch kein Code. + +Nächster Schritt: M1 Skelett (Modul registriert, Dexie-Table, leere ListView). + +--- + +## Ziel + +Ein Modul, mit dem der Nutzer **E-Mail-Kampagnen an Kontakte** verschickt: Newsletter, Ankündigungen, Serienmails. Kernfrage: *"Wer bekommt welche Mail wann — und wie hat sie performt?"* + +Zielgruppen (in Reihenfolge der Priorität): +1. **Creator / Solo-Blogger** — periodischer Newsletter an Abonnenten, Substack-Alternative +2. **Solo-Freelancer** — Kunden-Updates, Projekt-Ankündigungen +3. **Kleine Unternehmen** — Kampagnen, Aktionen +4. **Vereine (später)** — Mitgliederinfos, Mahnungen, Einladungen (verbindet sich später mit Invoices + Events) + +## Abgrenzung + +**In scope (MVP):** +- Kampagnen CRUD (Entwurf → Geplant → Gesendet) +- Empfängerliste aus `contacts` via Tag/Filter-basierter Segmentierung +- Rich-Text-Editor (Tiptap) mit Basis-Formatierung, Bild-Einbettung, Link-Rewriting +- Versand über **mana-mail** mit neuem `/v1/mail/bulk-send` Endpoint +- Per-Empfänger-Tracking: `sent` / `delivered` / `bounced` / `opened` / `clicked` / `unsubscribed` +- Open-Tracking via 1×1 Pixel +- Click-Tracking via Link-Rewrite → Redirect-Endpoint +- Unsubscribe-Link (One-Click, DSGVO-konform, kein Login) +- Templates (Built-in Defaults + User-Custom) +- Basis-Statistik pro Kampagne: Öffnungsrate, Klickrate, Bounce-Rate, Unsubscribe-Rate + +**Out of scope (Phase 2+):** +- A/B-Testing +- Drip-Kampagnen / Automations (später über `rituals`) +- Heatmaps / Click-Maps +- Revenue Attribution +- Behavioral Triggers +- Transaktionale Mails (die bleiben in Stalwart direkt via JMAP) +- SMS +- Shop-Integration +- Double-Opt-In-Flow (MVP: angenommen, Empfänger wurden außerhalb geworben; Phase 2 fügt DOI-Landing-Pages hinzu) + +**Abgrenzung zu `mail`:** +- `mail` = 1:1-Kommunikation (wie Apple Mail / Gmail) +- `broadcast` = 1:N-Kommunikation (Newsletter, Ankündigungen, Serienmails) +- Share: beide nutzen Stalwart als Backend, aber `broadcast` braucht zusätzliche Infrastruktur (Tracking, Bulk-Orchestrierung, DSGVO-Consent-Logs) + +**Abgrenzung zu `invoices`:** +- Invoices sendet 1:1 transaktionale Mails via `mailto:` (weil Stalwart aktuell keine Attachments kann) +- Broadcast sendet 1:N Marketing-/Info-Mails direkt serverseitig +- Later: Invoices könnte `broadcast` nutzen für "Rechnung an alle Mitgliedergruppe" (Vereins-Variante, Phase 3) + +## Namensschema + +- **Modul-/appId:** `broadcast` +- **UI-Label:** "Broadcasts" (oder "Newsletter & Broadcasts") +- **Route:** `/broadcasts` + +Begründung: "Newsletter" impliziert periodisch; "Broadcast" deckt auch einmalige Ankündigungen ab. User-Copy kann trotzdem "Newsletter" sagen, wo das passender ist. + +## Architektur-Entscheidungen + +### Tracking-Infrastruktur: neuer Endpoint-Cluster in `mana-mail` + +`mana-mail` ist der logische Ort — es besitzt schon den SMTP-Stack (Stalwart), die Auth-Integration, und eine Service-Key-basierte internal-Route. + +Drei neue öffentliche (kein Auth, weil Empfänger sie anklicken) Endpoints: +- `GET /v1/mail/track/open/:token` → 1×1 transparentes GIF, loggt Open +- `GET /v1/mail/track/click/:token?url=` → 302-Redirect zum Ziel, loggt Click +- `GET /v1/mail/track/unsubscribe/:token` → HTML-Bestätigungsseite ("bist du sicher?"), POST-Endpoint setzt Empfänger auf `unsubscribed` + +Ein JWT-authed Endpoint für den Bulk-Versand: +- `POST /v1/mail/bulk-send` — nimmt Kampagnen-Payload + Empfängerliste entgegen, verarbeitet serverseitig, streamt Status-Updates zurück via SSE (oder 202-Accepted + Polling) + +**Token-Design:** `{campaignId}:{recipientId}:{nonce}` signiert mit HMAC-SHA256 über einen Broadcast-spezifischen Server-Secret. Kurz (base64url, ~50 chars), stateless validierbar, nicht vorhersagbar. + +### Versand-Orchestrierung: serverseitig, asynchron + +mana-mail nimmt den Bulk-Auftrag entgegen, persistiert ihn als Job, arbeitet ihn im Hintergrund ab: +- Rate-Limiting (max X Mails/Minute pro User, verhindert Spam-Listings) +- Retry bei transienten Fehlern (SMTP 4xx) +- Bounce-Handling (SMTP 5xx → permanent, markiert Empfänger als `bounced`) +- Per-Empfänger Status wird zurückgemeldet (via WebSocket oder Polling auf `/v1/mail/bulk-send/:jobId/status`) + +Für MVP: **synchroner Loop**. Der Nutzer wartet 30 Sekunden für 100 Empfänger — akzeptabel. Echte Async-Jobs + Retry sind Phase 2, wenn Listen wachsen. + +### Client vs. Server für Tracking-Events + +Tracking-Events (Open, Click, Unsubscribe) entstehen auf dem Server (Empfänger öffnen die Mail in Gmail, nicht in Mana). Sie müssen: +1. Serverseitig persistiert werden (Postgres, per-campaign + per-recipient) +2. Zum User-Client synchronisiert werden (damit das Dashboard Öffnungsraten zeigt) + +Zwei Optionen: +- **A)** mana-mail schreibt in eigene Postgres-Tabelle, Webapp liest über authenticated API (`GET /v1/mail/campaigns/:id/events`) +- **B)** mana-mail pusht Events in den normalen Sync-Stream (mana-sync), Webapp bekommt sie wie alle anderen Daten + +**Gewählt: Option A.** Tracking-Events sind viele (potenziell Millionen), und sie müssen nie ein anderes Client-Device beschreiben. Kein Mehrwert durch Sync-Overhead. API-Polling via liveQuery reicht. + +### Rich-Text-Editor: Tiptap + +Bereits im Mana-Stack anerkannt (wird im Notes-Modul schon erwogen). Alternativen verworfen: +- **Unlayer** — hochwertig aber closed-source + SaaS-License für Embed +- **Lexical** (Facebook) — React-only +- **ProseMirror direkt** — zu low-level +- **Plain Markdown + Preview** — okay für v0, aber Bilder-Einbetten wird Krampf + +Tiptap 2 hat Svelte-Wrapper (`@tiptap/svelte`). Extension-basiert → wir nehmen nur was wir brauchen (Starterkit + Image + Link + Placeholder). + +### HTML-Generierung & Inlining + +E-Mail-HTML ≠ Web-HTML. Viele Clients (Outlook, Gmail) rendern Flexbox/Grid/CSS-Variables nicht. Wir brauchen: +1. **Inlining** — Style-Rules werden in `style=""`-Attribute aufgelöst +2. **Table-basiertes Layout** (Altlast aus Outlook-Zeiten) — für komplexe Layouts; MVP hält sich an einfache single-column +3. **Dark-Mode-Compat** — Standard-CSS bypass wird in einigen Clients ausgehebelt; zunächst light-only + +Library-Wahl: **`juice`** (npm, ~20KB) für CSS-Inlining. Ausgeführt serverseitig in mana-mail beim Versand, nicht client-side — dann kann das Webapp-Bundle schlank bleiben. + +### DSGVO & Compliance + +**Critical Path** für EU/CH-Deploy: +1. **Einwilligung:** jeder Empfänger muss explizit zugestimmt haben. Im MVP ist das out-of-scope; User trägt die Verantwortung. Dokumentiert in Settings-Seite mit Warnung. +2. **Double-Opt-In (DOI):** Phase 2 — Landing-Page + Confirm-Mail-Flow. +3. **Unsubscribe-Link in jeder Mail:** Pflicht. Ein-Klick-Abmeldung, kein Login. +4. **List-Unsubscribe-Header** (RFC 8058): `List-Unsubscribe: , ` + `List-Unsubscribe-Post: List-Unsubscribe=One-Click` → Gmail/Apple-Mail-nativer Abmelden-Knopf. +5. **Consent-Audit-Log:** wann/wie kam der Kontakt auf die Liste? (Phase 2) +6. **Data-Processing-Agreement (AVV):** User-Settings-Seite erzeugt ausfüllbares PDF. +7. **Right-to-be-forgotten:** hash-basierte Dedupe + harter Delete beim Unsubscribe (nicht nur soft-delete). + +MVP-Pragmatik: Punkte 1-4 sind Must-Have. 5-7 sind Phase 2. + +### Deliverability + +Newsletter fallen häufiger ins Spam als 1:1-Mail. Checkliste: +1. **SPF** — Stalwart hat's, User-Domain muss eintragen +2. **DKIM** — Stalwart signiert, User-Domain muss eintragen +3. **DMARC** — User-Domain braucht Policy (`p=none` anfangs reicht) +4. **Bounce-Handling** — harte Bounces → Empfänger dauerhaft ausschließen +5. **Warm-up** — erste Kampagne an ≤ 100 Empfänger, dann Ausweitung +6. **Reputation** — User-Feedback-Loop mit Gmail/Hotmail (Phase 2) + +Für MVP: Settings-Onboarding zeigt SPF/DKIM/DMARC-Check mit DNS-Einträgen zum Kopieren. Wenn nicht konfiguriert, warnt der Send-Flow. + +## Modul-Struktur + +``` +apps/mana/apps/web/src/lib/modules/broadcast/ +├── types.ts # LocalCampaign, LocalTemplate, LocalRecipientList, LocalSendStatus +├── collections.ts # Dexie tables, no seed (leere Liste bei First-Run) +├── queries.ts # useAllCampaigns, useCampaign(id), useTemplates, useSendStats(id) +├── stores/ +│ ├── campaigns.svelte.ts # create, update, schedule, cancel, send, duplicate, delete +│ ├── templates.svelte.ts # createTemplate, updateTemplate, deleteTemplate +│ └── settings.svelte.ts # From-Name, Reply-To, Footer, DNS-Check-Status +├── audience/ +│ ├── segment-builder.ts # Pure: Filter-Ausdrücke parsen + auf Contacts matchen +│ └── AudienceBuilder.svelte # UI: Tag-Chips + Feld-Filter + Preview-Liste +├── editor/ +│ ├── Editor.svelte # Tiptap-Wrapper mit Newsletter-spezifischen Extensions +│ ├── ImageUpload.svelte # Via uload, wie bei invoices/logo +│ └── extensions/ # Tiptap-Extensions für Merge-Tags ({{vorname}}), Divider, Button +├── preview/ +│ ├── DesktopPreview.svelte # iframe mit gerendertem HTML +│ ├── MobilePreview.svelte # iframe, schmal, mit User-Agent-Mobile-Hack +│ └── PlainTextPreview.svelte # Pflicht-Alternative für Non-HTML-Clients +├── components/ +│ ├── CampaignCard.svelte # Listenzeile: Betreff + Status + Empfänger + Datum +│ ├── StatusBadge.svelte # draft / scheduled / sending / sent / cancelled +│ ├── StatsPanel.svelte # Open-Rate, Click-Rate, Bounces als Kennzahlen +│ └── DnsCheckBanner.svelte # Warnung wenn SPF/DKIM/DMARC fehlt +├── views/ +│ ├── ListView.svelte # Kampagnen-Liste mit Status-Filter + Stats-Cards +│ ├── DetailView.svelte # Kampagnen-Detail mit Preview + Stats +│ ├── ComposeView.svelte # Compose: Audience → Content → Review → Send +│ └── TemplateManager.svelte # Template-Bibliothek +├── tools.ts # AI-Tools +├── constants.ts # DEFAULT_TEMPLATES, STATUS_LABELS, TRACK_PIXEL_URL +├── module.config.ts # appId: 'broadcast', tables: [...] +└── index.ts +``` + +## Daten-Schema + +### Dexie-Tabellen (client, encrypted) + +```typescript +export type CampaignStatus = + | 'draft' + | 'scheduled' // zeitgesteuert, noch nicht versendet + | 'sending' // aktiv im Versand + | 'sent' // komplett abgeschlossen + | 'cancelled'; + +export interface LocalCampaign extends BaseRecord { + name: string; // encrypted — interner Arbeitstitel + subject: string; // encrypted — E-Mail-Betreff + preheader?: string | null; // encrypted — die Zeile nach dem Betreff in Gmail-Liste + fromName: string; // encrypted — "Till Ilmatar" + fromEmail: string; // encrypted — muss zu verifizierter Domain gehören + replyTo?: string | null; // encrypted + content: { // encrypted — Tiptap-JSON + generiertes HTML + tiptap: object; // Roh-JSON vom Editor + html?: string; // Gerenderter HTML-Output (Cache, regeneriert bei save) + plainText?: string; // Fallback für Non-HTML-Clients + }; + templateId?: string | null; // plaintext — FK zu broadcastTemplates, optional + audience: { // encrypted — Segment-Definition als serialisierter AST + filters: Array<{ + field: 'tag' | 'email' | 'custom'; + op: 'has' | 'not-has' | 'eq' | 'contains'; + value: string; + }>; + estimatedCount: number; // cached, plaintext (für Listen-Preview) + }; + scheduledAt?: string | null; // plaintext — ISO timestamp für Scheduled-Send + sentAt?: string | null; // plaintext + status: CampaignStatus; // plaintext + serverJobId?: string | null; // plaintext — verknüpft mit mana-mail-Job + stats?: { // plaintext — cached, wird per API aktualisiert + totalRecipients: number; + sent: number; + delivered: number; + bounced: number; + opened: number; + clicked: number; + unsubscribed: number; + lastSyncedAt: string; + }; +} + +export interface LocalBroadcastTemplate extends BaseRecord { + name: string; // encrypted + description?: string | null; // encrypted + subject?: string | null; // encrypted — Vorlage für Betreff + content: { tiptap: object }; // encrypted + isBuiltIn: boolean; // plaintext — built-in templates come from constants.ts + thumbnailUrl?: string | null; // plaintext — mana-media +} + +export interface LocalBroadcastSettings extends BaseRecord { + // Sender-Profil (Default für neue Kampagnen) + defaultFromName: string; // encrypted + defaultFromEmail: string; // encrypted + defaultReplyTo?: string | null; // encrypted + defaultFooter?: string | null; // encrypted — erscheint unter jeder Kampagne + + // DNS-Check-Status (last-checked) + dnsCheck?: { // plaintext + domain: string; + spf: 'ok' | 'missing' | 'wrong'; + dkim: 'ok' | 'missing' | 'wrong'; + dmarc: 'ok' | 'missing' | 'wrong' | 'weak'; + checkedAt: string; + } | null; + + // Compliance / DSGVO + legalAddress: string; // encrypted — Pflicht im Footer (ImpressumsPflicht) + unsubscribeLandingCopy?: string | null;// encrypted — Copy für Abmelde-Seite +} +``` + +**Per-Recipient-Status** bleibt serverseitig — siehe Backend-Schema unten. Kein Dexie-Mirror (könnte bei 10k+ Empfängern teuer werden). + +### Postgres (mana-mail-seitig, neuer `broadcast` Schema) + +```sql +-- Server-seitiger Spiegel einer gesendeten Kampagne (für Tracking-Events) +CREATE TABLE broadcast.campaigns ( + id TEXT PRIMARY KEY, -- matches LocalCampaign.id + user_id TEXT NOT NULL, + subject TEXT, -- für Audit; wird nicht re-sendet + sent_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE broadcast.sends ( + id TEXT PRIMARY KEY, -- UUID + campaign_id TEXT REFERENCES broadcast.campaigns(id) ON DELETE CASCADE, + recipient_email TEXT NOT NULL, + recipient_contact_id TEXT, -- FK zu contacts, optional + tracking_token TEXT NOT NULL UNIQUE, -- für Open/Click/Unsubscribe-Links + status TEXT NOT NULL, -- queued | sent | delivered | bounced | failed + sent_at TIMESTAMPTZ, + bounced_at TIMESTAMPTZ, + bounce_reason TEXT +); + +CREATE TABLE broadcast.events ( + id BIGSERIAL PRIMARY KEY, + send_id TEXT REFERENCES broadcast.sends(id) ON DELETE CASCADE, + kind TEXT NOT NULL, -- open | click | unsubscribe + occurred_at TIMESTAMPTZ DEFAULT NOW(), + ip_hash TEXT, -- HMAC — für Dedup, nicht PII + user_agent_hash TEXT, -- HMAC — für Dedup + link_url TEXT, -- nur bei kind=click + metadata JSONB +); + +CREATE INDEX ON broadcast.events (send_id, kind); +CREATE INDEX ON broadcast.events (occurred_at DESC); +``` + +### Encryption-Registry + +```typescript +broadcastCampaigns: entry([ + 'name', 'subject', 'preheader', 'fromName', 'fromEmail', 'replyTo', + 'content', 'audience', +]), +broadcastTemplates: entry([ + 'name', 'description', 'subject', 'content', +]), +broadcastSettings: entry([ + 'defaultFromName', 'defaultFromEmail', 'defaultReplyTo', 'defaultFooter', + 'legalAddress', 'unsubscribeLandingCopy', +]), +``` + +## Backend-Erweiterungen (`mana-mail`) + +### Neue Routes + +``` +services/mana-mail/src/routes/ +├── broadcast-send.ts # POST /v1/mail/bulk-send (JWT auth) +├── broadcast-stats.ts # GET /v1/mail/campaigns/:id/events (JWT auth) +├── broadcast-track.ts # GET /v1/mail/track/... (PUBLIC, no auth) +└── broadcast-dns.ts # GET /v1/mail/dns-check?domain=... (JWT auth) +``` + +### Neue Services + +``` +services/mana-mail/src/services/ +├── broadcast-orchestrator.ts # Bulk-Send-Loop mit Rate-Limiting +├── html-inliner.ts # juice-Wrapper für inline-css +├── tracking-token.ts # HMAC-Sign/Verify +├── bounce-handler.ts # SMTP-Response-Parser +└── dns-check.ts # SPF/DKIM/DMARC-Verifier +``` + +### Neue Env-Vars + +``` +BROADCAST_TRACKING_SECRET=... # HMAC-Secret für Tokens +BROADCAST_MAX_RECIPIENTS_PER_HOUR=500 +BROADCAST_MAX_RECIPIENTS_PER_CAMPAIGN=5000 +BROADCAST_UNSUBSCRIBE_LANDING_URL=https://mana.how/unsubscribe +``` + +## Tracking-Pipeline + +### Open-Tracking (Pixel) + +Beim Rendern der Mail wird vor `` ein 1×1-Bild eingefügt: +```html + +``` + +Server-Endpoint: +- Prüft Token (HMAC), findet `send_id` +- INSERT in `broadcast.events` mit `kind='open'`, IP/UA-Hash +- Dedupe per (send_id, ip_hash, user_agent_hash, day) — sonst zählt "User öffnet 5×" als 5 Opens +- Response: 1×1 transparent GIF, `Cache-Control: no-store` + +### Click-Tracking (Link-Rewrite) + +Beim HTML-Rendering werden alle ``-Links umgeschrieben zu: +```html + +``` + +Server-Endpoint: +- Prüft Token, findet `send_id` +- INSERT in `broadcast.events` mit `kind='click'`, `link_url=decoded` +- 302-Redirect zum Original-URL +- Bei ungültigem Token: 302 zum Original (graceful fail — User soll nicht stehen bleiben) + +**Known issue:** User, die die Mail als "als Text anzeigen" öffnen, bekommen kein Tracking. Akzeptabel. + +### Unsubscribe + +Zwei Pfade: +1. **Link im Mail-Footer** → GET `/track/unsubscribe/:token` → HTML-Seite mit Bestätigen-Button +2. **List-Unsubscribe-Header** → Gmail-Native → POST direkt + +Beide landen im gleichen Handler: `UPDATE broadcast.sends SET status='unsubscribed' WHERE tracking_token = ?`, plus Event-Log. Bei zukünftigen Kampagnen automatisch ausgeschlossen. + +## UI-Konzept + +### Landing (`/broadcasts`) + +- **Kennzahlen-Karten** oben: Kampagnen dieses Jahr, durchschnittliche Öffnungsrate, Unsubscribes +- **Status-Chips**: Alle | Entwurf | Geplant | Gesendet | Abgebrochen +- **Kampagnen-Liste**: Karte pro Kampagne mit Betreff + Empfänger-Count + Sent-Datum + Mini-Stats (%) +- **FAB**: "+ Neue Kampagne" → öffnet ComposeView + +### Compose-Flow (4 Schritte mit Stepper) + +1. **Audience** — Filter-Builder mit Live-Preview der Empfängerliste +2. **Content** — Editor mit Live-Preview (Desktop + Mobile Tabs) +3. **Preflight** — Review: Absender, Betreff, Empfänger-Count, DNS-Check, Unsubscribe-Link-Check, Spam-Score-Preview +4. **Send** — "Jetzt senden" ODER "Später senden" (Datepicker) + +### DetailView + +- **Header**: Betreff + Status + Sent-At + Empfänger-Count +- **Stats-Panel** (für `sent` Kampagnen): Open-Rate, Click-Rate, Bounce-Rate, Unsubscribe-Rate als Prozent-Balken +- **Preview**: iframe mit finalem HTML +- **Empfänger-Liste**: tabellarisch, filterbar (alle / bounced / unsubscribed) +- **Klick-Liste** (für Performance-Analyse): Top-geklickte Links mit Count + +### Settings + +- Absender-Defaults (Name, E-Mail, Reply-To) +- Legal-Address (Impressum-Pflicht) +- Default-Footer +- **DNS-Check-Widget** — klickbar "jetzt prüfen" → ruft `/v1/mail/dns-check`, zeigt SPF/DKIM/DMARC-Status mit Copy-Paste-DNS-Records + +## Milestones + +| Milestone | Umfang | Aufwand | +|---|---|---| +| **M1 Skelett** | Modul registriert, Dexie-Tables, leere ListView + ComposeView-Stub, Settings-Placeholder | 1 Tag | +| **M2 Audience + Editor** | AudienceBuilder (Tag-Filter), Tiptap-Editor mit Bild-Upload, Live-Count der Empfänger | 3 Tage | +| **M3 HTML-Render** | Tiptap-JSON → HTML mit Footer + Unsubscribe-Link, Desktop/Mobile-Preview, PlainText-Fallback | 2 Tage | +| **M4 Bulk-Send (synchron)** | `/v1/mail/bulk-send` in mana-mail (sync loop), Tracking-Token-Signing, HTML-Inlining via juice | 3 Tage | +| **M5 Open + Click Tracking** | Track-Endpoints, Pixel + Link-Rewrite, Postgres-Schema, Stats-API | 2 Tage | +| **M6 Unsubscribe** | Landing-Seite, List-Unsubscribe-Header, Empfänger-Exclusion bei nächster Kampagne | 1 Tag | +| **M7 Stats-Dashboard** | StatsPanel mit Raten, Empfänger-Liste filterbar, Klick-Liste | 1.5 Tage | +| **M8 DNS-Check + Settings** | DNS-Verifier, Settings-UI mit Copy-Paste-Records, Legal-Address-Pflicht | 1 Tag | +| **M9 AI-Tools** | `suggest_subject_line`, `segment_audience`, `draft_campaign_from_notes` | 1.5 Tage | +| **M10 Dashboard-Widget + Tests** | "Offene Kampagnen + YTD-Stats" als Widget, Unit-Tests für Pure-Helpers | 1 Tag | + +**Summe MVP (M1–M10):** ~17 Arbeitstage → realistisch **~4 Wochen** mit Polish + Testing. + +## AI-Integration + +Nach M4-Basis-Versand. Tools werden in `@mana/shared-ai`'s `AI_TOOL_CATALOG` registriert: + +| Tool | Policy | Beschreibung | +|------|--------|--------------| +| `create_campaign` | propose | Entwurf aus Kurzbeschreibung — Subject, Audience-Filter, Body-Grobstruktur | +| `suggest_subject_line` | auto | 5 Varianten eines Betreffs, optimiert für Öffnungsrate (kurz, aktiv, konkret) | +| `segment_audience` | auto | "Kontakte mit Tag X, aber nicht Y" — parse Natural-Language-Query zu Filter-AST | +| `draft_campaign_from_notes` | propose | Nimmt eine `note` als Input, generiert Newsletter-Outline | +| `list_campaigns` | auto | Status + Zeitraum, für Planner-Kontext | +| `get_campaign_stats` | auto | Raten für eine spezifische Kampagne | +| `schedule_campaign` | propose | Entwurf → Scheduled mit Timestamp | + +**Mission-Beispiel:** *"Jeden Freitag meine Notizen der Woche zu einem Newsletter-Entwurf machen"* — auto-Call `list_notes(thisWeek)`, dann propose `draft_campaign_from_notes`. + +## Offene Fragen + +1. **Sender-Domain:** Mana als SaaS braucht eine eigene Versand-Domain (z.B. `mail.mana.how`). Wenn User eigene Domain (`news.kunde.ch`) nutzt, müssen wir DKIM/SPF für deren Domain co-signen — das ist Stalwart-Config-Arbeit. MVP: nur mana-Domain, DNS-Check für Custom-Domain kommt M8. + +2. **Spam-Score Preview (M3 Preflight):** Nutzen wir `SpamAssassin` oder eine SaaS (Mail-Tester.com API)? SpamAssassin selbst hosten ist Wartungsaufwand. Für MVP: einfache Regex-basierte Hinweise ("Betreff ist zu lang", "zu viele Ausrufezeichen") + externe manuelle Prüfung empfehlen. + +3. **Image-Hosting:** Bilder in Mails müssen von einer öffentlichen URL kommen. Nutzen wir uload wie bei invoices/logo? Dann müssen uload-URLs CDN-cached + nicht hinter Auth sein. Klären vor M2. + +4. **Webhooks für Bounce-Handling:** Stalwart kann Bounce-Notifications als Webhook senden. Alternative: periodisches IMAP-Polling der `bounces@`-Mailbox. Einfacher = IMAP-Poll, robuster = Webhook. + +5. **Rate-Limits:** wie viele Mails pro Stunde sind OK für unser Stalwart-Setup? Muss man mit dem Hoster (Nine/Cloudflare/…) abklären. Konservativ: 100/h pro User, 500/h global, im MVP. + +6. **Zero-Knowledge-Mode:** Newsletter-Content ist by-design nicht sensitiv (geht ja an N Empfänger raus). Aber: Empfänger-Listen SIND sensitiv. Im ZK-Mode müsste der Server die Empfängerliste entschlüsseln können, um Mails zu versenden — das bricht ZK. Lösung: **Broadcast-Modul ist explizit nicht ZK-kompatibel**, warnt im Onboarding. + +7. **Tiptap-Bundle-Size:** Tiptap core + Starterkit + Image + Link ≈ 100 KB gzipped. Akzeptabel. Wir lazy-laden den Editor nur in ComposeView. + +8. **Empfänger aus mehreren Quellen:** Nur contacts für MVP. Phase 2: manuelle Listen (CSV-Import), Phase 3: externe Integrationen (Mailchimp-Import). + +## Abhängigkeiten + +- `contacts` — Empfängerlisten (vorhanden) +- `mana-mail` — muss Bulk-Send + Track-Endpoints bekommen (neue Routes) +- `mana-media` (`uload`) — für Bild-Einbettung im Editor (vorhanden) +- `rituals` — Phase 2 für wiederkehrende Kampagnen (vorhanden) +- `notes` — AI-Tool `draft_campaign_from_notes` (vorhanden) +- npm: `@tiptap/core`, `@tiptap/starter-kit`, `@tiptap/extension-image`, `@tiptap/extension-link`, `juice` (server-seitig in mana-mail) + +## Erfolgs-Kriterien + +- **Tag 1 nach MVP-Launch:** Till verschickt Newsletter an 5 Test-Empfänger, sieht Open-Stats im Dashboard +- **Woche 2:** 3 externe Alpha-Tester nutzen das Modul für echte Kampagnen +- **Monat 1:** Eine Kampagne mit ≥ 100 Empfängern verschickt, Open-Rate ≥ 25% (Consumer-Benchmark) +- **Monat 3:** Mission "Jeden Freitag Newsletter-Entwurf aus Notizen" läuft automatisch für mindestens einen User + +## Phase 2 (nach MVP) + +- Double-Opt-In mit Landing-Pages +- Consent-Audit-Log (wer hat wann zugestimmt, woher) +- A/B-Testing (zwei Subjects, 20% Test, automatischer Gewinner-Versand) +- Drip-Kampagnen via `rituals`-Integration +- Empfänger-CSV-Import +- Externe Imports (Mailchimp/Substack) +- Bounce-Webhook statt IMAP-Poll +- Custom-Domain-DKIM-Signing + +## Phase 3 (Vereinsvariante) + +- Mitglieder-Gruppen als Audience-Quelle (nach Paket A Rollen-/Rechtematrix) +- "Invoices + Newsletter"-Kombi: Mitgliederrechnung mit Anschreiben-Mail +- Vereinssatzungen als Anhang bei Jahresmailing