Postbox Architecture
How the Postbox personal-mail feature is wired — schema, IMAP server, app-password auth, outbound relay, inbound delivery, and external mailboxes.
Postbox is Owlat's personal-mail feature: per-user mailboxes with a webmail UI and native IMAP4rev1 / SMTP submission support. This page covers the implementation. For the user-facing guide see Postbox in the product guide.
Hosted Postbox is gated by the postbox flag (off by default). Enabling it activates the personal-mail Docker Compose profile, which starts the apps/imap service. The separate mail.external flag (covered below) is independent of postbox — it lets a user connect an existing external mailbox without registering a sending domain.
Component map
┌────────────────────────────────────┐
┌──────────────────► │ 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
The mail tables are defined in apps/api/convex/schema/mail.ts (re-exported into schema.ts as mailTables and spread into defineSchema()). Key relationships:
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
There is no mailOutbound table — outbound state lives in the embedded mailMessages.outbound object (a denormalized aggregate plus a per-recipient array). There is no mailIdentities table — sending identities are a module (mail/identities.ts) layered over mailSignatures and the mailbox's allowed-from set. There is no mailSnooze table — snooze is the snoozedUntil / snoozedFromFolderId fields on mailMessages, swept by a 1-minute cron via the by_snoozed_until index.
Index notes:
mailMessagesinbox sorting usesby_mailbox_and_receivedon[mailboxId, receivedAt]. IMAP UID ranges useby_folder_and_uidon[folderId, uid]; CONDSTORE fast-resync usesby_folder_and_modseq. Thread reads useby_thread; the snooze cron usesby_snoozed_until. There is also asearch_messagesfull-text search index onsnippet. (There is nointernalDateindex.)mailAppPasswordsstores PBKDF2-SHA256 hashes (100k iterations, encoded<salt-hex>:<hash-hex>) — the cleartext is shown to the user exactly once on creation. A separatepasswordPrefix(first 4 chars) narrows the candidate set viaby_prefixbefore the deliberately slow hash compare.mailAliasesstores the canonical lowercase address in thealiasfield and is indexed byby_aliason[alias](plusby_target).organizationIdis stored on the row but is not part of the lookup index — the inbound router resolves a recipient by thealiasfield alone.
Module layout (Convex)
All mail modules live under apps/api/convex/mail/.
| Module | Responsibility |
|---|---|
mailbox.ts / mailboxActions.ts / mailboxQueries.ts | Mailbox lifecycle, reads, and message reads/index |
pendingMailbox.ts | Reserved-mailbox intent on a BetterAuth invitation, claimed on accept |
folders.ts | System folder initialization, folder CRUD |
messageActions.ts | Message mutations (read/unread, label ops, moves) — there is no messages.ts; reads live in mailbox.ts / imap.ts |
labels.ts | User-defined labels |
drafts.ts / draftLifecycle.ts | Compose drafts with autosave; the draft state machine + send cascade |
outbound.ts / outboundCron.ts / outboundQueries.ts / postboxOutboundLifecycle.ts | Outbound dispatch action, scheduled-send cron, query helpers, and the per-recipient outbound state machine |
filters.ts | Sieve-like rule engine |
aliases.ts / aliasesActions.ts | Aliases routing into a mailbox |
forwarding.ts | Outbound forwarding rules |
signatures.ts / identities.ts | Signatures + the allowed-from / sending-identity resolution |
appPasswords.ts | PBKDF2-SHA256 credential storage + verification |
authHttp.ts | HMAC-signed verify-credential endpoint for the MTA/IMAP |
authRateLimit.ts | Per-address auth throttle |
snooze.ts / vacation.ts | Snooze sweep + RFC 3834 vacation auto-responder |
contacts.ts | Per-mailbox address book |
ai.ts / aiGate.ts | In-inbox AI (thread summarize + suggest replies) on the shared LLM seam; aiGate enforces the ai flag + per-user rate limit before each call |
delivery.ts | Inbound mail acceptance, folder routing, filter application |
deliveryHooks.ts | Post-delivery hooks (notifications, triggers) |
webhook.ts | Inbound delivery webhook handler from the MTA (handleMailWebhook) |
imap.ts | Convex-side helpers for the IMAP server (fetch slices, store/copy/move, folder state); full-text search lives in mailbox.ts for the webmail UI |
permissions.ts | Mailbox-level permission checks |
externalAccounts.ts / externalAccountsActions.ts / externalDelivery.ts | External-mailbox connect/test/sync (see External mailboxes) |
App-password auth flow
Native IMAP/SMTP clients can't use the dashboard session. They authenticate with app passwords — single-mailbox, revocable tokens stored as PBKDF2-SHA256 hashes in mailAppPasswords. Both verification paths terminate in the same internal Convex action, internal.mail.appPasswords.verify, but they reach it differently.
IMAP (apps/imap) — the IMAP server holds CONVEX_ADMIN_KEY and calls the action over the Convex client directly:
1. user opens /dashboard/postbox/settings/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 (via apps/mta) — the MTA does not hold the admin key, so it posts to the HMAC-signed HTTP endpoint instead:
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, ... }
The HMAC pattern means the MTA never needs the Convex admin key for credential checks — only the shared MTA_WEBHOOK_SECRET. The IMAP server, which runs the full command loop against Convex, does hold CONVEX_ADMIN_KEY.
IMAP server (apps/imap)
Configuration is read from env on startup:
| Env var | Default | Purpose |
|---|---|---|
IMAP_PORT | 993 | TCP listen port |
IMAP_LISTEN | 0.0.0.0 | Bind address |
IMAP_GREETING_HOST | hostname() | Hostname in * OK greeting |
IMAP_TLS_CERT / IMAP_TLS_CERT_FILE | — | TLS cert (inline or path) |
IMAP_TLS_KEY / IMAP_TLS_KEY_FILE | — | TLS private key |
TLS_CERT_DIR | /opt/owlat/certs | Shared cert dir as fallback |
CONVEX_URL | — required | Convex backend URL |
CONVEX_ADMIN_KEY | — required | Admin key for the IMAP→Convex client |
REDIS_URL | — | Optional rate-limit backend |
Source layout:
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 is the pump — it owns the socket, line buffering, and literal absorption, and knows nothing about IMAP verbs. Command handling was extracted into per-verb modules under commands/<verb>/index.ts, dispatched through commands/walker.ts (see ADR-0016 and the header comment in connection.ts). Multi-verb modules (LIST + LSUB, SELECT + EXAMINE, UNSELECT + CLOSE) register themselves under each verb they declare; the walker also assembles the CAPABILITY line from each module's declared atoms.
connection.ts leaves the socket in its default binary mode (no setEncoding) and buffers raw octets in a Buffer, so literal absorption (RFC 3501 §4.3) counts bytes, not decoded characters. 8-bit/binary MIME bodies and {N} octet declarations frame correctly; command text is decoded as UTF-8 only once a full CRLF-terminated line has been sliced off.
Each connection lifecycle: * OK greeting → LOGIN/AUTHENTICATE (→ mail/authHttp) → SELECT inbox → command loop (FETCH, STORE, COPY, MOVE, IDLE, …).
Outbound: composing → SMTP
Sending mail from Postbox goes through the existing MTA, not the IMAP server. The flow is:
- User clicks Send. The web app moves the draft into
pending_send(undo-send window) and schedulesinternal.mail.outbound.dispatchDraft(apps/api/convex/mail/outbound.ts). dispatchDraft(a Node action) validates the draft state and undo token, scans each attachment through the MTA's ClamAV endpoint (fail-open on outage), renders the final HTML + plain-text bodies through@owlat/email-renderer, builds an RFC 5322 multipart message, and stores the raw.emlinctx.storage. It then hands off tointernal.mail.draftLifecycle.transition({ to: 'sent' }), which atomically inserts the Sent-foldermailMessagesrow withoutbound.state='queued'and deletes the draft. See ADR-0028.- For a hosted mailbox it POSTs one MTA
/sendper recipient, prefixing the MTA message id withpb-<mailMessageId>-<idx>so the bounce/sent webhook can look the row back up. A synchronous5xxtransitions that recipient tobounced; a network error transitions it tofailed— both viainternal.mail.postboxOutboundLifecycle.transition. (For an external mailbox it takes the single-POSTdispatchViaExternalWorkerpath described below.) - Asynchronous MTA delivery events arrive at
POST /webhooks/mta. The webhook ceremony (rate limit, signature check, audit, parse, dispatch) lives inwebhooks/pipeline.ts+webhooks/adapters/mta.ts;mtaWebhook.tsis just a thinhttpActionentry that delegates torunInboundPipeline. For events whose provider message id has thepb-prefix, the dispatcher (webhooks/dispatcher.ts) callsinternal.mail.postboxOutboundLifecycle.transitionByMtaMessageId, which parses the id and applies the per-recipient transition.
postboxOutboundLifecycle.ts is the sole writer of every mailMessages.outbound.recipients[].state and the only producer of the derived mailMessages.outbound.state aggregate (see ADR-0012). The per-recipient states are queued | sent | bounced | failed; bounced and failed are terminal. The aggregate column adds one extra literal, partial, when the recipients are in a mix of states. (Personal mail discards the hard/soft bounce classification — that is a campaign-side concern.)
Native SMTP submission from a desktop client takes the same path: the MTA accepts SMTP on its submission port, calls mail/authHttp to verify the credential against mailAppPasswords, then enqueues the message normally.
Inbound: MX → mailbox
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)
The MTA routes personal-mailbox deliveries to the dedicated POST /webhooks/mta-mailbox route (apps/api/convex/http.ts, handled by handleMailWebhook from mail/webhook.ts); the routing decision is in apps/mta/src/webhooks/convexNotifier.ts (inbound.mailbox.received → /webhooks/mta-mailbox, everything else → /webhooks/mta).
mailAliases is the lookup table — every accepted recipient address must match an alias row before delivery. Spam and ClamAV checks (when enabled) run before insert.
External mailboxes (mail.external)
Separate from hosted Postbox, the mail.external flag (label "Connect external mailbox") lets each user connect their own existing Gmail / Fastmail / company mailbox over IMAP+SMTP — personal mail without registering a sending domain. It is independent of the hosted postbox flag (intentionally not requires: ['postbox']), activates the external-mail Docker Compose profile, and runs the apps/mail-sync worker.
The mail-sync worker is reached via MAIL_SYNC_API_URL + MAIL_SYNC_API_KEY. Credentials for the connected account are stored encrypted at rest (AES-256-GCM) on externalMailAccounts; read queries never return the ciphertext. The Convex backend holds INSTANCE_SECRET and decrypts (in the internal getCredentialsForWorker action), then hands the plaintext credentials to the mail-sync worker over the admin-key channel.
How it differs from a hosted mailbox:
- A connected account creates a
mailboxesrow withkind='external'linked 1:1 to anexternalMailAccountsrow (host/port/TLS, auth method, encrypted credential envelope, connection status). - The worker is the client of the remote IMAP server.
externalMailFolderSynctracks per-(account, folder) incremental fetch cursors (remoteUidValidity,lastSeenUid, optional CONDSTORElastSeenModseq), distinct frommailFolders' own UID state (which tracks Owlat-as-IMAP-server). - Inbound messages fetched from the remote server are ingested via
mail/externalDelivery.ts(ingestExternalRaw/ingestExternalMessage). - Outbound for an external mailbox skips the per-recipient MTA path:
dispatchDraftchecksinternal.mail.externalAccounts.resolveOutboundTransport, and forkind='external'callsdispatchViaExternalWorker— a single POST to the worker's/send, which sends through the user's own SMTP and APPENDs the sent copy to the remote Sent folder. SMTP is synchronous, so the worker's per-recipient result maps straight ontopostboxOutboundLifecyclewith no webhook.
The connect/test/credential modules are externalAccounts.ts (queries + internal mutations), externalAccountsActions.ts (connect / testConnection / credential handling), and externalDelivery.ts (ingest).
Integration points
| Other feature | How it interacts with Postbox |
|---|---|
MTA (apps/mta) | Submits inbound mail via /webhooks/mta-mailbox; verifies SMTP-submission credentials via mail/authHttp (/webhooks/mta-verify-credential); receives outbound sends; posts delivery events to /webhooks/mta |
mail-sync worker (apps/mail-sync) | For mail.external: connects to remote IMAP servers, ingests inbound, and sends outbound via the user's own SMTP |
Content scanner (scan.content) | Runs on inbound mail before insert |
ClamAV (scan.files) | Scans attachments during inbound delivery and on outbound dispatch |
| Notification provider | mail/deliveryHooks calls the notification provider for new-mail alerts |
AI agent (ai.agent) | Listens to inbound events on the shared support inbox feature, not Postbox — Postbox is intentionally personal-only |
Where to start when extending
- New folder type / system folder →
mail/folders.ts+ dashboard sidebar inapps/web/app/components/postbox/ - New filter action →
mail/filters.tsrule executor + UI in/dashboard/postbox/settings/filters - New IMAP command → add a
apps/imap/src/commands/<verb>/index.tsmodule and register it incommands/walker.ts(see ADR-0016) - New outbound state transition → extend
mail/postboxOutboundLifecycle.ts(the single writer of outbound state) - Server-side enforcement of the feature flag → check
isFlagEnabled(stored, 'postbox')(or'mail.external') early in every mutation