---
name: mailforge-send-pipeline
description: Send-Pipeline-Wissen für MailForge — Worker-Job-Lifecycle, MJML-Platzhalter-Substitution, M365-Sender-Resolution, A/B-Champion-Promotion. Nutze diesen Skill bevor du an `src/worker/`, `src/lib/email/` (m365-sender, tag-substitution, utm, tracking, render-template) oder `src/lib/db/campaign-repo.ts` editierst. Triggert auch bei "Job hängt in sending", "`{{TAG}}` wird nicht ersetzt", "M365 sendet nicht", "Tracking-Link kaputt", "A/B-Champion wird nicht promotet", "Campaign hängt", "warum sendet der Worker doppelt", "UTM falsch", "wer ist der Sender". Nicht für Deploy (→ hetzner-deploy), nicht für Content-Generierung (→ mailforge-ai-pipeline), nicht für Schema-Changes (→ mailforge-data-layer).
---

# MailForge Send-Pipeline

Alles, was zwischen `email_queue.status='pending'` und einer ausgelieferten Mail bei M365 passiert. Wenn ein Send-Bug auftritt, ist die Ursache fast immer eine **falsche Reihenfolge** oder ein **fehlender Fallback** in dieser Pipeline.

## Architektur in einem Bild

```
emailQueue (status='pending')
   ↓ pickPendingJobs (atomic UPDATE → 'sending', LIMIT batch)
getJobContext(campaignId, variantId)   ← variant-aware HTML/MJML/Text laden
   ↓
Personalisierung (FIXE Reihenfolge — siehe unten)
   ↓
UTM-Auto-Append
   ↓
Tracking-Wrapper (Click + Open-Pixel)
   ↓
resolveM365Sender({ explicitEmail, companyId })
   ↓
sendMail()  → M365 Graph
   ↓
finalizeCampaignIfDone(campaignId)  ← MUSS auch im catch laufen!
```

## Personalisierung — die FIXE Reihenfolge

Niemals umstellen, sonst leakt entweder roher `{{TAG}}` oder Tracking-Links zeigen auf falsche URLs.

| # | Schritt | File / Funktion | Anmerkung |
|---|---|---|---|
| 1 | System-Tags | `tag-substitution.ts::substituteTagsHtml/Text` mit System-Map | `{{RECIPIENT_ID}}`, `{{CRM_ID}}`, `{{EMAIL}}`, `{{FIRST_NAME}}` |
| 2 | CRM-Spalten | dito, mit `recipient.customDataJson` | Whitespace-tolerant: `{{ KEY }}` matcht. `escapeRegExp` schützt Sonderzeichen in Keys |
| 3 | HTML-Escape | `escapeHtmlForTag()` innerhalb substituteTagsHtml | NUR für HTML-Variante. Plain-Text bleibt roh |
| 4 | UTM-Auto-Append | `lib/email/utm.ts::applyUtmToHtml/Text` | Bei Multi-Variant wird `utm_content` per Variante überschrieben: `base ? ${base}-${label} : label`. Single-Variant lässt User-Wert wie konfiguriert |
| 5 | Tracking-Wrapper | `lib/email/tracking.ts` | Click-Wrapper baut `to=`-Param via URLSearchParams (URL-encoded). Open-Pixel als `<img>`. **Nach UTM**, sonst landet User auf nicht-UTM-URL |

### Platzhalter, die das LLM NICHT generieren/ersetzen darf

Diese werden deterministisch im Worker / `inject-sections.ts` / `render-template.ts` ersetzt. LLM bekommt sie als Literal-Tokens und lässt sie stehen:

```
{{FIRST_NAME}}        ← Worker (System-Tag)
{{RECIPIENT_ID}}      ← Worker (System-Tag, z.B. für Pixel-Beacon)
{{CRM_ID}}            ← Worker (System-Tag)
{{EMAIL}}             ← Worker (System-Tag)
{{HERO_URL}}          ← render-template / Brief-URLs
{{CTA_PRIMARY_URL}}   ← render-template / Brief-URLs
{{UNSUBSCRIBE_URL}}   ← Brand-Tags (render-template)
{{BROWSER_URL}}       ← Brand-Tags
{{IMPRINT_URL}}       ← Brand-Tags
{{PRIVACY_URL}}       ← Brand-Tags
{{LOGO_URL}}          ← Brand-Tags
{{BRAND_NAME}}        ← Brand-Tags
{{LOGIN_LINK}}        ← kompass-link.ts (Magic-Link, deterministisch)
```

Wenn der User darum bittet, neue Platzhalter aufzunehmen: **zuerst** in `tag-substitution.ts` registrieren (oder in der relevanten Render-Stage), **dann** im System-Prompt der LLM-Pipeline als "do not substitute"-Hinweis ergänzen.

## M365-Sender-Resolution

`resolveM365Sender({ explicitEmail, companyId })` Reihenfolge:

1. `explicitEmail` gesetzt und im Pool aktiv → nimm den.
2. Sonst ersten **aktiven** Sender mit passender `companyId` aus `app_settings.m365_senders`.
3. Sonst `undefined` → Worker markiert Job als `failed`. **Es gibt keinen Fallback-Mailbox.**

Brand ohne `companyId` UND ohne expliziten Sender im Briefing = harter Send-Fehler. Im Diagnose-Fall: `GET /api/m365-diagnose?email=<sender@>` aufrufen — checkt Auth + `pingGraphUser` für jeden Pool-Sender.

**Token-Cache:** `getAccessToken()` cached den OAuth2-Token (~55min TTL), refresht bei <5min remaining. Bei `M365Error` mit `kind='auth'` ist meistens **Admin-Consent verloren** oder Client-Secret rotiert — `.env`-Werte gegen Azure Portal abgleichen.

## Worker-Loop-Atomarität

**Niemals umgehen.** Doppel-Sends sind hier teuer (M365 Daily-Budget + Empfänger-Annoyance).

- **`pickPendingJobs(limit)`** macht `UPDATE email_queue SET status='sending' WHERE status='pending' ORDER BY priority, scheduledAt LIMIT N` in einer Transaktion. Picked rows werden zurückgegeben. Solange dieser Pattern hält, kann zweiter Worker-Prozess theoretisch parallel laufen ohne Konflikt.
- **`finalizeCampaignIfDone(campaignId)`** MUSS am Ende **jedes** Job-Tries laufen — sowohl im Success- als auch im catch-Path (`src/worker/index.ts:232`). Sonst:
  - Letzter Job einer Campaign failed
  - Campaign bleibt in `status='sending'`
  - UI zeigt "running" forever
  - Stats werden nicht final aggregiert

## Throttling & Limits

| Limit | Wert | Ort |
|---|---|---|
| `MIN_GAP_MS` | `3_000` ms zwischen Sends | `src/worker/index.ts:39` |
| `DAILY_BUDGET` | 9.500 Mails/Tag/Mailbox (Safety unter 10k) | `src/worker/index.ts:41` |
| `BATCH_SIZE` | 10 Jobs pro Poll | `src/worker/index.ts` |
| `POLL_INTERVAL_MS` | 5000 ms | dito |
| Empfänger pro `sendMail()` | 30 (M365 hard limit, wir nutzen 1:1) | `m365-sender.ts` |
| Retries | 3 Versuche, dann `failed` | `campaign-repo.ts` |
| Retry-Backoff | 5min / 15min / 60min | dito |

`dailyCount >= DAILY_BUDGET` → Job wird postponed (nicht failed). Worker logged `'warn'` und versucht beim nächsten UTC-Tag-Wechsel weiter.

## A/B-Tests (Pattern C)

Jede `campaignVariant` trägt **eigenes komplettes** MJML/HTML/Text/Strategy — keine Diff-Logik.

1. `assignRecipientsToVariants()` (`campaign-repo.ts`) splittet Recipients **modulo N** evenly.
2. Initial-Send: jeder Job hat `variantId` gesetzt; Worker zieht `getJobContext(campaignId, variantId)` pro Job.
3. Rest-Empfänger werden mit `status='champion_held'` parkiert (warten auf Sieger).
4. Alle 5 Minuten: `findCampaignsReadyForPromotion()` → Campaigns mit `abEnabled=true` deren `abWinnerHours` abgelaufen sind.
5. `promoteChampion(campaignId)`:
   - Wählt Sieger nach **Open-Rate**
   - Tiebreak: höchster Send-Count
   - Setzt `champaigns.abWinnerVariantId`
   - Promotet alle `champion_held`-Jobs auf Winner-`variantId`, Status → `pending`

**Debugging-Hinweis:** Wenn ein A/B-Test "hängt", check:
- `abEnabled=true`? `abWinnerHours` gesetzt und in der Vergangenheit?
- Mindestens ein Send mit Open in jeder Variante?
- `tracking_events` mit `eventType='open'` für mindestens eine Variante vorhanden?

## Job-Debugging — Snippets

**Sieht der Worker überhaupt was?**

```bash
# Lokal (dev):
npm run worker
# Schaut auf Logs: "Tick: picked N jobs"

# Server: Worker-Container-Logs
ssh root@188.245.248.79 'docker logs --tail 100 -f kompassmail-worker'
```

**Stuck `sending`-Jobs aufräumen (NUR wenn du sicher bist, dass kein Worker mehr läuft):**

Niemals direkt SQL. Stattdessen über ein einmaliges `tsx`-Script, das eine Drizzle-Repo-Funktion aufruft. Wenn die Funktion noch nicht existiert, **erst** in `campaign-repo.ts` ergänzen (z.B. `resetStuckSendingJobs(olderThanMinutes)`), **dann** aufrufen.

**`finalizeCampaignIfDone` manuell triggern** falls eine Campaign hängt:

```bash
# Ein-Zeilen-Script via tsx, das die Funktion importiert und ruft.
# Im Repo gibt es nichts vorgefertigtes — ggf. in scripts/ ein
# helper-script anlegen wenn das öfter passiert.
```

**M365-Sender prüfen:**

```bash
curl -s "http://localhost:3000/api/m365-diagnose" | jq .
# Oder live:
curl -s "https://kompassmail.in3ttt.com/api/m365-diagnose" | jq .
# (Wenn UI hinter BasicAuth → Basic-Header brauchen)
```

## Häufige Symptome → Ursachen

| Symptom | Wahrscheinliche Ursache |
|---|---|
| Job bleibt in `pending` | Worker läuft nicht. `docker ps` checken |
| Job in `sending`, dann nichts | Worker crashed nach `pickPendingJobs`, hat aber `finalize` nicht durchlaufen. Logs prüfen, `status` manuell zurück auf `pending` via Repo-Funktion |
| `M365Error kind='config'` | `companyId` ohne Sender im Pool. `app_settings.m365_senders` prüfen + `getM365Senders()` self-heal |
| Tracking-Link 404 / führt auf falsche URL | UTM nach Tracking-Wrapper angewendet (Reihenfolge gedreht). Pipeline-Reihenfolge oben prüfen |
| Roher `{{KEY}}` in Mail | Tag in `customDataJson` heißt anders (Case-Sensitive!) oder Whitespace-Variante nicht erfasst. `tagRegex()` matcht beide Varianten |
| Falsche Variante an Recipient | `getJobContext` bekommt `variantId=null` (sollte gesetzt sein). `assignRecipientsToVariants` lief nicht |
| Campaign hängt forever in `sending` | `finalizeCampaignIfDone` nicht im catch-Path. Worker-Code prüfen (`src/worker/index.ts:~232`) |

## Cross-References

- Failure-Patterns aus früheren Sessions (z.B. BasicAuth blockt Tracking-Endpoint): siehe `mailforge-learnings` Skill → `references/failure-patterns.md`.
- Schema-Änderungen an `emailQueue` / `campaignVariants`: siehe `mailforge-data-layer` Skill.
- LLM-Output-Pflege (welche Platzhalter darf das LLM überhaupt produzieren): siehe `mailforge-ai-pipeline` Skill.

## Kritische Files

| Datei | Verantwortung |
|---|---|
| `src/worker/index.ts` | Main-Loop, Throttling, Personalisierung-Aufruf |
| `src/lib/db/campaign-repo.ts` | `pickPendingJobs`, `getJobContext`, `finalizeCampaignIfDone`, `findCampaignsReadyForPromotion`, `promoteChampion`, `assignRecipientsToVariants` |
| `src/lib/email/m365-sender.ts` | OAuth2-Token-Cache, `sendMail`, `pingGraphUser`, `resolveM365Sender` (über `getSenderForCompany`) |
| `src/lib/email/tag-substitution.ts` | Whitespace-tolerante Tag-Replacement für HTML + Text |
| `src/lib/email/utm.ts` | `applyUtmToHtml/Text`, per-Variante `utm_content` |
| `src/lib/email/tracking.ts` | Click-Wrapper + Open-Pixel |
| `src/lib/email/render-template.ts` | Brand-Tag-Substitution (Imprint/Privacy/Logo/…) |
| `src/lib/email/inject-sections.ts` | Post-LLM Section-Splicing |
| `src/app/api/m365-diagnose/route.ts` | Sender-Health-Check |
