Kampagnen-Interna

Wie das Kampagnen-Backend arbeitet: zwei Statusmaschinen, Versand-Pre-Flight, der Versand-Orchestrator, emailSends-Datensätze und die priorisierten Workpools.

Diese Seite ist die Entwicklerreferenz für den Kampagnen-Versandpfad im Convex-Backend — was zwischen dem Klick auf Senden und dem Postfach einer empfangenden Person passiert. Sie behandelt die beiden Lebenszyklus-Zustandsmaschinen, das Versand-Pre-Flight-Gate, die Orchestrator-Pipeline, die emailSends-Datensätze pro Empfänger und die ratenbegrenzten Workpools. Die anwenderseitige Anleitung finden Sie unter Kampagne senden & überwachen; wie die Zielgruppe in Empfänger aufgelöst wird, steht unter Zielgruppen-Interna.

Zwei-Maschinen-Design

Eine Kampagnenzeile trägt zwei unabhängige Statusspaltenstatus (den Kampagnen-Lebenszyklus) und abTestStatus (den A/B-Test-Lebenszyklus). Jede hat ihren eigenen Graphen erlaubter Kanten und ihr eigenes Single-Writer-Modul. Diese Aufteilung ist in docs/adr/0017-campaign-lifecycle-modules.md festgehalten.

Zustandsmaschine des Kampagnen-Lebenszyklus

Das Feld status der Kampagne wird von genau einem Modul geschrieben — apps/api/convex/campaigns/lifecycle.ts. Dessen öffentliche transition-Mutation ist der einzige Schreiber von campaigns.status und der Begleitfelder, die sie zusammen damit patcht (sentAt, cancelledAt, scheduledAt, contentBlockReason sowie der Stats-Null-Block beim Versand). Ein direktes ctx.db.patch von status an anderer Stelle ist ein Schichtverstoß.

Die sechs Status

StatusBedeutung
draftBearbeitbar; nicht eingereiht. Der Standard für eine neue Kampagne.
scheduledEin künftiger Versandzeitpunkt ist gesetzt; der Orchestrator ist für dann eingeplant.
sendingDer Orchestrator läuft (oder seine Sends sind unterwegs).
sentTerminal. Jeder eingereihte Send hat die Queue verlassen.
cancelledTerminal. Die Nutzerin hat vor oder während des Versands abgebrochen.
pending_reviewDer Content-Scanner hat den Versand als verdächtig markiert; er wird bis zur Prüfung zurückgehalten.

Erlaubte Kanten

Der Reducer lehnt jeden Übergang ab, der nicht in LEGAL_EDGES steht. Es wird keine Exception geworfen — ein unzulässiger Aufruf gibt { ok: false, reason: 'illegal_edge' } zurück, und ein Übergang aus einem terminalen Status gibt reason: 'terminal' zurück. Die Aufrufer übersetzen das Ergebnis in eine Antwort an die Nutzerin.

VonErlaubte to
draftscheduled, sending
scheduleddraft, cancelled, sending
sendingsent, draft, pending_review
pending_reviewsending, draft
sent(terminal)
cancelled(terminal)

Eine Selbstschleife (from === to) ist idempotent: Sie schreibt eine Audit-Log-Zeile, gibt applied: 'recorded' zurück und erzeugt kein Patch, keinen Scheduler-Hop und kein PostHog-Event. Genau das macht einen erneut ausgelösten Scheduler-Tick unbedenklich.

Freigeben/Ablehnen der Prüfung ist noch nicht angebunden

Der Content-Scanner kann eine verdächtige Kampagne in pending_review bewegen, aber die beiden Kanten heraus (pending_review → sending freigeben, pending_review → draft ablehnen) haben heute keinen Produktaufrufer — es gibt in diesem OSS-Repository keine Review-Queue-UI und keine Freigabe-/Ablehn-Mutation. Die Kanten existieren im Graphen erlaubter Kanten und die Audit-Aktionen (campaign.review_approved / campaign.review_rejected) sind definiert, aber eine Betreiberin müsste die interne Mutation lifecycle.transition von Hand aufrufen, um eine zurückgehaltene Kampagne freizugeben.

Effekte

Der Reducer ist pur: Aus der geladenen Kampagne, der typisierten Eingabe und einer userId gibt er ein patch plus eine Liste von effects zurück. Ein separater Runner wendet sie der Reihe nach an, atomar mit dem Patch der Zeile. Es gibt vier Effektarten:

EffektWann er auslöst
audit_logBei jedem Übergang (auch bei idempotenten Selbstschleifen).
schedule_campaign_send_orchestratorBei → scheduled (Verzögerung = scheduledAt - at) und → sending (Verzögerung = 0). Konsument ist campaigns.send.startCampaignSend.
track_eventNur bei nutzergetriebenem → scheduled / → sending / → cancelled. Wird an PostHog gemeldet.
start_ab_test_if_enabledMaschinenübergreifender Anstoß bei → sending, wenn isABTest. Ruft → testing des A/B-Test-Lebenszyklus auf.

Das Argument userId unterscheidet nutzergetriebene von systemseitigen Übergängen. Nutzerseitige Mutationen übergeben session.userId; interne Aufrufer übergeben ein Tag 'system:<source>' (z. B. 'system:scheduler_tick', 'system:content_scan', 'system:orchestrator', 'system:send_completion'). Das Audit-Log hält das Tag wortgetreu fest, und der track_event-Effekt wird für jeden Aufrufer mit system:-Präfix unterdrückt — Hintergrundübergänge verschmutzen PostHog also nie.

Semantik von „Campaign sent"

Das PostHog-Event campaign_sent löst auf der Kante → sending aus, nicht bei → sent. Für die Nutzerin bedeutet „die Kampagne wurde gesendet" so viel wie „der Versand wurde angestoßen". Die Kante pending_review → sending ist vom Event ausgenommen — sie wäre eine Freigabe nach Prüfung, kein nutzerinitiierter Versand (und hat, wie oben erwähnt, ohnehin noch keinen Aufrufer).

sent erreichen: Batch-Abschluss

→ sent wird nicht von der Nutzerin ausgelöst. Jeder Workpool-Callback pro Send bewegt seine eigene emailSends-Zeile weiter, aber die Kampagne selbst schließt erst ab, wenn ihr letzter eingereihter Send die Queue verlässt. tryCompleteCampaign (im selben Modul) ist der gemeinsame Wächter, der von zwei Stellen aufgerufen wird:

  • reconcileCampaignCompletion — der Einstiegspunkt pro Send, aufgerufen aus dem Workpool-Completion-Callback, nachdem jeder Send einen terminalen Status erreicht hat. Ein günstiger No-Op, bis die Kampagne wirklich fertig ist.
  • reconcileSendingCampaigns — ein Sicherheitsnetz-Cron (process scheduled campaigns läuft jede Minute; dieser Sweep läuft ebenfalls jede Minute als reconcile sending campaigns), der jede noch in sending befindliche Kampagne abgleicht. Er fängt Callbacks ab, die fehlgeschlagen sind, oder finale Sends, die statt vom Workpool durch einen Provider-Webhook überführt wurden.

tryCompleteCampaign wechselt nur dann zu sent, wenn alles davon gilt: Der Status ist sending; der gecheckpointete Send-Walker hat das Streamen beendet (keine campaignSendJobs-Zeile mehr in Phase resolving); ein etwaiger A/B-Test hat winner_selected erreicht (sonst würde der Rest-Versand der zweiten Welle übersprungen); mindestens eine emailSends-Zeile existiert; und keine emailSends-Zeile ist noch queued. queued ist der einzige nicht terminale Send-Status, sein Fehlen bedeutet also, dass die Kampagne fertig ist.

A/B-Test-Lebenszyklus und die Rest-Versand-Garantie

Die Spalte abTestStatus hat ihre eigene Maschine in apps/api/convex/campaigns/abTestLifecycle.ts — dieselbe Zeile, andere Spalte, eigener Graph. Ihre transition-Mutation ist der einzige Schreiber von abTestStatus und dessen Begleitfeldern (abTestConfig, abWinner, abWinnerSelectedAt sowie der Block zum Zurücksetzen der Variantenstatistiken beim Deaktivieren).

VonErlaubte toAuslöser
(keiner)pendingenableABTest-Mutation
pendingtestingMaschinenübergreifend — der Effekt start_ab_test_if_enabled des Kampagnen-Lebenszyklus bei → sending
testingwinner_selecteddeclareABTestWinner (manuell) oder autoDeclareWinner (kriteriengesteuert)
*nonedisableABTest (vollständiger Reset)

Zwei Effekte garantieren, dass die zurückgehaltene Zielgruppe nie verwaist:

  • schedule_auto_winner löst bei → testing aus, wenn winnerCriteria nicht manual ist und testDuration (Stunden) gesetzt ist. Es plant campaigns.abTest.autoDeclareWinner nach dem Testfenster ein. Ohne diesen Effekt würde eine open_rate- / click_rate-Kampagne (der Wizard-Standard) für immer in testing sitzen und die 40–60 % Restzielgruppe nie versendet. Manuelle Kriterien setzen stattdessen darauf, dass die Nutzerin einen „Gewinner wählen"-Button klickt, den die Report-Seite nur bei manual anzeigt.
  • schedule_winner_remainder löst bei → winner_selected aus. Es plant campaigns.send.sendCampaignWinnerToRemainder (den Orchestrator der zweiten Phase, siehe unten) ein, um den Inhalt der Siegervariante an alle Zurückgehaltenen auszuliefern.
Was splitPercentage bedeutet

splitPercentage (validiert auf 10–50) ist der Prozentsatz pro Variante der Testkohorte. Die Kohorte ist damit 2 × splitPercentage % der Zielgruppe — 20 erzeugt also eine 40-%-Testkohorte (20 % A, 20 % B) und einen zurückgehaltenen Rest von 60 %.

Versand-Pre-Flight-Validierung

Bevor irgendein Aufrufer eine Kampagne nach scheduled oder sending überführt, führt er das Pre-Flight in apps/api/convex/campaigns/preflight.ts aus. Der Lebenszyklus-Reducer vertraut seiner Eingabe — er validiert die Versandbereitschaft nicht erneut —, sodass das Pre-Flight das eine Gate ist, das sich die vier Versand-/Planungs-Einstiegspunkte (und der Orchestrator zum Auslösezeitpunkt) teilen.

validateReadyToSend gibt eine PreflightResult-Union zurück; die erste fehlschlagende Prüfung gewinnt. Die Prüfungen in ihrer Reihenfolge:

reasonBedingung
no_templateemailTemplateId ist nicht gesetzt
no_audienceaudience ist nicht gesetzt
no_from_emailfromEmail ist nicht gesetzt
sending_not_allowedDer abuseStatus der Instanz ist suspended oder banned
no_delivery_providerKein E-Mail-Delivery-Provider (EMAIL_PROVIDER + Credentials oder eine Provider-Route) ist konfiguriert. Ein verbundenes externes IMAP-Postfach erfüllt das nicht.
domain_not_verifiedDie Domain der Absenderadresse ist nicht verifiziert
scheduled_in_pastscheduledAt liegt in der Vergangenheit (nur geprüft, wenn scheduledAt übergeben wird)

Die interne Query validateReadyToSendQuery kapselt dieselbe Logik, damit der Orchestrator das Pre-Flight zum Auslösezeitpunkt erneut ausführen kann — und so Zustände abfängt, die zwischen dem ursprünglichen schedule-Aufruf und dem Scheduler-Tick abgedriftet sind (Organisation gesperrt, Template gelöscht, Domain-Verifizierung abgelaufen).

Der Versand-Orchestrator

campaigns.send.startCampaignSend (apps/api/convex/campaigns/send.ts) ist die einzige laufende Action, die eine Kampagne von scheduled | sending durch die gesamte Vorbereitungs-Pipeline führt. Sie wird von drei Produzenten ausgelöst: dem Cron-Tick processScheduledCampaigns, dem Effekt schedule_campaign_send_orchestrator des Lebenszyklus und einer direkten Neuplanung in campaigns/scheduling.ts. Sie ist der einzige Schreiber von emailSends.abVariant und der einzige Aufrufer von enqueueCampaignEmails in der ersten Phase.

Wächter gegen Status-Races

War die Kampagne cancelled oder draft (ungeplant) oder bereits sent, gibt der Orchestrator skipped: true mit einem Grund zurück. Bei sending überspringt er nicht — der sendNow-Pfad kommt bereits auf sending umgestellt an, und Übergänge in denselben Zustand lösen den Orchestrator-Effekt nicht erneut aus.

Pre-Flight erneut ausführen

validateReadyToSendQuery läuft erneut. Ein Fehlschlag gibt skipped mit der Pre-Flight-Meldung zurück — es werden keine Empfänger angefasst.

Umschalten auf sending

Ist die Kampagne noch scheduled, überführt der Orchestrator sie über den Lebenszyklus nach sending (userId system:scheduler_tick).

Content-Scan-Gate

Betreff und gerendertes HTML werden auf Spam, Phishing und verbotene Inhalte geprüft. Ist ein GOOGLE_SAFE_BROWSING_API_KEY konfiguriert, werden Link-URLs zusätzlich gegen Google Safe Browsing geprüft (ein Fehlschlag dieser Prüfung blockiert den Versand nicht). Der kombinierte Score klassifiziert den Versand: ≥ 40blocked, ≥ 15suspicious, sonst clean. Ein nicht sauberes Ergebnis wird in contentScanResults persistiert. Ein blocked-Versand setzt die Kampagne mit einem contentBlockReason auf draft zurück (userId system:content_scan); ein suspicious-Versand überführt sie nach pending_review. Beides bricht den Orchestrator ab.

Archiv-Snapshot

Ist die Archivierung aktiviert (kampagnenweise über archiveEnabled, ersatzweise über das aufgelöste Feature-Flag campaigns.archive) und ist SITE_URL gesetzt, erzeugt der Orchestrator einen öffentlichen HTML-Archiv-Snapshot, speichert ihn mit einem 24-Zeichen-Token und berechnet eine viewInBrowserUrl, die in jeden Send eingefädelt wird.

Zielgruppe einfrieren

freezeCampaignAudience snapshottet die Filter einer Segment-Zielgruppe zum Versandzeitpunkt (ADR-0033), sodass die Kampagne exakt die Zielgruppe reproduziert, auf die sie abzielte, selbst wenn das Segment später bearbeitet wird. Themen-Zielgruppen und bereits eingefrorene Segmente gehen unverändert durch.

Send-Job-Checkpoint öffnen und Empfänger streamen

Der Orchestrator löst die gesamte Zielgruppe nicht mehr inline auf. Er öffnet einen campaignSendJobs-Checkpoint (createSendJob, in campaigns/sendJob.ts) und plant resolveCampaignPage (campaigns/send.ts:611) ein, einen sich selbst neu einplanenden Walker, der die eingefrorene Zielgruppe über resolveRecipientPage seitenweise in beschränkten Seiten streamt. Jede Seite wendet das eine Eignungsprädikat an — lebender Kontakt → E-Mail vorhanden → nicht unterdrückt → Double-Opt-in bestätigt (nur bei Themen-Zielgruppen — Segment-Zielgruppen sind nie DOI-gegatet) — gruppiert dann nach Sprache und ordnet jeden Kontakt dem A/B-Split zu (siehe unten), wobei die Seite eingereiht wird, bevor die nächste geholt wird. Der Walker plant sich selbst neu, bis die Zielgruppe erschöpft ist. Der Schnellpfad „leere Zielgruppe → sent" liegt im Last-Page-Zweig des Walkers (campaigns/send.ts:884-895, userId system:orchestrator).

Empfänger werden für i18n nach ihrer Sprachpräferenz gruppiert (ersatzweise nach der defaultLanguage des Templates). Innerhalb jeder Sprachgruppe ordnet das A/B-Fanout (falls isABTest und abTestStatus === 'testing') jeden Kontakt per Hash der Testkohorte oder dem zurückgehaltenen Rest zu, danach werden die Varianten A und B getrennt eingereiht. Nicht-A/B-Gruppen reihen alle mit dem Inhalt von Variante A ein, ohne Tag.

Details zum A/B-Fanout

resolveAbFanout (campaigns/sendVariantSplit.ts) liefert nur dann ein Fanout, wenn für die Kampagne isABTest gilt, abTestStatus === 'testing' ist und eine Konfiguration vorliegt — eine Kampagne in winner_selected oder pending splittet in der ersten Phase also nie. Der Split ist ein deterministischer FNV-1a-Hash von (campaignId:contactId) (hashFraction), verglichen mit testFraction = 2 × splitPercentage / 100: h < testFraction ist die Testkohorte (per variantForHash am Kohorten-Mittelpunkt in A/B unterteilt), und h >= testFraction ist der zurückgehaltene Rest. Empfänger strömen Seite für Seite durch den gecheckpointeten Send-Walker (resolveCampaignPage) — sie werden nie gemischt oder vollständig materialisiert.

Der zurückgehaltene Rest wird in der ersten Phase nicht versendet. Nachdem ein Gewinner feststeht, löst die Action der zweiten Phase, campaigns.send.sendCampaignWinnerToRemainder, die Zielgruppe erneut auf, schließt jeden Kontakt aus, der für diese Kampagne bereits eine emailSends-Zeile hat (das deckt die Testkohorte ab und schützt vor Doppelversand), und reiht den Inhalt der Siegervariante für den Rest ein. Beide Phasen teilen sich den Helfer enqueueVariantBatch pro Variante — die einzige Stelle, die emailSends-Zeilen anlegt und enqueueCampaignEmails einplant.

emailSends-Datensätze und die „ever-reached"-Statistiksemantik

Jeder Empfänger einer Kampagne erhält eine emailSends-Zeile (apps/api/convex/schema/campaigns.ts). E-Mail-Adresse, Vor- und Nachname des Kontakts werden zum Versandzeitpunkt gesnapshottet und nie aktualisiert — die Zeile ist der Audit-Trail dessen, was tatsächlich gesendet wurde, nicht eine Live-Ansicht des Kontakts. status ist ein einzelnes Feld mit acht Werten; queued ist der einzige nicht terminale.

statusBedeutung
queuedEingereiht, noch nicht an den Provider übergeben.
sentVom Provider angenommen.
failedDie Workpool-Action lief auf einen Fehler (nicht dasselbe wie ein Bounce).
deliveredProvider hat die Zustellung bestätigt.
openedEine Öffnung wurde erfasst.
clickedEin Link-Klick wurde erfasst.
bouncedProvider hat angenommen, aber der Empfangsserver hat abgelehnt. bounceType hält hard / soft fest.
complainedEmpfänger hat die Mail als Spam markiert.

Statusschreibungen laufen über einen Schreiber — internal.delivery.sendLifecycle.transition mit einem SendRef { kind: 'campaign', id }. Die alten Mutationen pro Ereignis in delivery/sends.ts (dem Mutationsmodul der emailSends-Tabelle) wurden entfernt; dieses Modul liest nur noch, legt an (einzeln über create und gebündelt über createBatch) und löscht.

Statistiken sind „ever-reached", nicht der aktuelle Status

getStatsByCampaign zählt delivered / opened / clicked nicht über den aktuellen status. Weil der status einer Zeile mit neuen Ereignissen weiterrückt (aus einer delivered-Zeile wird opened, eine opened-Zeile kann später bounced werden), würde ein Zählen nach status jeden Empfänger stillschweigend verlieren, der über einen Bucket hinaus fortgeschritten ist, und jeden Ratennenner kaputtmachen. Stattdessen werden diese Buckets aus monotonen Zeitstempeln abgeleitet: delivered zählt jede Zeile mit einem deliveredAt, openedAt oder clickedAt; opened zählt jede Zeile mit einem openedAt; clicked zählt jede Zeile mit einem clickedAt oder nicht leeren clickedLinks. Derselbe „jemals zugestellt"-Nenner wird für die A/B-Statistiken pro Variante verwendet, sodass die beiden Oberflächen nicht auseinanderdriften können.

getStatsByCampaign und die anderen Aggregat-Reads pro Send begrenzen ihre Scans (typischerweise take(10_000)); Kampagnen, die größer sind, sollten sich auf die denormalisierten stats*-Zähler an der Kampagnenzeile stützen, die der Send-Lebenszyklus pro Empfänger hochzählt.

Kampagnen- vs. Transaktions-Workpools und Rate Limiting

E-Mail-Sends laufen über zwei getrennte Convex-Workpools, definiert in apps/api/convex/delivery/workpool.ts:

PoolmaxParallelismVerwendet für
transactionalEmailPool30/Sek.Zeitkritische transaktionale E-Mails (Transactional API)
campaignEmailPool20/Sek.Massenversand von Marketingkampagnen

Zwei Pools verhindern, dass Transaktionsmail hinter einer langen Kampagnen-Queue blockiert wird. Die kombinierten ~50/Sek. bleiben mit Sicherheitsmarge unter den Rate-Obergrenzen der Provider. Beide Pools setzen retryActionsByDefault, aber maxAttempts: 1 — genau ein Worker-Lauf und kein Retry auf Pool-Ebene. Die versandseitige Retry-Schleife gehört ausschließlich dem Dispatch-Helfer (lib/sendProviders/dispatch.ts, ADR-0020); ein Pool-Retry würde den ganzen Worker erneut ausführen und Doppelversand riskieren. Die Felder für 1 s Basis und Faktor 2 im Backoff existieren, werden bei maxAttempts: 1 aber nie ausgeübt.

enqueueVariantBatch des Orchestrators plant enqueueCampaignEmails ein, das campaignEmailPool.enqueueAction einmal pro Empfänger aufruft und dabei auf internal.delivery.worker.sendSingleEmail zielt. Jedes Enqueue verdrahtet den Completion-Callback auf internal.delivery.sendCompletion.completeSend und trägt eine typisierte sendRef im Workpool-Kontext, damit das Completion-Modul Worker-Ergebnisse einheitlich in Send-Lebenszyklus-Übergänge übersetzen kann. Standard-Sends werden sofort in Blöcken zu 50 eingeplant; zeitzonenbewusste Sends (useRecipientTimezone plus scheduledHour / scheduledMinute) werden nach IANA-Zeitzone gruppiert und so verzögert, dass jede Zone zur lokalen Zeit der Empfänger landet (DST-korrekt, nicht offsetbasiert).

Weiterführendes