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
| Komponente | Zweck |
|---|---|
EmailBuilder.vue | Haupt-Editorkomponente |
DocumentCanvas.vue | Renderbereich für Blöcke (einspaltiges Canvas) |
SubjectFields.vue | Inline-Felder für Betreff/Name oben im Canvas |
UnifiedToolbar.vue | Kontextbezogene Block-Toolbar |
FloatingBlockSidebar.vue | Seitenleiste mit Blockaktionen (verschieben, löschen usw.) |
PreviewPanel.vue | Live-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
| Option | Typ | Standard | Beschreibung |
|---|---|---|---|
variableType | string | - | Rendermodus für Variablen ('personalization' oder 'data') |
blockTypes | BlockType | - | Verfügbare Blocktypen in der Seitenleiste einschränken (Standard: alle) |
theme | EmailTheme | - | E-Mail-Theme für das Styling (Farben, Schriften, Abstände) |
showMandatoryUnsubscribeFooter | boolean | false | Verpflichtende, nicht bearbeitbare Abmelde-Fußzeile anzeigen |
hideSubject | boolean | true | Eingabefeld für den Betreff ausblenden |
mode | string | '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
| Typ | Slash-Befehl | Beschreibung |
|---|---|---|
text | /text, /h1-/h3 | Rich Text als Absatz- oder Überschriftenvariante |
image | /image | Bild mit URL, Alt-Text, Link, Retina-/Dark-Austausch |
button | /button | CTA-Button mit kugelsicherem VML-Rendering für Outlook |
divider | /divider | Horizontale Trennlinie |
spacer | /spacer | Vertikaler Abstand |
columns | /columns | Layout mit 1–4 Spalten, Verhältnis-Presets und Abstand |
social | /social | Icon-Links zu sozialen Netzwerken (17 Plattformen) |
container | /container | Blöcke mit gemeinsamem Styling gruppieren |
hero | /hero | Vollbreiter Abschnitt mit Hintergrundbild und VML |
table | /table | Datentabelle mit Rich-Zellen und responsiven Modi |
rawHtml | /rawHtml | Einfügen von rohem HTML (Notausstieg für Fortgeschrittene) |
video | /video | Video-Thumbnail mit Play-Button-Overlay |
accordion | /accordion | Ausklappbare Abschnitte, nur mit CSS |
menu | /menu | Horizontale Navigation mit Hamburger-Menü auf Mobilgeräten |
carousel | /carousel | Bilder-Slideshow, nur mit CSS |
list | /list | Gestaltete Liste mit eigenen Markern (tabellenbasiert) |
progressBar | /progressBar | Visuelle 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-Animationen —
fadeInundslideUpmit Unterstützung fürprefers-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:
- 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.
- Registry —
SEND_PROVIDERSbildet einenSendProviderKindauf seinen Adapter ab;providerFor(kind)schlägt einen davon nach. - Routing —
resolveRoute()liest dieproviderRoutes-Konfiguration einer Organisation, wählt eine Strategie und liefert den zu verwendenden Provider zurück. - Dispatch —
sendProviderDispatch()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:
- Führt die Retry-Schleife aus, gesteuert von
module.retryDelaysund dem typisiertenerrorCode(wiederholtRATE_LIMIT/SERVER_ERROR, gibt ansonsten oder beim letzten Versuch auf). - Plant nach jedem terminalen Ergebnis (Erfolg oder erschöpfte Retries)
recordSendResultein, um den Gesundheitszustand dieses Providers zu aktualisieren. - Liefert ein
DispatchResultmit dem finalen Versuch,providerType,latencyMsundattemptszurü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.
| Strategie | Verhalten | Quelle |
|---|---|---|
single | Verwendet immer den ersten aktivierten Provider; ignoriert den Gesundheitszustand. | strategies/single/ |
priority_failover | Geht 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_split | Gewichtet-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_mix | Deterministische 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:
| Status | Bedingung |
|---|---|
healthy | Erfolgsquote ≥ 90 % |
degraded | 50 % ≤ Erfolgsquote < 90 % |
down | Erfolgsquote < 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.
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_exceeded → RATE_LIMIT, invalid_to_field → INVALID_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änge —
SendEmailCommandmit dem HTML-Body. - Mit Anhängen —
SendRawEmailCommandmit einer manuell zusammengebautenmultipart/mixed-MIME-Nachricht (der MIME-Builder ist im Modul inline enthalten).
Fehler werden anhand des name des SDK-Fehlers klassifiziert (z. B. Throttling → RATE_LIMIT, MailFromDomainNotVerified → INVALID_SENDER, MessageRejected → CONTENT_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. lateinischesa) - 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
urlReputationCachezwischengespeichert (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/attachmentbereit - Convex ruft in
delivery/worker.tsdiesen 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
| Datei | Zweck |
|---|---|
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.ts | Anhangvalidierung + Einbindung der ClamAV-Prüfung |
apps/api/convex/mediaAssets.ts | Validierung hochgeladener Dateien |
apps/mta/src/routes/scan.ts | Scan-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:
| Methode | SES-Befehle | Zweck |
|---|---|---|
registerDomain() | VerifyDomainIdentity + VerifyDomainDkim | Legt die Identity an, liefert Verifizierungstoken + 3 DKIM-Token |
setupMailFromDomain() | SetIdentityMailFromDomain | Konfiguriert die eigene MAIL-FROM-Subdomain |
getVerificationStatus() | GetIdentityVerificationAttributes + GetIdentityDkimAttributes | Prüft den Verifizierungsstand auf SES-Seite |
deleteIdentity() | DeleteIdentity | Entfernt die Identity aus SES |
Generierte DNS-Einträge
| Eintrag | Typ | Host | Wert |
|---|---|---|---|
| SPF | TXT | @ | v=spf1 include:amazonses.com -all |
| DKIM (x3) | CNAME | {token}._domainkey | {token}.dkim.amazonses.com |
| DMARC | TXT | _dmarc | v=DMARC1; p=none (das Reporting-Tag rua= entfällt, sofern der Betreiber nicht MTA_DMARC_RUA setzt) |
| MAIL FROM MX | MX | mail | 10 feedback-smtp.{region}.amazonses.com |
| MAIL FROM SPF | TXT | mail | v=spf1 include:amazonses.com ~all |
Statusablauf einer Domain
registeringpendingfailedverifiedfailedZentrale Dateien
Alle Pfade unten liegen unter apps/api/convex/.
| Datei | Zweck |
|---|---|
domains/domains.ts | Domain-CRUD (create, remove, regenerateDnsRecords) + List-/Read-Queries; Typdefinitionen (DnsRecords, VerificationResults) |
domains/lifecycle.ts | Einziger Schreiber von domains.status; create/transition/recordVerification/remove sowie die Effekte register_with_provider / delete_with_provider |
domains/dnsVerification.ts | Action 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.ts | Interne Read-Query (getDomainForRegistration), genutzt von der Verifizierung und den Registrierungs-Actions |
domains/providers/index.ts | Registry 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.ts | Wrapper um die SES-Identity-API (reine Bibliothek, aufgerufen vom SES-Domain-Adapter) |
lib/emailProviders/domainVerification.ts | Durchsetzung zur Sendezeit über validateDomainForSending |
Verifizierungsablauf
- Nutzerin oder Nutzer fügt unter Einstellungen > Domains eine Domain hinzu →
domains.create domains/lifecycle.tslegt die Zeile mit Statusregisteringan und stößt den Effektregister_with_provideran- Der Provider-Adapter (MTA oder SES) registriert die Domain und baut die zu veröffentlichenden DNS-Einträge; bei SES ruft das
registerDomain+setupMailFromDomainauf - Der Status wechselt auf
pendingmit den echten DKIM-Token des Providers, und die zugehörige Identity-Zeile je Provider wird persistiert - Nutzerin oder Nutzer legt die DNS-Einträge beim eigenen DNS-Provider an
- Klick auf „Verifizieren“ →
dnsVerification.verifyDomainprüft alle DNS-Einträge sowie den Verifizierungsstatus des Providers und ruft danachrecordVerificationauf - 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 verlangtSuccess)
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
- Kampagne erstellt – Template ausgewählt, Zielgruppe gewählt
- Domain verifiziert – Prüfung, ob die Absenderdomain verifiziert ist
- Inhalt geprüft – Analyse auf Spam, Phishing, Homoglyphen und unzulässige Inhalte
- URL-Reputation geprüft – Google Safe Browsing API (falls konfiguriert)
- Zielgruppe aufgelöst – berechtigte Kontakte ermitteln (Themen-Zielgruppen beachten das Double Opt-in; Segment-Zielgruppen unterliegen keinem DOI-Gate)
- HTML gerendert – Rendering je Sprache
- Anhänge validiert – Dateitypprüfung + ClamAV-Malware-Scan (sofern Anhänge vorhanden)
- Batch-Verarbeitung – Versand in Batches mit Rate Limiting
- 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.