Doota is a monorepo of five deployed Cloudflare Workers over one D1 / R2 / KV / Durable-Object backbone, with two shared packages holding the logic.
Workers
| Worker | Package | Role |
|---|---|---|
| doota | apps/web |
SvelteKit app: mail UI, admin, onboarding, auth. Produces to queues. |
| doota-mail-inbound | apps/mail-in |
email() handler + inbound queue consumer: store raw, dedupe, thread, fan out deliveries. |
| doota-mail-jobs | apps/mail-jobs |
Outbound consumer + events consumer + 5-min cron. Owns the MailEventHub Durable Object. |
| doota-landing | apps/landing |
Marketing site. |
| docs | apps/docs |
This documentation. |
Shared packages
@doota/db— Drizzle schema for both namespaces:auth.*(better-auth) andmail.*(app-owned). One D1 database.@doota/mail-core— inbound, outbound, threading, crypto, events, drafts, and search. Imported by all three mail-touching Workers.
Storage backbone
| Store | Binding | Holds |
|---|---|---|
| D1 (SQLite) | DB |
Auth + mail metadata; encrypted content columns; FTS index. |
| R2 | MAIL_RAW |
Raw RFC 5322 messages, attachments, draft blobs. Source of truth. |
| KV | AUTH_KV |
Session read-cache over D1. |
| Durable Object | MAIL_EVENTS |
MailEventHub — live delivery/read ticks over WebSocket. |
| Queues | — | doota-mail-inbound, doota-mail-outbound, doota-mail-events. |
A queue binds to exactly one consumer, which is why the web app only produces — the async work lives in the two mail Workers.
Data model
Two namespaces share one D1 database. The load-bearing split in mail.*:
message— one immutable row per unique email (deduped by org + message-id). Content columns (*_enc) are encrypted; routing and threading columns are cleartext.delivery— the per-mailbox receipt; a message fans out to one delivery row per recipient mailbox.thread_state— per-mailbox triage: placement, star, assignee.submission— outbound send state, withundoUntiland anidempotencyKey.
Around them: mailbox, alias, label, draft, attachment,
internalNote, systemEvent, suppression, and apiKey. See the ER diagram
in the repo for the full set and relationships.
Inbound pipeline (receive)
- Cloudflare Email Routing invokes
email()on doota-mail-inbound with the raw RFC 5322 message. - The Worker buffers the raw, stores it in R2, and enqueues a job on the inbound queue.
- The consumer parses it, resolves the org/mailbox, handles DSNs (bounces),
upserts the
message(deduped by org + message-id), matches or creates a thread, and fans outdeliveryrows. - It notifies the
MailEventHub, which pushes a live tick to the web app over WebSocket.
Outbound pipeline (send + undo + events)
- The web app writes the
message+submission(statusqueued, with anidempotencyKey), then enqueues the send.submission.undoUntilis the source of truth for the undo window. - doota-mail-jobs consumes after the undo delay, claims the submission with
a compare-and-set (
queued → sending), checks suppression, charges the send counter, and transmits via Cloudflare Email Sending (chunked ≤ 50 recipients). - It rolls up submission + recipient status and pushes a live tick.
- Provider delivery/bounce/complaint events land on the events queue; the jobs Worker consumes them, updates recipient status, suppresses hard bounces, and ticks the hub.
- A 5-minute cron releases scheduled sends and runs garbage collection.
Local-first client (offline reading)
The web app keeps a per-user, per-device mirror in client-side SQLite
(@sqlite.org/sqlite-wasm in a dedicated Worker; OPFS opfs-sahpool VFS where
available, memory + IndexedDB snapshot elsewhere):
- Thread list — seeded across all folders on sign-in (up to 1,000 threads;
above that the client falls back to remote pagination), then kept live by
change_logdeltas (changesSince) triggered from the realtime WebSocket. The mirror wins the visible paint; the network refreshes in the background. - Thread timeline — lazily mirrored per opened thread: the full timeline
(messages, internal notes, system events) plus the server-rendered framed
HTML for rich messages, re-rendered into a sandboxed
iframe[srcdoc]locally. Notes and system events have no per-item delta, so a thread re-validates whole on open and on realtime events. - App shell — the service worker precaches build assets (best-effort,
per-asset, so one bad asset can’t wedge the install) and serves
/appnavigations network-first with a cached-shell fallback. A cold launch with no network boots the shell, resolves the active mailbox from the persisted pick, and renders entirely from the mirror.
Sanitization and framing stay server-side only — the mirror stores the already-framed document, never raw sender HTML. The mirror and shell cache are cleared on logout and account switch; what this leaves readable on the device is covered in Security. Offline is read-only: compose and search (server FTS) are disabled with an offline indicator, and there is no offline outbox.
Security posture
Doota is zero-access at rest, not end-to-end encrypted — the operator can decrypt by design. What’s encrypted, and what isn’t, is spelled out in Security. Read it before making any security claims about your instance.