# MailForge Tracking & Analytics — Test- und Verifikations-Guide

> **Single Source of Truth** zum Prüfen, dass das Tracking-System wie erwartet funktioniert.
> Pro Phase werden hier die Funktionen dokumentiert, mit Test-Szenarien, DB-Queries und UI-Checklisten.
>
> Stand: Phase 1 + Phase 2 ausgerollt. Phase 3 + 4 sind TODO.

---

> ### 📘 Für Shop-Entwickler:innen
>
> Diese Doku ist für das **MailForge-Team** (Test-Szenarien, DB-Queries, UI-Checklisten).
>
> Wenn du die Shop-Seite (its.de, kompass-versicherung.de etc.) für das Tracking integrieren willst, ist die richtige Doku:
>
> **→ [`docs/shop-integration-guide.md`](./shop-integration-guide.md)** ←
>
> Dort findest du: Snippet-Einbau, HTML-Markup-Attribute, Conversion-Pixel, Webhook-HMAC-Code in Node/PHP/Python/curl, GA4-Service-Account-Setup, Test-Checklist und Troubleshooting.

---

---

## 1. Glossar — was wird gemessen, was nicht

### 1.1 Email-Events (`tracking_events` Tabelle)

| Event-Typ | Wann geschrieben | Quelle | Bemerkung |
|---|---|---|---|
| `delivered` | Direkt nach erfolgreichem `sendMail()` im Worker | `source='system'` | "Microsoft hat die Mail angenommen" — kein echter Mailbox-Receipt, aber bessere KPI als nur Queue-Status |
| `open` | Open-Pixel `/api/track/open` wird vom Client geladen | implizit (kein source) | Bilder müssen geladen werden — Outlook/Apple-Mail blocken oft per Default |
| `click` | Click-Wrapper `/api/track/click` redirected zum Ziel | implizit | Wird VOR dem 302 geschrieben — wenn Redirect fehlschlägt, fehlt Event |
| `bounce` | (Phase 3) Inbox-Scanner findet NDR-Mail | `source='system'` | Nur Hard-Bounces, Soft-Bounces werden ignoriert |
| `unsubscribe` | User klickt Footer-Link, Token wird verifiziert | — | Schreibt zusätzlich Row in `unsubscribes` |
| `complaint` | (Phase 3) Aus FBL-Reports | `source='system'` | Aktuell kein automatischer Pfad |
| `conversion` | Pixel auf Thank-You oder Cumas-Polling findet Order | `source='pixel'\|'api_poll'\|'webhook'\|'manual'` | Pflicht-Spalte `recipientId` — sonst nicht zuordenbar |

**Raw vs. Verified Opens:** Apple Mail Privacy Protection lädt seit 2021 alle Pixel automatisch beim Mailserver, ohne dass der User die Mail jemals geöffnet hat. Das pumpt die Open-Rate künstlich um 30–50 % auf. Wir markieren solche Pings mit `is_mpp = 1` und filtern sie aus dem "Verified Open" KPI raus. Die rohe `openCount`-Spalte auf der Variante bleibt mit-MPP für historische Vergleichbarkeit.

### 1.2 Onsite-Events (`behavior_events` Tabelle, Phase 3)

| Event-Typ | Auslöser | Bemerkung |
|---|---|---|
| `pageview` | DOMContentLoaded auf Brand-Zielseite | Auto-getrackt von `mf-track.js` |
| `product_view` | `<a data-mf-event="product_view">` oder JSON-LD-Heuristik | Mit `productId` |
| `add_to_cart` | `<button data-mf-event="add_to_cart" data-mf-value="…">` | `value` in EUR-Cent |
| `quote_form` | `<form data-mf-event="quote_form">` Submit | |
| `scroll_depth` | 25/50/75/100% Schwellen | Mit `scrollPct` |
| `time_on_page` | `beforeunload` / `visibilitychange` | Mit `durationMs` |
| `custom` | Manuell vom Snippet | Frei-formatige `metaJson` |

**Quelle:** Phase 3 unterscheidet via `source` zwischen `'js'` (mf-track.js Beacon) und `'webhook'` (Shop-Backend Push).

### 1.3 MPP — Apple Mail Privacy Protection

Apple's Privacy Proxy lädt alle Email-Pixel über einen Apple-Datacenter-Proxy, mit:
- **User-Agent** exakt `Mozilla/5.0` (kein Browser-Token, kein Engine-Suffix)
- **IP** aus einem Apple-eigenen /20-Range (siehe `data/apple-mpp-ranges.json`)

Unsere Detection in `src/lib/tracking/mpp-detect.ts:isMpp()`:
- Wenn beides zutrifft → `isMpp = true`
- Falsche Positive selten, weil "nackte UA" außerhalb von Apple ungewöhnlich ist
- Falsche Negative möglich wenn Apple-IP-Liste veraltet — quartalsweise refreshen

**Konsequenz für KPIs:** "Verified Open Rate" = `uniqueOpensVerified / sentCount`, NICHT `uniqueOpens / sentCount`. Im UI ist das die Hauptmetrik, "Raw Open" ist die Sub-Info mit MPP-Anteil als Tooltip.

### 1.4 Attribution — Last-Click vs. Multi-Touch

Brand-Setting `attributionModel` in `brands` legt fest, wie Conversions auf Touches verteilt werden:
- `last-click` (Default) → 100 % Credit auf letzten `click`/`open` im Lookback-Window
- `first-click` → 100 % Credit auf ersten Touch
- `linear` → 1/N pro Touch
- `time-decay` → exponentiell, Halbwertszeit 7 Tage

Lookback in `brands.attributionLookbackDays` (Default 30). Ein Touch außerhalb des Windows bekommt 0 Credit.

---

## 2. End-to-End-Test-Szenarien

### 2.1 Szenario "Normaler Empfänger" (Outlook)

**Setup:**
- Test-Campaign mit 1 Variant, 1 Empfänger (eigene Email-Adresse)
- Brand mit `attributionLookbackDays = 30`, `attributionModel = 'last-click'`

**Flow:**
1. Campaign senden → Worker schreibt `delivered` direkt nach `sendMail()` ✅
2. Mail in Outlook (Desktop) öffnen → Open-Pixel lädt → `tracking_events.open` mit `isMpp = 0` ✅
3. Auf CTA klicken → `tracking_events.click` mit `linkUrl` der ge-UTM-ten URL ✅
4. Auf Thank-You-Page kommen → falls Pixel eingebaut, `tracking_events.conversion` mit `source='pixel'` ✅

**Erwartetes Resultat in der UI:**
- Stats-Übersicht: Verified Open 100 %, Click-Rate 100 %, CTOR 100 %, Delivery 100 %
- Stats → Empfänger: 1 Reihe mit Status "konvertiert", Conversion-Spalte zeigt Wert oder Anzahl
- Drawer öffnen: 4 Events chronologisch (delivered → open → click → conversion)

### 2.2 Szenario "Apple-Mail-Empfänger" (MPP-Filter)

**Setup:**
- Wie 2.1, aber Empfänger ist Apple-Mail-User (iOS oder macOS mit aktivem MPP)

**Flow:**
1. Senden → `delivered`-Event
2. Mail ankommen lassen, **nicht öffnen** → Apple's Proxy lädt das Pixel trotzdem → `tracking_events.open` mit `isMpp = 1`
3. Verified Opens bleibt bei 0, Raw Opens steigt auf 1

**Erwartetes Resultat:**
- UI: "Raw Open 100 %, Verified Open 0 %, MPP-Anteil 100 %"
- Status-Badge des Empfängers bleibt "zugestellt", NICHT "geöffnet"
- Im Drawer: Open-Event hat MPP-Marker und Description "Apple-MPP-Proxy-Ping (kein verifizierter Open)"

### 2.3 Szenario "Hard-Bounce"

**Setup:**
- Settings → Onsite-Tracking → noch nicht relevant. Aktivierung läuft via API direkt.
- `app_settings.bounce_polling.enabled = true` setzen, `senderMailboxes = ['its@…', 'ksm@…']`, `intervalMinutes = 10`.
- Microsoft Graph App-Permission `Mail.Read` muss für die Sender-Postfächer gewährt sein (Admin-Consent im Tenant).

**Flow:**
1. Test-Campaign senden an `bounce-test@invalid.local` (Domain existiert nicht → Hard-Bounce garantiert)
2. Worker schreibt `delivered`-Event direkt nach Send (Microsoft akzeptiert die Mail erstmal)
3. Microsoft schickt NDR ("Mail Delivery Failure") ins Sender-Postfach
4. Beim nächsten `runBounceInboxScannerTick()` (alle 10 Min):
   - Graph-Inbox-Scan findet die NDR per Subject-Pattern `undeliverable` / `Mail Delivery Failure`
   - `parseNdrBody()` extrahiert die fehlgeschlagene Email + Diagnose-Code (5.x.x)
   - `recordBounce()` schreibt `tracking_events.eventType='bounce'` + `unsubscribes`-Row mit `source='complaint'`

**Erwartetes Resultat:**
- DB-Query 4.5 liefert die Bounce-Zeile mit korrekter Email + Diagnose
- Stats-Tab Empfänger zeigt den Empfänger mit roter "Bounce"-Badge
- Künftige Sends an diese Email werden automatisch übersprungen (durch Unsubscribe-Row)

**Wartung:**
- Bei wiederholten "parse_failed"-Logs: `parseNdrBody()` in `src/lib/bounces/inbox-scanner.ts` erweitern um neue Body-Patterns
- NDR-Mails werden NICHT automatisch aus dem Postfach gelöscht — manuelle Aufräumung oder Outlook-Inbox-Regel "ältere als 30 Tage in Archiv"

### 2.4 Szenario "Onsite-Engagement"

**Setup:**
- Settings → Onsite-Tracking → für die Brand (z.B. ITS) die GA4-Property-ID eingeben + Service-Account-JSON hochladen (optional, für GA4-Validierung)
- Snippet kopieren: `<script src="https://kompassmail.in3ttt.com/mf-track.js" data-brand="its" defer></script>`
- Snippet auf der Shop-Seite (`its.de` oder Test-Subdomain) vor `</body>` einbauen

**Flow:**
1. Test-Mail senden mit Tracking-Link auf eine Shop-Seite, auf der `mf-track.js` eingebunden ist
2. Empfänger klickt CTA → `/api/track/click` Redirect → Set-Cookie `mf_attr={r,c,v,ts}` mit Domain `.its.de`
3. Browser landet auf der Shop-Seite, `mf-track.js` lädt:
   - Liest Cookie `mf_attr` → kennt Recipient-Context
   - Sendet `pageview` an `/api/track/behavior`
   - Bei JSON-LD `@type:"Product"` zusätzlich `product_view`
4. Empfänger scrollt → `scroll_depth` Events bei 25/50/75/100%
5. Empfänger füllt Formular `<form data-mf-event="quote_form">` aus + submit → `quote_form` Event
6. Beim Tab-Wechsel/Schließen: `time_on_page` Event mit `durationMs`

**Erwartetes Resultat:**
- DB-Query 4.6 zeigt alle Behavior-Events des Recipients chronologisch
- Stats-Tab Empfänger zeigt in der "Verhalten"-Spalte die Event-Count
- Recipient-Timeline-Drawer zeigt die Events als Lifeline mit `onsite`-Pill (rechts vom Email-Event-Strom)
- Hot-Lead-Markierung 🔥 erscheint sobald Status=clicked UND behaviorCount ≥ 2

**Manueller Test ohne Shop-Seite:**
```bash
curl -X POST https://kompassmail.in3ttt.com/api/track/behavior \
  -H "Content-Type: application/json" \
  -d '{"recipientId":"<RECIPIENT_UUID>","type":"pageview","url":"https://test.de/"}'
```
→ Response `{"ok":true}` und ein Behavior-Event in der DB.

### 2.5 Szenario "Konversion via Cumas-Polling"

**Setup:**
- Cumas-Polling im Settings-Drawer aktiviert, Interval z.B. 10 Min
- `brands.cumasRecipientField` oder globales `app_settings.conversion_polling.recipientIdField` zeigt auf das Cumas-Feld, das die Recipient-ID enthält

**Flow:**
1. Test-Empfänger erhält Mail, klickt CTA mit UTM-Parametern (`utm_content=A-r7f3a91…`)
2. Empfänger schließt einen Antrag im Cumas-Backend ab
3. Beim nächsten `runConversionPollingTick()`: Poller findet neue Order, matcht über Recipient-ID-Feld
4. `recordConversion()` schreibt `tracking_events.conversion` mit `source='api_poll'`

**Erwartetes Resultat:**
- Drawer zeigt Conversion-Event mit Description "Quelle: api_poll · 540 €"
- Stats-Tile: Conversion-Rate steigt, € Wert summiert

---

## 3. UI-Checkliste

### 3.1 Stats-Tab "Übersicht" (Phase 1)

Pro Variant-Tile sollten **6 Metriken in 2 Reihen** sichtbar sein:

**Primärreihe (große KPIs):**
- 🛡 Verified Open: `uniqueOpensVerified / sentCount × 100`
- 🖱 Click-Rate: `uniqueClicks / sentCount × 100`
- 🎯 CTOR: `uniqueClicks / uniqueOpensVerified × 100`

**Sekundärreihe (Detail):**
- 📨 Delivery: `deliveredCount / sentCount × 100`
- 💰 Conversion: `conversionCount / sentCount × 100`, Wert in € wenn `conversionValueTotal > 0`
- 👁 Raw Open: `uniqueOpens / sentCount × 100`, mit MPP-Anteil-Tooltip

**Alert-Badges:**
- 🛑 Roter Bounce-Badge oben rechts wenn `bounceRate > 2 %`

Roh-Counter-Zeile darunter: "X opens · Y clicks · Z delivered".

### 3.2 Stats-Tab "Empfänger" (Phase 2)

**Filter-Bar:**
- Such-Input (Email/Name, 300ms debounced)
- Status-Dropdown: `Alle Status | Konvertiert | Geklickt | Geöffnet (verified) | Zugestellt | Bounced | Abgemeldet | Nur versendet`
- Variant-Dropdown (nur sichtbar wenn ≥ 2 Varianten)
- Rechtsbündig: "X Empfänger" Total-Count

**Tabellen-Spalten:**
1. **Empfänger** — Name (oder Email) groß, Email klein darunter
2. **Variant** — Label-Badge "A"/"B"/"C"
3. **Status** — farbiges Badge (Status) + 🔥 Hot bei `status=clicked` UND `behaviorCount ≥ 2` + grauer "MPP"-Badge bei `isMppOnly`
4. **Zugestellt** — Timestamp oder "—"
5. **Öffnung** — Erst-Open-Timestamp + Anzahl-Subtext bei `>1`
6. **Klick** — Erst-Click-Timestamp + Anzahl-Subtext
7. **Verhalten** — Behavior-Event-Count grün (Phase 3) oder "—"
8. **Conversion** — Wert (€) oder Anzahl oder "Bounce"-Badge

**Pagination:**
- 50 Einträge pro Seite
- "Seite X von Y" links, "Zurück"/"Weiter" rechts
- Bei einzigem Page kein Pagination-Footer

**Click-Verhalten:** Click auf Zeile öffnet Timeline-Drawer (Phase 2.3).

### 3.3 Timeline-Drawer (Phase 2)

**Header:**
- Name + Variant-Badge
- Email darunter
- Brand · Campaign

**Quick-Stats-Strip (4 Zahlen):**
- Opens (Icon ändert sich bei MPP-Only)
- Klicks
- Onsite-Events
- Conversion-Wert

**Lifeline (vertikale Liste):**
- Älteste Events oben, neueste unten
- Pro Event: farbiges Icon-Bubble + Label + Description + Timestamp
- Email-Events sind farblich subtiler, Onsite-Events haben "onsite"-Pill
- MPP-Open hat zusätzlichen "MPP"-Pill
- Bei Click-Events: "Link öffnen"-Mini-Link unter der Beschreibung

**CRM-Sektion (collapsible):**
- Toggle "CRM-Daten" mit Datenbank-Icon
- Bei Expand: Key-Value-Liste aus `customDataJson`, CRM-ID als kleine Mono-Pill im Header
- Nur sichtbar wenn `customDataJson` Daten hat

**GA4-Placeholder:**
- Strichliniertes Box am Ende: "GA4-Session-Lookup folgt in Phase 4"

**Verhalten:**
- ESC schließt Drawer
- Click auf Backdrop schließt Drawer
- X-Button rechts oben schließt Drawer

### 3.4 Settings-Drawer Onsite-Tracking-Tab (Phase 3+4)

**Aufruf:** Top-rechts ⋯-Menü → "Einstellungen" → Tab "Onsite-Tracking".

**Pro Brand-Card sollte sichtbar sein:**

1. **Onsite-Snippet-Sektion**
   - Code-Block mit `<script src="https://kompassmail.in3ttt.com/mf-track.js" data-brand="<slug>" defer></script>`
   - "Kopieren"-Button kopiert das Snippet in die Zwischenablage und zeigt "✓"-Feedback für 2s
   - Hinweis-Text mit der konfigurierten `shopDomain` der Brand (oder "Brand-Zielseite" wenn nicht gesetzt)

2. **Webhook-Endpoint-Sektion**
   - Code-Block mit `POST https://kompassmail.in3ttt.com/api/track/webhook/<slug>`
   - "URL"-Copy-Button kopiert die Endpoint-URL
   - Secret-Anzeige: gekürzt als `abc1234567…ef89` (erste 10 + letzte 4 Hex-Chars) oder "nicht konfiguriert"
   - Button "Generieren" / "Rotieren" mit Confirm-Dialog
   - Nach Rotation: vollständiger Secret-Wert + "Kopieren"-Button (NUR diese eine Anzeige, danach nicht mehr abrufbar)

3. **GA4-Quervalidierung-Sektion**
   - Input "GA4 Property-ID" mit Save-Button — speichert via `/api/brands` (upsertBrand)
   - Textarea "Service-Account JSON" mit Save-Button — speichert via `/api/brands/[slug]/credentials` (PATCH)
   - Bei vorhandenen Credentials: grüner "✓ konfiguriert"-Marker + "Löschen"-Link
   - Validierung beim Save: JSON muss `client_email` (mit @) und `private_key` (mit `BEGIN PRIVATE KEY`) enthalten

**Feedback-Banner:**
- Grün (oben in Card): "GA4-Property-ID gespeichert" / "Credentials gespeichert" / "Webhook-Secret rotiert" — verschwindet nach 3s
- Rot: Fehler-Message bei Validierungs- oder Save-Failures

**Sicherheits-Pattern:**
- Credentials-JSON wird NIE im Frontend zurück geliefert — nur ein "konfiguriert ja/nein"-Boolean
- Webhook-Secret nach Rotation NUR EINMAL sichtbar — danach Hash-Vorschau
- Confirm-Dialog vor Rotation/Löschung schützt vor versehentlicher Ungültigkeit der laufenden Shop-Integration

---

## 4. DB-Verifikation (copy-paste-Queries)

Alle Queries direkt gegen `data/mailforge.db` ausführbar via SQLite-CLI:

```bash
sqlite3 data/mailforge.db
```

> ⚠️ Nur lesende Queries hier dokumentieren — Schreiboperationen IMMER über Drizzle-Repos in `src/lib/db/` laufen lassen (siehe `CLAUDE.md` "Nie raw SQL über die Bash schreiben").

### 4.1 Opens insgesamt vs. verified pro Campaign

```sql
SELECT
  c.name AS campaign,
  COUNT(*) AS opens_total,
  SUM(CASE WHEN te.is_mpp IS NULL OR te.is_mpp = 0 THEN 1 ELSE 0 END) AS opens_verified,
  SUM(CASE WHEN te.is_mpp = 1 THEN 1 ELSE 0 END) AS opens_mpp,
  ROUND(100.0 * SUM(CASE WHEN te.is_mpp = 1 THEN 1 ELSE 0 END) / COUNT(*), 1) AS mpp_pct
FROM tracking_events te
JOIN campaigns c ON c.id = te.campaign_id
WHERE te.event_type = 'open'
GROUP BY te.campaign_id
ORDER BY opens_total DESC;
```

**Interpretation:** `mpp_pct > 50 %` ist typisch für B2C-Campaigns mit vielen Apple-Usern. Wenn dauerhaft `< 5 %`, Apple-IP-Ranges in `data/apple-mpp-ranges.json` checken — vermutlich veraltet.

### 4.2 Geklickt aber nicht konvertiert (Sales-Lead-Liste)

```sql
SELECT DISTINCT
  r.id,
  r.email,
  r.first_name,
  r.last_name,
  click_ev.created_at AS first_clicked_at
FROM recipients r
JOIN tracking_events click_ev
  ON click_ev.recipient_id = r.id
  AND click_ev.event_type = 'click'
LEFT JOIN tracking_events conv_ev
  ON conv_ev.recipient_id = r.id
  AND conv_ev.event_type = 'conversion'
WHERE r.campaign_id = ?
  AND conv_ev.id IS NULL
ORDER BY click_ev.created_at DESC;
```

**Use-Case:** Hot Leads für Sales-Hand-off — haben Interesse gezeigt, aber noch nicht abgeschlossen.

### 4.3 MPP-Anteil pro Campaign

```sql
SELECT
  campaign_id,
  COUNT(*) AS opens_total,
  SUM(CASE WHEN is_mpp = 1 THEN 1 ELSE 0 END) AS opens_mpp,
  ROUND(100.0 * SUM(CASE WHEN is_mpp = 1 THEN 1 ELSE 0 END) / COUNT(*), 1) AS mpp_pct
FROM tracking_events
WHERE event_type = 'open'
GROUP BY campaign_id
ORDER BY opens_total DESC;
```

### 4.4 Delivery-Rate (delivered / sent)

```sql
SELECT
  c.name AS campaign,
  COUNT(DISTINCT q.id) AS queue_jobs,
  SUM(CASE WHEN q.status = 'sent' THEN 1 ELSE 0 END) AS jobs_sent,
  (SELECT COUNT(*) FROM tracking_events
   WHERE campaign_id = c.id AND event_type = 'delivered') AS delivered_events,
  ROUND(100.0 *
    (SELECT COUNT(*) FROM tracking_events
     WHERE campaign_id = c.id AND event_type = 'delivered')
    / NULLIF(SUM(CASE WHEN q.status = 'sent' THEN 1 ELSE 0 END), 0),
    1) AS delivery_pct
FROM campaigns c
LEFT JOIN email_queue q ON q.campaign_id = c.id
WHERE c.id = ?
GROUP BY c.id;
```

**Erwartetes Ergebnis:** `delivery_pct` sollte sehr nah an 100 % sein. Wenn dauerhaft < 95 %, prüfen ob `recordDelivered()` im Worker korrekt aufgerufen wird (siehe `src/worker/index.ts` — direkt nach erfolgreichem `sendMail()`).

### 4.5 Bounce-Liste eines Tages (Phase 3 — TODO)

```sql
SELECT
  te.created_at,
  r.email,
  c.name AS campaign,
  te.meta_json
FROM tracking_events te
JOIN recipients r ON r.id = te.recipient_id
JOIN campaigns c ON c.id = te.campaign_id
WHERE te.event_type = 'bounce'
  AND te.created_at >= unixepoch('now', '-1 day') * 1000
ORDER BY te.created_at DESC;
```

### 4.6 Behavior-Events vor Conversion (Phase 3 — TODO)

```sql
SELECT
  be.recipient_id,
  be.type,
  be.url,
  be.product_id,
  be.created_at
FROM behavior_events be
WHERE be.recipient_id IN (
  SELECT DISTINCT recipient_id FROM tracking_events
  WHERE event_type = 'conversion' AND campaign_id = ?
)
ORDER BY be.recipient_id, be.created_at;
```

### 4.7 Empfänger ohne `delivered`-Event (Indikator für Worker-Lücke)

```sql
SELECT r.id, r.email, r.campaign_id
FROM recipients r
LEFT JOIN tracking_events te_d
  ON te_d.recipient_id = r.id AND te_d.event_type = 'delivered'
LEFT JOIN email_queue q
  ON q.recipient_id = r.id
WHERE r.campaign_id = ?
  AND te_d.id IS NULL
  AND q.status = 'sent';
```

**Interpretation:** Wenn diese Liste nicht leer ist obwohl die Jobs auf `'sent'` stehen, hat `recordDelivered()` im Worker nicht durchgeschlagen — Worker-Log auf Errors checken (`[recordDelivered] failed:`).

### 4.8 Status-Verteilung pro Campaign (für Stats-Validation)

```sql
WITH agg AS (
  SELECT
    r.id AS rid,
    MIN(CASE WHEN te.event_type = 'delivered' THEN te.created_at END) AS delivered_at,
    MIN(CASE WHEN te.event_type = 'open' AND (te.is_mpp IS NULL OR te.is_mpp = 0) THEN te.created_at END) AS opened_verified,
    MIN(CASE WHEN te.event_type = 'click' THEN te.created_at END) AS clicked_at,
    MIN(CASE WHEN te.event_type = 'bounce' THEN te.created_at END) AS bounced_at,
    COUNT(CASE WHEN te.event_type = 'conversion' THEN 1 END) AS conversions
  FROM recipients r
  LEFT JOIN tracking_events te ON te.recipient_id = r.id
  WHERE r.campaign_id = ?
  GROUP BY r.id
)
SELECT
  CASE
    WHEN bounced_at IS NOT NULL THEN 'bounced'
    WHEN conversions > 0 THEN 'converted'
    WHEN clicked_at IS NOT NULL THEN 'clicked'
    WHEN opened_verified IS NOT NULL THEN 'opened'
    WHEN delivered_at IS NOT NULL THEN 'delivered'
    ELSE 'sent'
  END AS status,
  COUNT(*) AS n
FROM agg
GROUP BY status
ORDER BY n DESC;
```

**Use-Case:** Manuelle Validierung der Empfänger-Tabelle im UI — die Status-Verteilung hier sollte mit den Status-Filter-Counts in der Tabelle übereinstimmen.

---

## 5. Bekannte Limitationen

### 5.1 `delivered` ist Soft-Accept, nicht echte Mailbox-Zustellung

Microsoft Graph `Mail.Send` liefert keinen echten Delivery-Webhook. Unser `delivered`-Event wird direkt nach dem API-Aufruf geschrieben — bedeutet "von Microsoft akzeptiert", nicht "im Posteingang des Empfängers gelandet". Eine Mail, die später vom Empfänger-Mailserver hart abgelehnt wird, hat trotzdem ein `delivered`-Event. Der echte Hard-Bounce-Pfad kommt erst mit dem Inbox-Scanner in Phase 3.

### 5.2 MPP-Detect ist Heuristik

`isMpp()` prüft UA `Mozilla/5.0` (exakt) + Apple-IP-Prefix. Falsche Negative möglich wenn:
- Apple-IPs nicht in `data/apple-mpp-ranges.json` aktuell sind
- Apple in Zukunft den UA ändert

Falsche Positive sehr selten — nackte UA "Mozilla/5.0" außerhalb Apple's Proxy ist ungewöhnlich.

**Wartung:** Apple-IP-Ranges quartalsweise refreshen. Quelle siehe Kommentar in `data/apple-mpp-ranges.json`.

### 5.3 Behavior-Tracking nur mit `mf-track.js` (Phase 3)

Aktuell zeigt die "Verhalten"-Spalte überall 0, weil noch kein Snippet auf Brand-Zielseiten eingebunden ist. Nach Phase-3-Deploy: Snippet pro Brand-Domain (its.de, ksm.de, versicherungsspartarif.de) einbinden, dann beginnt die Tabelle Werte anzuzeigen.

### 5.4 Cumas-Polling-Latenz

`runConversionPollingTick()` läuft alle `app_settings.conversion_polling.intervalMinutes` (Default 30). Eine Conversion zwischen zwei Ticks erscheint im UI erst nach dem nächsten Tick. Sofort-Conversions kommen nur via `/api/track/conversion`-Pixel auf der Thank-You-Page.

### 5.5 Pagination wird ungenau bei aktivem Status-Filter

`listRecipientsForCampaign` mit `status`-Filter zieht bis zu 1000 Zeilen und filtert in JS. Bei Campaigns > 1000 Empfänger mit seltenem Status-Treffer kann die `total`-Zahl in der Pagination ungenau werden. Für Praxis-Campaigns (≤ 2000 Empfänger) ausreichend. Bei Skalierung später: HAVING-Klausel im SQL bauen.

### 5.6 `tracking_events.recipientId` ist nullable

Open-Pixel kann ohne Recipient-Resolution feuern (z.B. wenn URL-Param fehlt). Solche Events tauchen in `tracking_events` auf, aber nicht in `getRecipientTimeline()` (welches per `recipientId` joined). Für Aggregat-Stats sind sie weiterhin sichtbar, weil dort nach `campaignId` gefiltert wird.

---

## 6. Troubleshooting (häufige Fehler)

### 6.1 "Open-Count ist zu niedrig"

**Diagnose:** Bilder werden vom Mail-Client blockiert.

Outlook 365 lädt externe Bilder default nicht — User muss "Bilder herunterladen" klicken. Gmail lädt Bilder über Google-Proxy (Proxy-Caching, daher ein Open-Event auch wenn Mail nie geöffnet wird). Apple-Mail siehe MPP.

**Was checken:**
- Test-Mail in mehreren Clients öffnen (Outlook Web, Apple-Mail iOS, Gmail Web)
- Im Browser DevTools → Network → "track/open" suchen, wenn die Mail im Vorschau-Pane sichtbar ist

### 6.2 "Open-Count ist zu hoch"

**Diagnose:** Apple-MPP-Inflation.

**Was checken:**
- DB-Query 4.1 → `mpp_pct` checken. Wenn > 30 % normal, > 60 % deutet auf B2C-Liste hin.
- Im UI immer "Verified Open" als primäre KPI nutzen, nicht "Raw Open".

### 6.3 "Click ohne Open"

**Diagnose:** Mehrere Ursachen.

- Apple-Mail-Vorschau lädt Bilder lazy — User klickt, ohne dass das Pixel geladen wurde
- Plain-Text-Mail-Empfänger sehen kein Pixel
- Mail-Client mit aggressivem Image-Blocking

**Bedeutet:** Klicks-ohne-Opens sind **normal** und kein Bug. In der Conversion-Statistik fließen sie korrekt ein.

### 6.4 "Bounce nicht erkannt" (Phase 3 — TODO)

Wird mit Inbox-Scanner in Phase 3 adressiert. Aktuell: keine automatische Bounce-Detection — Hard-Bounces tauchen in der Statistik nicht auf.

### 6.5 "Conversion wird nicht zugeordnet"

**Diagnose-Schritte:**
1. Lookback-Window prüfen: `SELECT attribution_lookback_days FROM brands WHERE id = ?` — sollte größer sein als die Zeit zwischen Klick und Conversion
2. Recipient-ID-Match-Feld prüfen: `brands.cumasRecipientField` oder `app_settings.conversion_polling.recipientIdField`
3. Manuell die UTM-URLs der versendeten Mail prüfen — `utm_content` sollte einen Recipient-Hinweis enthalten
4. Worker-Log auf `[runConversionPollingTick]` durchsuchen — wirft Errors wenn Cumas nicht erreichbar

### 6.6 "Empfänger fehlt in der Drilldown-Tabelle"

**Mögliche Ursachen:**
- Status-Filter aktiv und Empfänger hat anderen Status
- Such-Filter aktiv und Email/Name passt nicht
- Pagination — Empfänger auf späterer Seite
- Variant-Filter aktiv

**Quick-Check:** Total-Count rechts oben in der Filter-Bar — wenn nur 50 statt erwartet 500, ist ein Filter aktiv.

---

## 7. Smoke-Test-Script (TODO — Phase 3 oder dediziert später)

Geplant: `scripts/smoke-test-tracking.ts`

Was es tun wird:
- Test-Brand + Test-Campaign + 3 Test-Recipients anlegen
- Open-Pixel-Aufrufe simulieren (regulärer UA + Apple-UA)
- Click-Redirect simulieren
- Conversion-Pixel simulieren
- DB-State assert (`uniqueOpensVerified === 2`, `isMpp = 1` auf 1 Event, `conversionCount === 1`)

Aufruf: `npx tsx scripts/smoke-test-tracking.ts`
Cleanup: `--cleanup` löscht angelegte Test-Rows.

---

## 8. Production-Health-Check

### 8.1 SQLite-DB-Größe pro Tag

```bash
ls -lh data/mailforge.db data/mailforge.db-wal data/mailforge.db-shm
```

**Erwartung:** Bei ~10.000 Mails/Tag wächst die DB um ~1-2 MB/Tag. Ungewöhnliches Wachstum (>10 MB/Tag) deutet auf Behavior-Event-Spam oder fehlerhaft loopende Conversion-Polling hin.

### 8.2 Stale Events (Sanity-Check)

```sql
-- Events mit Timestamp in der Zukunft
SELECT COUNT(*) FROM tracking_events WHERE created_at > (unixepoch() * 1000);

-- Events vor dem ersten Send einer Campaign (Worker-Bug-Indikator)
SELECT te.id, te.created_at, c.started_at
FROM tracking_events te
JOIN campaigns c ON c.id = te.campaign_id
WHERE te.created_at < c.started_at;
```

### 8.3 Worker-Tick-Lag

Prüfe Worker-Logs auf den letzten erfolgreichen Tick — Worker-Restart wenn > 10 Min Pause.

### 8.4 Disk-Space-Alarm-Schwellen

VPS hat ~80 GB SSD. WAL-File darf bis 1 GB wachsen, dann wird automatisch checkpointed. Wenn `mailforge.db-wal > 2 GB` → manueller `PRAGMA wal_checkpoint(TRUNCATE)`-Call überlegen (über Drizzle, NIE direkt via Bash).

---

## 9. Phase 3 + 4 (TODO)

### Phase 3 — Onsite-Tracking & Bounces ✓

- [x] `public/mf-track.js` Snippet (vanilla JS, ~3 KB minified)
- [x] `/api/track/behavior` Endpoint (Beacon-Receiver)
- [x] `/api/track/webhook/[brandSlug]` Endpoint (HMAC-verifiziert)
- [x] `/api/track/optout` Endpoint (DSGVO-Optout)
- [x] Cookie `mf_attr` setzen im Click-Redirect
- [x] Bounce-Inbox-Scanner als Worker-Tick (alle 10 Min)
- [x] Section 2.3, 2.4 in dieser Doku befüllen
- [ ] DB-Query 4.5 und 4.6 verifizieren — gegen Live-DB testen nach Deploy

### Phase 4 — GA4 + Dashboard ✓

- [x] `src/lib/analytics/ga4-client.ts` (GA4 Data API Wrapper)
- [x] `/api/recipients/[id]/ga4-session` Endpoint
- [x] `/api/dashboard/overview` Cross-Campaign-Aggregat
- [x] `Ga4SessionPanel.tsx` Komponente (im Drawer ersetzt Placeholder)
- [x] `DashboardPanel.tsx` Cross-Campaign-Übersicht (Top-Level-Tab im EditorHeader)
- [x] Settings-Drawer-Tab "Onsite-Tracking" (Snippet-Generator + Webhook-Secret + GA4-Credentials)
- [x] Section 3.4 in dieser Doku befüllen

---

## Wartung dieser Doku

Diese Doku ist **live** — wenn sich Stats-UI, Datenmodell oder Test-Szenarien ändern, hier mit aktualisieren. Konvention:
- Phase-1- und Phase-2-Sektionen sind Produktiv-Wissen
- Phase-3- und Phase-4-Sektionen werden in der jeweiligen Phase befüllt
- Pro signifikanter Änderung im Tracking-System: einen Eintrag im Changelog am Ende ergänzen

## Changelog

- **2026-05-12** — Phase 1 (Foundation: Schema + MPP-Filter + Delivered-Event + Stats-Endpoint-Erweiterung) und Phase 2 (Recipient-Drilldown UI + Timeline-Drawer) ausgerollt. Sections 1, 2.1, 2.2, 2.5, 3.1, 3.2, 3.3, 4.1–4.4, 4.7, 4.8, 5, 6 befüllt.
- **2026-05-12** — Phase 3 (Onsite-Tracking: mf-track.js + `/api/track/behavior` + Webhook-Endpoint mit HMAC + Click-Cookie `mf_attr` + Bounce-Inbox-Scanner) und Phase 4 (GA4-Client + On-Demand-Session-Lookup + Cross-Campaign-Dashboard + Settings-Drawer-Tab "Onsite-Tracking") ausgerollt. Migration `0011_phase34_tracking.sql` fügt `brands.ga4CredentialsJson` hinzu. Sections 2.3, 2.4, 3.4 befüllt.
