Zustellbarkeits-Infrastruktur
Das Zustellbarkeits-Backend auf Convex-Seite: Provider-Routing, gesundheitsbewusstes Failover, Versandreputation mit automatischer Durchsetzung, Cache für IP-Warming, die Blockliste und das Content-Scan-Gate.
Diese Seite bildet die Zustellbarkeits-Maschinerie ab, die im Convex-Backend liegt: wie ein Sendevorgang einen Provider auswählt, wie Provider-Ausfälle in das Failover einfließen, wie sich Zustellereignisse zu einem Reputationswert summieren, der das Deployment automatisch verwarnen oder sperren kann, die zwischengespeicherte Sicht auf den IP-Warming-Zustand, die Adress-Blockliste sowie die täglichen Sendezähler samt der Schwellenwerte für den Content-Scan vor dem Versand.
Die Intelligenz auf der Sendeseite — Throttling pro ISP, der eigentliche IP-Warming-Fahrplan, Circuit Breaker und DNSBL-Monitoring — liegt im MTA, nicht in Convex. Siehe dazu MTA-System. Diese Seite behandelt alles, was Convex verantwortet; beide treffen sich am /ip-reputation-Snapshot des MTA, an der authentifizierten Lease-Grenze /send/decision und an den Webhooks für Zustellung und Routing-Reentry.
Provider-Routing und -Strategien pro Organisation
Ein Deployment kann jeden Nachrichtentyp an einen anderen E-Mail-Provider routen oder einen einzelnen Typ auf mehrere aufteilen. Routen liegen in der Tabelle providerRoutes (apps/api/convex/schema/delivery.ts) und werden über apps/api/convex/providerRoutes.ts verwaltet.
Jede Routenzeile ist auf einen Nachrichtentyp geschlüsselt und trägt eine Strategie, eine geordnete Provider-Liste und einen optionalen IP-Pool-Override:
| Feld | Typ | Bedeutung |
|---|---|---|
messageType | campaign | transactional | automation | Eine Route pro Nachrichtentyp |
strategy | single | priority_failover | workload_split | adaptive_mix | Auswahlalgorithmus (adaptive_mix gehört dem Controller, nicht vom Betreiber wählbar) |
providers | Array aus { providerType, weight?, isEnabled } | Geordnete Kandidatenmenge; eingebaut sind mta / ses / resend / smtp |
ipPool | String (optional) | Überschreibt den MTA-IP-Pool für Sendevorgänge auf dieser Route |
setRoute legt eine Route an bzw. aktualisiert sie, removeRoute löscht eine (wodurch dieser Nachrichtentyp auf den globalen Standard zurückfällt). Beide erfordern die Berechtigung organization:manage. Der öffentliche Leser ist listRoutes (eine authedQuery); Sendepfade lösen Routen über resolveSendRoute / resolveSendRouteFromDb (apps/api/convex/lib/sendProviders/route.ts) auf.
Die vier Strategien
Die Auswahl ist eine reine Funktion. Der dünne Dispatcher resolveRoute (apps/api/convex/lib/sendProviders/routing.ts) schlägt anhand von strategy ein Strategie-Modul nach und ruft dessen select(entries, ipPool, healthStatuses, mix) mit den aktivierten Providern, dem ipPool der Route, dem aktuellen Provider-Health-Snapshot und dem Mix-Kontext pro Empfänger auf. Nur adaptive_mix liest dieses vierte Argument; die drei ausgelieferten anderen ignorieren es. Jede Strategie liegt in einem eigenen Ordner unter lib/sendProviders/strategies/:
| Strategie | Verhalten |
|---|---|
single | Verwendet immer den ersten aktivierten Provider. Ignoriert die Health. |
priority_failover | Geht die aktivierten Provider der Reihe nach durch und wählt den ersten, der nicht down ist. Fällt auf den ersten aktivierten Provider zurück, wenn alle down sind oder keine Health-Daten vorliegen. |
workload_split | Gewichtet zufällige Auswahl über die aktivierten Provider, unter Ausschluss aller, die down sind. Gewichte haben den Standardwert 100 (gleichverteilt). Sind alle Provider down, wird trotzdem einer gewählt, statt den Versand zu blockieren. |
adaptive_mix | Deterministische Aufteilung PRO EMPFÄNGER zwischen dem eigenen MTA und dem Referenztransport, mit dem Anteil, den der Ramp-Controller für die Zelle (stream, destinationProvider) hält. Controller-eigen: Sie wird im Strategie-Auswahlfeld nie angeboten. Ein entarteter Anteil löst die GESAMTE Zelle auf einen Arm auf — den Referenztransport bei s = 0 (das heutige Fallback-aktive Verhalten), den eigenen MTA bei s = 1 — ausgewählt nach Arm und nicht nach Health; das gesundheitsgetriebene Failover bleibt bei der Fallback-Sequenz und der erneuten Auflösung zum Dispatch-Zeitpunkt. |
Gibt es keine Route, keinen aktivierten Provider oder liefert die Strategie nichts, fällt resolveRoute auf die Umgebungsvariable EMAIL_PROVIDER durch und gibt andernfalls null zurück (nicht konfiguriert). Die Auflösung ist fail-closed — es gibt keinen impliziten mta-Standard, sodass ein unkonfiguriertes Deployment niemals still an einen Phantom-MTA dispatcht. Die zurückgegebene ResolvedRoute hält ihre source (org_config, env_fallback oder deliverability_fallback) für die Observability fest.
Mandantenbezogene Kampagnen-, Automations-, Agent-Antwort-, transaktionale und Test-Sendevorgänge lösen unmittelbar vor dem Dispatch eine frische Last-Mile-Entscheidung auf. Die kontrollierte MTA-Annahme setzt die resultierende Lease voraus und bindet sie an die Nachricht, die Organisation, den Empfänger, den Absender, den Nachrichtentyp, den Provider bzw. Pool, die Warmup-Overflow-Policy, die gewählte IP und die Breaker-Generationen. Stellt ein eingereihter MTA-Job vor dem Öffnen von SMTP fest, dass sich die Entscheidung geändert hat oder abgelaufen ist, gibt er seine Reservierungen frei und sendet ein authentifiziertes Routing-Reentry-Ergebnis an Convex. Convex reiht denselben Sendevorgang atomar erneut über den begrenzten Workpool mit demselben Idempotenzschlüssel ein, wo eine frische Entscheidung eigene Zustellung, Relay oder Zurückstellung wählen kann. System- und Auth-Mail sowie Postbox verwenden separate MTA-Endpunkte mit fixem Scope, die nur den Master-Key akzeptieren; authentifizierte rohe SMTP-Einlieferung ist ein Pfad über den eigenen MTA und nimmt am Mandanten-Relay-Fallback nicht teil.
Die Übergabe selbst ist durch eine dauerhafte Re-Entry-Quittung pro Job abgesichert (apps/mta/src/queue/routingReentryHandoff.ts), weil die Queue den Abschluss erst festhält, nachdem der Worker zurückgekehrt ist. reserved bedeutet, dass der Nachfolger möglicherweise noch nicht persistiert ist, sodass ein Replay dieselbe Übergabe idempotent zu Ende führt — die Outbox-Payload ist in der Quittung eingefroren, jeder Neuaufbau ist also byteidentisch. accepted bedeutet, dass die geschützte Outbox den Nachfolger bereits besitzt und der wiederholte Job nicht erneut in den Dispatch eintreten darf: Ohne diese Absicherung würde ein Absturz im Abschlussfenster den Job die gesamte Pipeline gegen frische Kapazität erneut durchlaufen lassen und eine Nachricht zustellen, die der Convex-Nachfolger ebenfalls zustellt. Der Re-Entry-Callback trägt den Retry-Zustand, über den der Callback-Digest ausgestellt wurde, einschließlich der Felder workAttemptId und acceptanceReconciliation, die nach einem Dispatch mit unbekanntem Annahmestatus hinzukamen; ließe man eines von beiden weg, würde der Callback zu einem dauerhaften binding_mismatch.
Jeder terminale Pfad, der nie SMTP geöffnet hat — Screening- und Suppression-Drops sowie das Aufgeben nach vier Tagen Ablauffrist —, gibt die Warming-Reservierung der Nachricht und die Half-Open-Probe des Breakers frei. Es wurde nichts übertragen, sie festzuhalten würde also das Tageslimit einer Warming-IP für Mail verbrauchen, die nie gesendet wurde. Die Annahmequittung wird bei jedem Worker-Lauf, der sie noch besitzt, neu scharf gestellt, sodass eine bis zum maximalen Alter zurückgestellte Nachricht ihren terminalen Ablauf-Bounce immer noch emittieren kann, statt im Dead-Letter zu landen, während der Sendevorgang in queued feststeckt.
Zwei Invarianten verhindern, dass eine mehrdeutige MTA-Annahme zu doppelter oder verlorener Mail wird. Erstens verlässt ein Sendevorgang, solange er eine unbekannte Annahme abgleicht, nie den Pfad über den eigenen MTA — nur dieser Pfad dedupliziert die wiederverwendete workAttemptId, und ein Relay trägt überhaupt keinen Idempotenzschlüssel, sodass jeder andere Transport eine zweite Kopie einer Nachricht senden würde, die der MTA möglicherweise bereits zustellt. Jedes nicht-eigene Routing-Ergebnis stellt stattdessen zurück. Zweitens ist ein Routing-Reentry-Nachfolger ein neuer Arbeitsversuch: Er prägt seine eigene workAttemptId und verwirft das Reconciliation-Flag, weil die Übergabe vor SMTP stattfindet und damit beweist, dass der vorherige Versuch nicht zugestellt hat. Ein Nachfolger, der die alte Identität erbte, würde gegen die Annahmequittung des Jobs deduplizieren, der die Eigentümerschaft gerade abgegeben hat, und der Sendevorgang würde in queued auf einen Webhook warten, der nie eintreffen kann.
Vorübergehende Routing-Verweigerungen halten, statt zu terminalisieren. Ein offener organisationsweiter Sicherheits-Circuit oder ein Fallback, dessen Relay-Identität nicht verifiziert ist, wirft aus resolveRoute heraus; unbehandelt würde das als Workpool-Fehler auftauchen und jeden laufenden Sendevorgang als WORKPOOL_FAILED markieren, sodass das Auslösen eines Sicherheitssignals genau die Mail zerstören würde, die es pausieren sollte. Die kontrollierten Routen-Queries melden diese stattdessen als typisierte Zurückstellungen, protokolliert mit ihrem Code, weil das der einzige Hinweis für den Betreiber ist, warum Mail pausiert. Ein Sicherheits-Hold verbraucht keinen Routing-Versuch — die Versuchsobergrenze begrenzt Routing-Churn, und acht Versuche à 60 Sekunden würden den Sendevorgang nach etwa sieben Minuten terminalisieren, also innerhalb des eigenen Zehn-Minuten-Frischefensters eines einzelnen Signals. Gehaltene Sendevorgänge prüfen an genau diesem Horizont erneut und sind durch die Vier-Tage-Zustellfrist begrenzt.
Die Verbuchung von Bounces und Beschwerden bleibt paarweise. Eine Nachricht, die der MTA bei SMTP ablehnt, geht queued → bounced, ohne sent zu durchlaufen, weshalb der terminale Übergang auch die ausgehenden Zähler emittiert, die sent erzeugt hätte — sonst hätte die Bounce-Rate der Reputation, die die automatische Durchsetzung steuert, einen Zähler ohne Nenner und würde einen zur Hälfte gebouncten Batch als 100 % ausweisen.
Das SMTP-Outcome-Journal deckt ausschließlich das wirklich unumkehrbare Fenster ab. sendToMx löst Leases, MX- und Provider-Profile, DKIM-Signierung und den TLS-Mindeststandard auf, bevor es eine Verbindung akquiriert; ein Fehler in diesem Abschnitt hat nichts übertragen, deshalb wird die Reservierung freigegeben und der Job wiederholt normal. Sobald die erste Verbindungsakquise beginnt, bleibt ein unterbrochener Versuch ungewiss und ein Replay löst ihn als ambiguous auf, statt die Nachricht ein zweites Mal auf die Leitung zu geben.
Die kontrollierte Dispatch-Grenze (apps/api/convex/delivery/governedDispatch.ts) reicht den ipPool der frisch aufgelösten Route über MtaExtras an den MTA durch; existiert kein Routen- bzw. Eingabepool, verwendet der MTA-Adapter standardmäßig transactional. Sie trägt außerdem die stabile Message-ID, den Nachrichtentyp, die Routing-Lease, das Warmup-Overflow-Bit und das begrenzte Convex-Arbeitselement, das für einen Routing-Reentry vor dem Netzwerkzugriff nötig ist. Die Pool-Auswahl für Kampagnen übersteht damit sowohl den initialen Dispatch als auch den Reentry eines angenommenen Jobs; sie wird nicht auf einen Adapteraufruf mit reiner Message-ID reduziert.
Sende-Dispatch und gesundheitsbewusstes Provider-Failover
Jeder Provider-Versuch läuft durch einen einzigen Helper, sendProviderDispatch (apps/api/convex/lib/sendProviders/dispatch.ts), beschrieben in ADR-0020 (docs/adr/0020-send-provider-adapter-modules.md). Der Pfad über den kontrollierten Workpool, Kampagnen-Testsendungen und der System-/Auth-Absender mit fixem Scope erreichen alle diese Grenze; die Produzenten für Automationen und Agent-Antworten laufen zunächst auf demselben Workpool-Worker zusammen, statt direkt einen Transport aufzurufen. Der Dispatcher erledigt einheitlich drei Dinge:
- Retry-Schleife, gesteuert von
retryDelaysundcategorizeErrordes jeweiligen Provider-Moduls. Jeder Versuch ruft dassendEmaildes Moduls für genau einen Versuch auf. - Health-Erfassung — nach jedem terminalen Ergebnis (Erfolg oder aufgebrauchte Retries) plant er
recordSendResultim Modul Send provider health ein, sodass auch Bypass-Aufrufer (Testsendungen, Automationsschritte) Health-Daten erfassen. - Fehlerkategorisierung — das Ergebnis trägt einen typisierten
EmailErrorCode, keinen rohen String.
Provider-Health
recordSendResult (apps/api/convex/lib/sendProviders/health.ts) pflegt eine providerHealth-Zeile pro Provider-Art und nutzt dafür exponentiell abklingende rollierende Erfolgs-/Fehlerzähler, eine EMA-Latenz und einen Zähler für aufeinanderfolgende Fehler. Der Status wird daraus abgeleitet:
| Status | Bedingung |
|---|---|
healthy | Erfolgsrate ≥ 90 % |
degraded | Erfolgsrate ≥ 50 % und < 90 % |
down | Erfolgsrate < 50 % oder ≥ 5 Fehler in Folge |
Die providerHealth-Zeilen werden von resolveSendRouteFromDb (apps/api/convex/lib/sendProviders/route.ts) eingesammelt und vor jedem Dispatch an das reine resolveRoute übergeben, was den Regelkreis schließt: Ein Provider, der zu scheitern beginnt, kippt auf down, und priority_failover / workload_split routen automatisch um ihn herum.
Versandreputation (Organisation + pro Domain, abgeleitetes Risiko, automatische Durchsetzung)
Zustellergebnisse summieren sich in der Tabelle sendingReputation, die ausschließlich dem Modul Sending reputation (apps/api/convex/analytics/sendingReputation.ts) gehört, gemäß ADR-0042 (docs/adr/0042-sending-reputation-module.md). Es handelt sich um eine nach Scope diskriminierte Tabelle: Zeilen mit scope: 'org' verfolgen das gesamte Deployment, Zeilen mit scope: 'domain' eine einzelne Sendedomain. Bounce-Rate, Beschwerderate und Risikostufe werden nie gespeichert — sie werden beim Lesen abgeleitet.
Wie Ereignisse eintreffen
Der Send-Lifecycle (apps/api/convex/delivery/sendLifecycle.ts) emittiert bei jedem Zustellübergang einen reputation_update-Effekt, der recordEvent mit einem Ereignistyp und (sofern bekannt) der Sendedomain einplant. recordEvent ist der einzige Schreiber: Es erhöht immer den heutigen Tages-Bucket der Organisation und, sofern eine Domain vorliegt, zusätzlich den Tages-Bucket der Domain.
| Ereignistyp | Erhöhte Zähler |
|---|---|
send | totalSent |
deliver | totalDelivered |
bounce | totalBounced |
hard_bounce | totalBounced und totalHardBounced |
complaint | totalComplaints |
Abgeleitetes Risiko
summarize (und summarizeDomains für die Sicht pro Domain) ist die einzige Stelle, an der das rollierende 30-Tage-Risikofenster aufsummiert wird; sie ist lesertypisiert, sodass der Schreiber, die Session-Auth-Queries, die Plattform-Admin-Queries und der Control-Plane-Reporter alle exakt dieselbe Zahl ableiten. Das Risiko wird aus internen Durchsetzungsschwellen berechnet. Die davon getrennte, providerseitige FBL-Spamrate verwendet Beschwerden geteilt durch zugestelltes Volumen: Unter 0,1 % ist das Ziel, 0,3 % ist die harte Grenze. Googles aktuelle Formulierung spricht ab dieser harten Grenze von Auswirkungen auf die Zustellung und fehlender Eignung für Mitigation, nicht von einer universellen Zusage der SMTP-Ablehnung. Absender unterhalb der Mindeststichprobengröße sind stets low:
| Risiko | Auslöser (bei ≥ 100 Sendevorgängen im Fenster) |
|---|---|
low | unterhalb der Schwellen für medium |
medium | Beschwerderate ≥ 0,1 % oder Bounce-Rate ≥ 2 % |
high | Beschwerderate ≥ 0,2 % oder Bounce-Rate ≥ 5 % |
critical | Beschwerderate ≥ 0,3 % oder Bounce-Rate ≥ 10 % |
Automatische Durchsetzung
Die automatische Durchsetzung läuft nicht mehr innerhalb von recordEvent (das jetzt nur noch die geshardeten Zähler erhöht). Sie läuft stündlich über den Cron evaluateAutoEnforce, der das Organisationsfenster einmal zusammenfasst und — bei high oder critical — autoEnforceReputation einplant, dabei einen Ziel-Abuse-Status wählt (high → warned, critical → suspended) und den Übergang an das Abuse-Status-Modul (ADR-0011) delegiert, das idempotent dedupliziert und Herabstufungen der Schwere verweigert. Domain-Buckets speisen ausschließlich das Dashboard pro Domain — der Abuse-Status ist ein Zustand auf Deployment-Ebene.
recalculateAll ist ein reiner Aufräum-Cron, der stündlich läuft (verdrahtet in apps/api/convex/crons.ts); er lässt Tages-Buckets, die älter als 60 Tage sind, über beide Scopes hinweg auslaufen. Das Risiko muss nicht mehr periodisch neu berechnet werden, weil es beim Lesen abgeleitet wird.
Compliance-Telemetrie
analytics/complianceTelemetry.ts verknüpft drei begrenzte Signale für die Seite „Delivery“:
summarizeSpamRateund die gemeinsame Bucket-Gruppierung pro Domain leiten FBL-Beschwerden über zugestelltem Volumen ab, ohne den bestehenden Circuit Breaker bei 0,2 % / 100 Sendevorgängen zu verändern. Das Domain-Dashboard liest und gruppiert die Reputations-Buckets einmal und leitet dann sowohl Risiko als auch Spamrate aus demselben begrenzten Ergebnis ab. Der interne Sieben-Tage-Nachweiszähler zählt abgeschlossene UTC-Tage mit zugestelltem Verkehr und einer Rate strikt unter 0,3 %; ein Tag ohne Volumen kann keinen sauberen Nachweis beanspruchen. Es ist kein Urteil über Googles Mitigation-Eignung: Betreiber müssen Googles Tagesrate in den Postmaster Tools sowie jede weitere Absenderanforderung selbst prüfen.- Ein erfolgreiches MTA-
POST /sendbedeutet nur die Annahme in die Queue und bringt einen Sendevorgang aufsent; es erhöht nie das zugestellte Volumen. Der spätere authentifizierte MTA-Webhooksentbedeutet, dass der Ziel-SMTP-Server DATA angenommen hat, deshalb bildet der Adapter dieses wahrheitsgemäße Remote-Annahmesignal auf einen Zustellnachweis ab. Der Send-Lifecycle persistiert die erste Beobachtung von Annahme/Öffnung/Klick/Beschwerde indeliveredAt; diese eine Idempotenznaht erfasst Kampagnen-, Tages- und Reputationszustell-Effekte genau einmal, ohne einen fortgeschrittenen oder terminalen Anzeigestatus zurückzudrehen.bouncedAt/failedAtam Sendevorgang undbouncedAt/failedAtam Postbox-Empfänger stellen die Ordnung nach Ereigniszeit her, sodass eine frühere Annahme zurechenbar bleibt, selbst wenn ihr Webhook zuletzt eintrifft; ein terminales Ereignis, das wirklich zuerst eintrat, weist sie zurück. Eine wiederholt eingespielte frühere Annahme löscht keinen Kontakt-Wiederherstellungszustand, den das neuere terminale Ereignis erzeugt hat. Postbox persistiert die unabhängige Beobachtung inacceptedAt. DerDestinationSnapshot.providerKeyaus Phase 2 reist auf dem Webhook mit. Gmail-Beobachtungen werden nur geschrieben, wenn der Lifecycle ein zurechenbares Send-/Postbox-Ergebnis auflöst, und sind idempotent über die Provider-Message-ID. Der Hot Path shardet die stündlichen Schreibvorgänge jeder PSL-korrekten primären DKIM-Domain deterministisch über acht Dokumente und vermeidet so einen OCC-Engpass auf einem einzelnen Convex-Dokument. Ein stabiler Job pro Domain fasst Bursts zusammen und aktualisiert das materialisierte Rollup fester Breite asynchron; die indizierte Dashboard-Query liefert die Top-100-Domains plus ein explizites Kürzungs-Flag, ohne die Kardinalität Domain × Stunde × Shard zu scannen. Das authentifizierteacceptedAtdes MTA bestimmt die Messstunde, während das vertrauenswürdige Convex-ingestedAtdie Aufbewahrung der Quittungen steuert. Ein begrenzter Zeitversatz von fünf Minuten in die Zukunft wird gekappt; Ereignisse jenseits davon oder älter als der 48-Stunden-Telemetriehorizont behalten ihre Idempotenz, erzeugen aber kein Volumen. Das Aufräumen löscht, aktualisiert oder plant pro Transaktion höchstens 128 Zeilen aus jeder indizierten Klasse neu und plant sofort einen weiteren Batch ein, solange Rückstand besteht. Das Dashboard warnt bei 4.000 auf dem Weg zu Googles ungefährem dauerhaftem Klassifikator bei 5.000/24 h. Stundensummen können die exakte nachlaufende Grenze um bis zu 60 Minuten überlappen, was API und UI offenlegen. - Der RFC-8058-POST-Handler erfasst ein begrenztes tägliches Latenzhistogramm erst, nachdem seine synchrone Abmelde-Mutation abgeschlossen ist. Das Dashboard leitet p95 über 30 Tage ab und alarmiert jenseits des 48-Stunden-Einlösefensters. Ein Telemetriefehler ist für den Endpunkt fail-open; er kann eine angewandte Abmeldung nie in einen für den Provider sichtbaren Fehler verwandeln.
delivery/marketingCompliance.ts verantwortet die einzige finale Envelope-Zusicherung für Kampagnen- und Automationsmail. Sie verlangt beide One-Click-Header und liest die von @owlat/mail-message exportierte SIGNED_HEADERS; in Convex wird keine zweite Liste signierter Header gepflegt.
Empfänger-Policy, abgebildet auf Owlat-Kontrollen
Diese Tabelle trennt die Policy der Mailbox-Provider von Owlats Implementierung. Die Provider-Werte sind nicht austauschbar: Google berechnet eine tägliche Spamrate in den Postmaster Tools, Yahoo teilt Beschwerden durch die in den Posteingang zugestellte Mail, und Owlats FBL-Sicht verwendet zugestelltes Volumen über ihr eigenes rollierendes Fenster.
| Policy-Grenze | Aktuelle Empfängerregel | Owlat-Kontrolle |
|---|---|---|
| Gmail-Bulk-Nähe | Die Klassifizierung liegt bei rund 5.000 Nachrichten in 24 Stunden an private Gmail-Adressen, aggregiert nach primärer Sendedomain | Warnung bei 4.000 angenommenen, Gmail zugerechneten Nachrichten in der rollierenden Näherung; Anzeige der Klassifikatorgrenze von 5.000 |
| Gmail-Spamrate | Postmaster Tools unter 0,10 % halten; 0,30 % oder mehr vermeiden | Ziel und harte Grenze anzeigen; sieben interne saubere Sendetage nur als Untersuchungsnachweis zählen |
| Yahoo-Spamrate | Unter 0,3 % halten; Yahoos Nenner ist die in den Posteingang zugestellte Mail | Dieselbe harte Grenze anzeigen, Owlats Nenner aber nie als Yahoos Rate bezeichnen |
| One-Click-Verarbeitung | Gmail verlangt für Bulk-Marketing-/Abo-Mail RFC 8058; Yahoo verlangt ein funktionierendes List-Unsubscribe und empfiehlt RFC 8058 dringend; beide erwarten Erledigung binnen 2 Tagen | Kampagnen-/Marketing-Automations-Envelopes ohne signierte RFC-8058-Header ablehnen; Suppression synchron anwenden; alarmieren, wenn p95 48 Stunden überschreitet |
| Kampagnenbeschwerden | Die Platzierung beim Empfänger kann sich verschlechtern, bevor eine ganze Organisation einen Breaker überschreitet | Alarm oberhalb von 0,3 % nach mindestens 100 zurechenbaren Zustellungen |
Der Echtzeit-Organisations-Breaker des MTA ist bewusst eine andere, strengere Kontrolle. Jeder Vergleich ist ein striktes Größer-als:
| Signal | Fenster | Öffnet oberhalb von |
|---|---|---|
| Bounce, schnell | Letzte 50 Ergebnisse | 15 % |
| Bounce, anhaltend | Letzte 100 Ergebnisse | 8 % |
| Beschwerde, schnell | Letzte 50 Ergebnisse | 4 % |
| Beschwerde, anhaltend | Letzte 100 Ergebnisse | 0,2 % |
Ein geöffneter Breaker kühlt 30 Minuten ab, bevor Half-Open-Proben laufen. Er ist nicht Googles oder Yahoos Messung und darf nicht als Provider-Policy beschrieben werden.
Aktuelle Anforderungen für Consumer-Mailboxen
| Empfänger-Scope | Mindeststandard Authentifizierung | Weitere aktuelle Anforderungen |
|---|---|---|
| Gmail, alle Absender | SPF oder DKIM | Gültiges Forward-/Reverse-DNS, TLS, RFC 5322, Spam unter 0,3 % |
| Gmail, Bulk | SPF und DKIM; DMARC mindestens p=none; From ausgerichtet an SPF oder DKIM | Mindeststandard für alle Absender; RFC 8058 plus sichtbarer Abmeldelink im Text für Marketing-/Abo-Mail; Einlösung binnen 48 Stunden |
| Yahoo, alle Absender | SPF oder DKIM | Gültiges Forward-/Reverse-DNS, RFC 5321/5322, Spam unter 0,3 % |
| Yahoo, Bulk | SPF und DKIM; bestandenes DMARC mindestens p=none; From-Ausrichtung | Funktionierendes List-Unsubscribe plus sichtbarer Link im Text für Marketing-/Abo-Mail; Einlösung binnen 2 Tagen |
| Outlook.com Consumer, ab 5.000 pro Tag und From-Domain | SPF und DKIM bestehen beide; DMARC mindestens mit p=none veröffentlicht; ausgerichtetes SPF oder DKIM lässt DMARC bestehen | Nicht konforme Mail mit hohem Volumen wird mit 550 5.7.515 abgelehnt |
Die Zeile von Microsoft gilt für Consumer-Adressen von Outlook.com, Hotmail, Live und MSN, nicht automatisch für jeden Microsoft-365-Tenant. Die Ablehnung begann am 5. Mai 2025.
Offizielle Quellen zuletzt geprüft am 26. Juli 2026: Google-Absenderrichtlinien, Google-Absender-FAQ, Yahoo Sender Requirements & Recommendations, Yahoo-Absender-FAQ, Microsoft-Hinweise zu 550 5.7.515, Microsofts Ankündigung der Durchsetzung, und RFC 8058.
Googles Eskalation vom November 2025 betrifft nicht konformen Absenderverkehr im weiteren
Sinne. Die aktuelle Durchsetzungstabelle führt fehlendes One-Click oder ein verfehltes
48-Stunden-Einlösefenster als nicht mitigationsfähig auf; die FAQ sagt, dass dieses
Versäumnis allein eine Nachricht nicht automatisch ablehnt oder in den Spam-Ordner
befördert. Yahoo begann die Durchsetzung von List-Unsubscribe im Juni 2024 und
akzeptiert ausdrücklich mailto, empfiehlt jedoch die POST-Methode nach RFC 8058
dringend.
Eingecheckte Profile der Zielprovider
Das sind Startwerte, keine unveränderlichen Provider-Kontingente. Sie werden in Redis eingesät, ohne Änderungen des Betreibers zu überschreiben, und lassen sich über die ISP-Profil-API des MTA anpassen. TLS-Mindeststandards setzen sich weiterhin nach dem Prinzip „strengste gewinnt“ zusammen.
| Provider | Start / Obergrenze / Untergrenze pro Minute | TLS-Mindeststandard | Verbindungen | Zustellungen pro Verbindung |
|---|---|---|---|---|
| Gmail | 100 / 300 / 5 | Erforderlich | 5 | 50 |
| Microsoft | 80 / 200 / 5 | Opportunistisch | 3 | 100 |
| Yahoo | 50 / 150 / 3 | Opportunistisch | 3 | 100 |
| Apple | 60 / 150 / 5 | Opportunistisch | 3 | 100 |
| Sonstige | 30 / 100 / 2 | Opportunistisch | 3 | 100 |
Das session-bezogene Reputations-Dashboard liest getSendingOverview in apps/api/convex/analytics/reputationQueries.ts. Die deploymentweite Reputationsoberfläche für Plattform-Admins (Roster, Abuse-Status, Content-Review) ist in diesem OSS-Repository eine Backend-/API-Fläche und kein mitgeliefertes Produkt-Dashboard — die umfangreiche Control-Plane-UI wurde in ein separates privates Repository ausgelagert.
IP-Warming-Zustand (Convex-gecacht) und Sendeschätzungen
Die authentifizierte Antwort von GET /ip-reputation trägt außerdem einen begrenzten
Routing-Snapshot, wenn Convex die ID der Singleton-Organisation liefert. Jedes Signal hat
einen expliziten Scope für den Empfängerprovider, eine feste Taxonomie aus Quelle und
Schweregrad sowie einen Beobachtungszeitpunkt. Convex weist den gesamten Snapshot zurück,
wenn er fehlerhaft oder veraltet ist, und materialisiert dann höchstens eine dauerhafte
Hysterese-Zeile pro bekanntem Provider plus den globalen Pool-Scope. Provider-lokaler
Zustand wird nie zu einem globalen Ausfall verallgemeinert.
Der MTA verantwortet den echten Warming-Fahrplan und den Zustand pro IP in Redis. Convex hält eine gecachte, reaktive Kopie, damit Queries sie abonnieren können, ohne bei jedem Lesevorgang den MTA anzusprechen. syncWarmingState (apps/api/convex/delivery/warmingSync.ts) läuft alle 5 Minuten (Cron in crons.ts), holt GET /ip-reputation vom MTA, cacht jede transaktionale und jede Kampagnen-IP, aggregiert die Kampagnenkapazität und legt die Singleton-Zeile warmingState an bzw. aktualisiert sie. Sind MTA_INTERNAL_URL / MTA_API_KEY nicht gesetzt, überspringt sie den Lauf still.
Die gecachte Zeile trägt eine Gesamt-phase (ramp / plateau / graduated), das aufsummierte tägliche Kampagnenlimit, die heutige Zahl der Kampagnensendungen, eine IP-Anzahl und eine Aufschlüsselung pro IP (Phase, Warming-Tag, Tageslimit, heute gesendet, Bounce-/Deferral-Rate, Pool, Aktiv-/Blockgründe, DNSBL-Status und die FCrDNS-Checkliste).
Zwei clientseitige Queries lesen sie (reputationQueries.ts):
getSendingOverview— kombiniert Warming-Zustand, tägliches Sendevolumen, die rollierende 30-Tage-Reputationszusammenfassung der Organisation und den aktuellen Abuse-Status in einer Karte.getCampaignSendEstimate— schätzt bei gegebener Empfängerzahl, wie viele Tage eine Kampagne dauern wird, ausgehend von der verbleibenden Tageskapazität, und projiziert konservativ nach vorn (~1,5-faches Kapazitätswachstum pro Tag), solange IPs noch aufgewärmt werden. Vollständig aufgewärmte Deployments melden eine Schätzung von einem Tag.
getCampaignSendEstimate ist eine Projektion für die Oberfläche, kein Scheduler. Das tatsächliche Pacing erzwingt die Warming-Drossel des MTA zum Zustellzeitpunkt; diese Query setzt lediglich die Erwartungen der Nutzer.
Suppression-Liste und Blockliste
Die Tabelle blockedEmails ist die Suppression-Liste auf Adressebene — die letzte Verteidigungslinie für die Absenderreputation. Verwaltet wird sie in apps/api/convex/blockedEmails.ts. Jede Zeile speichert eine normalisierte (kleingeschriebene, getrimmte) Adresse und einen Grund:
| Grund | Quelle |
|---|---|
bounced | Hard Bounce — die Adresse existiert nicht |
complained | Empfänger hat die E-Mail als Spam markiert |
manual | Betreiber hat sie von Hand hinzugefügt |
Blockierte Adressen werden als Teil des Eignungsprädikats für die Kampagnen-Zielgruppe vom Versand ausgeschlossen (Soft-Delete + E-Mail vorhanden + Suppression + DOI-falls-Thema). Das automatische Blockieren geschieht über addFromEvent, den internen Schreiber, den die Bounce- und Beschwerde-Handler aufrufen; es ist idempotent (das erneute Blockieren einer bestehenden Adresse gibt den vorhandenen Datensatz zurück). Betreiberseitige Fläche:
add/bulkAdd/remove— erfordern die Berechtigungcontacts:manage.listByTeam(optional nach Grund gefiltert),get,getByEmail,getCountsByReason— Lesevorgänge.isBlocked(Session) undisBlockedInternal(von anderen Convex-Funktionen genutzt, ohne Zugriffsprüfung) — Punktabfragen.
Alle Abfragen laufen über den Index by_email auf der normalisierten Adresse; by_reason unterlegt die gefilterte Liste und die Zählungen.
Tägliche Sendestatistiken und Schwellenwerte des Content-Scan-Gates
Tageszähler
Es existieren zwei getrennte Tageszähler für unterschiedliche Zwecke:
instanceSettings.dailySendCount— ein einzelner laufender Zähler für den aktuellen UTC-Tag, erhöht übernextDailySendCount(apps/api/convex/lib/sendingLimits.ts), das das Inkrement in deninstanceSettings-Patch einfaltet (der Schreiber für Massenversand istincrementDailySendCountInternalincampaigns/sendQueries.ts). Er dient nur der Anzeige; tarifbasierte Limits wurden entfernt, und das Pacing ist Aufgabe des MTA.getDailySendVolumesetzt ihn beim ersten Lesevorgang eines neuen UTC-Tags träge zurück.sendDailyStats— eine Zeile pro UTC-Tag mit den Zählernsent/delivered/opened/clicked, geschrieben vomdaily_stats_bump-Effekt des Send-Lifecycle überbumpSendDailyStat(apps/api/convex/lib/sendDailyStats.ts). Die Zusammenfassungskarte des Dashboards liest die letzten 30 Zeilen dieser Tabelle, statt jeden einzelnen Sendevorgang zu scannen.
Das Content-Scan-Gate
Bevor eine Kampagne ausgefächert wird, lässt der Orchestrator (apps/api/convex/campaigns/send.ts) die Adresse durch den E-Mail-Scanner laufen. Er kombiniert den lokalen Content-Score (scanContent aus @owlat/email-scanner) mit einem optionalen URL-Reputationsdurchlauf über Google Safe Browsing (nur wenn GOOGLE_SAFE_BROWSING_API_KEY gesetzt ist; fehlgeschlagene URL-Prüfungen blockieren einen Versand nie). Der kombinierte Score von 0–100 wird auf drei Stufen abgebildet:
| Score | Stufe | Ergebnis |
|---|---|---|
| ≥ 40 | blocked | Kampagne fällt mit einem contentBlockReason auf draft zurück; Versand bricht ab |
| 15–39 | suspicious | Kampagne wechselt zu pending_review für die Prüfung durch Plattform-Admins |
| < 15 | clean | Versand läuft weiter |
Nicht saubere Ergebnisse werden als Prüfpfad in contentScanResults persistiert (geschlüsselt über resourceType + resourceId). Derselbe Scanner unterlegt an anderer Stelle die Validierung von Anhängen und Medien-Uploads; URL-Urteile werden in urlReputationCache gecacht (24 h für sauber, 1 h für auffällig). Zu den Interna des Sicherheits-Scannings siehe E-Mail-Sicherheit.
Yahoo Complaint Feedback Loop (geführte Anmeldung über die DKIM-Domain)
Yahoos CFL basiert auf der DKIM-Domain: Es gibt keine API und keine zu speichernden Zugangsdaten, nur eine beidseitige Anmeldung, die ein Betreiber auf Yahoos Absenderseite für eine Domain vornimmt, die wir bereits signieren. Die Integration ist deshalb ein geführter Ablauf plus ein festgehaltener Zustand, kein Client.
- Kein zweiter Parser. Ein Yahoo-CFL-Report ist eine gewöhnliche ARF-Nachricht nach RFC 5965 und läuft durch den ausgelieferten Prozessor in
apps/mta/src/bounce/fblProcessor.ts, dessen ISP-Mapyahoobereits auflöst, sowie durch die ausgelieferte Zurechnung über signierte VERP plus persistierte Provenienz infeedbackProvenance.ts. Eine Beschwerde-Pipeline, drei Quellen (Yahoo CFL, der CFBL-Address-Feed nach RFC 9477 und der Abmelderaten-Proxy). - Was der MTA jetzt weiterreicht. Das reduzierte Webhook-Ereignis
complainedträgt dieReported-Domainnach RFC 5965 (unsere Sende- bzw. DKIM-Domain) und den aufgelösten Quell-ISP. Beide werden an der Emissionsstelle inoutcome.tsbegrenzt: Die gemeldete Domain ist vom Internet kontrollierter Reporttext, und ein übergroßer Wert, der den Validator für Webhook-Ereignisse nicht besteht, würde die gesamte Beschwerde verwerfen — und die muss immer die Blockliste erreichen. - Die Zustandsmaschine liegt in
packages/shared/src/yahooCfl.tsund ist rein —not_started → awaiting_yahoo → enrolled, pluslapsed, wenn eine angemeldete Domain 90 Tage lang keine Yahoo-Beschwerde gesehen hat. Ein Report darf eine Anmeldung bestätigen und auffrischen, sie aber nie herbeiführen: Alles, woran der Beobachtungspfad ansetzen könnte, stammt aus dem Report (das Quell-ISP-Token kommt aus dessen eigenemUser-Agent, die gemeldete Domain aus dessen eigenem RFC-5965-Feld), und die einzige vorgelagerte Authentifizierung ist eine VERP-zugerechnete Message-ID, die jeder Empfänger eines Sendevorgangs ohnehin besitzt. Ein Report gegen einenot_started-Domain wird deshalb mit dem Grundnot_submittedabgelehnt und schreibt überhaupt keine Zeile. Abawaiting_yahoobefördert und belebt ein Report die Anmeldung unabhängig davon, was der Betreiber festgehalten hat, und ein Replay in falscher Reihenfolge drehtlastReportAtnie zurück. Die Convex-Hülle (apps/api/convex/domains/yahooCfl.ts) lädt, ruft auf und schreibt. - Die erneute Prüfung wird beim Lesen abgeleitet, nicht eingeplant. Es gibt keinen Cron und keinen Schreibvorgang für die erneute Prüfung:
getGuideberechnetlapsedauslastReportAt(ersatzweiseenrolledAt) gegen die Uhr zum Lesezeitpunkt, sodass das Urteil immer aktuell ist und zwischen zwei Durchläufen nie veralten kann. Die Beobachtung eines Reports ist der einzige Schreibvorgang und wird auf höchstens einen Zeilen-Patch pro Stunde zusammengefasst (YAHOO_CFL_REPORT_COALESCE_MS) — Beschwerden treffen in Schüben ein und alle Reports einer Domain landen auf einer Zeile, sodass ein Patch pro Report genau jene Einzeldokument-Contention wäre, wegen der ADR-0042 geschrieben wurde. Die Beobachtung läuft außerdem nach der Suppression und innerhalb einestry/catch, und nur für Verkehr über Zustelldomains inproduction: Eine Beschwerde muss unabhängig von der Buchführung immer die Blockliste erreichen, und Mail aus der Mitglieder-Vorschau darf eine Anmeldung nie als live markieren. - Die Betreiberoberfläche ist ein Panel pro Domain in der aufgeklappten Domainzeile (Delivery → Domains,
apps/web/app/components/domains/YahooCflPanel.vue): die vier geführten Schritte samt „woran Sie erkennen, dass es geklappt hat“, die Steuerelemente Absenden / Bestätigen / Neu beginnen für Owner und Admins (mit dem Grund für ein deaktiviertes Absenden als sichtbarer, peraria-describedbyverknüpfter Text und einer Bestätigung bei „Neu beginnen“, die die Konsequenz benennt) sowie der Vertrauenssatz, den der reine Kern immer mitliefert.not_startedwird als ruhige, noch nicht begonnene Option dargestellt — nie als Warnbadge und nie als „Einrichtung unvollständig“-Ermahnung. - Jedes Betreiberereignis wird auditiert.
submitEnrollment/confirmEnrollment/resetEnrollmentschreiben jeweils eine Audit-Log-Zeilesending_domain.yahoo_cfl_changed— auch bei Ablehnungen —, die das Ereignis trägt, ob sich die Zeile geändert hat, den resultierenden Zustand samt Grund und auf welcher Beschwerdequelle die Yahoo-Zelle danach läuft. „Neu beginnen“ ist der scharfe Fall: Es ist destruktiv und stuft diese Quelle von Yahoos eigenem Feed auf den Abmelderaten-Proxy herab, sodass die Herabstufung immer einen Akteur benennt. - Der Auslösepunkt von Gate 3 hat genau ein Zuhause, und er ist eine Vereinigung aus zweien.
apps/api/convex/delivery/signals/yahooCfl.tswählt die aktive Quelle und veröffentlicht einenYahooComplaintTrip— dieselbe Regel, dieevaluateStandaloneComplaintGateausführt, nie eine zweite Deklaration davon. Die beiden Feed-Quellen (Yahoo CFL, CFBL-Address) liefern einenabsolute_rate-Auslöser beiRAMP_GATE_THRESHOLDS.complaintMaxausdelivery/ramp/gateConfig.ts— dieselbe gebrandeteRateFraction, gegen die das eigene Beschwerde-Gate der Rampe vergleicht — und er ist auf der Fehlerseite strikt: Genau der Schwellenwert besteht noch. Die Proxy-Quelle liefert einentrailing_multiple-Auslöser beiRAMP_GATE_THRESHOLDS.unsubscribeProxyMultiplegegen die eigene nachlaufende 30-Tage-Abmelderate der Zelle, und dieser ist auf der Fehlerseite einschließend: Genau das 3-Fache fällt durch, passend zuboundary: 'inclusive_fail'inUNSUBSCRIBE_PROXY_SPECintrailingBaselineGates.ts, wo die Regel des Proxys liegt. Zwei Arten von Auslösepunkt, zwei Grenzen, ein Komparator:compareYahooComplaintRateist dreiwertig —breach/no_breach/not_comparable—, sodass eine Rate, die die Zähler nicht ausdrücken konnten, oder eine nachlaufende Baseline, die kein Nenner sein kann, als das gelesen wird, was sie ist: ein Halt, und nicht als gesunde Zelle. - Die DKIM-Vorbedingung kontrolliert nur unseren eigenen Assistenten: Yahoo akzeptiert keine Anmeldung für eine Domain, auf der es unsere Signatur nicht sehen kann, deshalb bleibt Schritt 2
blocked, bis die Domainverifiedist und einen MTA-DKIM-Selektor trägt. Den Versand kontrolliert sie nie. - Abwesenheit ist eine unterstützte Konfiguration. Eine Domain ohne Anmeldung ist schlicht
not_started. Das Beschwerde-Gate der Yahoo-Zelle ersetzt die direkte Beschwerdeobergrenze von 0,1 % durch den CFBL-Feed (mittlere Konfidenz) oder den Abmelderaten-Proxy beim 3-Fachen der eigenen nachlaufenden 30-Tage-Abmelderate der Zelle (mittlere Konfidenz — ein Proxy, als solcher gekennzeichnet, mit dem Vertrauenssatz und dem Verbesserungsvorschlag an der Zelle). Der Assistent veröffentlicht exakt die Regel, die der Controller anwendet: Es gibt eine Definition dieses Auslösepunkts, nicht eine für den Bildschirm und eine für das Gate.lapsedist eine Aufforderung, bei Yahoo erneut nachzusehen, kein Alarm: Nichts stoppt, nichts läuft auf einen Fehler, und es gibt keine „Einrichtung unvollständig“-Ermahnung. - Das erneute Prüfen einer abgelaufenen Anmeldung ist ein echter Übergang. Der gespeicherte Zustand einer abgelaufenen Domain ist weiterhin
enrolled, deshalb setzen sowohl die Bedienmöglichkeit als auch die Zustandsmaschine am abgeleiteten Zustand an:yahooCflAvailableActionsbietet „Absenden“ erneut an, der Absendeschritt öffnet sich wieder als To-do, und ein erneutes Absenden bringt den Datensatz mit einem frischensubmittedAtzurück nachawaiting_yahoo, behältlastReportAt(beobachtete Historie) und verwirft das nun veralteteenrolledAt. Eine aktive Anmeldung verweigert das Absenden weiterhin mitalready_enrolled— geprüft vor der DKIM-Vorbedingung, sodass einer angemeldeten Domain, die später ihre DKIM-Bereitschaft verloren hat, nie gesagt wird, sie solle einen Eintrag für eine Domain veröffentlichen, die Yahoo bereits akzeptiert hat. Der vierte Schritt öffnet sich mit: „Beschwerden eintreffen sehen“ fragt, ob ein Report seit der aktuellen Einreichung gesehen wurde (lastReportAt >= submittedAt), nicht ob jemals einer gesehen wurde — so kann das beibehaltenelastReportAtden Schritt nicht alsdonestehen lassen (und behaupten, es flössen Beschwerden) für eine Anmeldung, die wir gerade nicht mehr für live halten. Er schließt sich wieder beim ersten Report, der das neuesubmittedAtübertrifft. Das Panel rendert diese Bedienmöglichkeiten wortgetreu, statt eigene abzuleiten, sodass ein angebotenes Steuerelement immer eines ist, das die Zustandsmaschine auch bedient.
Eigene Tracking-Domains
Öffnungs- und Klicklinks zeigen auf den eigenen Tracking-Host des Deployments — die HTTP-Actions /t/o und /t/c, ausgeliefert unter CONVEX_SITE_URL. Ein Deployment kann eine gebrandete Subdomain registrieren und per DNS verifizieren, sodass getrackte Links irgendwann seine eigene Domain statt eines gemeinsamen Hosts tragen. Registrierung, admin-geschützte DNS-Verifizierung und die Einstellungsoberfläche sind vorhanden; das Umschreiben der Links zum Sendezeitpunkt ist noch nicht verdrahtet (siehe den zweiten Punkt). Die Tabelle trackingDomains wird in apps/api/convex/domains/trackingDomains.ts verwaltet und unter Einstellungen → Domains (TrackingDomainsSection.vue) angezeigt:
addTrackingDomainerfasst die Subdomain mit einemcnameTarget, das aus dem Hostnamen vonCONVEX_SITE_URLabgeleitet wird — dem Host, der die Tracking-Handler tatsächlich ausliefert, nie einem externen SaaS-Host.verifyTrackingDomainplant eine CNAME-Prüfung per DNS-over-HTTPS (Cloudflare) ein (verifyTrackingDomainDns), die die Zeile nur dann auf verifiziert umstellt, wenn der CNAME auf dieses Ziel auflöst. Alle drei Mutations erfordernrequireAdminContext.- Die interne Query
getActiveTrackingDomain(trackingDomains.ts) stellt der Sende-Pipeline die erste verifizierte Zeile bereit, aber noch konsumiert sie kein Produzent.delivery/worker.tsleitettrackingBaseUrlalsenvelopeInput.trackingBaseUrl ?? convexSiteUrlab, und nichts auf dem Kampagnen-Sendepfad befüllttrackingBaseUrlaus einer Tracking-Domain (apps/api/convex/campaigns/enthält keinerlei Referenzen darauf). Getrackte Links fallen also unabhängig von jeder verifizierten Domain weiterhin aufCONVEX_SITE_URLzurück — die Branding-Hälfte dieser Funktion ist registriert und verifiziert, aber noch nicht in Links gerendert.
Pre-Flight für die Ausrichtung zweier Transporte
Wenn eine Sendedomain zwei konfigurierte Arme hat — den eigenen MTA und eine Referenz-Relay-Identität —, müssen die beiden für den Empfänger in allem außer der Sendeinfrastruktur ununterscheidbar sein. Sonst misst eine Anteilsrampe zwischen ihnen die DMARC-Ausrichtung und nicht die Zustellbarkeit. apps/api/convex/delivery/alignmentPreflight.ts (Zustand) und alignmentPreflightGather.ts (Live-DNS, Node-Runtime) verifizieren vier Prüfungen erneut und speichern das Urteil in deliverabilityAlignmentStates; der reine Entscheidungskern ist packages/shared/src/deliverabilityAlignment.ts. Der Sweep-Cron läuft stündlich, und jede Domain trägt ihr eigenes nextCheckDueAt — 24 h nach einem aufgelösten Urteil, ~1 h nach einer nicht aufgelösten Abfrage —, sodass die kürzere Wiederholungsfrequenz tatsächlich eingehalten wird; eine Domain, die nicht fällig ist, kostet einen indizierten Lesevorgang. Jeder Lauf durchläuft eine begrenzte Zahl von Seiten verifizierter Domains und reicht den Cursor an eine eingeplante Fortsetzung weiter, sodass ein Deployment mit vielen Domains sie alle erneut prüft statt nur die erste Seite.
- From-Domain — auf beiden Armen identisch. Blockierend. Eine Subdomain pro Transport spaltet die Domainreputation auf und macht die Arme unvergleichbar; Subdomains pro Stream (
news.,bounces.) sind die legitime, davon getrennte Sache und bestehen, wenn beide Arme sie teilen. - SPF — ein
v=spf1-Eintrag, der beide Arme autorisiert, innerhalb des 10-Lookup-Limits von RFC 7208. Zählung und Merge liegen inpackages/shared/src/spfCoexistence.ts(eine Implementierung, aufbauend auf den ausgelieferten Merge-Primitiven inspf.ts); ein Eintrag über dem Limit benennt das zu flachendeinclude:. - DKIM — beide Arme signieren dasselbe
d=mit unterschiedlichen Selektoren, nachgewiesen per Live-TXT-Abfrage. Ein leeresp=(widerrufen) fällt durch. - DMARC — ein einzelner
_dmarc-Eintrag existiert und beide Arme richten sich unter dessen Modus aus (adkim=sverlangt, dassd=exakt der From-Domain entspricht).
Der Referenzarm, und warum er singulär sein muss
Der Adaptive Mix hat genau zwei Arme: den eigenen Arm (OWN_ARM_TRANSPORT_KIND = 'mta') und einen Referenzarm — dasjenige Relay, über das dieses Deployment den anderen Anteil versendet. Jede andere Art ist automatisch der Referenzarm; es gibt keinen Controller-Code pro Provider, weshalb Mailchimp Transactional zu einem erstklassigen Migrationsarm wurde, ohne dass der Controller irgendetwas darüber lernen musste. Die Identität, die einen Arm beschreibt, stammt aus der Provider-Registry der Sendedomain (domains/providers/<kind>/describeReferenceArm), sodass ein Provider seinen eigenen Arm genauso mitbringt wie seinen eigenen Relay-Nachweis.
Halten Sie während einer Migration genau ein Relay aktiviert. Bei zweien gibt es keinen eindeutigen zweiten Arm, mit dem eine Domain verglichen werden könnte: Der Pre-Flight schreibt unknown (ein Halt, nie ein Bestehen), das Urteil verschlechtert die Messkonfidenz, und jede Zelle bleibt bei ihrem aktuellen Anteil. Das ist kein Fehlerzustand, den man durch Verifizieren von irgendetwas behebt — es ist eine Konfigurationsfrage mit genau einer Antwort, deshalb nennt das unknown-Detail, welche Relays aktiviert sind, und das Dashboard zeigt es unter Delivery → Provider und Delivery → Cells als eigene Warnung an statt als vierten DNS-Befund. Ein Team, das von Mailchimp kommt, läuft genau in dem Moment hinein, in dem es einen alten SES- oder Resend-Key neben MANDRILL_API_KEY stehen lässt.
Relays, deren Nachweis altert, werden genauso gehalten. Eine Mandrill-Sendedomain-Identität wird von einem stündlichen Sweep neu bestätigt und läuft nach 7 Tagen ab (MANDRILL_RELAY_PROOF_MAX_AGE_MS); eine abgelaufene liest sich als „wird erneut geprüft“ und hält den Anteil, statt ein verifiziertes Relay zu melden, dem das Routing bereits nicht mehr vertraut.
Semantik, die stärker zählt als die Prüfungen selbst:
- Eine Abfrage, die nicht beantwortet werden konnte, ist
unknown, kein Fehlschlag und kein Bestehen. Timeout / SERVFAIL / REFUSED halten die Zelle bei ihrem aktuellen Anteil und werden in einer Stunde wiederholt; NXDOMAIN und eine leere Antwort sind autoritative Abwesenheiten und fallen durch. Das ist der eine DNS-Lesevorgang im Backend, der bewusst nicht weich auf „nicht gefunden“ zurückfällt. - Kein Referenztransport bedeutet nichts auszurichten — und nichts zu speichern. Der Sweep überspringt die Domain vollständig: keine zu vergleichenden Arme, keine DNS-Abfragen, keine Urteilszeile. Das Bereitschaftspanel rendert deshalb für ein Deployment ohne jegliche Drittanbieter-Konten keine Ausrichtungswarnung; nirgendwo erscheint ein Fehler- oder „Einrichtung unvollständig“-Zustand.
- Ein gespeichertes
single_arm-Urteil hört in dem Moment auf, Beweis zu sein, in dem ein Relay konfiguriert wird. Es wurde erfasst, als es keinen zweiten Arm gab, und es ist das eine Urteil, das eine Domain ohne Auffrischung ewig halten könnte, deshalb honoriert das Gate es nicht: Sobald ein Referenzarm im Spiel ist, fällt die Zeile aufnot_yet_checkeddurch und hält, bis der Sweep ein echtes Zwei-Arm-Urteil erzeugt.referenceArm === 'none'ist der einzige Ein-Arm-Grund zum Öffnen. (Solche Zeilen existieren nur aus der Zeit, bevor der Sweep aufhörte, sie zu schreiben; das Spiegelbild der Regel „eine reine Relay-Domain wird übersprungen“ weiter unten.) - Ein Relay, das wir nicht beschreiben können, hält, und heißt nie
single_arm. Die ausgelieferte Transportmenge ist breiter als SES (mta/ses/resend/smtpplusplugin.*). Ist ein Relay aktiviert, die Domain hat aber keine signierende Identität, gegen die wir vergleichen können, lautet das Urteilunknown— ein Halt —, denn mit „kein zweiter Arm“ für einen Transport zu antworten, den wir bloß nicht beschreiben konnten, ließe zwei tatsächlich nicht ausgerichtete Arme hochrampen. - Die SPF-Mechanismen des eigenen Arms stammen aus
MTA_IP_POOLS, nicht aus dem gespeicherten SPF-Wert der Domain (der für eine bei SES registrierte Domain das Include des Relays ist). Ohne konfigurierten Pool ist die SPF-Prüfungunknown, nie ein Bestehen auf einem reinen Relay-Eintrag. - Eine reine Relay-Domain wird übersprungen, nicht blockiert. Eine verifizierte Domain ohne eigene MTA-Identität hat keinen Arm zum Vergleichen, deshalb wird keine Urteilszeile geschrieben — ein nicht handhabbares dauerhaftes
blockedwäre ein für eine unterstützte Konfiguration herbeigeführter Fehlerzustand. - Die Bereitschaftsoberfläche (
getAlignmentReadiness→apps/web/app/utils/deliveryReadiness.ts→ das Delivery-Bereitschaftspanel) rendert ein Gate nur dann, wenn wirklich ein Referenztransport im Spiel ist, und dieses Gate ändert nie das Urteil des Sendepfads: Was es hält, ist die Rampe, nicht der Versand. - Der Return-Path wird erfasst, blockiert aber nie. Ein Relay, das den eigenen VERP-Return-Path nicht tragen kann, markiert lediglich die Messung der Domain als verschlechtert.
Bildschirm zur Zustellmessung (nur lesend)
/dashboard/admin/delivery/advanced/measurement rendert das, was die Ramp-Gates sehen, bevor irgendetwas darauf reagiert. Eine organisationsbezogene Query — apps/api/convex/delivery/deliverabilityDashboard.ts — liest für jede Zelle (stream, destinationProvider) die Transportergebnisse beider Arme und liefert pro Zelle: die Zähler und Raten beider Arme, das Urteil jedes Gates samt der Zahlen, die es erzeugt haben, die Messkonfidenz und einen Trendpunkt pro Tag des Fensters. Hinter diesem Bildschirm liegen keine Steuerelemente und keine Schreibvorgänge.
- Das Fenster ist fest verankert, nicht gewählt. Die Query nimmt keine Argumente entgegen:
dashboardWindow()leitet das 7-Tage-Auswertungsfenster ab, die nachlaufende Baseline, gegen die Gate 4b vergleicht, und die weiteste Grenze, die der einzelne Index-Lesevorgang abdecken muss. Die Baseline endet dort, wo das Auswertungsfenster beginnt — eine Baseline, die das jüngste Fenster überlappte, trüge bereits den Zerfall in sich, den der Slow-Poison-Boden aufdecken soll, und der Bildschirm würde ein Urteil rendern, das der Controller nie erreichen würde. - Jede Rate ist die des Summierers. Die Query führt
summarizeTransportOutcomeBucketsüber den gelesenen Zeilen erneut aus — ein Lesevorgang pro (Zelle, Arm), daraus abgeleitet mehrere disjunkte Teilfenster — und die Vue-Komponenten formatieren, was ihnen übergeben wurde. Nichts inapps/web/app/utils/deliverabilityMeasurement.tsdividiert, sodass der Bildschirm und der Ramp-Controller für denselben Verkehr keine unterschiedlichen Zahlen melden können (ADR-0042). - Dünne Daten sind ein Zustand, kein Fehler. Ein Gate unterhalb seiner Stichprobenuntergrenze wird neutral als „noch nicht genug Daten — N von 400 Sendevorgängen in diesem Fenster“ gerendert. Eine Zelle, durch die niemand gesendet hat, wird leer und ruhig dargestellt.
- Kein Referenztransport ist eine unterstützte Konfiguration — gemessen von einer anderen Gate-Implementierung. Ohne Relay im Spiel wählt die Query
trailingBaselineGateEvaluator(apps/api/convex/delivery/ramp/gateEvaluation.ts, einmal pro Organisation statt pro Zelle gewählt), sodass die Gate-Zeilen, die ein eigenständiger Betreiber liest, die Substitutionen über die nachlaufende Baseline sind — die eigene 30-Tage-Historie einer Zelle steht für den zweiten Arm — und nicht die Zwei-Arm-Vergleiche, die der Rest dieses Abschnitts beschreibt: Hard Bounce ist absolut plus ≤ 1,5× die nachlaufende Rate der Zelle, Deferral wird zum primären schnellen Signal befördert — die Deferral-Rate, wobei der harte Stopp über Blockmeldungen pro ISP (evaluateSmtpBlockMessages) sie überstimmt: Der MTA klassifiziert jedes empfangene 4xx/5xx in das gemeinsame Vokabular@owlat/shared/smtpBlockCategoriesund meldet das Urteil als typisiertes Feld auf einemsmtp.classified-Webhook,analytics/smtpResponseCategories.tszählt es pro Zelle(stream, destinationProvider), Arm und UTC-Tag, und ein Fenster, dessen klassifizierte Antworten zu mindestens 0,5 % Verweigerungen sind, hält die Zelle rundheraus an, statt sie bloß durchfallen zu lassen (Issue #501). Verweigerung heißt, dass der Empfänger unseren Inhalt, unsere Absenderidentität oder unsere IP ablehnt — Throttling und Greylisting sind Ratendruck, sie werden im selben Fenster als Nenner gezählt und tragen nie zu diesem Zähler bei. Eine Zelle, deren Fenster überhaupt keine klassifizierte Antwort enthält, ist ABWESEND und nicht sauber: Die Klausel liefert kein Urteil und die Deferral-Rate entscheidet allein — One-Click-Abmeldung bei ≥ 3× der nachlaufenden Rate steht dort für Beschwerden, wo es keinen Feedback-Loop gibt, Engagement vergleicht gegen die eigene EWMA der Zelle (≥ 0,85, 7-Tage-Fenster, Untergrenze 2000 Sendevorgänge) und darf immer nur eine Verringerung rechtfertigen, und die Platzierung liest die selbst gehosteten Seeds absolut. Wasapps/api/convex/delivery/ramp/trailingBaselineGates.tsverantwortet, ist die Form jeder Substitution, nicht ihre Zahlen:TRAILING_HARD_BOUNCE_SPEC,UNSUBSCRIBE_PROXY_SPECundTRAILING_ENGAGEMENT_SPEClegen fest, welche Reihe gegen welche verglichen wird (das jüngste Fenster gegen die eigene nachlaufende Baseline der Zelle statt gegen einen zweiten Arm), dass die Bounce- und Abmeldevergleichekind: 'multiple'sind — ein Verhältnis, weil ein anderer Monat mit einer anderen Liste nur relativ beurteilt werden kann — und auf welcher Seite der Grenze jeder von ihnen liegt:inclusive_passfür die 1,5×-Bounce-Toleranz (genau 1,5× besteht noch) undinclusive_failfür den 3×-Abmelde-Proxy (genau 3× fällt durch). Die Werte liegen dort, wo jeder andere Ramp-Schwellenwert liegt:RAMP_GATE_THRESHOLDS.hardBounceTrailingMultiple,RAMP_GATE_THRESHOLDS.unsubscribeProxyMultipleundRAMP_GATE_SAMPLE_FLOORS.engagementTrailingindelivery/ramp/gateConfig.tssowieENGAGEMENT_GATE_THRESHOLDS.trailingBaselineRatioindelivery/ramp/engagementConfig.ts. Das 7-Tage-Fenster ist bewusst nirgends eine Konstante — die Gates nehmen ihre Fenster als Parameter entgegen, sodass derjenige Aufrufer, der das Fenster gewählt hat, es auch benennt. - Die Konfidenz einer einarmigen Zelle hat drei Fälle, nicht einen.
dashboardConfidencenimmt das Schwächste aus dem, was die Gates tatsächlich gemessen haben, und einer Obergrenze, die sich daraus ergibt, welche Instrumente existieren:nonefür eine Zelle, durch die niemand gesendet hat (ownSent <= 0, kurzgeschlossen, bevor irgendeine Note gelesen wird),low, wenn weder ein Relay noch Seed-Postfächer verbunden sind, undmedium, wenn Seed-Postfächer ohne Relay existieren — eine ausgelieferte Konfiguration, seit die selbst gehosteten Seeds gelandet sind, keine hypothetische.highist nur mit einem Referenzarm erreichbar. Neben der Stufe benennt die Karte als Einladung, was sie anheben würde — ein Relay verbinden, Seed-Postfächer hinzufügen, mehr Volumen senden. Die Überschrift lautet „Aufwärm-Autopilot“ statt eines abwertenden „Sendeunabhängigkeit“. Nichts läuft auf einen Fehler, warnt oder ermahnt.