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.

Feature-Flag

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 mailMessages nutzt by_mailbox_and_received auf [mailboxId, receivedAt]. IMAP-UID-Bereiche nutzen by_folder_and_uid auf [folderId, uid]; die schnelle CONDSTORE-Resynchronisierung nutzt by_folder_and_modseq. Thread-Lesezugriffe nutzen by_thread; der Snooze-Cron nutzt by_snoozed_until. Außerdem gibt es einen Volltextsuchindex search_messages auf snippet. (Einen internalDate-Index gibt es nicht.)
  • mailAppPasswords speichert PBKDF2-SHA256-Hashes (100k Iterationen, kodiert als <salt-hex>:<hash-hex>) — der Klartext wird dem Benutzer bei der Erstellung genau einmal angezeigt. Ein separates passwordPrefix (die ersten 4 Zeichen) engt die Kandidatenmenge über by_prefix ein, bevor der bewusst langsame Hash-Vergleich läuft.
  • mailAliases speichert die kanonische Kleinschreibung der Adresse im Feld alias und ist über by_alias auf [alias] indexiert (zusätzlich by_target). Die organizationId steht zwar in der Zeile, ist aber nicht Teil des Lookup-Index — der Inbound-Router löst einen Empfänger allein über das Feld alias auf.

Modulaufbau (Convex)

Alle Mail-Module liegen unter apps/api/convex/mail/.

ModulVerantwortung
mailbox.ts / mailboxActions.ts / mailboxQueries.tsPostfach-Lebenszyklus, Lesezugriffe sowie Nachrichten-Lesezugriffe/-Index
pendingMailbox.tsReservierungsabsicht für ein Postfach an einer BetterAuth-Einladung, beim Annehmen eingelöst
folders.tsInitialisierung der Systemordner, Ordner-CRUD
messageActions.tsNachrichten-Mutationen (gelesen/ungelesen, Label-Operationen, Verschieben) — es gibt keine messages.ts; Lesezugriffe liegen in mailbox.ts / imap.ts
labels.tsBenutzerdefinierte Labels
drafts.ts / draftLifecycle.tsEntwürfe beim Verfassen mit Autospeicherung; die Zustandsmaschine der Entwürfe + Sende-Kaskade
outbound.ts / outboundCron.ts / outboundQueries.ts / postboxOutboundLifecycle.tsVersand-Action für ausgehende Mail, Cron für geplanten Versand, Query-Helfer und die Zustandsmaschine pro Empfänger
filters.tsSieve-ähnliche Regel-Engine
aliases.ts / aliasesActions.tsAliasse, die in ein Postfach routen
forwarding.tsRegeln für ausgehende Weiterleitung
signatures.ts / identities.tsSignaturen + Auflösung von Allowed-From / Versandidentität
appPasswords.tsSpeicherung und Verifizierung von PBKDF2-SHA256-Credentials
authHttp.tsHMAC-signierter verify-credential-Endpunkt für MTA/IMAP
authRateLimit.tsAuth-Drosselung pro Adresse
snooze.ts / vacation.tsSnooze-Sweep + Abwesenheits-Autoresponder nach RFC 3834
contacts.tsAdressbuch pro Postfach
ai.ts / aiGate.tsKI 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.tsAnnahme eingehender Mail, Ordner-Routing, Anwendung der Filter
deliveryHooks.tsHooks nach der Zustellung (Benachrichtigungen, Trigger)
webhook.tsHandler für den eingehenden Zustell-Webhook vom MTA (handleMailWebhook)
imap.tsConvex-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.tsBerechtigungsprüfungen auf Postfachebene
externalAccounts.ts / externalAccountsActions.ts / externalDelivery.tsVerbinden/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:

UmgebungsvariableStandardZweck
IMAP_PORT993TCP-Listen-Port
IMAP_LISTEN0.0.0.0Bind-Adresse
IMAP_GREETING_HOSThostname()Hostname in der * OK-Begrüßung
IMAP_TLS_CERT / IMAP_TLS_CERT_FILETLS-Zertifikat (inline oder Pfad)
IMAP_TLS_KEY / IMAP_TLS_KEY_FILEPrivater TLS-Schlüssel
TLS_CERT_DIR/opt/owlat/certsGemeinsames Zertifikatsverzeichnis als Fallback
CONVEX_URL— erforderlichURL des Convex-Backends
CONVEX_ADMIN_KEY— erforderlichAdmin-Key für den IMAP→Convex-Client
REDIS_URLOptionales 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.

Oktettgenaues Literal-Framing

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 greetingLOGIN/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:

  1. Der Benutzer klickt auf Senden. Die Web-App überführt den Entwurf nach pending_send (Zeitfenster zum Rückgängigmachen) und plant internal.mail.outbound.dispatchDraft ein (apps/api/convex/mail/outbound.ts).
  2. 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 .eml in ctx.storage ab. Anschließend übergibt sie an internal.mail.draftLifecycle.transition({ to: 'sent' }), das atomar die mailMessages-Zeile im Ordner „Gesendet“ mit outbound.state='queued' einfügt und den Entwurf löscht. Siehe ADR-0028.
  3. Bei einem gehosteten Postfach POSTet sie einen MTA-/send-Aufruf pro Empfänger und stellt der MTA-Nachrichten-ID pb-<mailMessageId>-<idx> voran, damit der Bounce-/Sent-Webhook die Zeile wieder auffinden kann. Ein synchroner 5xx überführt diesen Empfänger nach bounced; ein Netzwerkfehler überführt ihn nach failed — beides über internal.mail.postboxOutboundLifecycle.transition. (Bei einem externen Postfach wird der unten beschriebene Pfad dispatchViaExternalWorker mit einem einzigen POST genommen.)
  4. Asynchrone MTA-Zustellereignisse treffen unter POST /webhooks/mta ein. Die Webhook-Zeremonie (Rate Limiting, Signaturprüfung, Audit, Parsen, Dispatch) liegt in webhooks/pipeline.ts + webhooks/adapters/mta.ts; die Route selbst wird vom gemeinsamen Handler providerFeedbackWebhook('mta') bedient, der nichts weiter tut, als diesen Adapter aus der Registry aufzulösen und an runInboundPipeline zu delegieren. Für Ereignisse, deren Provider-Nachrichten-ID das Präfix pb- trägt, ruft der Dispatcher (webhooks/dispatcher.ts) internal.mail.postboxOutboundLifecycle.transitionByMtaMessageId auf, 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.

Konfiguration

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 mit kind='external', die 1:1 mit einer externalMailAccounts-Zeile verknüpft ist (Host/Port/TLS, Auth-Methode, verschlüsselter Credential-Umschlag, Verbindungsstatus).
  • Der Worker ist der Client des entfernten IMAP-Servers. externalMailFolderSync verfolgt inkrementelle Fetch-Cursor pro (Konto, Ordner) (remoteUidValidity, lastSeenUid, optional CONDSTORE lastSeenModseq), getrennt vom eigenen UID-Zustand der mailFolders (der Owlat-als-IMAP-Server abbildet).
  • Vom entfernten Server abgeholte eingehende Nachrichten werden über mail/externalDelivery.ts eingespeist (ingestExternalRaw / ingestExternalMessage).
  • Ausgehende Mail eines externen Postfachs umgeht den MTA-Pfad pro Empfänger: dispatchDraft prüft internal.mail.outboundTransport.resolveOutboundTransport und ruft bei kind='external' dispatchViaExternalWorker auf — ein einzelner POST an das /send des 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 auf postboxOutboundLifecycle abgebildet 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 FunktionWie 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
Benachrichtigungsanbietermail/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 in apps/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.ts anlegen und in commands/walker.ts registrieren (siehe ADR-0016)
  • Neuer Zustandsübergang für ausgehende Mail → mail/postboxOutboundLifecycle.ts erweitern (der einzige Schreiber des Outbound-Zustands)
  • Serverseitige Durchsetzung des Feature-Flags → früh in jeder Mutation isFlagEnabled(stored, 'postbox') (bzw. 'mail.external') prüfen