Postbox-Architektur
Wie die Postbox-Funktion für persönliche Mail verdrahtet ist — Schema, IMAP-Server, Authentifizierung per App-Passwort, ausgehendes Relay, eingehende Zustellung und externe Postfächer.
Postbox ist Owlats Funktion für persönliche Mail: Postfächer pro Benutzer mit Webmail-Oberfläche und nativer Unterstützung für IMAP4rev1 / SMTP-Submission. Diese Seite behandelt die Implementierung. Die anwenderseitige Anleitung finden Sie unter Postbox in der Produktanleitung.
Gehostetes Postbox wird durch das Flag postbox gegatet (standardmäßig aus). Das Aktivieren schaltet das Docker-Compose-Profil personal-mail frei, das den Service apps/imap startet. Das separate Flag mail.external (weiter unten behandelt) ist unabhängig von postbox — es erlaubt einem Benutzer, ein bestehendes externes Postfach zu verbinden, ohne eine Versanddomain zu registrieren.
Komponentenübersicht
┌────────────────────────────────────┐
┌──────────────────► │ apps/api (Convex) │ ◄── webmail UI
│ │ ├── mail/mailbox / mail/imap │ /dashboard/postbox/*
│ │ ├── mail/folders / mail/labels │
│ │ ├── mail/drafts + draftLifecycle │
│ │ ├── mail/outbound (+ lifecycle) │
│ │ ├── mail/filters / mail/aliases │
│ │ ├── mail/appPasswords (PBKDF2) │
│ │ ├── mail/authHttp (HMAC verify) │
│ │ └── mail/delivery (inbound route) │
│ └────────────────────────────────────┘
│ ▲ ▲
│ │ webhook │ verify-cred
│ │ (delivery) │ (HMAC)
│ │ │
┌──┴───────────┐ SMTP ┌────┴──────────────┴────┐ IMAP/SMTP ┌──────────────┐
│ apps/web │ submission │ apps/mta │ submission │ native │
│ (composer) │────────────►│ outbound queue │◄────────────────│ clients │
└──────────────┘ │ inbound routing │ │ (Apple Mail, │
└────────┬───────────────┘ │ Thunderbird,│
│ bind to │ mobile) │
│ mailboxResolver └──────┬───────┘
▼ │
┌────────────────────────┐ │
│ apps/imap │ ◄──────────────────────┘
│ IMAP4rev1 server │ IMAP fetch / store
│ port 993 implicit TLS │
└────────────────────────┘
│
└─── reads/writes via Convex client
(uses CONVEX_URL + CONVEX_ADMIN_KEY)
Schema
Die Mail-Tabellen sind in apps/api/convex/schema/mail.ts definiert (als mailTables nach schema.ts re-exportiert und dort in defineSchema() gespreadet). Die wichtigsten Beziehungen:
mailboxes (1)──(*) mailFolders (*)──(*) mailMessages (*)──(1) mailThreads
├────(*) mailAliases
├────(*) mailAppPasswords
├────(*) mailFilters
├────(*) mailLabels
├────(*) mailDrafts
├────(*) mailSignatures
├────(*) mailForwarding
├────(*) mailVacationResponders / mailVacationLog
├────(*) mailContacts
└────(*) mailAuditLog / mailAuthFailures
externalMailAccounts (1)──(1) mailboxes (kind='external')
└────(*) externalMailFolderSync
pendingMailboxes — reserved-mailbox intent attached to an invitation
Es gibt keine Tabelle mailOutbound — der Zustand ausgehender Mail liegt im eingebetteten Objekt mailMessages.outbound (ein denormalisiertes Aggregat plus ein Array pro Empfänger). Es gibt keine Tabelle mailIdentities — Versandidentitäten sind ein Modul (mail/identities.ts), das über mailSignatures und die Allowed-From-Menge des Postfachs gelegt ist. Es gibt keine Tabelle mailSnooze — das Zurückstellen steckt in den Feldern snoozedUntil / snoozedFromFolderId auf mailMessages und wird über den Index by_snoozed_until von einem Cron im Minutentakt abgeräumt.
Hinweise zu den Indizes:
- Die Posteingangssortierung von
mailMessagesnutztby_mailbox_and_receivedauf[mailboxId, receivedAt]. IMAP-UID-Bereiche nutzenby_folder_and_uidauf[folderId, uid]; die schnelle CONDSTORE-Resynchronisierung nutztby_folder_and_modseq. Thread-Lesezugriffe nutzenby_thread; der Snooze-Cron nutztby_snoozed_until. Außerdem gibt es einen Volltextsuchindexsearch_messagesaufsnippet. (EineninternalDate-Index gibt es nicht.) mailAppPasswordsspeichert PBKDF2-SHA256-Hashes (100k Iterationen, kodiert als<salt-hex>:<hash-hex>) — der Klartext wird dem Benutzer bei der Erstellung genau einmal angezeigt. Ein separatespasswordPrefix(die ersten 4 Zeichen) engt die Kandidatenmenge überby_prefixein, bevor der bewusst langsame Hash-Vergleich läuft.mailAliasesspeichert die kanonische Kleinschreibung der Adresse im Feldaliasund ist überby_aliasauf[alias]indexiert (zusätzlichby_target). DieorganizationIdsteht zwar in der Zeile, ist aber nicht Teil des Lookup-Index — der Inbound-Router löst einen Empfänger allein über das Feldaliasauf.
Modulaufbau (Convex)
Alle Mail-Module liegen unter apps/api/convex/mail/.
| Modul | Verantwortung |
|---|---|
mailbox.ts / mailboxActions.ts / mailboxQueries.ts | Postfach-Lebenszyklus, Lesezugriffe sowie Nachrichten-Lesezugriffe/-Index |
pendingMailbox.ts | Reservierungsabsicht für ein Postfach an einer BetterAuth-Einladung, beim Annehmen eingelöst |
folders.ts | Initialisierung der Systemordner, Ordner-CRUD |
messageActions.ts | Nachrichten-Mutationen (gelesen/ungelesen, Label-Operationen, Verschieben) — es gibt keine messages.ts; Lesezugriffe liegen in mailbox.ts / imap.ts |
labels.ts | Benutzerdefinierte Labels |
drafts.ts / draftLifecycle.ts | Entwürfe beim Verfassen mit Autospeicherung; die Zustandsmaschine der Entwürfe + Sende-Kaskade |
outbound.ts / outboundCron.ts / outboundQueries.ts / postboxOutboundLifecycle.ts | Versand-Action für ausgehende Mail, Cron für geplanten Versand, Query-Helfer und die Zustandsmaschine pro Empfänger |
filters.ts | Sieve-ähnliche Regel-Engine |
aliases.ts / aliasesActions.ts | Aliasse, die in ein Postfach routen |
forwarding.ts | Regeln für ausgehende Weiterleitung |
signatures.ts / identities.ts | Signaturen + Auflösung von Allowed-From / Versandidentität |
appPasswords.ts | Speicherung und Verifizierung von PBKDF2-SHA256-Credentials |
authHttp.ts | HMAC-signierter verify-credential-Endpunkt für MTA/IMAP |
authRateLimit.ts | Auth-Drosselung pro Adresse |
snooze.ts / vacation.ts | Snooze-Sweep + Abwesenheits-Autoresponder nach RFC 3834 |
contacts.ts | Adressbuch pro Postfach |
ai.ts / aiGate.ts | KI im Posteingang (Thread zusammenfassen + Antworten vorschlagen) an der gemeinsamen LLM-Naht; aiGate erzwingt vor jedem Aufruf das ai-Flag + das Rate-Limit pro Benutzer |
delivery.ts | Annahme eingehender Mail, Ordner-Routing, Anwendung der Filter |
deliveryHooks.ts | Hooks nach der Zustellung (Benachrichtigungen, Trigger) |
webhook.ts | Handler für den eingehenden Zustell-Webhook vom MTA (handleMailWebhook) |
imap.ts | Convex-seitige Helfer für den IMAP-Server (Fetch-Slices, Store/Copy/Move, Ordnerzustand); die Volltextsuche für die Webmail-Oberfläche liegt in mailbox.ts |
permissions.ts | Berechtigungsprüfungen auf Postfachebene |
externalAccounts.ts / externalAccountsActions.ts / externalDelivery.ts | Verbinden/Testen/Synchronisieren externer Postfächer (siehe Externe Postfächer) |
Auth-Ablauf mit App-Passwörtern
Native IMAP-/SMTP-Clients können die Dashboard-Sitzung nicht verwenden. Sie authentifizieren sich mit App-Passwörtern — auf ein Postfach beschränkte, widerrufbare Tokens, die als PBKDF2-SHA256-Hashes in mailAppPasswords liegen. Beide Verifikationspfade münden in derselben internen Convex-Action, internal.mail.appPasswords.verify, erreichen sie aber unterschiedlich.
IMAP (apps/imap) — der IMAP-Server hält CONVEX_ADMIN_KEY und ruft die Action direkt über den Convex-Client auf:
1. user opens /dashboard/preferences/app-passwords
2. mail/appPasswords.generate creates a row with PBKDF2(password); returns plaintext once
3. user pastes plaintext into Apple Mail; client connects to apps/imap (port 993)
4. commands/login calls the Convex action `mail/appPasswords:verify`
({ address, password }) over the admin-key client
5. appPasswords.verify narrows candidates by passwordPrefix, runs PBKDF2(password),
and returns { ok: true, mailboxId, appPasswordId, ... } on success
6. apps/imap binds the IMAP session to that mailboxId
— every subsequent IMAP command runs against Convex as that mailbox
SMTP-Submission (über apps/mta) — der MTA hält den Admin-Key nicht und postet stattdessen an den HMAC-signierten HTTP-Endpunkt:
1. desktop client submits over SMTP to apps/mta's submission port
2. apps/mta POSTs /webhooks/mta-verify-credential
body: { address, password, scope: 'imap' | 'smtp' }
headers: x-mta-signature: HMAC-SHA256(`${timestamp}.${body}`, MTA_WEBHOOK_SECRET)
x-mta-timestamp: <unix-seconds>
3. mail/authHttp.ts verifies the HMAC + freshness, then delegates to
internal.mail.appPasswords.verify, returning { ok, mailboxId, appPasswordId, ... }
Das HMAC-Muster bedeutet, dass der MTA für Credential-Prüfungen nie den Convex-Admin-Key benötigt — nur das gemeinsame MTA_WEBHOOK_SECRET. Der IMAP-Server, der die vollständige Kommandoschleife gegen Convex fährt, hält hingegen CONVEX_ADMIN_KEY.
IMAP-Server (apps/imap)
Die Konfiguration wird beim Start aus der Umgebung gelesen:
| Umgebungsvariable | Standard | Zweck |
|---|---|---|
IMAP_PORT | 993 | TCP-Listen-Port |
IMAP_LISTEN | 0.0.0.0 | Bind-Adresse |
IMAP_GREETING_HOST | hostname() | Hostname in der * OK-Begrüßung |
IMAP_TLS_CERT / IMAP_TLS_CERT_FILE | — | TLS-Zertifikat (inline oder Pfad) |
IMAP_TLS_KEY / IMAP_TLS_KEY_FILE | — | Privater TLS-Schlüssel |
TLS_CERT_DIR | /opt/owlat/certs | Gemeinsames Zertifikatsverzeichnis als Fallback |
CONVEX_URL | — erforderlich | URL des Convex-Backends |
CONVEX_ADMIN_KEY | — erforderlich | Admin-Key für den IMAP→Convex-Client |
REDIS_URL | — | Optionales Backend für Rate Limiting |
Aufbau des Quellcodes:
apps/imap/src/
├── index.ts # process entry, signal handling
├── server.ts # net.createServer + TLS upgrade
├── connection.ts # IMAP pump: socket lifecycle, line/literal buffering
├── parser.ts # IMAP command parser
├── mime.ts # RFC 5322 / 2045 parser
├── convex.ts # Convex client wrapper used by command modules
├── rateLimit.ts # per-IP and per-credential throttling
├── config.ts # env loading
├── logger.ts
└── commands/ # one module per IMAP verb (ADR-0016)
├── walker.ts # typed dispatch registry + CAPABILITY-line assembly
├── types.ts # ImapVerb, CommandModule, session/deps types
├── helpers/ # shared session helpers
└── <verb>/index.ts # login, select, fetch, store, copy, move, idle, …
connection.ts ist die Pumpe — sie besitzt den Socket, die Zeilenpufferung und die Literal-Absorption und weiß nichts über IMAP-Verben. Die Kommandobehandlung wurde in Module pro Verb unter commands/<verb>/index.ts ausgelagert, die über commands/walker.ts dispatcht werden (siehe ADR-0016 und den Kopfkommentar in connection.ts). Module mit mehreren Verben (LIST + LSUB, SELECT + EXAMINE, UNSELECT + CLOSE) registrieren sich unter jedem Verb, das sie deklarieren; der Walker setzt außerdem die CAPABILITY-Zeile aus den deklarierten Atomen jedes Moduls zusammen.
connection.ts belässt den Socket in seinem Standard-Binärmodus (kein setEncoding) und puffert rohe Oktette in einem Buffer, sodass die Literal-Absorption (RFC 3501 §4.3) Bytes zählt, nicht dekodierte Zeichen. 8-Bit-/Binär-MIME-Bodys und {N}-Oktettdeklarationen werden korrekt gerahmt; Kommandotext wird erst dann als UTF-8 dekodiert, wenn eine vollständige, per CRLF terminierte Zeile abgetrennt wurde.
Lebenszyklus jeder Verbindung: * OK greeting → LOGIN/AUTHENTICATE (→ mail/authHttp) → SELECT inbox → Kommandoschleife (FETCH, STORE, COPY, MOVE, IDLE, …).
Ausgehend: Verfassen → SMTP
Der Mailversand aus Postbox läuft über den bestehenden MTA, nicht über den IMAP-Server. Der Ablauf:
- Der Benutzer klickt auf Senden. Die Web-App überführt den Entwurf nach
pending_send(Zeitfenster zum Rückgängigmachen) und plantinternal.mail.outbound.dispatchDraftein (apps/api/convex/mail/outbound.ts). dispatchDraft(eine Node-Action) validiert den Entwurfszustand und das Undo-Token, prüft jeden Anhang über den ClamAV-Endpunkt des MTA (fail-open bei Ausfall), rendert die finalen HTML- und Klartext-Bodys über@owlat/email-renderer, baut eine RFC-5322-Multipart-Nachricht und legt die rohe.emlinctx.storageab. Anschließend übergibt sie aninternal.mail.draftLifecycle.transition({ to: 'sent' }), das atomar diemailMessages-Zeile im Ordner „Gesendet“ mitoutbound.state='queued'einfügt und den Entwurf löscht. Siehe ADR-0028.- Bei einem gehosteten Postfach POSTet sie einen MTA-
/send-Aufruf pro Empfänger und stellt der MTA-Nachrichten-IDpb-<mailMessageId>-<idx>voran, damit der Bounce-/Sent-Webhook die Zeile wieder auffinden kann. Ein synchroner5xxüberführt diesen Empfänger nachbounced; ein Netzwerkfehler überführt ihn nachfailed— beides überinternal.mail.postboxOutboundLifecycle.transition. (Bei einem externen Postfach wird der unten beschriebene PfaddispatchViaExternalWorkermit einem einzigen POST genommen.) - Asynchrone MTA-Zustellereignisse treffen unter
POST /webhooks/mtaein. Die Webhook-Zeremonie (Rate Limiting, Signaturprüfung, Audit, Parsen, Dispatch) liegt inwebhooks/pipeline.ts+webhooks/adapters/mta.ts; die Route selbst wird vom gemeinsamen HandlerproviderFeedbackWebhook('mta')bedient, der nichts weiter tut, als diesen Adapter aus der Registry aufzulösen und anrunInboundPipelinezu delegieren. Für Ereignisse, deren Provider-Nachrichten-ID das Präfixpb-trägt, ruft der Dispatcher (webhooks/dispatcher.ts)internal.mail.postboxOutboundLifecycle.transitionByMtaMessageIdauf, das die ID parst und den Übergang für den jeweiligen Empfänger anwendet.
postboxOutboundLifecycle.ts ist der einzige Schreiber jedes mailMessages.outbound.recipients[].state und der einzige Erzeuger des abgeleiteten Aggregats mailMessages.outbound.state (siehe ADR-0012). Die Zustände pro Empfänger sind queued | sent | bounced | failed; bounced und failed sind terminal. Die Aggregatspalte ergänzt ein weiteres Literal, partial, wenn die Empfänger in gemischten Zuständen sind. (Persönliche Mail verwirft die Klassifikation in Hard- und Soft-Bounces — das ist ein Belang der Kampagnenseite.)
Native SMTP-Submission von einem Desktop-Client nimmt denselben Weg: Der MTA nimmt SMTP auf seinem Submission-Port an, ruft mail/authHttp auf, um die Credentials gegen mailAppPasswords zu prüfen, und reiht die Nachricht dann regulär ein.
Eingehend: MX → Postfach
inbound SMTP ──► apps/mta ──► mailboxResolver (apps/mta/src/inbound)
│
│ POST /webhooks/mta-mailbox
▼
mail/webhook.ts ──► mail/delivery.ts
│
├── resolve alias → mailbox
├── apply mailFilters
├── insert mailMessages row
├── run mail/deliveryHooks
└── notify subscribers (Convex realtime)
Der MTA routet Zustellungen an persönliche Postfächer auf die dedizierte Route POST /webhooks/mta-mailbox (apps/api/convex/http.ts, bedient von handleMailWebhook aus mail/webhook.ts); die Routing-Entscheidung fällt in apps/mta/src/webhooks/convexNotifier.ts (inbound.mailbox.received → /webhooks/mta-mailbox, alles andere → /webhooks/mta).
mailAliases ist die Nachschlagetabelle — jede angenommene Empfängeradresse muss vor der Zustellung auf eine alias-Zeile passen. Spam- und ClamAV-Prüfungen laufen (sofern aktiviert) vor dem Einfügen.
Externe Postfächer (mail.external)
Getrennt vom gehosteten Postbox erlaubt das Flag mail.external (Label "Connect external mailbox") jedem Benutzer, sein eigenes bestehendes Gmail-, Fastmail- oder Firmenpostfach über IMAP+SMTP zu verbinden — persönliche Mail, ohne eine Versanddomain zu registrieren. Es ist unabhängig vom gehosteten postbox-Flag (bewusst kein requires: ['postbox']), aktiviert das Docker-Compose-Profil external-mail und startet den Worker apps/mail-sync.
Der mail-sync-Worker wird über MAIL_SYNC_API_URL + MAIL_SYNC_API_KEY erreicht. Die Credentials des verbundenen Kontos werden verschlüsselt gespeichert (AES-256-GCM) auf externalMailAccounts; lesende Queries geben den Chiffretext nie zurück. Das Convex-Backend hält INSTANCE_SECRET und entschlüsselt (in der internen Action getCredentialsForWorker), um die Klartext-Credentials anschließend über den Admin-Key-Kanal an den mail-sync-Worker zu übergeben.
Worin es sich von einem gehosteten Postfach unterscheidet:
- Ein verbundenes Konto erzeugt eine
mailboxes-Zeile mitkind='external', die 1:1 mit einerexternalMailAccounts-Zeile verknüpft ist (Host/Port/TLS, Auth-Methode, verschlüsselter Credential-Umschlag, Verbindungsstatus). - Der Worker ist der Client des entfernten IMAP-Servers.
externalMailFolderSyncverfolgt inkrementelle Fetch-Cursor pro (Konto, Ordner) (remoteUidValidity,lastSeenUid, optional CONDSTORElastSeenModseq), getrennt vom eigenen UID-Zustand dermailFolders(der Owlat-als-IMAP-Server abbildet). - Vom entfernten Server abgeholte eingehende Nachrichten werden über
mail/externalDelivery.tseingespeist (ingestExternalRaw/ingestExternalMessage). - Ausgehende Mail eines externen Postfachs umgeht den MTA-Pfad pro Empfänger:
dispatchDraftprüftinternal.mail.outboundTransport.resolveOutboundTransportund ruft beikind='external'dispatchViaExternalWorkerauf — ein einzelner POST an das/senddes Workers, der über das eigene SMTP des Benutzers sendet und die gesendete Kopie per APPEND in den entfernten Sent-Ordner legt. SMTP ist synchron, sodass das Ergebnis des Workers pro Empfänger ohne Webhook direkt aufpostboxOutboundLifecycleabgebildet wird.
Die Module für Verbinden/Testen/Credentials sind externalAccounts.ts (Queries + interne Mutationen), externalAccountsActions.ts (connect / testConnection / Credential-Handhabung) und externalDelivery.ts (Ingest).
Integrationspunkte
| Andere Funktion | Wie sie mit Postbox zusammenspielt |
|---|---|
MTA (apps/mta) | Liefert eingehende Mail über /webhooks/mta-mailbox ein; verifiziert Credentials der SMTP-Submission über mail/authHttp (/webhooks/mta-verify-credential); nimmt ausgehende Sendungen entgegen; postet Zustellereignisse an /webhooks/mta |
mail-sync-Worker (apps/mail-sync) | Für mail.external: verbindet sich mit entfernten IMAP-Servern, speist eingehende Mail ein und versendet ausgehende Mail über das eigene SMTP des Benutzers |
Inhaltsscanner (scan.content) | Läuft bei eingehender Mail vor dem Einfügen |
ClamAV (scan.files) | Prüft Anhänge bei der eingehenden Zustellung und beim ausgehenden Versand |
| Benachrichtigungsanbieter | mail/deliveryHooks ruft den Benachrichtigungsanbieter für Hinweise auf neue Mail auf |
KI-Agent (ai.agent) | Lauscht auf eingehende Ereignisse der gemeinsamen Support-Posteingangsfunktion, nicht auf Postbox — Postbox ist bewusst rein persönlich |
Wo Sie beim Erweitern ansetzen
- Neuer Ordnertyp / Systemordner →
mail/folders.ts+ Dashboard-Sidebar inapps/web/app/components/postbox/ - Neue Filteraktion → Regel-Executor in
mail/filters.ts+ Oberfläche unter/dashboard/preferences/filters - Neues IMAP-Kommando → ein Modul
apps/imap/src/commands/<verb>/index.tsanlegen und incommands/walker.tsregistrieren (siehe ADR-0016) - Neuer Zustandsübergang für ausgehende Mail →
mail/postboxOutboundLifecycle.tserweitern (der einzige Schreiber des Outbound-Zustands) - Serverseitige Durchsetzung des Feature-Flags → früh in jeder Mutation
isFlagEnabled(stored, 'postbox')(bzw.'mail.external') prüfen