E-Mail-System

Das E-Mail-System von Owlat besteht aus einem visuellen Editor, der Template-Verwaltung und einer Versandinfrastruktur für mehrere Provider.

Das E-Mail-System von Owlat besteht aus einem visuellen Editor, der Template-Verwaltung und einer Versandinfrastruktur für mehrere Provider.

Architekturüberblick

Paket „email-builder“

Der E-Mail-Builder liegt in packages/email-builder/ und bietet:

Komponenten

KomponenteZweck
EmailBuilder.vueHaupt-Editorkomponente
DocumentCanvas.vueRenderbereich für Blöcke (einspaltiges Canvas)
SubjectFields.vueInline-Felder für Betreff/Name oben im Canvas
UnifiedToolbar.vueKontextbezogene Block-Toolbar
FloatingBlockSidebar.vueSeitenleiste mit Blockaktionen (verschieben, löschen usw.)
PreviewPanel.vueLive-HTML-Vorschau

Verwendung

<template>
    <EmailBuilder
        v-model:blocks="blocks"
        v-model:subject="subject"
        v-model:name="name"
        v-model:background-color="backgroundColor"
        :variables="variables"
        :config="config"
        :is-saving="isSaving"
        @save="handleSave"
        @settings="handleSettings"
    />
</template>

<script setup>
import { provideEmailBuilderHandlers } from '@owlat/email-builder';

const blocks = ref([]);
const subject = ref('');
const name = ref('');
const backgroundColor = ref('#ffffff');
const variables = ref([]);
const isSaving = ref(false);

const config = {
    hideSubject: true, // Hide subject field (default: true)
    mode: 'email', // 'email' | 'block'
};

// Backend integration (image upload, saved blocks, media library) is wired
// through a provide/inject contract, NOT props. Call this in a parent
// component before EmailBuilder mounts.
provideEmailBuilderHandlers({
    uploadImage: async (file) => ({ url: await upload(file) }),
    savedBlocks: {
        fetch: (params) => fetchSavedBlocks(params),
        save: (block) => saveBlock(block),
    },
});
</script>

Die erforderlichen Props sind blocks, subject, name und variables; backgroundColor, config und isSaving sind optional (EmailBuilder.vue:70-78). Eine Prop saved-blocks gibt es nicht — gespeicherte Blöcke, Bild-Upload und die Auswahl aus der Medienbibliothek werden über den Provide/Inject-Vertrag EmailBuilderHandlers bereitgestellt (packages/email-builder/src/types/editor.ts:44) und innerhalb der Komponente über useEmailBuilderHandlers() aufgelöst.

Konfigurationsoptionen

OptionTypStandardBeschreibung
variableTypestring-Rendermodus für Variablen ('personalization' oder 'data')
blockTypesBlockType-Verfügbare Blocktypen in der Seitenleiste einschränken (Standard: alle)
themeEmailTheme-E-Mail-Theme für das Styling (Farben, Schriften, Abstände)
showMandatoryUnsubscribeFooterbooleanfalseVerpflichtende, nicht bearbeitbare Abmelde-Fußzeile anzeigen
hideSubjectbooleantrueEingabefeld für den Betreff ausblenden
modestring'email''email' für Templates, 'block' für gespeicherte Blöcke

Blocksystem

Blockstruktur

Blöcke sind in @owlat/shared definiert:

type BlockType =
    | 'text'
    | 'image'
    | 'button'
    | 'divider'
    | 'spacer'
    | 'columns'
    | 'social'
    | 'container'
    | 'hero'
    | 'table'
    | 'rawHtml'
    | 'video'
    | 'accordion'
    | 'menu'
    | 'carousel'
    | 'list'
    | 'progressBar';

interface EditorBlock {
    id: string;
    type: BlockType;
    content: BlockContent; // Discriminated union per type
}

Jeder Blocktyp besitzt ein eigenes Content-Interface. Zum Beispiel:

// Text blocks handle both paragraphs and headings
interface TextBlockContent {
    html: string;
    blockType: 'paragraph' | 'h1' | 'h2' | 'h3';
    fontSize: number;
    textColor: string;
    textAlign?: 'left' | 'center' | 'right';
    // ... padding, margin, border, backgroundColor
}

// Containers can nest other blocks recursively
interface ContainerBlockContent {
    items: ContainerItem[];
    maxWidth: number;
    backgroundColor?: string;
    borderRadius: number;
    // ... padding, margin, border
}

Verfügbare Blocktypen

TypSlash-BefehlBeschreibung
text/text, /h1-/h3Rich Text als Absatz- oder Überschriftenvariante
image/imageBild mit URL, Alt-Text, Link, Retina-/Dark-Austausch
button/buttonCTA-Button mit kugelsicherem VML-Rendering für Outlook
divider/dividerHorizontale Trennlinie
spacer/spacerVertikaler Abstand
columns/columnsLayout mit 1–4 Spalten, Verhältnis-Presets und Abstand
social/socialIcon-Links zu sozialen Netzwerken (17 Plattformen)
container/containerBlöcke mit gemeinsamem Styling gruppieren
hero/heroVollbreiter Abschnitt mit Hintergrundbild und VML
table/tableDatentabelle mit Rich-Zellen und responsiven Modi
rawHtml/rawHtmlEinfügen von rohem HTML (Notausstieg für Fortgeschrittene)
video/videoVideo-Thumbnail mit Play-Button-Overlay
accordion/accordionAusklappbare Abschnitte, nur mit CSS
menu/menuHorizontale Navigation mit Hamburger-Menü auf Mobilgeräten
carousel/carouselBilder-Slideshow, nur mit CSS
list/listGestaltete Liste mit eigenen Markern (tabellenbasiert)
progressBar/progressBarVisuelle Fortschrittsanzeige

Überschriften sind kein eigener Blocktyp. Sie sind text-Blöcke, deren blockType auf h1, h2 oder h3 gesetzt ist.

Funktionen des Renderers

Das Paket @owlat/email-renderer leistet mehr als die Umwandlung von Blöcken in HTML. Zu den zentralen Fähigkeiten gehören:

  • CSS-Inlining — Styles werden inline angewendet, für Kompatibilität mit Gmail/Yahoo (standardmäßig aktiviert)
  • Dark Mode — Regeln über @media (prefers-color-scheme: dark), Bildaustausch, Overrides pro Block
  • Outlook-VML — kugelsichere Buttons, Hintergrundbilder und Tabellen mit fester Breite
  • Plain Text — Multipart-Klartextausgabe für Barrierefreiheit und Zustellbarkeit
  • AMP for Email — drittes Format für interaktive Komponenten in Gmail/Yahoo
  • Bedingte Inhalte — Blöcke abhängig von Variablenwerten ein-/ausblenden
  • Wiederholungsblöcke — über Array-Variablen iterieren, etwa für Produktlisten oder Bestellpositionen
  • Blockvalidierung — strukturelle Prüfungen vor dem Rendern samt Barrierefreiheits-Audit
  • E-Mail-Analyse — Größenanalyse nach dem Rendern, Erkennung von Gmail-Clipping, Zählung von Bildern/Links
  • Template-Diff — struktureller Vergleich zwischen E-Mail-Versionen
  • Link-Transformationen — Umschreiben von URLs für UTM/Klick-Tracking
  • Registry für eigene Blöcke — Renderer von Drittanbieterblöcken registrieren
  • Verlaufshintergründe — CSS linear-gradient mit VML-Fallback auf Buttons, Containern und Hero-Blöcken
  • CSS-AnimationenfadeIn und slideUp mit Unterstützung für prefers-reduced-motion

Alle Details finden Sie in der Dokumentation zum Email Renderer.

Gespeicherte Blöcke

Nutzerinnen und Nutzer können wiederverwendbare Inhalte speichern:

interface SavedBlock {
    _id: string;
    name: string;
    description?: string;
    content: string; // JSON string of blocks
    usageCount: number;
    blockCount?: number;
}

Gespeicherte Blöcke erscheinen im Slash-Menü (Eingabe von /) unter ihrem eigenen Namen; die Auswahl fügt sie ein.

Mehrsprachigkeit

Templates unterstützen Übersetzungen:

interface EmailTemplate {
  // Default language content
  subject: string
  content: string           // JSON blocks
  defaultLanguage: string   // e.g., 'en'

  // Translations
  supportedLanguages: string[]    // ['en', 'de', 'fr']
  translations: string            // JSON of translations

  // Pre-rendered HTML per language
  htmlContent: string             // Default language
  htmlTranslations: string        // JSON: { "de": { htmlContent, subject }, ... }
}

// Translations structure
{
  "de": {
    "subject": "German subject",
    "previewText": "German preview",
    "blocks": { /* text content overrides */ }
  }
}

Wichtig: Das Styling (Farben, Innenabstände, Bilder) ist für alle Sprachen identisch. Nur die Textinhalte unterscheiden sich je Sprache.

Abstraktion der E-Mail-Provider

Die versandseitige Provider-Logik liegt in apps/api/convex/lib/sendProviders/ und folgt ADR-0020. Die Schicht besteht aus vier beweglichen Teilen:

  1. Adapter — ein Modul als Objektliteral je Core-Kind, das der Katalog deklariert, dazu das generierte Modul jedes mitgelieferten Plugin-Transports; jedes führt genau einen Sendeversuch durch und klassifiziert seine eigenen Fehler.
  2. RegistrySEND_PROVIDERS bildet einen SendProviderKind auf seinen Adapter ab; providerFor(kind) schlägt einen davon nach.
  3. RoutingresolveRoute() liest die providerRoutes-Konfiguration einer Organisation, wählt eine Strategie und liefert den zu verwendenden Provider zurück.
  4. DispatchsendProviderDispatch() besitzt die Retry-Schleife und schreibt nach jedem terminalen Ergebnis den Gesundheitszustand des jeweiligen Providers.

Adapter-Interface

Jeder Provider implementiert SendProviderModule<K> aus lib/sendProviders/types.ts. Es gibt weder sendBatch noch getProviderName — das Modul ist ein einzelner Sendeversuch plus Fehlerkategorisierung, den Rest übernimmt der Dispatch-Helfer.

// The kind union is DERIVED, not written: there is no "declare the kind" step.
// `lib/sendProviders/catalogTypes.ts`, re-exported from `types.ts`:
type SendProviderKind = CoreSendProviderKind | PluginSendTransportKind;
// …and the core half is the catalog literal itself
// (`packages/shared/src/sendProviderCatalog.ts`):
type CoreSendProviderKind = (typeof CORE_SEND_PROVIDER_CATALOG)[number]['kind'];

// lib/sendProviders/types.ts
interface SendProviderModule<K extends SendProviderKind> {
    readonly kind: K;
    /** Backoff schedule consumed by the dispatch helper (the module never retries). */
    readonly retryDelays: readonly number[];
    /** One attempt. No internal retry. */
    sendEmail(params: EmailSendParams, extras?: ExtrasFor<K>): Promise<EmailSendAttempt>;
    /** Map a raw provider error string (+ optional HTTP status) to a typed code. */
    categorizeError(message: string, httpStatus?: number): EmailErrorCode;
}

interface EmailSendParams {
    to: string;
    from: string;
    subject: string;
    html: string;
    replyTo?: string;
    headers?: Record<string, string>;
    attachments?: EmailAttachment[];
}

interface EmailAttachment {
    filename: string;
    content: Buffer; // raw binary, not a base64 string or URL
    contentType?: string; // defaults to application/octet-stream
}

Das zweite Argument extras ist je Provider über ExtrasFor<K> typisiert. Die MTA verwendet MtaExtras (messageId, ipPool, engagementScore, dkimDomain, gesteuerte Organisation/Nachrichtentyp/Routing-Lease oder den expliziten System-Intake-Pfad), Resend verwendet ResendExtras (idempotencyKey, weitergereicht als Header Idempotency-Key); SES nimmt keine Extras entgegen.

Sendeergebnis & Fehlercodes

Ein einzelner Versuch liefert die Discriminated Union EmailSendAttempt zurück — die Form { success, id?, error? } gibt es nicht mehr:

type EmailSendAttempt =
    | { success: true; id: string }
    | { success: false; errorMessage: string; errorCode: EmailErrorCode };

enum EmailErrorCode {
    RATE_LIMIT = 'RATE_LIMIT', // retryable
    SERVER_ERROR = 'SERVER_ERROR', // retryable
    INVALID_RECIPIENT = 'INVALID_RECIPIENT',
    INVALID_SENDER = 'INVALID_SENDER',
    AUTH_FAILED = 'AUTH_FAILED',
    CONTENT_REJECTED = 'CONTENT_REJECTED',
    UNKNOWN = 'UNKNOWN',
}

isRetryableErrorCode(code) ist das Retry-Prädikat: Nur RATE_LIMIT und SERVER_ERROR werden wiederholt, alles andere ist terminal.

Registry & Dispatch

Die Providerauswahl ist kein switch über eine Umgebungsvariable mehr. Die Registry bildet jedes Kind auf seinen Adapter ab, und providerFor() löst ihn auf:

// lib/sendProviders/index.ts
export const SEND_PROVIDERS = {
    mta: mtaSendProvider,
    ses: sesSendProvider,
    resend: resendSendProvider,
} as const;

export function providerFor<K extends SendProviderKind>(kind: K): SendProviderModule<K> {
    /* ... */
}

Alle Produzenten — der Workpool-Worker, der Testversand des Kampagnen-Orchestrators, der Nachversand nach dem Senden, der E-Mail-Schritt in Automationen und der transaktionale HTTP-Versand — laufen über einen einzigen Einstiegspunkt, sendProviderDispatch():

// lib/sendProviders/dispatch.ts
export async function sendProviderDispatch<K extends SendProviderKind>(
    ctx: ActionCtx,
    kind: K,
    params: EmailSendParams,
    extras?: ExtrasFor<K>
): Promise<DispatchResult>;

Der Dispatch-Helfer:

  1. Führt die Retry-Schleife aus, gesteuert von module.retryDelays und dem typisierten errorCode (wiederholt RATE_LIMIT/SERVER_ERROR, gibt ansonsten oder beim letzten Versuch auf).
  2. Plant nach jedem terminalen Ergebnis (Erfolg oder erschöpfte Retries) recordSendResult ein, um den Gesundheitszustand dieses Providers zu aktualisieren.
  3. Liefert ein DispatchResult mit dem finalen Versuch, providerType, latencyMs und attempts zurück.

Die Umgebungsvariable EMAIL_PROVIDER ist lediglich der instanzweite Fallback und benennt selbst kein eigenes Kind: Sie wird von resolveRoute() herangezogen, wenn eine Organisation keine providerRoutes-Konfiguration hat, keine Provider aktiviert sind oder die gewählte Strategie nichts zurückgibt — siehe „Routing“ weiter unten. Sie ist kein harter Schalter für die Providerauswahl mehr, und wenn auch sie nicht gesetzt ist, löst die Route zu null auf (fail-closed) statt zur MTA.

Routing-Strategien

resolveRoute(routeConfig, healthStatuses) in lib/sendProviders/routing.ts liest die providerRoutes-Zeile einer Organisation, filtert auf aktivierte und bekannte Provider, schlägt über strategyFor() eine Strategie nach und gibt eine ResolvedRoute zurück (providerType, optional ipPool und ein source aus org_config | env_fallback | deliverability_fallback). Löst keine providerRoutes-Konfiguration einen Provider auf, greift die Funktion auf die Umgebungsvariable EMAIL_PROVIDER zurück (source: env_fallback); ist die Variable nicht gesetzt oder unbekannt, liefert resolveRoute null (fail-closed) — es gibt keinen impliziten mta-Standard.

StrategieVerhaltenQuelle
singleVerwendet immer den ersten aktivierten Provider; ignoriert den Gesundheitszustand.strategies/single/
priority_failoverGeht die aktivierten Provider der Reihe nach durch und wählt den ersten, der aktuell nicht down ist. Fällt auf den ersten aktivierten Provider zurück, wenn alle down sind oder keine Gesundheitsdaten vorliegen.strategies/priority_failover/
workload_splitGewichtet-zufällige Auswahl unter den aktivierten Providern, wobei down-Provider ausgeschlossen werden (Gewichte standardmäßig 100). Ist jeder Provider down, wird trotzdem aus der vollen Menge gewählt, damit der Versand hinausgeht.strategies/workload_split/
adaptive_mixDeterministische Aufteilung je Empfänger zwischen der eigenen MTA und dem Referenztransport gemäß dem Zellenanteil des Ramp-Controllers. Gehört dem Controller — wird vom Controller geschrieben und in der Strategieauswahl des Betreibers nie angeboten.strategies/adaptive_mix/

Jede Strategie ist eine reine Funktion select(entries, ipPool, healthStatuses, mix). Das vierte Argument ist der Mix-Kontext je Empfänger; nur adaptive_mix liest ihn, die drei ausgelieferten Strategien ignorieren ihn. Eine Strategie hinzuzufügen ist eine Änderung in genau einem Ordner unter lib/sendProviders/strategies/.

Provider-Gesundheit

lib/sendProviders/health.ts schreibt Versandergebnisse in die Tabelle providerHealth (eine Zeile je Provider-Kind). Der Dispatch-Helfer ist der einzige Schreiber (über recordSendResult), und resolveRoute() ist der einzige Leser des Snapshots über alle Provider (getAllProviderHealth). Jeder Datensatz führt einen abklingenden Zähler für Erfolge/Fehlschläge, eine gleitende Durchschnittslatenz und einen aus Schwellenwerten abgeleiteten Status:

StatusBedingung
healthyErfolgsquote ≥ 90 %
degraded50 % ≤ Erfolgsquote < 90 %
downErfolgsquote < 50 % oder ≥ 5 aufeinanderfolgende Fehlschläge

Die Strategien priority_failover und workload_split ziehen diese Status heran, um Traffic von einem down-Provider wegzulenken.

MTA-Provider

Der Standardprovider versendet über den Owlat-MTA-Dienst statt über eine Drittanbieter-API: Der Adapter schickt per POST einen E-Mail-Job an den HTTP-Endpunkt /send der MTA, die Queuing, Rate Limiting, DKIM-Signierung und MX-Zustellung übernimmt. Es handelt sich um ein einfaches Objekt, mtaSendProvider, in lib/sendProviders/mta/index.ts. Die Konfiguration stammt aus den Umgebungsvariablen MTA_API_URL und MTA_API_KEY (nicht aus Konstruktorfeldern); eine fehlende Variable führt zu einem fehlgeschlagenen Versuch mit AUTH_FAILED.

// lib/sendProviders/mta/index.ts
export const mtaSendProvider: SendProviderModule<'mta'> = {
    kind: 'mta',
    retryDelays: [1000, 5000], // consumed by the dispatch helper
    async sendEmail(params, extras) {
        // POST { messageId, to, from, subject, html, ipPool, engagementScore,
        //        dkimDomain, ... } to `${MTA_API_URL}/send` with a 30s timeout,
        // returning { success: true, id } or a failed EmailSendAttempt.
    },
    categorizeError(message, httpStatus) {
        /* 429 → RATE_LIMIT, 5xx → SERVER_ERROR, ... */
    },
};

categorizeError bildet zuerst nach HTTP-Status ab (429 → RATE_LIMIT, 5xx → SERVER_ERROR, 401/403 → AUTH_FAILED) und danach über Teilstring-Abgleiche im Body.

Vollständige MTA-Dokumentation

Details zur Intelligence-Pipeline, zur Bounce-Verarbeitung, zum IP-Warming und zur Konfiguration finden Sie auf der Seite MTA System.

Resend-Provider

resendSendProvider (lib/sendProviders/resend/index.ts) versendet über das Resend-SDK mit einem Timeout von 30 Sekunden und einem lazy gecachten Client, der aus RESEND_API_KEY aufgebaut wird. Anhänge werden als { filename, content, content_type } weitergereicht. Fehler werden anhand von error.name/statusCode von Resend klassifiziert (z. B. rate_limit_exceededRATE_LIMIT, invalid_to_fieldINVALID_RECIPIENT). retryDelays ist [1000, 5000, 30000].

SES-Provider

sesSendProvider (lib/sendProviders/ses/index.ts) versendet über das AWS-SDK mit einem lazy gecachten SESClient, der aus AWS_SES_REGION, AWS_SES_ACCESS_KEY_ID und AWS_SES_SECRET_ACCESS_KEY aufgebaut wird. Es gibt zwei Sendepfade:

  • Ohne AnhängeSendEmailCommand mit dem HTML-Body.
  • Mit AnhängenSendRawEmailCommand mit einer manuell zusammengebauten multipart/mixed-MIME-Nachricht (der MIME-Builder ist im Modul inline enthalten).

Fehler werden anhand des name des SDK-Fehlers klassifiziert (z. B. ThrottlingRATE_LIMIT, MailFromDomainNotVerifiedINVALID_SENDER, MessageRejectedCONTENT_REJECTED). retryDelays ist [1000, 5000, 30000].

Einen Sendeprovider hinzuzufügen ist eine Änderung in genau einem Ordner: den Eintrag in packages/shared/src/sendProviderCatalog.ts deklarieren (dort lebt das Kind-Literal — SendProviderKind im Backend ist ein Re-Export), lib/sendProviders/<kind>/index.ts anlegen und in SEND_PROVIDERS registrieren. Eine Prüfung der Registry zur Compile-Zeit erkennt eine fehlende Methode oder ein deklariertes Kind ohne Adapter.

Sicherheitsprüfung von E-Mails

Sämtliche ausgehende E-Mail durchläuft vor der Zustellung eine mehrschichtige Sicherheitsprüfung. Das Paket @owlat/email-scanner stellt die gesamte Prüflogik als gemeinsam genutzte Bibliothek bereit und wird sowohl von apps/api (Convex) als auch von apps/mta verwendet. Convex-Code importiert scanContent direkt aus @owlat/email-scanner (etwa in apps/api/convex/campaigns/send.ts und apps/api/convex/transactional/lifecycle.ts) — einen Re-Export-Wrapper lib/contentScanner.ts gibt es nicht mehr.

Alle Details finden Sie auf der Seite E-Mail-Sicherheit.

Inhaltsprüfung

Analysiert Betreff und HTML-Body einer E-Mail auf Spam, Phishing und unzulässige Inhalte:

  • Spam-Schlüsselwörter — über 40 gewichtete Muster (z. B. „free money“, „act now“, „Nigerian prince“)
  • Phishing-URLs — Erkennung von URL-Kürzern, Abweichung zwischen Anker- und href-Domain
  • Homoglyphen-Spoofing — verwechselbare Unicode-Zeichen (~50 Zuordnungen, z. B. kyrillisches а vs. lateinisches a)
  • Unzulässige Inhalte — Vorschussbetrug, Muster für Credential-Phishing
  • Betreffanalyse — Missbrauch von GROSSBUCHSTABEN, übermäßige Zeichensetzung

Die Ergebnisse ergeben einen Score (0–100) mit Schweregrad-Gewichtungen: hoch (20 Punkte), mittel (10 Punkte), niedrig (3 Punkte). Schwellen: sauber < 15, verdächtig 15–39, blockiert ≥ 40.

Dateivalidierung

Prüft Anhänge und Medien-Uploads vor Speicherung oder Versand:

  • Magic Bytes — ermittelt den tatsächlichen Dateityp aus den Binärheadern (erste 16 Bytes)
  • Doppelte Endung — erkennt Angriffe wie invoice.pdf.exe
  • Endungs-Allowlist — erlaubt Bilder, PDFs, Dokumente, Tabellen, Archive
  • MIME-Typ-Allowlist — validiert die deklarierten Content-Types

Angewendet in apps/api/convex/delivery/worker.ts (Anhänge) und mediaAssets.ts (Medien-Uploads).

URL-Reputation

Prüft URLs gegen die Google Safe Browsing API v4:

  • Batch-Prüfung von bis zu 500 URLs pro Anfrage
  • Bedrohungstypen: MALWARE, SOCIAL_ENGINEERING, UNWANTED_SOFTWARE
  • Kampagnenversand: blockierendes Gate — markierte URLs verhindern den Versand
  • Transaktionaler Versand: tolerant — markiert zur Prüfung, ohne zu blockieren
  • Ergebnisse werden in der Tabelle urlReputationCache zwischengespeichert (24 h sauber, 1 h markiert)
  • Erfordert die Umgebungsvariable GOOGLE_SAFE_BROWSING_API_KEY

ClamAV-Malware-Prüfung

Prüft die Binärdaten von Anhängen auf bekannte Malware-Signaturen:

  • ClamAV läuft als Docker-Sidecar neben der MTA
  • Die MTA stellt den Endpunkt POST /scan/attachment bereit
  • Convex ruft in delivery/worker.ts diesen Endpunkt für jeden Anhang vor dem Versand auf
  • Fail-open-Design: Ist ClamAV nicht verfügbar, blockiert das die E-Mail-Zustellung nicht

Feedback Loops

Spam-Beschwerden aus den Feedback Loops der Provider werden mit den Ergebnissen der Inhaltsprüfung der jeweiligen Kampagne verknüpft. Das ermöglicht Mustererkennung und die Verfolgung der Beschwerderate je Kampagne.

Zentrale Dateien

DateiZweck
packages/email-scanner/src/content/Inhaltsanalyse (Spam, Phishing, Homoglyphen)
packages/email-scanner/src/files/Dateitypvalidierung (Magic Bytes, Endungen)
packages/email-scanner/src/urls/URL-Reputation (Safe-Browsing-API)
packages/email-scanner/src/clamav/ClamAV-TCP-Client (nur Node.js, von der MTA verwendet)
apps/api/convex/delivery/worker.tsAnhangvalidierung + Einbindung der ClamAV-Prüfung
apps/api/convex/mediaAssets.tsValidierung hochgeladener Dateien
apps/mta/src/routes/scan.tsScan-Endpunkt der MTA

Domain-Verifizierung

Vor dem Versand müssen Domains mit den passenden DNS-Einträgen (SPF, DKIM, DMARC) verifiziert werden. Gemäß ADR-0018 liegt der Domain-Code in apps/api/convex/domains/, und die Registrierung je Provider sitzt hinter einer Adapter-Naht SendingDomainProviderModule in domains/providers/{mta,ses}/. Die MTA übernimmt die DKIM-Signierung direkt; der SES-Adapter registriert die Domain zusätzlich über die AWS-SES-Identity-API.

Das Modul domains/lifecycle.ts ist der einzige Schreiber von domains.status und der zugehörigen Felder. Es bietet vier Einstiegspunkte — create, transition, recordVerification, remove — und verzweigt nie über providerType; Providerunterschiede leben vollständig hinter domains/providers/.

SES-Identity-Verwaltung

Wenn der SES-Adapter (domains/providers/ses/) eine Domain registriert, ruft er lib/emailProviders/sesIdentity.ts auf, das die AWS-SES-Identity-APIs kapselt:

MethodeSES-BefehleZweck
registerDomain()VerifyDomainIdentity + VerifyDomainDkimLegt die Identity an, liefert Verifizierungstoken + 3 DKIM-Token
setupMailFromDomain()SetIdentityMailFromDomainKonfiguriert die eigene MAIL-FROM-Subdomain
getVerificationStatus()GetIdentityVerificationAttributes + GetIdentityDkimAttributesPrüft den Verifizierungsstand auf SES-Seite
deleteIdentity()DeleteIdentityEntfernt die Identity aus SES

Generierte DNS-Einträge

EintragTypHostWert
SPFTXT@v=spf1 include:amazonses.com -all
DKIM (x3)CNAME{token}._domainkey{token}.dkim.amazonses.com
DMARCTXT_dmarcv=DMARC1; p=none (das Reporting-Tag rua= entfällt, sofern der Betreiber nicht MTA_DMARC_RUA setzt)
MAIL FROM MXMXmail10 feedback-smtp.{region}.amazonses.com
MAIL FROM SPFTXTmailv=spf1 include:amazonses.com ~all

Statusablauf einer Domain

User adds domainregistering
Domain record created in database
Provider registration
Lifecycle effect: register_with_provider (per-adapter action)
SuccessDNS records populatedpending
FailurelastRegistrationError setfailed
User clicks Verify
DNS + SES checks triggered
All checks passverified
Checks failfailed

Zentrale Dateien

Alle Pfade unten liegen unter apps/api/convex/.

DateiZweck
domains/domains.tsDomain-CRUD (create, remove, regenerateDnsRecords) + List-/Read-Queries; Typdefinitionen (DnsRecords, VerificationResults)
domains/lifecycle.tsEinziger Schreiber von domains.status; create/transition/recordVerification/remove sowie die Effekte register_with_provider / delete_with_provider
domains/dnsVerification.tsAction verifyDomain — prüft die veröffentlichten DNS-Einträge (SPF, DKIM-Array, DMARC, MAIL FROM MX/SPF) plus die Prüfung des jeweiligen Providers und ruft dann recordVerification auf
domains/queries.tsInterne Read-Query (getDomainForRegistration), genutzt von der Verifizierung und den Registrierungs-Actions
domains/providers/index.tsRegistry SENDING_DOMAIN_PROVIDERS + providerFor(kind) (mta, ses)
domains/providers/{mta,ses}/Registrierung je Provider, Erzeugung der DNS-Einträge, Persistenz der Identity und Verifizierungsprüfungen
lib/emailProviders/sesIdentity.tsWrapper um die SES-Identity-API (reine Bibliothek, aufgerufen vom SES-Domain-Adapter)
lib/emailProviders/domainVerification.tsDurchsetzung zur Sendezeit über validateDomainForSending

Verifizierungsablauf

  1. Nutzerin oder Nutzer fügt unter Einstellungen > Domains eine Domain hinzu → domains.create
  2. domains/lifecycle.ts legt die Zeile mit Status registering an und stößt den Effekt register_with_provider an
  3. Der Provider-Adapter (MTA oder SES) registriert die Domain und baut die zu veröffentlichenden DNS-Einträge; bei SES ruft das registerDomain + setupMailFromDomain auf
  4. Der Status wechselt auf pending mit den echten DKIM-Token des Providers, und die zugehörige Identity-Zeile je Provider wird persistiert
  5. Nutzerin oder Nutzer legt die DNS-Einträge beim eigenen DNS-Provider an
  6. Klick auf „Verifizieren“ → dnsVerification.verifyDomain prüft alle DNS-Einträge sowie den Verifizierungsstatus des Providers und ruft danach recordVerification auf
  7. Der Lifecycle-Reducer markiert die Domain nur dann als verified, wenn die DNS-Regel erfüllt ist UND die Providerprüfung verifiziert meldet (die MTA hat keine zusätzliche Prüfung; SES verlangt Success)

Durchsetzung

// lib/emailProviders/domainVerification.ts
// Before sending any email
const result = await validateDomainForSending(db, organizationId, fromEmail);
// Throws if domain is 'registering' or not verified
// Returns warning if verification is stale (>24 hours)

Aktualität der Verifizierung

Die Verifizierung läuft nach 24 Stunden ab und wird vor dem Versand erneut geprüft.

Steuerung der Versandrate

Der E-Mail-Versand nutzt ein workpool-basiertes Rate Limiting, um innerhalb der Providerlimits zu bleiben:

  • Transaktionale E-Mails werden mit höherer Priorität und minimaler Drosselung versendet, damit zeitkritische Zustellungen (Passwort-Zurücksetzungen, Bestellbestätigungen) schnell ankommen.
  • Kampagnen-E-Mails werden in großen Batches mit workpool-basiertem Rate Limiting (20/Sekunde) versendet, um Rate Limits der Provider nicht zu reißen.
  • Providerseitige Limits gelten zusätzlich zur Drosselung in der Anwendung — die MTA erledigt das Rate Limiting intern (Drosselung je ISP, Obergrenzen beim IP-Warming); SES und Resend setzen jeweils eigene Sendekontingente durch.

Der Rate Limiter ist Teil der Provider-Abstraktion, sodass alle Sendevorgänge (Kampagnen, Automationen, transaktional) dieselbe Drosselungsschicht durchlaufen.

Kampagnenversand

Ablauf

  1. Kampagne erstellt – Template ausgewählt, Zielgruppe gewählt
  2. Domain verifiziert – Prüfung, ob die Absenderdomain verifiziert ist
  3. Inhalt geprüft – Analyse auf Spam, Phishing, Homoglyphen und unzulässige Inhalte
  4. URL-Reputation geprüft – Google Safe Browsing API (falls konfiguriert)
  5. Zielgruppe aufgelöst – berechtigte Kontakte ermitteln (Themen-Zielgruppen beachten das Double Opt-in; Segment-Zielgruppen unterliegen keinem DOI-Gate)
  6. HTML gerendert – Rendering je Sprache
  7. Anhänge validiert – Dateitypprüfung + ClamAV-Malware-Scan (sofern Anhänge vorhanden)
  8. Batch-Verarbeitung – Versand in Batches mit Rate Limiting
  9. Statusverfolgung – Erfassung von gesendet, zugestellt, geöffnet, geklickt

Code

Der einzige aktive Einstiegspunkt ist internal.campaigns.send.startCampaignSend — der Campaign send orchestrator (module) in apps/api/convex/campaigns/send.ts. Er führt die Vorbereitungs-Pipeline aus (Preflight → Inhaltsprüfung → Archiv-Snapshot → Auflösung der Zielgruppe → Fanout der A/B-Varianten → Einreihen in den Workpool) und wird vom Campaign lifecycle (module) bei → scheduled / → sending sowie vom täglichen Scheduler-Tick (processScheduledCampaigns) eingeplant.

Bei A/B-Testkampagnen fächert der Versand der ersten Phase eine Testkohorte auf (2 × splitPercentage % der Zielgruppe, aufgeteilt auf A/B). Der zurückgehaltene Rest erhält den Inhalt der Gewinnervariante über die Schwester-Action internal.campaigns.send.sendCampaignWinnerToRemainder, nachdem campaigns.abTest.declareABTestWinner den Lebenszyklus des AB-Tests auf winner_selected überführt hat — das vollständige Vokabular finden Sie in CONTEXT.md unter „Campaign send orchestrator (module)“.

Transaktionale E-Mails

Per API ausgelöste E-Mails mit Variablen:

Versand über die API

curl -X POST https://your-deployment.convex.site/api/v1/transactional \
  -H "Authorization: Bearer lm_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "slug": "order-confirmation",
    "email": "customer@example.com",
    "dataVariables": {
      "orderNumber": "12345",
      "total": "$99.00"
    },
    "language": "en"
  }'

Oder über das TypeScript-SDK:

import { Owlat } from '@owlat/sdk-js';

const owlat = new Owlat('lm_live_...');

await owlat.transactional.send({
    slug: 'order-confirmation',
    email: 'customer@example.com',
    dataVariables: {
        orderNumber: '12345',
        total: '$99.00',
    },
    language: 'en',
});

Variablensystem

Variablen werden über den Inline-Texteditor als Inline-Knoten eingefügt. Es gibt zwei Variablentypen:

  • Personalisierungsvariablen – Kontaktfelder wie firstName, lastName, email
  • Datenvariablen – eigene Werte, die über das API-Objekt dataVariables übergeben werden

Variablen werden beim Versand während des HTML-Renderings ersetzt.