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
| Komponente | Verzeichnis | Zweck |
|---|---|---|
| HTTP-API | src/routes/ | Hono-Server, der Sendeanfragen vom Convex-Backend annimmt |
| GroupMQ-Worker | src/queue/ | Redis-gestützte Job-Queue mit gruppenbasierter Verarbeitung |
| Dispatch-Pipeline | src/dispatch/ | Typisierte, komponierbare Phasen-Pipeline — zehn geordnete Prüfungen vor dem Versand (ADR-0007) |
| Intelligence | src/intelligence/ | Die Prüfimplementierungen, an die die Dispatch-Phasen delegieren |
| SMTP-Sender | src/smtp/ | Direkte MX-Zustellung mit DKIM-Signierung und MTA-STS-Durchsetzung |
| SMTP-Submission | src/smtp/ | Authentifizierter SMTP-Submission-Server (Port 587) |
| Connection Pool | src/smtp/ | Wiederverwendbarer SMTP-Transport-Pool je MX-Host, mit instanzübergreifender Koordination |
| DKIM-Key-Store | src/smtp/ | Redis-gestützte DKIM-Schlüsselablage mit automatischer Rotation |
| Bounce-/Inbound-Server | src/bounce/ | Eingehender SMTP-Server für DSN-/ARF-Parsing, Klassifizierung und eingehende Mail |
| Inbound-Router | src/inbound/ | Regelbasiertes Inbound-Routing + Auflösung persönlicher Postfächer |
| Credentials | src/auth/ | API-Key je Organisation und Postbox-Authentifizierung |
| Webhook-Notifier | src/webhooks/ | Ereignis-Callbacks an das Convex-Backend |
| Monitoring | src/monitoring/ | Prometheus-Metriken, strukturiertes Logging, Google-Postmaster-Abruf |
| Scaling | src/scaling/ | Verwaltung der IP-Pools, Pool-Regeln und kontrollierte Degradation |
| Leader Election | src/lib/ | Redis-Lock, damit periodische Crons auf genau einer Instanz laufen |
| Attachment Scanner | src/routes/scan.ts | Endpunkt 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
| Methode | Pfad | Auth | Beschreibung |
|---|---|---|---|
POST | /send | Bearer | Eine einzelne E-Mail zur Zustellung einreihen |
GET | /health | Keine | System-Health-Check (Redis, Queue-Tiefe) |
GET | /metrics | Keine | Prometheus-Metrik-Endpunkt |
Verwaltung von Zugangsdaten
| Methode | Pfad | Auth | Beschreibung |
|---|---|---|---|
POST | /credentials | Master | Zugangsdaten für eine Organisation anlegen |
GET | /credentials | Master | Zugangsdaten auflisten (Filter über ?organizationId=) |
DELETE | /credentials/:apiKey | Master | Zugangsdaten widerrufen |
DKIM-Verwaltung
| Methode | Pfad | Auth | Beschreibung |
|---|---|---|---|
POST | /dkim | Master | Einen DKIM-Schlüssel hinzufügen oder aktualisieren |
GET | /dkim | Master | Alle DKIM-Domains auflisten (Schlüssel geschwärzt) |
DELETE | /dkim/:domain | Master | Einen DKIM-Schlüssel entfernen |
POST | /dkim/:domain/rotate | Master | Ein neues RSA-2048-Schlüsselpaar erzeugen |
Inbound-Routing
| Methode | Pfad | Auth | Beschreibung |
|---|---|---|---|
POST | /inbound/routes | Master | Eine Inbound-Route anlegen oder aktualisieren |
GET | /inbound/routes | Master | Alle Inbound-Routen auflisten |
DELETE | /inbound/routes/:domain/:address | Master | Eine 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).
| Methode | Pfad | Auth | Beschreibung |
|---|---|---|---|
POST | /mailboxes/cache/:address | Master | Einen Cache-Eintrag für ein Postfach anlegen oder auffrischen (mailboxId, organizationId, optionales Kontingent) |
DELETE | /mailboxes/cache/:address | Master | Einen Cache-Eintrag entfernen |
GET | /mailboxes/cache | Master | Gecachte 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.
| Methode | Pfad | Auth | Beschreibung |
|---|---|---|---|
GET | /isp-profiles | Master | Alle Profile auflisten (eingespielt + eigene) |
GET | /isp-profiles/:domain | Master | Das wirksame Profil für eine Domain abrufen |
PUT | /isp-profiles/:domain | Master | Ein Profil anlegen oder aktualisieren (defaultRate, ceiling, floor, backoffFactor, recoveryFactor) |
DELETE | /isp-profiles/:domain | Master | Ein 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).
| Methode | Pfad | Auth | Beschreibung |
|---|---|---|---|
GET | /ip-reputation | Master | Übersichtszeile je konfigurierter IP (Metriken, Warming, Pool-Eignung, DNSBL, FCrDNS) |
GET | /ip-reputation/:ip | Master | Vollständige Reputationsansicht für eine IP, inklusive Blockierungsgründen und Identitätsbereitschaft |
Organisationslimits
| Methode | Pfad | Auth | Beschreibung |
|---|---|---|---|
POST | /org-limits | Master | Tages-/Stundenlimits für den Versand einer Organisation setzen |
GET | /org-limits/:orgId | Master | Verbrauch und Limits einer Organisation abrufen |
Pool-Regeln
| Methode | Pfad | Auth | Beschreibung |
|---|---|---|---|
POST | /pool-rules | Master | Pool-Zuordnung für eine Organisation setzen |
GET | /pool-rules/:orgId | Master | Pool-Regel einer Organisation abrufen |
DELETE | /pool-rules/:orgId | Master | Pool-Regel einer Organisation entfernen |
Suppression-Liste
| Methode | Pfad | Auth | Beschreibung |
|---|---|---|---|
POST | /suppression | Master | Adressen der Suppression-Liste hinzufügen (Batch) |
DELETE | /suppression/:email | Master | Eine Adresse aus der Suppression-Liste entfernen |
GET | /suppression/check/:email | Master | Suppression-Status einer Adresse prüfen |
POST | /suppression/bulk | Master | Bis zu 10.000 Adressen in einer Anfrage hinzufügen |
GET | /suppression/export | Master | Paginierter Export mit Metadaten (?reason=, ?cursor=) |
GET | /suppression/stats | Master | Anzahlen je Suppression-Grund |
Anhang-Scan
| Methode | Pfad | Auth | Beschreibung |
|---|---|---|---|
POST | /scan/attachment | Master | Datei auf Malware prüfen (Dateityp-Validierung + ClamAV) |
GET | /scan/health | Keine | Status 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
| Methode | Pfad | Auth | Beschreibung |
|---|---|---|---|
GET | /delivery-logs | Master | Ereignisse nach Datum, orgId, Status, Domain abfragen (paginiert) |
GET | /delivery-logs/stats | Master | Aggregierte Anzahlen nach Status/Domain/Pool für einen Datumsbereich |
GET | /delivery-logs/:messageId | Master | Alle Zustellereignisse für eine bestimmte Nachricht |
Queue-Inspektion
| Methode | Pfad | Auth | Beschreibung |
|---|---|---|---|
GET | /queue/stats | Master | Queue-Tiefe nach Zustand (pending, active, completed, failed, delayed) |
GET | /queue/pending | Master | Ausstehende Jobs auflisten (?limit=, ?offset=, ?domain=) |
GET | /queue/jobs/:jobId | Master | Vollständige Job-Details mit Versuchshistorie |
DELETE | /queue/jobs/:jobId | Master | Einen bestimmten ausstehenden Job abbrechen |
POST | /queue/flush | Master | Alle ausstehenden Jobs einer Organisation abbrechen (?orgId=) |
Dead Letter Queue
| Methode | Pfad | Auth | Beschreibung |
|---|---|---|---|
GET | /dlq | Master | Fehlgeschlagene Webhook-Ereignisse auflisten (?limit=, ?offset=) |
GET | /dlq/stats | Master | Gesamtanzahl und Alter des ältesten Eintrags |
POST | /dlq/:dlqId/retry | Master | Ein bestimmtes fehlgeschlagenes Webhook-Ereignis erneut versuchen |
POST | /dlq/retry-all | Master | Alle DLQ-Einträge erneut versuchen |
DELETE | /dlq/:dlqId | Master | Ein bestimmtes fehlgeschlagenes Ereignis verwerfen |
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.tsdefiniertPhase<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östenpool/ipkonsumiert (z. B.selectIp,acquireSlot), kann nicht vor der Phase stehen, die ihn erzeugt, sonst schlägt dercompose(...)-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) oderdrop(den Versuch mit Statusscreenedodersuppressedbeenden, ohne erneutes Einreihen). src/dispatch/phases/index.tssetztmainPipelinein der tatsächlichen Reihenfolge zusammen (siehe unten).src/queue/handler.tsist die einzige an GroupMQ gekoppelte Datei. Die Pipeline wirft niemals; der Handler übersetzt eindefer-Ergebnis in GroupMQsDeferError(mit ±15 % Jitter, um eine Thundering Herd zu vermeiden) und eindropin ein protokolliertes Endergebnis.- Nachdem die Pipeline
continuezurückgegeben hat, ruft der HandlersendToMxauf; anschließend klassifiziert der reine Reducer insrc/dispatch/outcome.tsdas 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:
| # | Phase | Delegiert an | Ergebnis bei Fehlschlag |
|---|---|---|---|
| 1 | content_screening | intelligence/contentScreening.ts | drop (screened) |
| 2 | suppression | intelligence/suppressionList.ts | drop (suppressed) |
| 3 | circuit_breaker | intelligence/circuitBreaker.ts | defer |
| 4 | org_limit | intelligence/orgLimits.ts | defer |
| 5 | smtp_intel | intelligence/smtpResponse.ts | defer |
| 6 | domain_backoff | scaling/degradation.ts | defer |
| 7 | resolve_pool | scaling/poolRules.ts | continue (reichert den Kontext um Pool + dedizierte IP an) |
| 8 | select_ip | scaling/ipPool.ts | defer (reichert den Kontext um die gebundene IP an) |
| 9 | acquire_slot | intelligence/domainThrottle.ts | defer |
| 10 | warming_cap | intelligence/warming.ts | defer |
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üfung | Anmerkungen |
|---|---|
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).
| Grund | Quelle |
|---|---|
hard_bounce | Automatisch ergänzt, wenn ein Hard Bounce erkannt wird |
complaint | Automatisch 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:
| Zustand | Verhalten |
|---|---|
| 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):
| Signal | Schnell (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.
| Limit | Standard | TTL des Zählers |
|---|---|---|
| Täglich | 50.000 | 48 Stunden |
| Stündlich | 5.000 | 2 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:
- Organisationsspezifische Pool-Regel (sofern über
POST /pool-rulesgesetzt) - Das Feld
ipPoolder Anfrage - 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:
| ISP | Standardrate | Obergrenze | Untergrenze |
|---|---|---|---|
| Gmail / Googlemail | 100/Min. | 300/Min. | 5/Min. |
| Outlook / Hotmail / Live | 80/Min. | 200/Min. | 5/Min. |
| Yahoo / AOL / Ymail | 50/Min. | 150/Min. | 3/Min. |
| iCloud / me / mac | 60/Min. | 150/Min. | 5/Min. |
| Andere Domains | 30/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:
| Tag | Tagesobergrenze |
|---|---|
| 1 | 50 |
| 2 | 100 |
| 3 | 200 |
| 5 | 700 |
| 7 | 1.500 |
| 10 | 3.000 |
| 14 | 7.500 |
| 18 | 15.000 |
| 21 | 20.000 |
| 25 | 30.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:
analytics/engagementScore.tsberechnet je Kontakt einen aktualitätsgewichteten Score von 0–100 aus dessen Aktivitätshistorie und speichert ihn incontacts.engagementScore.- 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.tsreicht ihn andelivery/enqueue.ts:enqueueCampaignEmailsweiter; - Automations- und Agentenantwort-Sendungen —
delivery/enqueue.ts:enqueueNonCampaignSendfü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 schreibtcontactIdin dietransactionalSends-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. - Kampagnensendungen — die Zielgruppenauflösung projiziert den Score aus der bereits geladenen Kontaktzeile, und
delivery/governedDispatch.tsnormalisiert den Envelope-Wert an der Lesegrenze erneut und prägt ihn aufMtaExtras.engagementScore; der MTA-Adapter reicht ihn im Body vonPOST /sendweiter.
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:
| Blocklist | Schweregrad |
|---|---|
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:
| Zustand | Erzeugt durch | Wirkung |
|---|---|---|
listed | Eine 127.0.0.x-Antwort (außer dem reservierten Block 127.255.255.x) | Spamhaus wirft die Adresse hinaus; die übrigen Feeds sind beratend |
clean | NXDOMAIN / NODATA | Die Adresse ist zugelassen |
unknown | Timeout, SERVFAIL, REFUSED, Ablehnung durch Resolver-Policy, Abfrageraten-Limitierung (127.255.255.x) oder jedes andere Resolver-Versagen | Zä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
| Einstellung | Standard | Beschreibung |
|---|---|---|
| Nebenläufigkeit | 50 | Parallele Slots für die Gruppenverarbeitung (WORKER_CONCURRENCY) |
| Maximale Versuche | 5 | Wiederholungslimit je Job |
| Job-Timeout | 2 Min. | Verarbeitungs-Timeout je Job |
| Backoff | Exponentiell | 30 s → 2 m → 8 m → 30 m → 2 h |
| Aufbewahrung abgeschlossener Jobs | 1.000 | Letzte N abgeschlossene Jobs in Redis behalten |
| Aufbewahrung fehlgeschlagener Jobs | 5.000 | Letzte 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-Verwaltung —
POST /dkim,GET /dkim,DELETE /dkim/:domain - Schlüsselrotation —
POST /dkim/:domain/rotateerzeugt 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 Umgebungsvariable —
DKIM_KEYSfunktioniert 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.
| Einstellung | Standard | Beschreibung |
|---|---|---|
| Maximale Verbindungen je Host | 3 | Gleichzeitige Transports je MX-Host |
| Idle-Timeout | 30 s | Untätige Verbindungen nach diesem Zeitraum schließen |
| Maximales Verbindungsalter | 5 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:
| Modus | Wirkung |
|---|---|
enforce | requireTLS + rejectUnauthorized; nur MX-Hosts, die auf die mx:-Muster der Policy passen, werden probiert |
testing | Opportunistisches TLS, zur Überwachung protokolliert (keine Durchsetzung) |
none | Opportunistisches 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 SieMTA_TLSRPT_RUAauf ein Ziel, das Sie überwachen (mailto:tls-reports@yourdomain.comoder einen HTTPS-Endpunkt); beim Registrieren einer Versanddomain wird neben den DKIM-/DMARC-Einträgen ein_smtp._tls-TXT-Eintrag der Formv=TLSRPTv1; rua=…(RFC 8460 §3) ausgegeben. IstMTA_TLSRPT_RUAnicht 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):- Ein DNS-TXT-Eintrag unter
_mta-sts.yourdomain.commit dem Wertv=STSv1; id=<unique-version>(erhöhen Sieidbei jeder Policy-Änderung, damit Resolver neu laden). - Eine Policy-Datei, die über HTTPS unter
https://mta-sts.yourdomain.com/.well-known/mta-sts.txtausgeliefert wird, z. B.:version: STSv1 mode: enforce mx: mx.yourdomain.com max_age: 604800
mode: testing, um TLS-RPT-Berichte zu sammeln, ohne die Zustellung zu gefährden, und erhöhen Sie aufmode: enforce, sobald das TLS Ihres MX nachweislich gesund ist. - Ein DNS-TXT-Eintrag unter
- 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 EintragstypTLSAmit den Parameternusage/selector/matchingTypewird 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):
initiateRotationerzeugt 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.- Nachdem das Überlappungsfenster verstrichen ist und der DNS-TXT-Eintrag des neuen Selectors als veröffentlicht bestätigt wurde, befördert
activatePendingKeyden 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 explizitesforceumgeht 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.
| Einstellung | Wert |
|---|---|
| Port | 587 (konfigurierbar über SUBMISSION_PORT) |
| Verschlüsselung | STARTTLS-Upgrade |
| Auth-Methoden | PLAIN, LOGIN |
| Maximale Nachrichtengröße | 25 MB |
| Standard-IP-Pool | transactional |
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.
| Variable | Standard | Beschreibung |
|---|---|---|
SUBMISSION_ENABLED | false | Den Submission-Server aktivieren |
SUBMISSION_PORT | 587 | Port für die SMTP-Submission |
SUBMISSION_TLS_CERT | erforderlich bei SUBMISSION_ENABLED=true | PEM-kodiertes TLS-Zertifikat |
SUBMISSION_TLS_KEY | erforderlich bei SUBMISSION_ENABLED=true | PEM-kodierter privater TLS-Schlüssel |
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
- VERP-Dekodierung — die ursprüngliche Message-ID aus der Empfängeradresse extrahieren
- DSN-Parsing — Delivery Status Notifications nach RFC 3464 auf erweiterte Statuscodes und Diagnoseinformationen parsen
- ARF-/FBL-Parsing — Feedback-Loop-Berichte im Abuse Reporting Format von ISPs erkennen
- 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
- Routen — Mail, die kein Bounce ist, wird auf eine Inbound-Route (
endpoint/accept/…) oder ein persönliches Postfach aufgelöst - 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_IPgleichzeitige Verbindungen von einer IP (Redis-Zähler mit 5-Minuten-Fenster); überzählige Verbindungen werden abgewiesen. - Globale Client-Obergrenze —
BOUNCE_MAX_CLIENTSgleichzeitige Verbindungen insgesamt. - Tarpit — ist
BOUNCE_TARPIT_ENABLEDaktiv, erhalten nicht lokale Verbindungen eine bewusste Verzögerung vonBOUNCE_TARPIT_DELAY_MS, um missbräuchliche Absender auszubremsen. - SPF-Validierung — ist
INBOUND_SPF_ENABLEDaktiv, wertetcheckSpfden SPF-Eintrag der Absenderdomain (ip4/ip6/a/mx/include/all) gegen die verbindende IP aus. - DKIM-Verifizierung — ist
INBOUND_DKIM_ENABLEDaktiv, prüftverifyDkim(RFC 6376) die Nachrichtensignatur über die rohen Bytes. Fail-open: Ein Absturz bei der Prüfung ergibttemperrorund wird alsdkimResulterfasst, nie als Ablehnung. - DMARC-Auswertung — ist
INBOUND_DMARC_ENABLEDaktiv, richtetevaluateDmarc(RFC 7489) die SPF- und DKIM-Ergebnisse an derFrom:-Domain aus und wendet deren veröffentlichte Policy an. Das Urteil (und die Policy) wird alsdmarcResulterfasst, damit Convex gefälschte Mail in den Spam-Ordner leiten kann. Fail-open: Ein Absturz bei der Abfrage ergibttemperror, 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_KEYfunktioniert 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
| Modus | Verhalten |
|---|---|
endpoint | Die geparste E-Mail an eine HTTP-Webhook-URL weiterleiten |
accept | Die E-Mail stillschweigend annehmen |
hold | Annehmen, aber zur manuellen Prüfung zurückhalten |
bounce | Eine Bounce-Antwort an den Absender zurückgeben |
reject | Wä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,referencesundattachments(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
| Pool | Zweck |
|---|---|
transactional | Zeitkritische E-Mails (Passwort-Resets, Bestellbestätigungen) |
campaign | Marketing-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:
- Organisationsspezifische Regel (sofern über
POST /pool-rulesgesetzt) - Feld
ipPoolder Anfrage aus dem Job - 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).
| Ereignis | Schweregrad | Beschreibung |
|---|---|---|
sent | info | E-Mail erfolgreich zugestellt (SMTP-250-Antwort) |
bounced | warning | Hard oder Soft Bounce (enthält bounceType: 'hard' | 'soft') |
complained | warning | Spam-Beschwerde aus dem Feedback-Loop des ISPs |
inbound.received | info | Eingehende E-Mail empfangen und geroutet |
inbound.mailbox.received | info | Eingehende Mail an ein persönliches Postfach (Postbox) zugestellt |
org.circuit_breaker | critical | Bounce-Rate der Organisation zu hoch, Versand pausiert |
ip.blocklisted | critical | Versand-IP in eine DNS-Blocklist aufgenommen |
ip.delisted | info | Versand-IP aus einer DNS-Blocklist entfernt |
ip.warming_complete | info | IP hat den Warming-Plan abgeschlossen |
all_ips_blocked | critical | Keine 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
| Variable | Beschreibung |
|---|---|
MTA_API_KEY | Shared Secret für die Authentifizierung der HTTP-API (Bearer-Token) |
EHLO_HOSTNAME | SMTP-EHLO-Hostname. Muss zum rDNS-PTR-Eintrag des Servers passen. |
RETURN_PATH_DOMAIN | Domain für VERP-Bounce-Adressen (z. B. bounces.owlat.com) |
CONVEX_SITE_URL | Convex-Site-URL für Webhook-Callbacks |
MTA_WEBHOOK_SECRET | Shared Secret zur Authentifizierung von Webhook-Anfragen an Convex |
IP_POOLS_TRANSACTIONAL | Kommagetrennte IP-Adressen für den transaktionalen Pool |
IP_POOLS_CAMPAIGN | Kommagetrennte IP-Adressen für den Kampagnen-Pool |
MTA_IPV6_ENABLED | Erlaubt native IPv6-Einträge im Pool ausdrücklich; Standard false |
Optionale Variablen
| Variable | Standard | Beschreibung |
|---|---|---|
PORT | 3100 | Port des HTTP-Servers |
BOUNCE_PORT | 25 | Eingehender SMTP-Port für die Bounce-Verarbeitung |
REDIS_URL | redis://localhost:6379 | Redis-Verbindungs-URL |
DKIM_KEYS | {} | JSON: {"domain.com": {"selector": "s1", "privateKey": "..."}} — spielt beim Start in Redis ein |
WORKER_CONCURRENCY | 50 | Nebenläufigkeit des GroupMQ-Workers (parallele Gruppen-Slots) |
MTA_SERVER_ID | Hostname | Serverkennung für Deployments mit mehreren Instanzen |
LOG_LEVEL | info | Pino-Log-Level |
NODE_ENV | — | Für Produktions-Deployments auf production setzen |
SUBMISSION_ENABLED | false | Den SMTP-Submission-Server aktivieren (Port 587) |
SUBMISSION_PORT | 587 | Port des SMTP-Submission-Servers |
SUBMISSION_TLS_CERT | — | PEM-kodiertes TLS-Zertifikat für die SMTP-Submission |
SUBMISSION_TLS_KEY | — | PEM-kodierter privater TLS-Schlüssel für die SMTP-Submission |
ORG_DEFAULT_DAILY_LIMIT | 50000 | Standard-Tagesobergrenze für den Versand je Organisation |
ORG_DEFAULT_HOURLY_LIMIT | 5000 | Standard-Stundenobergrenze für den Versand je Organisation |
CONTENT_SCREENING_ENABLED | true | Inhaltliche Vorprüfung vor der Zustellung aktivieren |
CONTENT_MAX_SIZE_KB | 500 | Maximale HTML-Größe in KB, bevor die Inhaltsprüfung ablehnt |
DELIVERY_LOG_MAX_LEN | 100000 | Maximale Einträge je täglichem Redis-Stream für Zustellprotokolle |
DELIVERY_LOG_TTL_HOURS | 72 | TTL in Stunden für die Zustellprotokoll-Streams |
WEBHOOK_DLQ_MAX_SIZE | 10000 | Maximale Einträge in der Webhook-Dead-Letter-Queue |
SMTP_OUTCOME_JOURNAL_MAX_SIZE | 10000 | Maximale unaufgelöste SMTP-Ergebnisreservierungen, bevor neue Versuche zurückgestellt werden |
FBL_DEDUP_PROTOCOL | erforderlich (owned-v2) | Versioniertes Reservierungsprotokoll für Beschwerden; fehlt es, schlägt der MTA-Start fehl |
FBL_DEDUP_CUTOVER_ACK | erforderlich | fresh-install für ein neues Redis/eine neue Installation oder quiesced-v1-intake nach der unten beschriebenen koordinierten Legacy-Umstellung |
SMTP_POOL_MAX_PER_HOST | 3 | Gleichzeitige SMTP-Transports je MX-Host (je Instanz) |
SMTP_POOL_IDLE_TIMEOUT_MS | 30000 | Untätige gepoolte Verbindungen nach diesem Zeitraum schließen |
SMTP_POOL_MAX_AGE_MS | 300000 | Gepoolte Verbindungen unabhängig von Aktivität zwangsweise schließen |
SMTP_POOL_GLOBAL_MAX_PER_HOST | 10 | Globale Obergrenze für Verbindungen je MX-Host über alle Instanzen hinweg |
BOUNCE_TLS_CERT | — | PEM-kodiertes TLS-Zertifikat für den Bounce-SMTP-Server (aktiviert STARTTLS) |
BOUNCE_TLS_KEY | — | PEM-kodierter privater TLS-Schlüssel für den Bounce-SMTP-Server |
BOUNCE_MAX_CONNECTIONS_PER_IP | 10 | Maximale gleichzeitige eingehende Verbindungen je IP |
BOUNCE_MAX_CLIENTS | 200 | Maximale gleichzeitige eingehende Verbindungen insgesamt |
BOUNCE_TARPIT_ENABLED | true | Nicht lokale eingehende Verbindungen verzögern |
BOUNCE_TARPIT_DELAY_MS | 5000 | Tarpit-Verzögerung je verdächtiger Verbindung |
INBOUND_SPF_ENABLED | true | SPF bei eingehender Mail validieren |
INBOUND_DKIM_ENABLED | true | DKIM-Signaturen (RFC 6376) bei eingehender Mail verifizieren |
INBOUND_DMARC_ENABLED | true | DMARC (RFC 7489) bei eingehender Mail auswerten |
RSPAMD_URL | — | rspamd-HTTP-URL für die Spam-Bewertung ausgehender Inhalte |
RSPAMD_REJECT_THRESHOLD | 15 | Die Inhaltsprüfung lehnt ab, wenn der rspamd-Score diesen Wert übersteigt |
GOOGLE_POSTMASTER_CLIENT_ID | — | OAuth-Client-ID für Google Postmaster Tools; gemeinsam mit Secret und Refresh-Token setzen |
GOOGLE_POSTMASTER_CLIENT_SECRET | — | OAuth-Client-Secret für Google Postmaster Tools |
GOOGLE_POSTMASTER_REFRESH_TOKEN | — | Geheimes Offline-Token für einen Google-Nutzer mit Lesezugriff auf Domain/Traffic |
ABUSIX_DNSBL_API_KEY | — | Optionaler 32-Zeichen-Schlüssel für den Guardian-Mail-DNS-Namensraum; Abusix-Befunde sind reine Warnungen |
CLAMAV_HOST | clamav | Hostname des ClamAV-Daemons für den Anhang-Scan (direkt von der Scan-Route gelesen, nicht über config.ts) |
CLAMAV_PORT | 3310 | Port 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:
- 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.
- 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. - 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ändenreserved,retryableundcompleted. Das ist nur deshalb sicher, weil Schritt 1 sicherstellt, dass kein Legacy-Worker in ein Rennen geraten oder einen weiteren Marker anlegen kann. - 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.
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:
- Konfiguration aus Umgebungsvariablen laden
- Standardwerte für die Rate-Limits der Organisationen setzen
- SMTP-Connection-Pool konfigurieren und den Aufräum-Timer starten
- Mit Redis verbinden und die Konnektivität prüfen
- Verteilte Koordination des Connection-Pools aktivieren
- DKIM-Schlüssel aus
DKIM_KEYSin Redis einspielen (vorhandene Schlüssel werden nicht überschrieben) - ISP-Profile in Redis einspielen (
HSETNX, unter Erhalt von Laufzeit-Overrides) - IP-Pools in Redis initialisieren (alle IPs als aktiv markieren)
- Warming-Zustand für jede IP initialisieren
- GroupMQ-Queue und -Worker erzeugen
- HTTP-Server starten (Hono auf
PORT) - Bounce-/Inbound-SMTP-Server starten (auf
BOUNCE_PORT) - SMTP-Submission-Server starten (auf
SUBMISSION_PORT, fallsSUBMISSION_ENABLED=true) - Leader Election starten
- 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)
- GroupMQ-Worker starten (beginnt mit der Verarbeitung von Jobs)
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.
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:
- Alle periodischen Crons stoppen (DNSBL, Warming, Postmaster, TLS-RPT, DKIM-Rotation)
- HTTP-Server schließen
- Bounce-SMTP-Server schließen
- SMTP-Submission-Server schließen (sofern er läuft)
- GroupMQ-Worker ausleeren (auf laufende Jobs warten)
- SMTP-Connection-Pool ausleeren und schließen
- Führung abgeben (
stopLeaderElection) - Redis-Verbindung schließen
Zentrale Dateien
| Datei | Zweck |
|---|---|
src/index.ts | Haupteinstiegspunkt: startet alle Dienste, Leader Election und Crons |
src/config.ts | Laden der Konfiguration, ISP-Profile, Warming-Plan |
src/config/ispProfiles.ts | Laufzeit-Store für ISP-Profile (seed/get/set/list) |
src/server.ts | Aufsetzen der Hono-HTTP-App und Einhängen der Routen |
src/redis.ts | Redis-Verbindung |
src/types.ts | Gemeinsame Typdefinitionen, Union der Webhook-Ereignisse |
src/lib/leaderElection.ts | Redis-Lock für Crons auf genau einer Instanz |
src/auth/credentials.ts | Verwaltung der API-Keys je Organisation |
src/auth/postboxAuth.ts | Postbox-Authentifizierung (persönliche Postfächer) |
src/routes/send.ts | Handler für POST /send (Einreihen + Abbildung der Engagement-Priorität) |
src/routes/health.ts | Endpunkte für Health-Check und Metriken |
src/routes/credentials.ts | API-Routen zur Verwaltung von Zugangsdaten |
src/routes/dkim.ts | API-Routen zur DKIM-Verwaltung |
src/routes/inboundRoutes.ts | API-Routen für das Inbound-Routing |
src/routes/mailboxes.ts | API-Routen für den Cache persönlicher Postfächer |
src/routes/orgLimits.ts | API-Routen für Rate-Limits je Organisation |
src/routes/poolRules.ts | API-Routen für Pool-Regeln |
src/routes/ispProfiles.ts | Admin-Routen für ISP-Profile zur Laufzeit |
src/routes/ipReputation.ts | Routen für das Reputations-Dashboard je IP |
src/routes/suppression.ts | API-Routen für die Suppression-Liste |
src/routes/deliveryLogs.ts | Abfrage-Routen für Zustellprotokolle |
src/routes/queue.ts | Routen zur Queue-Inspektion |
src/routes/dlq.ts | Routen für die Webhook-Dead-Letter-Queue |
src/routes/scan.ts | Endpunkt für den Anhang-Scan (Dateivalidierung + ClamAV) |
src/queue/setup.ts | Erzeugung von GroupMQ-Queue und -Worker |
src/queue/handler.ts | GroupMQ-Adapter: führt Dispatch-Pipeline + Outcome-Reducer aus |
src/queue/groups.ts | Erzeugung des Gruppenschlüssels, ISP-Klassifizierung |
src/dispatch/pipeline.ts | Typisierte Primitive Phase/compose/runPipeline |
src/dispatch/phases/index.ts | Komposition von mainPipeline (die zehn Phasen) + Dateien je Phase |
src/dispatch/outcome.ts | Reiner Reducer: SMTP-Ergebnis → typisierte Effektliste |
src/dispatch/effects.ts | Effekt-Runner (delegiert an intelligence/scaling/monitoring) |
src/dispatch/types.ts | Kontexttypen der Phasen (BasePhaseCtx, CtxWithPool, CtxWithIp) |
src/intelligence/circuitBreaker.ts | Schutz je Organisation über Bounce- + Beschwerderate |
src/intelligence/domainThrottle.ts | Adaptives Rate-Limiting je IP und Domain |
src/intelligence/contentScreening.ts | Inhaltsprüfungen vor dem Versand + rspamd-Bewertung |
src/intelligence/smtpResponse.ts | Gesundheitsverfolgung der SMTP-Antworten |
src/intelligence/dnsbl.ts | Prüfung gegen DNS-Blocklisten |
src/intelligence/warming.ts | Warming-Plan für IPs und dessen Verfolgung |
src/intelligence/engagementPriority.ts | Abbildung der Engagement-Priorität beim Einreihen |
src/intelligence/orgLimits.ts | Tages-/Stunden-Rate-Limits je Organisation |
src/intelligence/suppressionList.ts | Globale E-Mail-Suppression-Liste |
src/smtp/sender.ts | Direkte MX-Zustellung (DKIM, MTA-STS, TLS-RPT-Erfassung) |
src/smtp/mxResolver.ts | MX-DNS-Abfragen mit Caching |
src/smtp/dkim.ts | Konfiguration der DKIM-Signierung |
src/smtp/dkimStore.ts | Redis-gestützte DKIM-Schlüsselablage |
src/smtp/dkimRotation.ts | Workflow zur automatischen DKIM-Schlüsselrotation |
src/smtp/mtaSts.ts | Abruf und Durchsetzung der MTA-STS-Policy (RFC 8461) |
src/smtp/tlsRpt.ts | TLS-RPT-Erfassung (RFC 8460) + tägliche Berichtserzeugung |
src/smtp/connectionPool.ts | SMTP-Transport-Pool mit instanzübergreifender Koordination |
src/smtp/submissionServer.ts | SMTP-Submission-Server (Port 587) |
src/inbound/router.ts | Abgleich der Routen für eingehende E-Mails |
src/inbound/mailboxResolver.ts | Adress-Cache persönlicher Postfächer (O(1)-Redis-Abfrage) |
src/inbound/forwarder.ts | HTTP-Webhook-Weiterleitung für eingehende E-Mails |
src/bounce/server.ts | Inbound-/Bounce-SMTP-Server |
src/bounce/pipeline.ts | Phasen-Pipeline zur Bounce-Klassifizierung |
src/bounce/outcome.ts | Reiner Bounce-Reducer (Effektausgabe) |
src/bounce/effects.ts | Runner für Bounce-Effekte |
src/bounce/inboundSecurity.ts | SPF-Validierung, Tarpit, Verbindungsobergrenzen je IP |
src/bounce/parser.ts | Parsing von DSN-Nachrichten |
src/bounce/fblProcessor.ts | Parsing der ARF-/FBL-Feedback-Loops |
src/bounce/classifier.ts | Klassifizierung des Bounce-Typs |
src/bounce/verp.ts | VERP-Return-Path-Kodierung/-Dekodierung |
src/scaling/ipPool.ts | Auswahl und Verwaltung der IP-Pools |
src/scaling/poolRules.ts | Regeln zur IP-Pool-Zuordnung je Organisation |
src/scaling/degradation.ts | Systemgesundheit und Domain-Backoff |
src/monitoring/logger.ts | Strukturiertes Pino-Logging |
src/monitoring/collector.ts | Erfassung der Prometheus-Metriken |
src/monitoring/deliveryLogger.ts | Redis-Streams für Zustellereignisse je Nachricht |
src/monitoring/postmaster.ts | Abrufkomponente für Google-Postmaster-Tools-Daten |
src/webhooks/convexNotifier.ts | Webhook-Zustellung an Convex |