MTA-System

Owlats eigener Mail Transfer Agent für die direkte SMTP-Zustellung mit intelligentem Rate-Limiting, Bounce-Verarbeitung und IP-Warming.

Der Owlat-MTA (apps/mta/) ist ein eigener Mail Transfer Agent, der E-Mails per direktem SMTP an die Mailserver der Empfänger zustellt. Er ersetzt die APIs von Drittanbietern durch einen selbst gehosteten Dienst, der volle Kontrolle über Versandreputation, ISP-spezifische Drosselung, IP-Warming und Bounce-Verarbeitung bietet.

Der MTA ist einer der Zustell-Provider, die ein Deployment auswählen kann, mit EMAIL_PROVIDER=mta (oder einer organisationsspezifischen Provider-Route). Er ist kein impliziter Standard: Ohne Route und ohne Wert für EMAIL_PROVIDER ist die Auflösung fail-closed, und Sendungen werden abgelehnt statt hierher weitergeleitet. Amazon SES, Resend, ein generisches SMTP-Relay und Mailchimp Transactional sind die Alternativen — die vollständige Liste finden Sie unter Provider und die Begründung für einen eigenen MTA in ADR-005.

Was der MTA ist

  • Eine spezialisierte Zustell-Engine — optimiert darauf, E-Mails mit maximaler Zustellbarkeit in Posteingänge zu bringen
  • Ein intelligenter Ratensteuerer — adaptive Drosselung je ISP, IP-Warming, Circuit Breaker, engagementbasierte Priorität
  • Ein Bounce-Prozessor — dedizierter SMTP-Server für DSN-/ARF-Parsing mit automatischer Unterdrückung
  • Ein Reputationswächter — DNSBL-Überwachung, Circuit Breaker je Organisation, kontrollierte Degradation unter Last
  • Ein zustandsloser Worker — stellt zu und meldet über Webhooks zurück; keine langfristige Nachrichtenspeicherung

Was der MTA nicht ist

  • Kein vollständiger Mailserver — keine Nachrichtenarchivierung, keine Volltextsuche, keine Web-UI zum Durchsehen von Nachrichten (anders als Postal, Mailcow oder iRedMail)
  • Kein eigenständiges Produkt — als Komponente der Owlat-Plattform konzipiert; er ist für Kampagnenorchestrierung, Tracking, Domainverwaltung und Abrechnung auf das Convex-Backend angewiesen
  • Kein Tracking-System — Klick-/Öffnungs-Tracking findet vorgelagert im Convex-Backend statt, nicht im MTA
  • Kein vollständiger Inbound-Spamfilter — er authentifiziert zwar eingehende Mail (SPF, DKIM und DMARC) und kann ausgehende Inhalte mit einem optionalen rspamd-Sidecar bewerten, aber Inhaltsprüfung und Malware-Scan von Anhängen erfolgen in erster Linie über das Paket @owlat/email-scanner (siehe E-Mail-Sicherheit)
  • Kein nutzerseitiger Dienst — reine API, programmatisch verwaltet; kein Admin-Dashboard und keine Self-Service-UI

Designphilosophie

Der MTA folgt einem Ansatz von Tiefe statt Breite: Statt einen vollständigen Mailserver zu bauen, der vieles ausreichend gut kann, konzentriert er sich ausschließlich auf Intelligenz beim ausgehenden Versand. Jede Funktion dient einem Ziel — die Inbox-Platzierung zu maximieren und dabei die Absenderreputation zu schützen.

Zentrale Architekturentscheidungen:

  • Zustandsloser Prozess, zustandsbehaftetes Redis — der MTA-Prozess selbst hält keinen Zustand; alle Intelligenzdaten liegen mit TTLs in Redis. Das ermöglicht horizontale Skalierung und Deployments ohne Ausfallzeit.
  • Intelligenz vor Zustellung — jede E-Mail durchläuft eine typisierte, zehnphasige Dispatch-Pipeline, bevor SMTP überhaupt berührt wird. Die meisten MTAs senden zuerst und reagieren auf Fehler; Owlat verhindert Fehler proaktiv.
  • Engagement-bewusste Reihenfolge — Empfänger mit hohem Engagement werden während des IP-Warmings zuerst angeschrieben, was positive ISP-Signale genau dann maximiert, wenn die Reputation am wichtigsten ist.
  • Kontrollierte Degradation — Gegendruck auf API-Ebene (429), Backoff je Domain, Notfallmodus (503), wenn alle IPs blockiert sind. Das System schützt sich und seine Kunden automatisch.

Systemkomponenten

KomponenteVerzeichnisZweck
HTTP-APIsrc/routes/Hono-Server, der Sendeanfragen vom Convex-Backend annimmt
GroupMQ-Workersrc/queue/Redis-gestützte Job-Queue mit gruppenbasierter Verarbeitung
Dispatch-Pipelinesrc/dispatch/Typisierte, komponierbare Phasen-Pipeline — zehn geordnete Prüfungen vor dem Versand (ADR-0007)
Intelligencesrc/intelligence/Die Prüfimplementierungen, an die die Dispatch-Phasen delegieren
SMTP-Sendersrc/smtp/Direkte MX-Zustellung mit DKIM-Signierung und MTA-STS-Durchsetzung
SMTP-Submissionsrc/smtp/Authentifizierter SMTP-Submission-Server (Port 587)
Connection Poolsrc/smtp/Wiederverwendbarer SMTP-Transport-Pool je MX-Host, mit instanzübergreifender Koordination
DKIM-Key-Storesrc/smtp/Redis-gestützte DKIM-Schlüsselablage mit automatischer Rotation
Bounce-/Inbound-Serversrc/bounce/Eingehender SMTP-Server für DSN-/ARF-Parsing, Klassifizierung und eingehende Mail
Inbound-Routersrc/inbound/Regelbasiertes Inbound-Routing + Auflösung persönlicher Postfächer
Credentialssrc/auth/API-Key je Organisation und Postbox-Authentifizierung
Webhook-Notifiersrc/webhooks/Ereignis-Callbacks an das Convex-Backend
Monitoringsrc/monitoring/Prometheus-Metriken, strukturiertes Logging, Google-Postmaster-Abruf
Scalingsrc/scaling/Verwaltung der IP-Pools, Pool-Regeln und kontrollierte Degradation
Leader Electionsrc/lib/Redis-Lock, damit periodische Crons auf genau einer Instanz laufen
Attachment Scannersrc/routes/scan.tsEndpunkt zur Dateityp-Validierung + ClamAV-Malware-Scan

HTTP-API-Endpunkte

Der MTA unterstützt zwei Authentifizierungsmodi: den Master-Key MTA_API_KEY (auf allen Endpunkten akzeptiert) und API-Keys je Organisation (nur auf /send-Endpunkten akzeptiert). Verwaltungsendpunkte erfordern den Master-Key.

Kern

MethodePfadAuthBeschreibung
POST/sendBearerEine einzelne E-Mail zur Zustellung einreihen
GET/healthKeineSystem-Health-Check (Redis, Queue-Tiefe)
GET/metricsKeinePrometheus-Metrik-Endpunkt

Verwaltung von Zugangsdaten

MethodePfadAuthBeschreibung
POST/credentialsMasterZugangsdaten für eine Organisation anlegen
GET/credentialsMasterZugangsdaten auflisten (Filter über ?organizationId=)
DELETE/credentials/:apiKeyMasterZugangsdaten widerrufen

DKIM-Verwaltung

MethodePfadAuthBeschreibung
POST/dkimMasterEinen DKIM-Schlüssel hinzufügen oder aktualisieren
GET/dkimMasterAlle DKIM-Domains auflisten (Schlüssel geschwärzt)
DELETE/dkim/:domainMasterEinen DKIM-Schlüssel entfernen
POST/dkim/:domain/rotateMasterEin neues RSA-2048-Schlüsselpaar erzeugen

Inbound-Routing

MethodePfadAuthBeschreibung
POST/inbound/routesMasterEine Inbound-Route anlegen oder aktualisieren
GET/inbound/routesMasterAlle Inbound-Routen auflisten
DELETE/inbound/routes/:domain/:addressMasterEine Inbound-Route entfernen

Persönliche Postfächer

Der MTA hält einen Redis-Cache mit Adressen persönlicher Postfächer (Postbox), damit der eingehende SMTP-Server einen Empfänger in O(1) ohne Round-Trip zu Convex auflösen kann. Convex spielt CRUD-Operationen für Postfächer in diese Endpunktgruppe (src/routes/mailboxes.ts).

MethodePfadAuthBeschreibung
POST/mailboxes/cache/:addressMasterEinen Cache-Eintrag für ein Postfach anlegen oder auffrischen (mailboxId, organizationId, optionales Kontingent)
DELETE/mailboxes/cache/:addressMasterEinen Cache-Eintrag entfernen
GET/mailboxes/cacheMasterGecachte Adressen auflisten

Wie Convex diesen Cache synchron hält, beschreibt die Postbox-Architektur.

ISP-Profile

Adaptive Drosselungsprofile lassen sich zur Laufzeit ohne erneutes Deployment anpassen. Profile werden beim Start aus ISP_PROFILES in src/config.ts eingespielt (über HSETNX, sodass Laufzeit-Overrides Neustarts überleben) und über src/routes/ispProfiles.ts verwaltet.

MethodePfadAuthBeschreibung
GET/isp-profilesMasterAlle Profile auflisten (eingespielt + eigene)
GET/isp-profiles/:domainMasterDas wirksame Profil für eine Domain abrufen
PUT/isp-profiles/:domainMasterEin Profil anlegen oder aktualisieren (defaultRate, ceiling, floor, backoffFactor, recoveryFactor)
DELETE/isp-profiles/:domainMasterEin eigenes Profil entfernen (setzt auf den eingespielten Standard zurück)

Aktualisierungen werden validiert: floor <= ceiling, defaultRate innerhalb von [floor, ceiling], 0 < backoffFactor < 1 und recoveryFactor > 1.

IP-Reputation

Ein rein lesendes Dashboard, das den Warming-Zustand je IP, den Pool-Status und die heutigen Zustellmetriken aggregiert (src/routes/ipReputation.ts).

MethodePfadAuthBeschreibung
GET/ip-reputationMasterÜbersichtszeile je konfigurierter IP (Metriken, Warming, Pool-Eignung, DNSBL, FCrDNS)
GET/ip-reputation/:ipMasterVollständige Reputationsansicht für eine IP, inklusive Blockierungsgründen und Identitätsbereitschaft

Organisationslimits

MethodePfadAuthBeschreibung
POST/org-limitsMasterTages-/Stundenlimits für den Versand einer Organisation setzen
GET/org-limits/:orgIdMasterVerbrauch und Limits einer Organisation abrufen

Pool-Regeln

MethodePfadAuthBeschreibung
POST/pool-rulesMasterPool-Zuordnung für eine Organisation setzen
GET/pool-rules/:orgIdMasterPool-Regel einer Organisation abrufen
DELETE/pool-rules/:orgIdMasterPool-Regel einer Organisation entfernen

Suppression-Liste

MethodePfadAuthBeschreibung
POST/suppressionMasterAdressen der Suppression-Liste hinzufügen (Batch)
DELETE/suppression/:emailMasterEine Adresse aus der Suppression-Liste entfernen
GET/suppression/check/:emailMasterSuppression-Status einer Adresse prüfen
POST/suppression/bulkMasterBis zu 10.000 Adressen in einer Anfrage hinzufügen
GET/suppression/exportMasterPaginierter Export mit Metadaten (?reason=, ?cursor=)
GET/suppression/statsMasterAnzahlen je Suppression-Grund

Anhang-Scan

MethodePfadAuthBeschreibung
POST/scan/attachmentMasterDatei auf Malware prüfen (Dateityp-Validierung + ClamAV)
GET/scan/healthKeineStatus der ClamAV-Verbindung

Der Scan-Endpunkt führt zwei Prüfungen durch: Dateityp-Validierung (Magic Bytes, doppelte Erweiterung, Erweiterungs-Allowlist) und ClamAV-Malware-Scan. Der Endpunkt ist fail-open — ist ClamAV nicht verfügbar, passiert die Datei mit einer Warnung. Details zur Scan-Pipeline finden Sie unter E-Mail-Sicherheit.

Zustellprotokolle

MethodePfadAuthBeschreibung
GET/delivery-logsMasterEreignisse nach Datum, orgId, Status, Domain abfragen (paginiert)
GET/delivery-logs/statsMasterAggregierte Anzahlen nach Status/Domain/Pool für einen Datumsbereich
GET/delivery-logs/:messageIdMasterAlle Zustellereignisse für eine bestimmte Nachricht

Queue-Inspektion

MethodePfadAuthBeschreibung
GET/queue/statsMasterQueue-Tiefe nach Zustand (pending, active, completed, failed, delayed)
GET/queue/pendingMasterAusstehende Jobs auflisten (?limit=, ?offset=, ?domain=)
GET/queue/jobs/:jobIdMasterVollständige Job-Details mit Versuchshistorie
DELETE/queue/jobs/:jobIdMasterEinen bestimmten ausstehenden Job abbrechen
POST/queue/flushMasterAlle ausstehenden Jobs einer Organisation abbrechen (?orgId=)

Dead Letter Queue

MethodePfadAuthBeschreibung
GET/dlqMasterFehlgeschlagene Webhook-Ereignisse auflisten (?limit=, ?offset=)
GET/dlq/statsMasterGesamtanzahl und Alter des ältesten Eintrags
POST/dlq/:dlqId/retryMasterEin bestimmtes fehlgeschlagenes Webhook-Ereignis erneut versuchen
POST/dlq/retry-allMasterAlle DLQ-Einträge erneut versuchen
DELETE/dlq/:dlqIdMasterEin bestimmtes fehlgeschlagenes Ereignis verwerfen
Authentifizierung

Der /send-Endpunkt akzeptiert sowohl den Master-Key MTA_API_KEY als auch API-Keys je Organisation (owlat_...). Alle Verwaltungsendpunkte — /credentials, /dkim, /org-limits, /pool-rules, /suppression, /inbound/routes, /mailboxes, /isp-profiles, /ip-reputation, /delivery-logs, /queue, /dlq und /scan — erfordern den Master-Key (innerhalb jeder Routengruppe durchgesetzt).

Send-Payload

interface EmailJob {
  messageId: string        // Unique ID for correlation
  to: string               // Recipient email
  from: string             // Sender address
  subject: string          // Email subject
  html: string             // HTML body
  text?: string            // Plain text body
  replyTo?: string         // Reply-to address
  headers?: Record<string, string>
  ipPool: 'transactional' | 'campaign'
  organizationId: string   // For circuit breaker scoping
  engagementScore?: number // 0-100 from Convex; omitted when unknown
  dkimDomain: string       // Domain for DKIM signing
}

Dispatch-Pipeline

Jeder Job durchläuft eine typisierte, komponierbare Dispatch-Pipeline, bevor irgendeine SMTP-Verbindung geöffnet wird (ADR-0007). Die Pipeline liegt in src/dispatch/ und ist die Orchestrierungsschicht; jede Phase delegiert die eigentliche Prüfung an ihre Implementierung in src/intelligence/*.ts oder src/scaling/*.ts.

Architektur

  • src/dispatch/pipeline.ts definiert Phase<TIn, TOut> — einen benannten Schritt, dessen Eingabekontexttyp in die nächste Phase weiterläuft. compose(...) verkettet Phasen, runPipeline(...) führt sie aus. Das Typsystem erzwingt die Reihenfolge: Eine Phase, die den aufgelösten pool/ip konsumiert (z. B. selectIp, acquireSlot), kann nicht vor der Phase stehen, die ihn erzeugt, sonst schlägt der compose(...)-Aufruf bei der Typprüfung fehl.
  • Eine Phase liefert eines von drei Ergebnissen: continue (den Kontext weitertragen), defer (den Versuch nach einer Verzögerung erneut einreihen) oder drop (den Versuch mit Status screened oder suppressed beenden, ohne erneutes Einreihen).
  • src/dispatch/phases/index.ts setzt mainPipeline in der tatsächlichen Reihenfolge zusammen (siehe unten).
  • src/queue/handler.ts ist die einzige an GroupMQ gekoppelte Datei. Die Pipeline wirft niemals; der Handler übersetzt ein defer-Ergebnis in GroupMQs DeferError (mit ±15 % Jitter, um eine Thundering Herd zu vermeiden) und ein drop in ein protokolliertes Endergebnis.
  • Nachdem die Pipeline continue zurückgegeben hat, ruft der Handler sendToMx auf; anschließend klassifiziert der reine Reducer in src/dispatch/outcome.ts das Ergebnis in eine typisierte Effektliste (src/dispatch/effects.ts) — er verfolgt Seiteneffekte für Circuit Breaker, Drosselung, Warming, Metriken, Suppression und Webhooks.

Phasenreihenfolge

mainPipeline führt zehn Phasen in genau dieser Reihenfolge aus:

#PhaseDelegiert anErgebnis bei Fehlschlag
1content_screeningintelligence/contentScreening.tsdrop (screened)
2suppressionintelligence/suppressionList.tsdrop (suppressed)
3circuit_breakerintelligence/circuitBreaker.tsdefer
4org_limitintelligence/orgLimits.tsdefer
5smtp_intelintelligence/smtpResponse.tsdefer
6domain_backoffscaling/degradation.tsdefer
7resolve_poolscaling/poolRules.tscontinue (reichert den Kontext um Pool + dedizierte IP an)
8select_ipscaling/ipPool.tsdefer (reichert den Kontext um die gebundene IP an)
9acquire_slotintelligence/domainThrottle.tsdefer
10warming_capintelligence/warming.tsdefer

Die beiden Pool-/IP-Phasen (7 und 8) reichern den Kontext an, statt zu blockieren; das Belegen des Drosselungs-Slots (9) läuft nach der IP-Auswahl, weil die Drosselung je IP und Empfängerdomain gilt.

Inhaltliche Vorprüfung (Phase 1)

Datei: intelligence/contentScreening.ts

Prüft den Inhalt, bevor irgendeine reputationsrelevante Arbeit passiert. Fängt fehlerhafte oder gefährliche Mail ab. Standardmäßig aktiviert (CONTENT_SCREENING_ENABLED=true); Fehlschläge führen zu einem drop des Jobs.

PrüfungAnmerkungen
DKIM-Domain-Alignment (dkimDomain stimmt mit der Domain des From-Headers überein)
Größenbudget der E-Mail (HTML überschreitet CONTENT_MAX_SIZE_KB, Standard 500 KB)
Leerer Body (sowohl html als auch text fehlen)
Erforderliche Header (From, Subject vorhanden und nicht leer)
URL-Blocklist (bekannte Phishing-/Malware-Muster in Redis)
rspamd-Spam-Score (optional)Nur wenn RSPAMD_URL gesetzt ist; lehnt oberhalb von RSPAMD_REJECT_THRESHOLD ab (Standard 15)

Suppression-Liste (Phase 2)

Datei: intelligence/suppressionList.ts

Globale Suppression-Liste. Ein unterdrückter Empfänger führt stillschweigend zu einem drop des Jobs (kein erneuter Versuch).

GrundQuelle
hard_bounceAutomatisch ergänzt, wenn ein Hard Bounce erkannt wird
complaintAutomatisch ergänzt bei einer Spam-Beschwerde beim ISP
manualÜber die Verwaltungs-API /suppression ergänzt

Unterdrückte Adressen liegen in einem Redis-Set, normalisiert auf Kleinschreibung. Verwaltet über POST /suppression, DELETE /suppression/:email und GET /suppression/check/:email.

Circuit Breaker (Phase 3)

Datei: intelligence/circuitBreaker.ts

Echtzeitschutz je Organisation, der sowohl Bounces als auch Beschwerden in einem gleitenden Ringpuffer verfolgt (die Marker d/b/c für zugestellt/gebounct/beschwert). Drei Zustände:

ZustandVerhalten
Closed (normal)Alle Sendungen laufen durch. Wechselt zu Open, sobald ein Schwellwert überschritten wird.
Open (pausiert)Alle Sendungen der Organisation werden zurückgestellt. Abkühlung für 30 Minuten.
Half-Open (Test)Bis zu 5 Testsendungen laufen durch. Ein Bounce/eine Beschwerde öffnet erneut mit 60 Minuten Abkühlzeit; ein sauberer Durchlauf schließt den Circuit.

Schwellwerte (circuitBreaker.ts):

SignalSchnell (letzte 50 Sendungen)Langsam (letzte 100 Sendungen)
Bounce-Rate> 15 %> 8 %
Beschwerderate> 4 %> 0,2 %

Beschwerden werden weit stärker gewichtet als Bounces, weil ISPs bei Beschwerderaten sperren, die um eine Größenordnung unter den Bounce-Raten liegen. Wenn der Circuit auslöst, wird ein Webhook-Ereignis org.circuit_breaker (Schweregrad critical) an Convex gesendet.

Rate-Limits je Organisation (Phase 4)

Datei: intelligence/orgLimits.ts

Tages- und Stundenobergrenzen für den Versand je Organisation, durchgesetzt über Redis-Zähler.

LimitStandardTTL des Zählers
Täglich50.00048 Stunden
Stündlich5.0002 Stunden

Overrides je Organisation werden über POST /org-limits gesetzt. Wird ein Limit überschritten, wird der Job mit einer retryAfter-Verzögerung bis zur nächsten Periodengrenze zurückgestellt (Mitternacht UTC für die Tagesgrenze, die nächste volle Stunde für die Stundengrenze).

SMTP-Response-Intelligenz (Phase 5)

Datei: intelligence/smtpResponse.ts

Verfolgt SMTP-Antwortcodes (4xx/5xx) je Empfängerdomain. Wenn jüngere Muster auf ein beeinträchtigtes oder blockierendes Ziel hindeuten, liefert shouldDefer eine positive Verzögerung und die Phase stellt den Versuch zurück.

Backoff bei Domainverbindungen (Phase 6)

Datei: scaling/degradation.ts

Verfolgt Verbindungsfehler je Domain und stellt Jobs zurück, die auf eine Domain zielen, die sich gerade im Backoff befindet — mit einer berechneten Wiederholungsverzögerung.

Pool-Auflösung (Phase 7)

Datei: scaling/poolRules.ts

Reichert den Kontext um den wirksamen IP-Pool und eine etwaige dedizierte IP an. Auflösungspriorität:

  1. Organisationsspezifische Pool-Regel (sofern über POST /pool-rules gesetzt)
  2. Das Feld ipPool der Anfrage
  3. Standard-Pool

Eine dedizierte IP umgeht, sofern konfiguriert, die nachgelagerte Round-Robin-Auswahl.

IP-Auswahl (Phase 8)

Datei: scaling/ipPool.ts

Round-Robin-Auswahl der IP innerhalb des aufgelösten Pools, unter Ausschluss von IPs, die die zusammengesetzten Bereitschaftsprüfungen nicht bestehen. Eine dedizierte IP aus Phase 7 wird direkt verwendet, nachdem sie dieselben Prüfungen bestanden hat. Bleibt keine geeignete IP übrig, wird der Versuch zurückgestellt und ein all_ips_blocked-Alarm ausgelöst; das System weicht niemals auf eine unter Quarantäne stehende Quelle aus.

Drosselungs-Slot je Domain (Phase 9)

Datei: intelligence/domainThrottle.ts (über dispatch/phases/acquireSlot.ts)

Adaptives Rate-Limiting je IP und Domain über ein gleitendes Redis-Fenster. Die Phase ruft domainThrottle.acquireSlot(redis, ip, domain) auf; ist kein Slot frei, wird der Versuch um 5 Sekunden zurückgestellt. Der Slot wird implizit freigegeben, sobald der passende domain_throttle_*-Effekt im Outcome-Reducer ausgelöst wird. Jeder ISP hat ein Versandprofil:

ISPStandardrateObergrenzeUntergrenze
Gmail / Googlemail100/Min.300/Min.5/Min.
Outlook / Hotmail / Live80/Min.200/Min.5/Min.
Yahoo / AOL / Ymail50/Min.150/Min.3/Min.
iCloud / me / mac60/Min.150/Min.5/Min.
Andere Domains30/Min.100/Min.2/Min.

Die Drosselung regelt bei SMTP 4xx zurück (Rate × backoffFactor), erholt sich bei anhaltendem Erfolg (Rate × recoveryFactor) und blockiert nach wiederholten Fehlschlägen. Diese Profile werden beim Start in Redis eingespielt und lassen sich zur Laufzeit über die /isp-profiles-Endpunkte anpassen.

Warming-Obergrenze (Phase 10)

Datei: intelligence/warming.ts

Neue IPs folgen einem 30-tägigen Warming-Plan mit täglichen Sendeobergrenzen, die schrittweise wachsen:

TagTagesobergrenze
150
2100
3200
5700
71.500
103.000
147.500
1815.000
2120.000
2530.000
30+Unbegrenzt (abgeschlossen)

Der Plan passt sich an — er beschleunigt bei niedrigen Bounce-/Zurückstellungsraten und verlangsamt bei schlechten Zustellbarkeitssignalen. Wenn eine IP das Warming abschließt, wird ein Webhook-Ereignis ip.warming_complete gesendet.

Wird die Tagesobergrenze erreicht, stellt der Versuch bis zum nächsten Obergrenzen-Fenster zurück statt auf eine feste kurze Wiederholung (intelligence/warmingCapWindow.ts). Das Fenster ist die nächste UTC-Tagesgrenze, an der das Tagesbudget tatsächlich zurückgesetzt wird — die Zurückstellung ist jedoch auf eine Stunde begrenzt, sodass ein Versuch außerhalb der letzten Stunde des Tages eine Stunde wartet statt bis Mitternacht. Beide Hälften davon sind Absicht. Eine kurze feste Wiederholung würde den gesamten zurückgestellten Rückstau für den Rest des Tages alle paar Minuten neu einreihen, um zu einer Antwort zu gelangen, die sich nicht ändern kann; ein unbegrenztes Warten bis Mitternacht würde einen Rückstau hinter Kapazität stranden lassen, die bereits existiert, denn eine Tagesobergrenze kann sich untertägig tatsächlich weiten — der Plan schreitet voran, eine neue IP tritt dem Pool bei oder das Tempo-Rad des Ramp-Controllers bewegt sich. Die Stunde ist der Kompromiss zwischen beidem, und eine Mindestzurückstellung verhindert, dass ein Versuch wenige Millisekunden vor Mitternacht sofort erneut eingereiht wird. Untertägiges Pacing ist wiederum etwas anderes und behält seine eigene, sofortige Wiederholung: Ein gepacter Versuch bekommt tatsächlich noch am selben Tag Kapazität zurück, entlang einer Kurve.

Obergrenzen je Mailbox-Provider

Dateien: intelligence/warmingProviderPolicy.ts (reine Policy), intelligence/warmingProviderStore.ts (Redis)

Die Reputation einer jungen IP ist selten einheitlich: Bei Google genießt sie routinemäßig Vertrauen, während sie bei Microsoft noch kriecht. Neben der Tagesobergrenze je IP trägt jedes Paar (IP × Mailbox-Provider) einen Obergrenzen-Multiplikator in [0.05, 1], der die Obergrenze je IP allein für diesen Provider einschränkt. Die Obergrenze je IP bleibt maßgeblich — eine Provider-Obergrenze wird stets daraus abgeleitet, sodass die Summe des Provider-Traffics die Tagesobergrenze niemals überschreiten kann.

Der Multiplikator bewegt sich einmal pro UTC-Tag, innerhalb derselben evaluateDay-Idempotenzabsicherung wie der Plan selbst, und er bewegt sich asymmetrisch — Rückzug ist billig, Vorrücken teuer. Multiplikative Verschärfung (×0,5) greift in dem Moment, in dem die Bounce-/Zurückstellungsraten dieses Providers die ausgelieferten Verlangsamungsschwellen überschreiten oder er anhaltenden Volumendruck signalisiert hat. Additive Erholung (+0,1) kostet drei aufeinanderfolgende saubere Tage; ein sauberer Tag baut lediglich eine Serie auf, und jeder Verstoß setzt sie zurück. Unterhalb einer Mindeststichprobe von 50 Sendungen lautet das Urteil insufficient_data und nichts bewegt sich — die Raten werden nicht einmal berechnet, sodass ein einzelner Bounce eine Obergrenze niemals halbieren kann. Ein Provider ohne erfassten Zustand ist unbeschränkt, sodass ein aktualisiertes Deployment seinen bestehenden Zustand und sein Verhalten je IP behält, bis die neue Dimension gefüllt ist.

Untertägiges Pacing

Datei: intelligence/warmingPacing.ts

Ein Burst, der die Tagesobergrenze in zehn Minuten leert, sieht für einen Empfänger ganz anders aus als eine gleichmäßige Kurve. Bulk-Traffic (Pool campaign) wird daher entlang einer linearen Kurve über den UTC-Tag ausgeliefert, beginnend bei 10 % der Bulk-Obergrenze des Tages.

Die Kurve wird gegen einen Zähler gemessen, der nur Bulk erfasst, nicht gegen die Gesamtsumme je IP: Transaktionales Volumen teilt sich dieselbe Tagesobergrenze, ist aber vom Pacing ausgenommen, und es in die Kurve einzurechnen würde einem Burst transaktionaler Mail erlauben, eine kleine Kampagne zurückzustellen. Zwei Absicherungen halten die Form ehrlich: eine Untergrenze von 100 sofort verfügbaren Bulk-Sendungen (damit eine Kampagne mit 50 Empfängern nie über einen Tag gestreckt wird) und ein Sicherheitspuffer von 20 %, den ausschließlich Traffic aus dem transactional-Pool verbrauchen darf — transaktionale Sendungen umgehen das Pacing vollständig und werden von einer Kampagne nie ausgehungert.

Ein Versuch, der bereits eine Warming-Reservierung hält, besitzt seinen Slot und umgeht alle drei Schranken, exakt wie zuvor: Die Routing-Schicht hat ihm Kapazität zugesagt, und diese zurückzunehmen würde die Reservierung stranden lassen.

Zurückstellungsbewusste Wiederholung

Urteile über Volumendruck je ISP aus dem SMTP-Klassifizierer (rate_limited, gmail_rate_limited, yahoo_ts03, yahoo_tss04, microsoft_resource_throttle) werden je (IP × Provider) über einen Sechs-Stunden-Horizont erfasst. Solange Druck aktenkundig ist, wird die vom Klassifizierer vorgeschlagene Wiederholungsverzögerung für dieses Ziel je aktuellem Ereignis verdoppelt (gedeckelt bei ×8 und bei vier Stunden), und dieselben Urteile speisen die obige Schranke der Provider-Obergrenze. Greylisting und volle Postfächer werden bewusst nicht als Volumendruck gewertet.

Engagementbasierte Priorität

Die Engagement-Priorität ist keine Dispatch-Phase — sie wird beim Einreihen angewendet. src/routes/send.ts (und der SMTP-Submission-Server) bilden den engagementScore des Jobs über die gemeinsamen Helfer mapToPriority und priorityToOrderMs in intelligence/engagementPriority.ts ab und setzen GroupMQs orderMs, sodass Empfänger mit hohem Engagement vor solchen mit niedrigem Engagement aus der Queue genommen werden. Ein niedrigeres orderMs bedeutet frühere Verarbeitung; die höchsten Prioritätsstufen bilden auf weit in der Vergangenheit liegende Zeitstempel ab, sodass sie stets die Warteschlange überspringen.

Woher der Score stammt

engagementScore ist optional und entsteht in Convex, nicht im MTA:

  1. analytics/engagementScore.ts berechnet je Kontakt einen aktualitätsgewichteten Score von 0–100 aus dessen Aktivitätshistorie und speichert ihn in contacts.engagementScore.
  2. Zwei der Sendeproduzenten legen diesen Wert beim Einreihen auf den dauerhaften Sende-Envelope, und der Wert wird dort normalisiert (in der Enqueue-Mutation sowie beim Lesen des Kontakts selbst auf dem Nicht-Kampagnen-Pfad), sodass nichts Entartetes in einen dauerhaften Envelope gelangen kann:
    • Kampagnensendungen — die Zielgruppenauflösung projiziert den Score aus der bereits geladenen Kontaktzeile, und campaigns/send.ts reicht ihn an delivery/enqueue.ts:enqueueCampaignEmails weiter;
    • Automations- und Agentenantwort-Sendungen — delivery/enqueue.ts:enqueueNonCampaignSend führt innerhalb der bestehenden Enqueue-Transaktion einen einzelnen indizierten Punktlesevorgang des Kontakts aus.

    Der transaktionale Produzent der Template-API (transactional/dispatch.ts) ist bewusst nicht angebunden: Er löst einen Kontakt auf (er schreibt contactId in die transactionalSends-Zeile), projiziert dessen Score aber nicht auf den Envelope. Eine Template-API-Bestätigung an einen vollständig bewerteten Kontakt wird daher ohne Score versendet und im Standardband einsortiert. Das ist beabsichtigt — transaktionale Bestätigungen sind latenzempfindliche Einzelfälle und kein Volumenstrom, dessen Reihenfolge zu differenzieren lohnt — und kein Versehen, über das hinwegzulesen wäre.
    Die Dispatch-Action liest niemals einen Kontakt je Empfänger.
  3. delivery/governedDispatch.ts normalisiert den Envelope-Wert an der Lesegrenze erneut und prägt ihn auf MtaExtras.engagementScore; der MTA-Adapter reicht ihn im Body von POST /send weiter.

Abwesenheit ist normal und kein Fehler. Ein Kontakt, den der Scorer noch nicht erreicht hat, eine Sendung ganz ohne Kontaktdatensatz (Testvorschauen, Agentenantworten an eine unbekannte Adresse), eine Sendung von einem Produzenten, der den Score nicht projiziert (der obige transaktionale Pfad der Template-API), sowie jeder Envelope, der vor Existenz des Feldes eingereiht wurde, treffen alle mit weggelassenem Feld ein. mapToPriority bildet einen fehlenden Score auf PRIORITY_BANDS.DEFAULT (das LOW-Band) ab, sodass solche Mail exakt so einsortiert wird wie vor jeglichem Scoring — weder bevorzugt noch hinter kalte Kontakte zurückgestellt.

Ein Score von 0 ist nicht dasselbe wie ein fehlender: 0 ist eine echte Messung mit der Bedeutung „kalt“ und landet im niedrigsten Band. Ein nicht endlicher oder außerhalb des Bereichs liegender Wert wird als unbekannt verworfen statt gekappt.

DNSBL-Überwachung

Datei: intelligence/dnsbl.ts

Eine periodische Hintergrundprüfung (alle 15 Minuten, startDnsblChecker) — getrennt von der Pipeline je Sendung — testet jede Versand-IP gegen DNS-basierte Blocklisten:

BlocklistSchweregrad
Spamhaus (zen.spamhaus.org)Kritisch
Barracuda (b.barracudacentral.org)Warnung
SpamCop (bl.spamcop.net)Warnung

Gelistete IPs werden aus dem aktiven Versand-Pool entfernt (die IP-Auswahlphase überspringt sie dann). Convex wird über die Webhook-Ereignisse ip.blocklisted und ip.delisted benachrichtigt.

Jede Abfrage löst in einen von drei Zuständen auf, und der Unterschied zwischen ihnen ist tragend:

ZustandErzeugt durchWirkung
listedEine 127.0.0.x-Antwort (außer dem reservierten Block 127.255.255.x)Spamhaus wirft die Adresse hinaus; die übrigen Feeds sind beratend
cleanNXDOMAIN / NODATADie Adresse ist zugelassen
unknownTimeout, SERVFAIL, REFUSED, Ablehnung durch Resolver-Policy, Abfrageraten-Limitierung (127.255.255.x) oder jedes andere Resolver-VersagenZählt niemals als Gesundheit: Die vorherige Entscheidung bleibt erhalten, und eine nie beobachtete Adresse bleibt aus dem Pool draußen

Ein vorübergehendes Resolver-Versagen wird innerhalb eines begrenzten Budgets mit exponentiellem Backoff wiederholt — insgesamt bis zu drei Versuche —, bevor der Durchlauf auf unknown schließt; so kostet ein langsamer Resolver kein falsches unknown, und ein toter kann den Durchlauf nicht blockieren. Ein beantworteter reservierter Code (127.255.255.x: Resolver-Policy, Ablehnung offener Resolver, Rate-Limiting) ist eine Antwort und kein Versagen: Er ergibt sofort unknown und wird nie erneut abgefragt.

Wenn jede konfigurierte Adresse ausgeworfen ist, stoppt der Versand: Die Pool-Auswahl hat keine geeignete Adresse, Zustellungen bleiben daher in der Queue (es gibt keinen „trotzdem senden“-Pfad aus einem vollständig gelisteten Pool), und ein all_ips_blocked-Alarm benennt die Adressen und die Zonen, die sie gelistet haben — oder sagt klar, dass der Status nicht gemessen werden konnte.

Der Routing-Snapshot von /ip-reputation führt dieselben drei Zustände, statt sie zusammenzufassen: dnsbl_listed (kritisch, gesamter Pool ausgeworfen) steuert das ausgelieferte Relay-Failover, während das beratende dnsbl_partial (einige Adressen ausgeworfen) und dnsbl_unknown (eine Abfrage konnte nicht abgeschlossen werden) zu Messzwecken erfasst werden, ohne für sich genommen ein Failover auszulösen.

Queue-System

Dateien: queue/setup.ts, queue/handler.ts, queue/groups.ts

Der MTA verwendet GroupMQ, eine Redis-gestützte Job-Queue mit Gruppenverarbeitung.

Gruppenschlüssel

Jobs werden nach {ipPool}:{recipientDomain} gruppiert — zum Beispiel campaign:gmail.com. Das stellt sicher, dass E-Mails an denselben ISP aus demselben IP-Pool sequenziell verarbeitet werden und die Rate-Limits je Domain eingehalten werden. Unterschiedliche Domains werden parallel verarbeitet.

Worker-Konfiguration

EinstellungStandardBeschreibung
Nebenläufigkeit50Parallele Slots für die Gruppenverarbeitung (WORKER_CONCURRENCY)
Maximale Versuche5Wiederholungslimit je Job
Job-Timeout2 Min.Verarbeitungs-Timeout je Job
BackoffExponentiell30 s → 2 m → 8 m → 30 m → 2 h
Aufbewahrung abgeschlossener Jobs1.000Letzte N abgeschlossene Jobs in Redis behalten
Aufbewahrung fehlgeschlagener Jobs5.000Letzte N fehlgeschlagene Jobs in Redis behalten

Gibt eine Dispatch-Phase ein defer-Ergebnis zurück, übersetzt src/queue/handler.ts es in GroupMQs DeferError, um mit der gewünschten Verzögerung (plus ±15 % Jitter) neu einzuplanen.

SMTP-Zustellung

MX-Auflösung

Datei: smtp/mxResolver.ts

DNS-MX-Abfragen mit Redis-Caching. Die MX-Einträge jeder Empfängerdomain werden aufgelöst und in Prioritätsreihenfolge probiert. Schlagen alle MX-Hosts fehl, wird die Domain im Degradationssystem vermerkt.

DKIM-Signierung

Dateien: smtp/dkimStore.ts, smtp/dkim.ts

Alle ausgehende Mail wird mit DKIM signiert. Schlüssel liegen in Redis, mit einem 5-minütigen In-Memory-Cache für die Performance. Beim Start werden Schlüssel aus der Umgebungsvariable DKIM_KEYS in Redis eingespielt (vorhandene Redis-Schlüssel werden nicht überschrieben).

Schlüsselverwaltung:

  • API-VerwaltungPOST /dkim, GET /dkim, DELETE /dkim/:domain
  • SchlüsselrotationPOST /dkim/:domain/rotate erzeugt ein neues RSA-2048-Bit-Schlüsselpaar und liefert den Wert des DNS-TXT-Eintrags (v=DKIM1; k=rsa; p={base64}) zur Veröffentlichung zurück
  • Einspielen per UmgebungsvariableDKIM_KEYS funktioniert weiterhin für das initiale Bootstrapping; zur Laufzeit ist Redis die Quelle der Wahrheit

Connection Pool

Datei: smtp/connectionPool.ts

Wiederverwendbarer, selbst entwickelter SMTP-Connection-Pool (@owlat/smtp-client), geschlüsselt nach {mxHost}:{bindIp}:{dkimDomain}. Ersetzt Einwegverbindungen zugunsten besserer Verbindungswiederverwendung und geringeren DNS-/TLS-Overheads.

EinstellungStandardBeschreibung
Maximale Verbindungen je Host3Gleichzeitige Transports je MX-Host
Idle-Timeout30 sUntätige Verbindungen nach diesem Zeitraum schließen
Maximales Verbindungsalter5 Min.Unabhängig von Aktivität zwangsweise schließen

Der Pool führt alle 10 Sekunden einen periodischen Aufräumdurchlauf aus, um untätige und überalterte Transports zu schließen. Verbindungen mit laufenden Sendungen werden nie entfernt. Ein Prometheus-Gauge (mta_smtp_pool_connections) erfasst die Anzahl aktiver und untätiger Verbindungen je Pool-Schlüssel.

Verteilte Koordination: In Deployments mit mehreren Instanzen aktiviert der Pool nach dem Verbinden mit Redis die instanzübergreifende Koordination (enableDistributedCoordination). Eine globale Obergrenze (SMTP_POOL_GLOBAL_MAX_PER_HOST, Standard 10) begrenzt zusätzlich zum Instanzlimit maxPerHost die Gesamtzahl gleichzeitiger Verbindungen zu einem einzelnen MX-Host über alle MTA-Instanzen hinweg — damit eine Flotte von MTA-Knoten nicht gemeinsam auf einen einzelnen empfangenden Server einprasselt.

Beim Herunterfahren lässt der Pool alle laufenden Sendungen auslaufen, bevor er Transports schließt.

VERP-Return-Path

Datei: bounce/verp.ts

Die Kodierung als Variable Envelope Return Path bettet die ursprüngliche Message-ID in die Bounce-Adresse ein:

bounce+{base64url(messageId)}@bounces.owlat.com

Die Message-ID ist base64url-kodiert (nicht roh) und das Präfix lautet im Singular bounce+. Dadurch kann der Bounce-Prozessor eingehende DSN-Nachrichten der ursprünglichen Sendung zuordnen, ohne eine Nachschlagetabelle zu pflegen.

BOUNCE_VERP_KEY ist beim Produktionsstart erforderlich. Das Setup erzeugt den Wert automatisch; Beispielumgebungen lassen ihn bewusst leer, und bekannte Platzhalterwerte werden abgelehnt. Das Token trägt einen authentifizierenden gekürzten HMAC — bounce+{base64url(messageId)}+{hmac}@bounces.owlat.com —, und nur ein verifiziertes signiertes Token darf eine exakte DSN-/FBL-Zuordnung liefern. Die unsignierte Hilfsform existiert ausschließlich für isolierte Kompatibilitätstests und wird von den produktiven Feedback-Parsern nie akzeptiert.

CFBL-Address (RFC 9477)

Jeder Complaint-Feedback-Loop bis auf einen erfordert ein Konto beim Mailbox-Provider: Google Postmaster, Microsoft SNDS/JMRP und Yahoo CFL benötigen allesamt eine beidseitige Anmeldung. RFC 9477 benötigt kein Drittanbieterkonto und überhaupt keine beidseitige Anmeldung — nur einen Header —, daher ist es das Beschwerdesignal, das ein Deployment ohne externe Zugangsdaten tatsächlich haben kann. Es hat allerdings eine lokale Voraussetzung: Das Paar wird nur für eine Versanddomain ausgegeben, die einen eigenen Return-Path-Host registriert hat, denn RFC 9477 §3.1.3 verlangt, dass der CFBL-Host zu RFC5322.From ausgerichtet ist (siehe unten). Auf dem gemeinsam genutzten globalen Return-Path-Host wird nichts ausgegeben. Dieses Schweigen ist eine unterstützte Konfiguration und niemals ein Fehler oder eine Setup-Ermahnung: Es ist ausschließlich als mta_cfbl_emissions_total{outcome="host_unaligned"} sichtbar und blockiert nie eine Sendung.

Eine zusammengesetzte ausgehende Nachricht trägt:

CFBL-Address: fbl+{base64url(messageId)}+{hmac}@bounce.acme.com; report=arf
CFBL-Feedback-ID: {base64url(messageId)}+{hmac}

Beide Felder stehen im DKIM-h=-Tag und beide sind übersigniert. RFC 9477 §3.1.4 verlangt diese Abdeckung — „if the header field is not covered by the h= tag, the Mailbox Provider SHALL NOT send a report message“ —, und Übersignierung bedeutet, dass eine unterwegs hinzugefügte zweite CFBL-Address die Signatur bricht, statt eine Mehrdeutigkeit zu erzeugen, für deren Auflösung die RFC keine Regel gibt. Eine vom Aufrufer gelieferte CFBL-Address oder CFBL-Feedback-ID wird unabhängig von der Groß-/Kleinschreibung aus den ausgehenden Headern entfernt, ganz gleich, ob wir eigene ausgeben oder nicht.

Das Paar wird nur ausgegeben, wenn der CFBL-Host die RFC5322.From-Domain oder eine Subdomain davon ist — also wenn die Versanddomain einen eigenen Return-Path-Host je Domain registriert hat. Auf dem gemeinsam genutzten globalen Return-Path-Host wäre die CFBL-Adresse relativ zu From eine Drittanbieterdomain, was RFC 9477 §3.1.3 nur mit einer zusätzlichen, auf diesen Host ausgerichteten DKIM-Signatur erlaubt; Owlat signiert einmal, und statt einen Header zu veröffentlichen, den jeder konforme Provider verwirft, veröffentlicht es keinen. RFC 9477 §3.1 verlangt darüber hinaus, dass die RFC5322.From-Domain durch eine gültige DKIM-Signatur abgedeckt ist, und §3.1.4 sagt dem Provider, dass er ohne eine solche „SHALL NOT send a report message“; eine Nachricht, die ohne registrierten DKIM-Schlüssel für ihre Domain versendet wird, trägt daher ebenfalls kein Paar. Jede Sendung erhöht mta_cfbl_emissions_total mit einem begrenzten outcome-Label (emitted, host_unaligned, no_signature, no_key, no_address, sealed_raw), sodass „CFBL ist für diese Domain aus, und warum“ ein Zähler ist, den eine Betreiberin oder ein Betreiber lesen kann, statt ein stiller Standard zu sein. emitted bedeutet, dass das Paar tatsächlich auf der Leitung liegt: Versiegelte Mail versendet ihr rohes MIME wortgetreu und erreicht den Composer nie, daher werden solche Sendungen als sealed_raw gezählt. Das Ergebnis wird aus den tatsächlich gebauten Bytes abgeleitet, nicht aus der Konfiguration — wirft ein konfigurierter DKIM-Schlüssel während des Signierens einen Fehler, versendet der MTA die Nachricht unsigniert statt fehlerhaft signiert, und auch diese Sendung wird als no_signature gezählt.

Die Adresse ist signiert, aus demselben Grund wie die VERP-Bounce-Adresse: Sie wird dem gesamten Internet veröffentlicht und lädt nicht authentifizierte Parteien dazu ein, uns Berichte zu schicken, die in die Beschwerdebilanz einfließen. Der HMAC wird aus demselben Secret BOUNCE_VERP_KEY abgeleitet, ist aber domänensepariert (MAC-Eingabe mit Präfix cfbl:), sodass ein abgefangenes Bounce-Token niemals als Beschwerde-Token wiedergegeben werden kann und umgekehrt. Ohne Signierschlüssel wird überhaupt kein Header ausgegeben — ein unsigniertes Beschwerde-Handle wäre strikt schlechter als gar keines.

Das Token kodiert ausschließlich die opake interne Message-ID: keine Empfängeradresse, keinen Empfänger-Hash, keine Organisations- oder Kampagnenkennung. Wer den Header passiv beobachtet, erfährt nichts darüber, wer angeschrieben wurde.

Eingehende Berichte treffen beim bestehenden Bounce-SMTP-Server ein (der Local-Part fbl+ wird bereits zur RCPT-Zeit akzeptiert, und die Adresse nutzt denselben Return-Path-Host wie VERP, einschließlich eines Overrides je Domain) und werden vom bestehenden ARF-Prozessor geparst — es gibt keinen zweiten Parser. Die Zuordnungsreihenfolge folgt dem stärksten Beweis zuerst: der verifizierte Envelope-Empfänger, dann eine verifizierte zurückgespiegelte CFBL-Feedback-ID, dann das bereits vorhandene Auslesen von Original-Mail-From per VERP. Verifizierungsfehler werden gezählt (mta_cfbl_rejections_total, begrenztes reason-Label) und verworfen, nie geworfen und nie zugeordnet; erfolgreiche Zuordnungen werden nach Quelle in mta_cfbl_attributions_total gezählt. Replays fängt die ausgelieferte Beschwerde-Deduplizierung ab, deren Aufbewahrung aus dem gemeinsamen Akzeptanzhorizont für Token abgeleitet wird (MAX_FEEDBACK_TOKEN_ACCEPTANCE_SECONDS in bounce/signedToken.ts, plus einem Tag Toleranz), sodass ein Dedup-Datensatz stets das Fenster überlebt, in dem ein abgefangenes Token noch verifiziert — ein Replay kann eine Beschwerderate niemals durch Wiederholung aufblähen.

Ein verifiziertes Token identifiziert die SENDUNG, nicht die berichtende Partei: Jedes Feld, dem ein nachgelagerter Effekt vertraut — Organisation, Kampagne, Zustelldomain und der Empfänger selbst —, wird aus dem beim Versand geschriebenen Provenienzdatensatz zurückgelesen, nie aus dem Bericht. Dieser Datensatz teilt dieselbe abgeleitete Aufbewahrung, sodass eine spät im Akzeptanzhorizont eintreffende Beschwerde weiterhin zugeordnet wird, statt ins Leere zu laufen. Ein gültiges CFBL-Token zu besitzen beweist nur, dass die haltende Partei eine Nachricht des Mandanten erhalten hat; ein Bericht, der eine andere Adresse nennt, kann diese daher nicht unterdrücken. Da stets der erfasste Empfänger gewinnt, unterdrückt eine DSN, deren Final-Recipient ein weitergeleitetes Postfach ist, nun die Adresse, an die Owlat tatsächlich gesendet hat, statt des Weiterleitungsziels.

Redis-Dimensionierung. Beschwerden sind ein Signal auf menschlicher Zeitskala, daher beträgt der Akzeptanzhorizont — und damit die Aufbewahrung sowohl des Provenienzdatensatzes je Sendung als auch des Beschwerde-Dedup-Datensatzes — 16 Tage (zuvor 8 Tage für Provenienz und 7 für Dedup). Provenienz ist ein Redis-Key je Sendung plus ein begrenzter Empfängerindex, sodass sich der Dauerbedarf des Namensraums mta:{feedback}:* etwa verdoppelt und der Beschwerde-Dedup-Namensraum gegenüber dem MTA vor RFC 9477 um rund das 2,3-Fache wächst. Dimensionieren Sie Redis für 16 Tage Sendevolumen, nicht für 8.

Transport

Selbst entwickelte SMTP-Verbindungen werden für jede Sendung aus dem Connection Pool bezogen (smtp/sender.ts):

  • Bindet an eine bestimmte IP aus dem gewählten Pool (oder die dedizierte IP)
  • Port 25, 30 Sekunden Verbindungs-/Begrüßungs-Timeout, 60 Sekunden Socket-Timeout
  • STARTTLS wird standardmäßig opportunistisch genutzt
  • MTA-STS-Durchsetzung: Veröffentlicht die Empfängerdomain eine Policy im Enforce-Modus, verlangt die Verbindung TLS und validiert das Peer-Zertifikat, und MX-Hosts, die nicht in der Policy aufgeführt sind, werden übersprungen (siehe MTA-STS & TLS-RPT)
  • DKIM-Signierschlüssel werden aus dem DKIM-Key-Store geladen

MTA-STS & TLS-RPT

Dateien: smtp/mtaSts.ts, smtp/tlsRpt.ts

Der Sender schützt ausgehende Verbindungen mit MTA-STS (RFC 8461) gegen STARTTLS-Stripping-Angriffe. Vor der Zustellung an eine Domain prüft getStsTlsOptions den DNS-TXT-Eintrag _mta-sts.{domain} und ruft bei einer Versionsänderung https://mta-sts.{domain}/.well-known/mta-sts.txt ab. Geparste Policies werden in Redis gecacht (mindestens 5 Minuten TTL, negative Ergebnisse 1 Stunde). Policy-Modi:

ModusWirkung
enforcerequireTLS + rejectUnauthorized; nur MX-Hosts, die auf die mx:-Muster der Policy passen, werden probiert
testingOpportunistisches TLS, zur Überwachung protokolliert (keine Durchsetzung)
noneOpportunistisches TLS

MTA-STS-Abfragen blockieren die Zustellung nie — jeder Fehlschlag fällt auf opportunistisches TLS zurück.

Jeder Verbindungsversuch erfasst ein TLS-Ergebnis (Erfolg oder ein klassifizierter Fehlschlag wie certificate-expired, starttls-not-supported, validation-failure). Ein täglicher, nur vom Leader ausgeführter Cron (generateAndSendReports) aggregiert Fehlschläge je Empfängerdomain und erzeugt und versendet für Domains, die einen _smtp._tls-TXT-Eintrag mit rua=-Adresse veröffentlichen, einen JSON-Bericht nach TLS-RPT (RFC 8460). Sowohl HTTPS-Berichtsendpunkte (direktes POST des gzip-komprimierten Berichts) als auch mailto:-Endpunkte (der gzip-komprimierte Bericht wird als Anhang auf dem normalen DKIM-/IP-Pool-Sendepfad eingereiht) werden bedient.

Eigene MTA-STS- & TLS-RPT-Einträge veröffentlichen

Das Obige beschreibt, wie Owlat beim ausgehenden Versand die MTA-STS-/TLS-RPT-Policies der Empfänger konsumiert. Um den umgekehrten Schutz für Mail zu erhalten, die an Ihre Domain zugestellt wird, veröffentlichen Sie eigene Einträge:

  • TLS-RPT (_smtp._tls) — Owlat erzeugt diesen für Sie. Setzen Sie MTA_TLSRPT_RUA auf ein Ziel, das Sie überwachen (mailto:tls-reports@yourdomain.com oder einen HTTPS-Endpunkt); beim Registrieren einer Versanddomain wird neben den DKIM-/DMARC-Einträgen ein _smtp._tls-TXT-Eintrag der Form v=TLSRPTv1; rua=… (RFC 8460 §3) ausgegeben. Ist MTA_TLSRPT_RUA nicht gesetzt, entfällt der Eintrag.
  • MTA-STS (_mta-sts + Policy-Datei) — muss vom Betreiber veröffentlicht werden, weil die Policy auf einem Webhost liegt und nicht im DNS, und Owlat betreibt diesen Host nicht. Zwei Teile sind erforderlich (RFC 8461):
    1. Ein DNS-TXT-Eintrag unter _mta-sts.yourdomain.com mit dem Wert v=STSv1; id=<unique-version> (erhöhen Sie id bei jeder Policy-Änderung, damit Resolver neu laden).
    2. Eine Policy-Datei, die über HTTPS unter https://mta-sts.yourdomain.com/.well-known/mta-sts.txt ausgeliefert wird, z. B.:
      version: STSv1
      mode: enforce
      mx: mx.yourdomain.com
      max_age: 604800
      
    Beginnen Sie mit mode: testing, um TLS-RPT-Berichte zu sammeln, ohne die Zustellung zu gefährden, und erhöhen Sie auf mode: enforce, sobald das TLS Ihres MX nachweislich gesund ist.
  • TLSA (DANE, optional) — wenn Sie DANE-TLSA-Einträge (_25._tcp.mx.yourdomain.com, RFC 6698) veröffentlichen, um Ihr MX-Zertifikat zu pinnen, akzeptiert Owlats DNS-Modell sie: Ein Eintragstyp TLSA mit den Parametern usage/selector/matchingType wird vom Validator und Verifizierer für DNS-Einträge erkannt.

Weiterleitung eingehender Mail

Weitergeleitete eingehende Mail wird unter der Postfachdomain neu erzeugt (remailed) — eine neue Nachricht, signiert mit dem eigenen DKIM-Schlüssel der Postfachdomain, mit Reply-To auf den ursprünglichen Absender, damit Antworten ihn erreichen (RFC 7960). ARC wird nicht verwendet.

Automatische DKIM-Rotation

Datei: smtp/dkimRotation.ts

DKIM-Schlüssel rotieren nach einem sicheren Überlappungsplan (standardmäßig Rotation alle 180 Tage, 48 Stunden Überlappung für die DNS-Propagierung):

  1. initiateRotation erzeugt einen neuen RSA-Schlüssel unter einem frischen Selector, speichert ihn als pending-Schlüssel und liefert den zu veröffentlichenden DNS-TXT-Eintrag zurück — der aktive Schlüssel signiert weiter.
  2. Nachdem das Überlappungsfenster verstrichen ist und der DNS-TXT-Eintrag des neuen Selectors als veröffentlicht bestätigt wurde, befördert activatePendingKey den pending-Schlüssel zum aktiven und räumt den pending-Zustand ab. Ist der Eintrag noch nicht live, wird weiter gewartet, sodass das Signieren nie auf einen Schlüssel umschaltet, den das DNS nicht bestätigen kann (ein explizites force umgeht die Prüfung für manuelle Eingriffe).

Ein nur vom Leader ausgeführter Cron prüft den Rotationsstatus alle 6 Stunden: Er aktiviert automatisch jeden pending-Schlüssel, dessen Überlappung abgelaufen ist und dessen DNS-Eintrag veröffentlicht ist, und protokolliert eine Empfehlung für Schlüssel, deren Rotationsdatum überschritten ist. Die Rotation wird vom Betreiber angestoßen (über initiateRotation), sodass der neue Eintrag vor der Aktivierung veröffentlicht werden kann.

SMTP-Submission-Server

Datei: smtp/submissionServer.ts

Der MTA enthält einen optionalen SMTP-Submission-Server für die Kompatibilität mit klassischen E-Mail-Clients. Standardmäßig deaktiviert.

EinstellungWert
Port587 (konfigurierbar über SUBMISSION_PORT)
VerschlüsselungSTARTTLS-Upgrade
Auth-MethodenPLAIN, LOGIN
Maximale Nachrichtengröße25 MB
Standard-IP-Pooltransactional

Authentifizierung: Akzeptiert sowohl den Master-Key MTA_API_KEY als auch API-Keys je Organisation als SMTP-Passwort (der Benutzername wird ignoriert). Keys je Organisation binden alle eingelieferten E-Mails an diese Organisation.

Verarbeitung: Eingehende Nachrichten werden mit dem selbst entwickelten parseMessage aus @owlat/mail-message geparst und dann auf je einen GroupMQ-Job pro akzeptiertem SMTP-Envelope-Empfänger aufgefächert. To-/Cc-/Bcc-Header sind Anzeigeinhalt und steuern die Zustellung nicht. Jeder Job durchläuft die reguläre Dispatch-Pipeline, und die Absenderdomain wird für die DKIM-Signierung extrahiert.

Job-Identitäten der Submission sind über einen ungewissen DATA-Retry hinweg deterministisch. Ein Client kann einen Idempotenzschlüssel über den MAIL FROM-Parameter XOWLATID=<key> oder den Nachrichtenheader X-Owlat-Idempotency-Key mitgeben. Verwenden Sie diesen Schlüssel nur erneut mit demselben authentifizierten Principal, demselben normalisierten Envelope-Absender und exakt denselben DATA-Bytes; ein geänderter Absender oder eine geänderte Nachricht wird dauerhaft mit 554 5.5.4 abgelehnt. Envelope-Empfänger werden unabhängig gebunden, sodass ein Retry seine Empfängermenge umsortieren oder verkleinern darf, um ein teilweises Auffächern abzugleichen. Verwenden Sie für eine neue, absichtliche Sendung einen neuen Schlüssel, auch wenn deren DATA-Bytes identisch sind. Schlüssel sind 1–200 Zeichen aus A-Z a-z 0-9 . _ ~ : + -.

Clients ohne expliziten Schlüssel behalten den sicheren Fallback: Die Identität hasht den authentifizierten Principal, den normalisierten Envelope-Absender und die exakten DATA-Bytes und leitet daraus je Empfänger eine Job-ID ab. Eine dauerhafte, vier Tage gültige Annahmequittung plus die explizite GroupMQ-Job-ID erlaubt einem Retry, ein bereits committetes Enqueue abzugleichen und nach teilweisem Auffächern nur die fehlenden Empfänger zu vervollständigen. Für eine bewusst nicht idempotente Transaktion setzen Sie XOWLATDEDUP=OFF bei MAIL FROM oder X-Owlat-Deduplication: off in der Nachricht. Das erzeugt für jeden DATA-Versuch eine frische Serveridentität und erlaubt beabsichtigte, byteidentische Sendungen innerhalb des Quittungsfensters, gibt aber zugleich den Abgleich nach einer verlorenen SMTP-Antwort oder teilweisem Auffächern auf und kann daher bei einem Retry doppelt zustellen. Ein expliziter Client-Schlüssel ist immer vorzuziehen, wenn der Aufrufer einen solchen persistieren kann.

VariableStandardBeschreibung
SUBMISSION_ENABLEDfalseDen Submission-Server aktivieren
SUBMISSION_PORT587Port für die SMTP-Submission
SUBMISSION_TLS_CERTerforderlich bei SUBMISSION_ENABLED=truePEM-kodiertes TLS-Zertifikat
SUBMISSION_TLS_KEYerforderlich bei SUBMISSION_ENABLED=truePEM-kodierter privater TLS-Schlüssel
TLS erforderlich

Der Submission-Server erfordert TLS. Bei SUBMISSION_ENABLED=true müssen sowohl SUBMISSION_TLS_CERT als auch SUBMISSION_TLS_KEY gesetzt sein — STARTTLS ist vor AUTH vorgeschrieben, und der Server verweigert ohne diese Werte den Start (RFC 8314 §3.3). Einen Klartext-AUTH-Modus gibt es nicht.

Bounce- & Inbound-Verarbeitung

Dateien: bounce/server.ts, bounce/pipeline.ts, bounce/phases/, bounce/outcome.ts, bounce/effects.ts, bounce/parser.ts, bounce/fblProcessor.ts, bounce/classifier.ts

Eingehender SMTP-Server

Ein dedizierter SMTP-Server lauscht auf dem Bounce-Port (Standard: 25) auf eingehende Delivery Status Notifications, Feedback-Loop-Berichte, geroutete eingehende Mail und die Zustellung an persönliche Postfächer (Postbox). Bounces treffen an der VERP-Return-Path-Adresse ein; andere Mail wird gegen Inbound-Routen und den Postfach-Cache abgeglichen.

Der Bounce-Server spiegelt die Dispatch-Architektur: Eine typisierte Phasen-Pipeline (bounce/pipeline.ts + bounce/phases/) klassifiziert jede Nachricht in einen BounceAttempt, und der reine Reducer in bounce/outcome.ts gibt eine typisierte Liste von BounceEffects aus (Convex-Webhooks, Circuit-Breaker-Ergebnisse, Bereitstellung von Anhängen, Kontingenterhöhungen).

Verarbeitungspipeline

  1. VERP-Dekodierung — die ursprüngliche Message-ID aus der Empfängeradresse extrahieren
  2. DSN-Parsing — Delivery Status Notifications nach RFC 3464 auf erweiterte Statuscodes und Diagnoseinformationen parsen
  3. ARF-/FBL-Parsing — Feedback-Loop-Berichte im Abuse Reporting Format von ISPs erkennen
  4. Klassifizieren — das Ereignis kategorisieren:
    • Hard Bounce (dauerhaft) — ungültiger Empfänger, Domain existiert nicht
    • Soft Bounce (vorübergehend) — Postfach voll, Server vorübergehend nicht verfügbar
    • Beschwerde — der Empfänger hat die E-Mail als Spam markiert
  5. Routen — Mail, die kein Bounce ist, wird auf eine Inbound-Route (endpoint/accept/…) oder ein persönliches Postfach aufgelöst
  6. Effekte — Hard Bounces/Beschwerden automatisch unterdrücken, das Circuit-Breaker-Ergebnis erfassen (eine Beschwerde fließt in die Schwellwerte der Beschwerderate ein) und einen Webhook an Convex posten

Inbound-Sicherheit

Datei: bounce/inboundSecurity.ts

Der eingehende SMTP-Server wendet mehrere Missbrauchskontrollen an (konfigurierbar, siehe Konfiguration):

  • Verbindungsobergrenze je IP — höchstens BOUNCE_MAX_CONNECTIONS_PER_IP gleichzeitige Verbindungen von einer IP (Redis-Zähler mit 5-Minuten-Fenster); überzählige Verbindungen werden abgewiesen.
  • Globale Client-ObergrenzeBOUNCE_MAX_CLIENTS gleichzeitige Verbindungen insgesamt.
  • Tarpit — ist BOUNCE_TARPIT_ENABLED aktiv, erhalten nicht lokale Verbindungen eine bewusste Verzögerung von BOUNCE_TARPIT_DELAY_MS, um missbräuchliche Absender auszubremsen.
  • SPF-Validierung — ist INBOUND_SPF_ENABLED aktiv, wertet checkSpf den SPF-Eintrag der Absenderdomain (ip4/ip6/a/mx/include/all) gegen die verbindende IP aus.
  • DKIM-Verifizierung — ist INBOUND_DKIM_ENABLED aktiv, prüft verifyDkim (RFC 6376) die Nachrichtensignatur über die rohen Bytes. Fail-open: Ein Absturz bei der Prüfung ergibt temperror und wird als dkimResult erfasst, nie als Ablehnung.
  • DMARC-Auswertung — ist INBOUND_DMARC_ENABLED aktiv, richtet evaluateDmarc (RFC 7489) die SPF- und DKIM-Ergebnisse an der From:-Domain aus und wendet deren veröffentlichte Policy an. Das Urteil (und die Policy) wird als dmarcResult erfasst, damit Convex gefälschte Mail in den Spam-Ordner leiten kann. Fail-open: Ein Absturz bei der Abfrage ergibt temperror, nie eine Ablehnung.

Persönliche Postfächer (Postbox)

Passt ein Empfänger zum Postfach-Cache, wird die Nachricht als Zustellung an ein persönliches Postfach klassifiziert statt als Bounce oder Route. Der Reducer gibt einen inbound.mailbox.received-Webhook aus, der die geparste Nachricht und die rohen RFC822-Bytes in Base64 (zur Speicherung) trägt, und erhöht den gespeicherten Kontingentverbrauch des Postfachs. Siehe Postbox-Architektur.

Zugangsdaten je Organisation

Datei: auth/credentials.ts

API-Keys im Format owlat_{32-char-hex} sorgen für Isolierung je Organisation. Jede Zugangsberechtigung wird in Redis gespeichert und nach Organisations-ID indiziert.

  • Der Master-Key MTA_API_KEY funktioniert weiterhin für alle Endpunkte (wird vom Convex-Backend genutzt)
  • Keys je Organisation authentifizieren sowohl /send-Anfragen der HTTP-API als auch SMTP-Submission-Verbindungen
  • Verwaltungsendpunkte (POST/GET/DELETE /credentials) erfordern den Master-Key

Zugangsdaten erfassen die Zeitstempel createdAt und lastUsedAt. Der Endpunkt GET /credentials liefert gekürzte Key-Präfixe (die ersten 10 Zeichen) für eine gefahrlose Anzeige zurück.

Routing eingehender E-Mails

Dateien: inbound/router.ts, inbound/forwarder.ts

Eingehende E-Mails nach Domain und Local-Part-Adresse routen. Routen werden in Redis gespeichert und über die /inbound/routes-API verwaltet.

Routenmodi

ModusVerhalten
endpointDie geparste E-Mail an eine HTTP-Webhook-URL weiterleiten
acceptDie E-Mail stillschweigend annehmen
holdAnnehmen, aber zur manuellen Prüfung zurückhalten
bounceEine Bounce-Antwort an den Absender zurückgeben
rejectWährend der SMTP-Aushandlung ablehnen

Routen-Abgleich

Priorität: exakte Adressübereinstimmung, dann Wildcard (*). Beispielsweise hat eine Route für support@example.com Vorrang vor *@example.com. Routen werden in Redis nach domain:address geschlüsselt.

Webhook-Weiterleitung

Im Modus endpoint wird die geparste E-Mail als JSON an die konfigurierte URL gepostet:

  • Das Payload enthält: from, to, subject, textBody, htmlBody, headers, date, messageId, inReplyTo, references und attachments (base64-kodiert)
  • 10 Sekunden Timeout je Anfrage
  • Bis zu 2 Wiederholungen mit exponentiellem Backoff (1 s, 2 s)

Für alle gerouteten eingehenden E-Mails wird ein Webhook-Ereignis inbound.received an Convex gesendet.

Verwaltung der IP-Pools

Dateien: scaling/ipPool.ts, scaling/degradation.ts

Zwei Pools

PoolZweck
transactionalZeitkritische E-Mails (Passwort-Resets, Bestellbestätigungen)
campaignMarketing-E-Mails im Massenversand

IPs werden über die Umgebungsvariablen IP_POOLS_TRANSACTIONAL und IP_POOLS_CAMPAIGN konfiguriert (kommagetrennt). Der Standard ist explizit auf IPv4 beschränkt. Native IPv6-Literale erfordern MTA_IPV6_ENABLED=true; sie durchlaufen denselben Warm-up-Pfad, können aber nicht in die Rotation eintreten, bevor IPv4-Identität, IPv6-PTR/-EHLO/-AAAA, SPF des Return-Paths und DNSBL-Bereitschaft allesamt bestanden sind. Jeder SMTP-Socket behält die gewählte Adresse als seine explizite localAddress, sodass das Betriebssystem nie implizit eine IPv6-Quelle wählt.

Auswahl

Round-Robin-Auswahl der IP innerhalb jedes Pools. Jede zusammengesetzte Bereitschafts-Quarantäne (FCrDNS, IPv4-Voraussetzung, SPF oder kritische DNSBL) schließt die Adresse aus. Bleibt keine geeignete Adresse übrig, bleibt die Zustellung in der Queue; der Sender weicht nie auf eine nicht verifizierte Quelle aus.

Pool-Regeln

Datei: scaling/poolRules.ts

Regeln zur Zuordnung von IP-Pools je Organisation, gespeichert in Redis. Eine Regel kann den Pool überschreiben oder eine dedizierte IP zuweisen, die die Round-Robin-Auswahl umgeht.

Auflösungsreihenfolge:

  1. Organisationsspezifische Regel (sofern über POST /pool-rules gesetzt)
  2. Feld ipPool der Anfrage aus dem Job
  3. Standard-Pool

Verwaltet über POST /pool-rules, GET /pool-rules/:orgId, DELETE /pool-rules/:orgId.

Degradation

Das Degradationssystem verfolgt Verbindungsfehler je Domain und wendet bei wiederholten Fehlschlägen ein automatisches Backoff an. Health-Checks auf Systemebene überwachen Queue-Tiefe und Redis-Konnektivität. Ist die Queue zu tief, liefert die API HTTP 429 (Gegendruck).

Webhook-Ereignisse

Ereignisse werden an {CONVEX_SITE_URL}/webhooks/mta gepostet, authentifiziert über den Header X-MTA-Signature (HMAC-SHA256 von timestamp.body, in konstanter Zeit verglichen) plus einen X-MTA-Timestamp-Header, mit Zeitstempelprüfung (5 Minuten Toleranz, 300 s).

EreignisSchweregradBeschreibung
sentinfoE-Mail erfolgreich zugestellt (SMTP-250-Antwort)
bouncedwarningHard oder Soft Bounce (enthält bounceType: 'hard' | 'soft')
complainedwarningSpam-Beschwerde aus dem Feedback-Loop des ISPs
inbound.receivedinfoEingehende E-Mail empfangen und geroutet
inbound.mailbox.receivedinfoEingehende Mail an ein persönliches Postfach (Postbox) zugestellt
org.circuit_breakercriticalBounce-Rate der Organisation zu hoch, Versand pausiert
ip.blocklistedcriticalVersand-IP in eine DNS-Blocklist aufgenommen
ip.delistedinfoVersand-IP aus einer DNS-Blocklist entfernt
ip.warming_completeinfoIP hat den Warming-Plan abgeschlossen
all_ips_blockedcriticalKeine Versand-IPs verfügbar (alle auf Blocklisten)

Convex-Integration

Das Convex-Backend empfängt MTA-Webhooks an der statischen Route POST /webhooks/mta, bedient von der gemeinsamen HTTP-Action für Provider-Feedback (webhooks/providerFeedbackHttp.ts, ein parametrisierter Handler für jede Art). Bounce- und Beschwerdeereignisse werden von webhooks/adapters/mta.ts — dem Adapter, den die Route über webhooks/adapters/index.ts auflöst — in typisierte email.bounced-/email.complained-InboundEvents geparst und durch die gemeinsame Inbound-Webhook-Pipeline geleitet (denselben runInboundPipeline-Dispatcher, den auch SES/Resend/Mandrill nutzen).

Auf der Sendeseite ist der MTA eines der austauschbaren Send-Provider-Module in apps/api/convex/lib/sendProviders/ — eines je Art, die der Katalog deklariert, zuzüglich etwaiger mitgelieferter Plugin-Transporte. Der Adapter ist mtaSendProvider, ein SendProviderModule<'mta'>-Objekt in lib/sendProviders/mta/index.ts. Er führt einen einzelnen sendEmail-Versuch aus (POST an /send des MTA mit 30 Sekunden Timeout) sowie ein typisiertes categorizeError, das den HTTP-Status bzw. den Fehler-Body auf einen EmailErrorCode abbildet; die gemeinsame Dispatch-Hilfe verantwortet die Retry-Schleife anhand der retryDelays des Moduls ([1000, 5000] für den MTA). Das Routing (lib/sendProviders/routing.ts) löst den Provider aus der providerRoutes-Konfiguration der Organisation auf und fällt auf die Umgebungsvariable EMAIL_PROVIDER zurück; nennt keines von beiden einen Provider, wird null zurückgegeben — unkonfiguriert, kein implizites 'mta'. Der gemeinsame Vertrag liegt in lib/sendProviders/types.ts (SendProviderModule, SendProviderKind, EmailErrorCode); die Strategieauswahl liegt in lib/sendProviders/strategies/.

Monitoring

Prometheus-Metriken

Datei: monitoring/collector.ts

Bereitgestellt unter GET /metrics. Zu den wichtigsten Metriken gehören:

  • Gesendete/fehlgeschlagene/zurückgestellte E-Mails (nach Domain, Pool)
  • Queue-Tiefe und Verarbeitungslatenz
  • Zustandswechsel des Circuit Breakers
  • Ergebnisse der DNSBL-Prüfungen
  • Warming-Fortschritt je IP
  • Größe des SMTP-Connection-Pools (aktiv/untätig)

Strukturiertes Logging

Datei: monitoring/logger.ts

Pino-JSON-Logging mit konfigurierbarem Log-Level (Umgebungsvariable LOG_LEVEL). Alle Log-Einträge enthalten strukturierten Kontext (Message-ID, Domain, IP-Pool, Organisations-ID).

Delivery Logger

Datei: monitoring/deliveryLogger.ts

Zustellereignisse je Nachricht (delivered/bounced/deferred/screened/suppressed) werden in einen täglichen Redis-Stream geschrieben und über die /delivery-logs-Endpunkte bereitgestellt. Streamlänge und TTL werden durch DELIVERY_LOG_MAX_LEN und DELIVERY_LOG_TTL_HOURS begrenzt.

Google Postmaster Tools

Datei: monitoring/postmaster.ts

Wenn GOOGLE_POSTMASTER_CLIENT_ID, GOOGLE_POSTMASTER_CLIENT_SECRET und GOOGLE_POSTMASTER_REFRESH_TOKEN gemeinsam gesetzt sind, ruft ein stündlicher, nur vom Leader ausgeführter Durchlauf über Googles v2-API domains.domainStats.query die vorangegangenen sieben Daten ab. Ein persistierter Domain-Cursor sorgt dafür, dass der begrenzte Durchlauf mit der Zeit jede verifizierte Domain abdeckt, während ein Collection-Lease, Request-Timeouts und eine absolute Laufzeitgrenze überlappende oder unbegrenzte Arbeit verhindern. Das Fenster toleriert die Veröffentlichungsverzögerung des Providers; Redis-Quittungen machen die erfolgreiche Zustellung von MTA nach Convex idempotent. Das Refresh-Token verbleibt in der MTA-Umgebung, und das Access-Token wird in Redis für weniger als seine providerseitige Lebensdauer gecacht. Nur validierte tägliche SPAM_RATE-Aggregate passieren den signierten Webhook. Die Google-API v2 stellt die Reputationsklassen für Domain/IP, Beispiel-IPs oder Konfidenzgrenzen, die es in v1 gab, nicht bereit; Owlat leitet sie nicht her. Convex akzeptiert Daten nur für eine exakt verifizierte Owlat-Versanddomain und bewahrt sie 90 Tage auf.

Die erforderlichen Lese-Scopes und die Anmeldeschritte beim Provider finden Sie unter Externes Reputations-Feedback.

Konfiguration

Die gesamte Konfiguration wird über config.ts aus Umgebungsvariablen geladen.

Erforderliche Variablen

VariableBeschreibung
MTA_API_KEYShared Secret für die Authentifizierung der HTTP-API (Bearer-Token)
EHLO_HOSTNAMESMTP-EHLO-Hostname. Muss zum rDNS-PTR-Eintrag des Servers passen.
RETURN_PATH_DOMAINDomain für VERP-Bounce-Adressen (z. B. bounces.owlat.com)
CONVEX_SITE_URLConvex-Site-URL für Webhook-Callbacks
MTA_WEBHOOK_SECRETShared Secret zur Authentifizierung von Webhook-Anfragen an Convex
IP_POOLS_TRANSACTIONALKommagetrennte IP-Adressen für den transaktionalen Pool
IP_POOLS_CAMPAIGNKommagetrennte IP-Adressen für den Kampagnen-Pool
MTA_IPV6_ENABLEDErlaubt native IPv6-Einträge im Pool ausdrücklich; Standard false

Optionale Variablen

VariableStandardBeschreibung
PORT3100Port des HTTP-Servers
BOUNCE_PORT25Eingehender SMTP-Port für die Bounce-Verarbeitung
REDIS_URLredis://localhost:6379Redis-Verbindungs-URL
DKIM_KEYS{}JSON: {"domain.com": {"selector": "s1", "privateKey": "..."}} — spielt beim Start in Redis ein
WORKER_CONCURRENCY50Nebenläufigkeit des GroupMQ-Workers (parallele Gruppen-Slots)
MTA_SERVER_IDHostnameServerkennung für Deployments mit mehreren Instanzen
LOG_LEVELinfoPino-Log-Level
NODE_ENVFür Produktions-Deployments auf production setzen
SUBMISSION_ENABLEDfalseDen SMTP-Submission-Server aktivieren (Port 587)
SUBMISSION_PORT587Port des SMTP-Submission-Servers
SUBMISSION_TLS_CERTPEM-kodiertes TLS-Zertifikat für die SMTP-Submission
SUBMISSION_TLS_KEYPEM-kodierter privater TLS-Schlüssel für die SMTP-Submission
ORG_DEFAULT_DAILY_LIMIT50000Standard-Tagesobergrenze für den Versand je Organisation
ORG_DEFAULT_HOURLY_LIMIT5000Standard-Stundenobergrenze für den Versand je Organisation
CONTENT_SCREENING_ENABLEDtrueInhaltliche Vorprüfung vor der Zustellung aktivieren
CONTENT_MAX_SIZE_KB500Maximale HTML-Größe in KB, bevor die Inhaltsprüfung ablehnt
DELIVERY_LOG_MAX_LEN100000Maximale Einträge je täglichem Redis-Stream für Zustellprotokolle
DELIVERY_LOG_TTL_HOURS72TTL in Stunden für die Zustellprotokoll-Streams
WEBHOOK_DLQ_MAX_SIZE10000Maximale Einträge in der Webhook-Dead-Letter-Queue
SMTP_OUTCOME_JOURNAL_MAX_SIZE10000Maximale unaufgelöste SMTP-Ergebnisreservierungen, bevor neue Versuche zurückgestellt werden
FBL_DEDUP_PROTOCOLerforderlich (owned-v2)Versioniertes Reservierungsprotokoll für Beschwerden; fehlt es, schlägt der MTA-Start fehl
FBL_DEDUP_CUTOVER_ACKerforderlichfresh-install für ein neues Redis/eine neue Installation oder quiesced-v1-intake nach der unten beschriebenen koordinierten Legacy-Umstellung
SMTP_POOL_MAX_PER_HOST3Gleichzeitige SMTP-Transports je MX-Host (je Instanz)
SMTP_POOL_IDLE_TIMEOUT_MS30000Untätige gepoolte Verbindungen nach diesem Zeitraum schließen
SMTP_POOL_MAX_AGE_MS300000Gepoolte Verbindungen unabhängig von Aktivität zwangsweise schließen
SMTP_POOL_GLOBAL_MAX_PER_HOST10Globale Obergrenze für Verbindungen je MX-Host über alle Instanzen hinweg
BOUNCE_TLS_CERTPEM-kodiertes TLS-Zertifikat für den Bounce-SMTP-Server (aktiviert STARTTLS)
BOUNCE_TLS_KEYPEM-kodierter privater TLS-Schlüssel für den Bounce-SMTP-Server
BOUNCE_MAX_CONNECTIONS_PER_IP10Maximale gleichzeitige eingehende Verbindungen je IP
BOUNCE_MAX_CLIENTS200Maximale gleichzeitige eingehende Verbindungen insgesamt
BOUNCE_TARPIT_ENABLEDtrueNicht lokale eingehende Verbindungen verzögern
BOUNCE_TARPIT_DELAY_MS5000Tarpit-Verzögerung je verdächtiger Verbindung
INBOUND_SPF_ENABLEDtrueSPF bei eingehender Mail validieren
INBOUND_DKIM_ENABLEDtrueDKIM-Signaturen (RFC 6376) bei eingehender Mail verifizieren
INBOUND_DMARC_ENABLEDtrueDMARC (RFC 7489) bei eingehender Mail auswerten
RSPAMD_URLrspamd-HTTP-URL für die Spam-Bewertung ausgehender Inhalte
RSPAMD_REJECT_THRESHOLD15Die Inhaltsprüfung lehnt ab, wenn der rspamd-Score diesen Wert übersteigt
GOOGLE_POSTMASTER_CLIENT_IDOAuth-Client-ID für Google Postmaster Tools; gemeinsam mit Secret und Refresh-Token setzen
GOOGLE_POSTMASTER_CLIENT_SECRETOAuth-Client-Secret für Google Postmaster Tools
GOOGLE_POSTMASTER_REFRESH_TOKENGeheimes Offline-Token für einen Google-Nutzer mit Lesezugriff auf Domain/Traffic
ABUSIX_DNSBL_API_KEYOptionaler 32-Zeichen-Schlüssel für den Guardian-Mail-DNS-Namensraum; Abusix-Befunde sind reine Warnungen
CLAMAV_HOSTclamavHostname des ClamAV-Daemons für den Anhang-Scan (direkt von der Scan-Route gelesen, nicht über config.ts)
CLAMAV_PORT3310Port des ClamAV-Daemons (direkt von der Scan-Route gelesen, nicht über config.ts)

Upgrade der FBL-Deduplizierung

Der Legacy-Beschwerdeschlüssel speichert lediglich 1; dieser Wert kann nicht offenlegen, ob ein alter Worker die Beschwerde nur beansprucht oder tatsächlich abgeschlossen hat. Folglich gibt es keinen sicheren Shadow-Modus mit gemischten Versionen: Den Legacy-Marker vor dem Abschluss zu schreiben, kann einen fehlgeschlagenen Versuch in einen siebentägigen Falscherfolg verwandeln. Der MTA scheitert nun beim Start, sofern nicht sowohl das owned-v2-Protokoll als auch eine explizite Installations-/Umstellungsbestätigung konfiguriert sind.

Verwenden Sie für eine bestehende Installation diese Umstellungssequenz:

  1. Stoppen Sie den Bounce-/FBL-SMTP-Verkehr am Load Balancer oder in der Firewall für jede Replik. Warten Sie, bis jeder Legacy-Handler fertig ist, und stoppen Sie dann jeden alten MTA.
  2. Deployen Sie, solange die Annahme gestoppt bleibt, das neue Binary und setzen Sie auf jeder Replik diese Werte:
    FBL_DEDUP_PROTOCOL=owned-v2
    FBL_DEDUP_CUTOVER_ACK=quiesced-v1-intake
    

    Die Bestätigung ist eine Zusicherung des Betreibers, kein verteiltes Lock. Setzen Sie sie niemals, solange ein altes Binary noch Feedback annehmen oder verarbeiten kann.
  3. Starten Sie die gesamte owned-v2-Flotte und nehmen Sie dann den SMTP-Verkehr wieder auf. Owned-v2 ignoriert verbliebene Legacy-1-Werte und nutzt ausschließlich seinen isolierten Version-2-Hash mit den eigenen Zuständen reserved, retryable und completed. Das ist nur deshalb sicher, weil Schritt 1 sicherstellt, dass kein Legacy-Worker in ein Rennen geraten oder einen weiteren Marker anlegen kann.
  4. Betreiben Sie ein altes und ein owned-v2-Binary niemals gleichzeitig. Ein Rollback erfordert dieselbe koordinierte Ruhigstellung der Annahme; alte Binaries können den v2-Zustand nicht deuten.

Eine frische Einrichtung kann belegen, dass kein alter Redis-Zustand existiert, daher schreibt die Setup-CLI FBL_DEDUP_PROTOCOL=owned-v2 und FBL_DEDUP_CUTOVER_ACK=fresh-install nur dann, wenn keine bestehende Umgebung gefunden wurde. Ein erneutes Setup auf einer bestehenden Umgebung weigert sich, einen der beiden Werte zu erfinden. Die eingecheckte Beispielumgebung lässt beide leer, damit ihr Kopieren nicht stillschweigend ein Upgrade bestätigen kann.

ClamAV-Sidecar (Docker)

Der MTA kann optional ClamAV als Docker-Sidecar für den Malware-Scan von Anhängen betreiben. ClamAV stellt den clamd-Daemon auf Port 3310 bereit, wobei freshclam die Virendefinitionen im offiziellen Image automatisch aktualisiert.

# docker-compose.yml (excerpt)
clamav:
  image: clamav/clamav:1.3
  ports:
    - '3310:3310'
  volumes:
    - clamav-data:/var/lib/clamav
  healthcheck:
    test: ["CMD", "clamdcheck"]
    interval: 60s
    timeout: 10s
    retries: 3

Das Paket @owlat/email-scanner stellt einen TCP-Client für das INSTREAM-Protokoll von clamd bereit. Der Endpunkt /scan/attachment des MTA kombiniert Dateityp-Validierung mit dem ClamAV-Scan.

Fail-open-Design

Ist ClamAV nicht verfügbar (Container läuft nicht, Netzwerkfehler), lässt der Scan-Endpunkt Anhänge mit einer protokollierten Warnung durch. Das verhindert, dass ClamAV-Ausfälle die gesamte E-Mail-Zustellung blockieren.

Startsequenz

Gemäß src/index.ts:

  1. Konfiguration aus Umgebungsvariablen laden
  2. Standardwerte für die Rate-Limits der Organisationen setzen
  3. SMTP-Connection-Pool konfigurieren und den Aufräum-Timer starten
  4. Mit Redis verbinden und die Konnektivität prüfen
  5. Verteilte Koordination des Connection-Pools aktivieren
  6. DKIM-Schlüssel aus DKIM_KEYS in Redis einspielen (vorhandene Schlüssel werden nicht überschrieben)
  7. ISP-Profile in Redis einspielen (HSETNX, unter Erhalt von Laufzeit-Overrides)
  8. IP-Pools in Redis initialisieren (alle IPs als aktiv markieren)
  9. Warming-Zustand für jede IP initialisieren
  10. GroupMQ-Queue und -Worker erzeugen
  11. HTTP-Server starten (Hono auf PORT)
  12. Bounce-/Inbound-SMTP-Server starten (auf BOUNCE_PORT)
  13. SMTP-Submission-Server starten (auf SUBMISSION_PORT, falls SUBMISSION_ENABLED=true)
  14. Leader Election starten
  15. Periodische Crons starten:
    • DNSBL-Prüfung — alle 15 Minuten (läuft auf jeder Instanz)
    • Warming-Auswertung — stündlich (nur Leader)
    • Google-Postmaster-Abruf — stündlich (nur Leader)
    • Erzeugung der TLS-RPT-Berichte — alle 24 Stunden (nur Leader)
    • Prüfung der DKIM-Rotation — alle 6 Stunden (nur Leader)
  16. GroupMQ-Worker starten (beginnt mit der Verarbeitung von Jobs)
Leader Election

In Deployments mit mehreren Instanzen sorgt ein Redis-Lock (src/lib/leaderElection.ts, 30 Sekunden TTL, alle 15 s erneuert) dafür, dass die Crons für Warming, Postmaster, TLS-RPT und DKIM-Rotation auf genau einer Instanz laufen. Die DNSBL-Prüfung läuft auf jeder Instanz. Die Führung wechselt automatisch, wenn der Leader die Erneuerung einstellt.

Zugriff auf Port 25

Der Bounce-SMTP-Server lauscht standardmäßig auf Port 25, was typischerweise Root-Rechte erfordert. Mappen Sie in containerisierten Deployments den Host-Port auf den Container-Port. Der MTA protokolliert eine Warnung, wenn der Bounce-Port erhöhte Rechte benötigt.

Kontrolliertes Herunterfahren

Bei SIGTERM/SIGINT ist das Herunterfahren idempotent (ein doppeltes Signal wird ignoriert) und läuft unter einem 40-Sekunden-Watchdog für den harten Abbruch, der zur stop_grace_period von Compose passt, sodass Docker nie mitten im Ausleeren SIGKILL senden muss:

  1. Alle periodischen Crons stoppen (DNSBL, Warming, Postmaster, TLS-RPT, DKIM-Rotation)
  2. HTTP-Server schließen
  3. Bounce-SMTP-Server schließen
  4. SMTP-Submission-Server schließen (sofern er läuft)
  5. GroupMQ-Worker ausleeren (auf laufende Jobs warten)
  6. SMTP-Connection-Pool ausleeren und schließen
  7. Führung abgeben (stopLeaderElection)
  8. Redis-Verbindung schließen

Zentrale Dateien

DateiZweck
src/index.tsHaupteinstiegspunkt: startet alle Dienste, Leader Election und Crons
src/config.tsLaden der Konfiguration, ISP-Profile, Warming-Plan
src/config/ispProfiles.tsLaufzeit-Store für ISP-Profile (seed/get/set/list)
src/server.tsAufsetzen der Hono-HTTP-App und Einhängen der Routen
src/redis.tsRedis-Verbindung
src/types.tsGemeinsame Typdefinitionen, Union der Webhook-Ereignisse
src/lib/leaderElection.tsRedis-Lock für Crons auf genau einer Instanz
src/auth/credentials.tsVerwaltung der API-Keys je Organisation
src/auth/postboxAuth.tsPostbox-Authentifizierung (persönliche Postfächer)
src/routes/send.tsHandler für POST /send (Einreihen + Abbildung der Engagement-Priorität)
src/routes/health.tsEndpunkte für Health-Check und Metriken
src/routes/credentials.tsAPI-Routen zur Verwaltung von Zugangsdaten
src/routes/dkim.tsAPI-Routen zur DKIM-Verwaltung
src/routes/inboundRoutes.tsAPI-Routen für das Inbound-Routing
src/routes/mailboxes.tsAPI-Routen für den Cache persönlicher Postfächer
src/routes/orgLimits.tsAPI-Routen für Rate-Limits je Organisation
src/routes/poolRules.tsAPI-Routen für Pool-Regeln
src/routes/ispProfiles.tsAdmin-Routen für ISP-Profile zur Laufzeit
src/routes/ipReputation.tsRouten für das Reputations-Dashboard je IP
src/routes/suppression.tsAPI-Routen für die Suppression-Liste
src/routes/deliveryLogs.tsAbfrage-Routen für Zustellprotokolle
src/routes/queue.tsRouten zur Queue-Inspektion
src/routes/dlq.tsRouten für die Webhook-Dead-Letter-Queue
src/routes/scan.tsEndpunkt für den Anhang-Scan (Dateivalidierung + ClamAV)
src/queue/setup.tsErzeugung von GroupMQ-Queue und -Worker
src/queue/handler.tsGroupMQ-Adapter: führt Dispatch-Pipeline + Outcome-Reducer aus
src/queue/groups.tsErzeugung des Gruppenschlüssels, ISP-Klassifizierung
src/dispatch/pipeline.tsTypisierte Primitive Phase/compose/runPipeline
src/dispatch/phases/index.tsKomposition von mainPipeline (die zehn Phasen) + Dateien je Phase
src/dispatch/outcome.tsReiner Reducer: SMTP-Ergebnis → typisierte Effektliste
src/dispatch/effects.tsEffekt-Runner (delegiert an intelligence/scaling/monitoring)
src/dispatch/types.tsKontexttypen der Phasen (BasePhaseCtx, CtxWithPool, CtxWithIp)
src/intelligence/circuitBreaker.tsSchutz je Organisation über Bounce- + Beschwerderate
src/intelligence/domainThrottle.tsAdaptives Rate-Limiting je IP und Domain
src/intelligence/contentScreening.tsInhaltsprüfungen vor dem Versand + rspamd-Bewertung
src/intelligence/smtpResponse.tsGesundheitsverfolgung der SMTP-Antworten
src/intelligence/dnsbl.tsPrüfung gegen DNS-Blocklisten
src/intelligence/warming.tsWarming-Plan für IPs und dessen Verfolgung
src/intelligence/engagementPriority.tsAbbildung der Engagement-Priorität beim Einreihen
src/intelligence/orgLimits.tsTages-/Stunden-Rate-Limits je Organisation
src/intelligence/suppressionList.tsGlobale E-Mail-Suppression-Liste
src/smtp/sender.tsDirekte MX-Zustellung (DKIM, MTA-STS, TLS-RPT-Erfassung)
src/smtp/mxResolver.tsMX-DNS-Abfragen mit Caching
src/smtp/dkim.tsKonfiguration der DKIM-Signierung
src/smtp/dkimStore.tsRedis-gestützte DKIM-Schlüsselablage
src/smtp/dkimRotation.tsWorkflow zur automatischen DKIM-Schlüsselrotation
src/smtp/mtaSts.tsAbruf und Durchsetzung der MTA-STS-Policy (RFC 8461)
src/smtp/tlsRpt.tsTLS-RPT-Erfassung (RFC 8460) + tägliche Berichtserzeugung
src/smtp/connectionPool.tsSMTP-Transport-Pool mit instanzübergreifender Koordination
src/smtp/submissionServer.tsSMTP-Submission-Server (Port 587)
src/inbound/router.tsAbgleich der Routen für eingehende E-Mails
src/inbound/mailboxResolver.tsAdress-Cache persönlicher Postfächer (O(1)-Redis-Abfrage)
src/inbound/forwarder.tsHTTP-Webhook-Weiterleitung für eingehende E-Mails
src/bounce/server.tsInbound-/Bounce-SMTP-Server
src/bounce/pipeline.tsPhasen-Pipeline zur Bounce-Klassifizierung
src/bounce/outcome.tsReiner Bounce-Reducer (Effektausgabe)
src/bounce/effects.tsRunner für Bounce-Effekte
src/bounce/inboundSecurity.tsSPF-Validierung, Tarpit, Verbindungsobergrenzen je IP
src/bounce/parser.tsParsing von DSN-Nachrichten
src/bounce/fblProcessor.tsParsing der ARF-/FBL-Feedback-Loops
src/bounce/classifier.tsKlassifizierung des Bounce-Typs
src/bounce/verp.tsVERP-Return-Path-Kodierung/-Dekodierung
src/scaling/ipPool.tsAuswahl und Verwaltung der IP-Pools
src/scaling/poolRules.tsRegeln zur IP-Pool-Zuordnung je Organisation
src/scaling/degradation.tsSystemgesundheit und Domain-Backoff
src/monitoring/logger.tsStrukturiertes Pino-Logging
src/monitoring/collector.tsErfassung der Prometheus-Metriken
src/monitoring/deliveryLogger.tsRedis-Streams für Zustellereignisse je Nachricht
src/monitoring/postmaster.tsAbrufkomponente für Google-Postmaster-Tools-Daten
src/webhooks/convexNotifier.tsWebhook-Zustellung an Convex