Skip to content

Repository files navigation

Doota

Doota

Your email, finally yours.

An email app you run yourself — where every conversation reads like a chat, on your own address, on infrastructure only you control.

Coming soon · building in the open


What is Doota?

Doota (say DOO-tah — it means messenger) is a self-hosted email app that runs entirely on your own Cloudflare account. No mail server to babysit, no company sitting in the middle of your inbox. Mail arrives through Cloudflare Email Routing, gets threaded into a WhatsApp-style conversation, and is stored encrypted at rest — the raw message, attachments, sent mail, and the derived render cache are all encrypted, with the raw message kept whole as the source of truth.

One deliberate exception: to provide fast full-text search, Doota keeps a readable search index of your mail's subjects and body text. A database dump could therefore recover the words in your mail via this index (not formatting or attachments). This is the Fastmail posture — a reasonable trade for a self-hosted tool where the operator already has legitimate access to their own box. Set a mailbox to non-indexed to exclude it from search and the index entirely.

It still speaks plain email underneath, so you can write to anyone on Gmail or Outlook, and they can write back.

Preview

Threaded conversation view

Every conversation as one chat-style timeline.

Composer
Composer — rich text, scheduled send, undo.
Hide-my-email aliases
Aliases — throwaway addresses on your own domain.
Admin dashboard
Admin — org, members, domains in one place.
Sign in
Sign in — passwords or passkeys, out of the box.

Features

  • Threads, not folders — every conversation is one simple timeline of messages, interoperable with any mail client.
  • Runs on your own account — Cloudflare Workers, D1, R2, KV, and Queues do the work. One deployment, one operator.
  • Private by default — subjects, bodies, attachments, sent mail, and the derived render cache are all encrypted at rest; only routing metadata stays cleartext so threading works without decryption. No plaintext copy exists.
  • Undo & scheduled send — a first-class submission object tracks every message (queued → sent → delivered → bounced), with delivery ticks and send-later.
  • Hide-my-email aliases — generate throwaway addresses on your domain, map them to a mailbox, disable them anytime.
  • Passwords or passkeys — WebAuthn sign-in out of the box.
  • Open source, end to end — read it, run it, change it. No subscriptions, no per-seat pricing, no lock-in.

Tech stack

LayerChoice
FrontendSvelteKit + Tailwind CSS
RuntimeCloudflare Workers (@sveltejs/adapter-cloudflare)
StorageD1 (SQLite) · R2 (raw messages) · KV (cache) · Queues (mail-out)
MailCloudflare Email Routing (inbound) + provider seam (outbound)
Authbetter-auth with passkeys
DataDrizzle ORM + drizzle-kit migrations

Architecture

Diagrams below are generated from the code — the D1 schemas in packages/db/src/*.schema.ts and each worker's wrangler.jsonc. The full set (binding matrix + @doota/mail-core module map) lives in docs/architecture-diagrams.md.

Component & deployment — services, bindings, pipeline

Five deployed Workers, two shared packages, one D1 / R2 / KV / Durable-Object backbone. A queue binds to exactly one consumer Worker, so the app only produces; the async handlers live in the two mail Workers.

flowchart TB
subgraph client["Client"]
browser["Browser · SvelteKit UI<br/>(mail, admin, onboarding)"]
extapp["External app / agent<br/>(Bearer API key)"]
end
subgraph cf["Cloudflare Edge"]
routing["Email Routing<br/>(inbound MX)"]
sending["Email Sending<br/>(EMAIL_SENDER binding)"]
end
subgraph workers["Workers (deployed)"]
web["doota · apps/web<br/>SvelteKit + Better Auth<br/>remote fns · PRODUCES to queues"]
mailin["doota-mail-inbound · apps/mail-in<br/>email() handler + inbound consumer"]
mailjobs["doota-mail-jobs · apps/mail-jobs<br/>outbound consumer · events consumer · cron"]
landing["doota-landing"]
docs["docs"]
end
subgraph pkgs["Shared packages"]
db["@doota/db<br/>drizzle schema (auth+mail)"]
core["@doota/mail-core<br/>inbound · outbound · threading<br/>crypto · events · drafts · search"]
end
subgraph storage["Storage & state"]
d1[("D1 · DB")]
r2[("R2 · MAIL_RAW<br/>raw RFC5322 + attachments + drafts")]
kv[("KV · AUTH_KV<br/>session read-cache")]
hub{{"Durable Object<br/>MailEventHub · live ticks"}}
end
subgraph queues["Cloudflare Queues"]
qin[["doota-mail-inbound"]]
qout[["doota-mail-outbound"]]
qev[["doota-mail-events"]]
end
browser -->|HTTPS / WS| web
extapp -->|POST send · Bearer| web
routing -->|"email()"| mailin
mailin -->|enqueue raw| qin
qin -->|consume| mailin
mailin -->|store raw| r2
mailin -->|dedupe · fan-out · thread| d1
mailin -->|notify| hub
web -->|enqueue send| qout
qout -->|consume| mailjobs
mailjobs -->|send| sending
mailjobs -->|status rollup| d1
mailjobs -->|copy outbound blob| r2
mailjobs -->|retry re-enqueue| qout
mailjobs -->|live tick| hub
sending -->|delivery/bounce events| qev
qev -->|consume| mailjobs
mailjobs -.->|"cron 5-min: scheduled sends · GC"| qout
hub -->|WebSocket ticks| web
web -.->|read/write| d1
web -.->|blobs| r2
web -.->|auth cache| kv
web -->|transactional mail| sending
web --- core
mailin --- core
mailjobs --- core
core --- db
web --- db
Loading
WorkerD1 DBR2 MAIL_RAWKV AUTH_KVDO MAIL_EVENTSEMAIL_SENDERQueues
doota (web)produces inbound, outbound
doota-mail-inboundproduces+consumes inbound
doota-mail-jobsconsumes outbound+events, produces outbound; cron
doota-landing
docs

ER diagram — data model (Cloudflare D1)

Two namespaces share one D1 database: auth.* (Better Auth) and mail.* (app owned). The load-bearing split — message is one immutable row per unique email, delivery is the per-mailbox receipt, thread_state is per-mailbox triage, submission is send state. Content columns (*_enc) are encrypted; routing + threading metadata stays cleartext.

erDiagram
user {
text id PK
text email UK
text role "member|admin|superadmin"
bool twoFactorEnabled
int onboardedAt
text invitedByUserId FK "→ user"
}
session {
text id PK
text userId FK
text activeOrganizationId
}
account {
text id PK
text userId FK
text providerId
}
organization {
text id PK
text slug UK
text domain UK
text zoneId
text status
}
member {
text id PK
text organizationId FK
text userId FK
text role
}
invitation {
text id PK
text organizationId FK
text inviterId FK
text email
}
twoFactor {
text id PK
text userId FK
}
passkey {
text id PK
text userId FK
}
orgMailSettings {
text orgId PK
bool subaddressingEnabled
text returnPathDomain
}
mailbox {
text id PK
text orgId FK
text address "org+address UK"
bool isPersonal
bool isService
}
mailboxAccess {
text id PK
text userId FK
text mailboxId FK
bool canSend
}
alias {
text id PK
text orgId FK
text mailboxId FK
text address
}
thread {
text id PK
text orgId FK
int lastMessageAt
}
message {
text id PK
text orgId FK
text threadId FK
text messageIdHeader "org+msgid UK"
text fromAddr
text r2RawKey
text bodyFullEnc
text bodyHtmlEnc
}
delivery {
text id PK
text orgId FK
text messageId FK
text mailboxId FK
text viaAliasId FK
text role "to|cc|bcc|from"
bool isRead
}
threadState {
text id PK
text threadId FK
text mailboxId FK
text assigneeUserId FK
text placement "inbox|archived|spam|trash|sent"
bool isStarred
}
threadRead {
text id PK
text userId FK
text threadId FK
text mailboxId FK
}
label {
text id PK
text orgId FK
text name
}
threadLabel {
text id PK
text threadId FK
text mailboxId FK
text labelId FK
}
attachment {
text id PK
text messageId FK
text r2Key
}
internalNote {
text id PK
text threadId FK
text mailboxId FK
text authorUserId FK
}
systemEvent {
text id PK
text threadId FK
text mailboxId FK
text actorUserId FK
}
draft {
text id PK
text orgId FK
text mailboxId FK
text createdByUserId FK
text threadId FK
text fromAliasId FK
text kind "new|reply|reply_all|forward"
text status "editing|sending|sent"
text submissionId
}
submission {
text id PK
text orgId FK
text messageId FK
text mailboxId FK
text fromAliasId FK
text createdByUserId FK
text idempotencyKey UK
text status "queued|sending|…|delivered|bounced"
int undoUntil
}
submissionRecipient {
text id PK
text submissionId FK
text address
text role
text status
}
suppression {
text id PK
text orgId FK
text address
text reason
}
sendCounter {
text id PK
text scope
text scopeKey
}
apiKey {
text id PK
text orgId FK
text userId FK
text mailboxId FK
text keyHash UK
}
user ||--o{ session : has
user ||--o{ account : has
user ||--o{ member : "belongs via"
user ||--o{ twoFactor : has
user ||--o{ passkey : has
user ||--o{ invitation : sends
user |o--o{ user : invited
organization ||--o{ member : has
organization ||--o{ invitation : has
organization ||--|| orgMailSettings : configures
organization ||--o{ mailbox : owns
organization ||--o{ alias : owns
organization ||--o{ thread : owns
organization ||--o{ message : owns
organization ||--o{ label : owns
organization ||--o{ suppression : owns
organization ||--o{ apiKey : owns
mailbox ||--o{ mailboxAccess : "granted to users"
user ||--o{ mailboxAccess : granted
mailbox ||--o{ alias : "forwards from"
mailbox ||--o{ delivery : receives
mailbox ||--o{ threadState : triages
mailbox ||--o{ draft : "composed in"
mailbox ||--o{ submission : "sends from"
mailbox ||--o{ apiKey : "sends as"
thread ||--o{ message : contains
thread ||--o{ threadState : "per mailbox"
thread ||--o{ threadRead : "read cursors"
thread ||--o{ threadLabel : tagged
thread ||--o{ internalNote : notes
thread ||--o{ systemEvent : events
message ||--o{ delivery : "fans out to"
message ||--o{ attachment : has
alias ||--o{ delivery : "received via"
label ||--o{ threadLabel : applied
message ||--o{ submission : "sent as"
submission ||--o{ submissionRecipient : "fans out to"
user ||--o{ draft : owns
user ||--o{ submission : sent
Loading

Mail pipeline — sequence

Inbound (receive):

sequenceDiagram
autonumber
participant CF as CF Email Routing
participant IN as doota-mail-inbound
participant Q as inbound queue
participant R2 as R2 MAIL_RAW
participant D1 as D1 DB
participant HUB as MailEventHub (DO)
participant WEB as doota (web)
CF->>IN: email() — raw RFC5322
IN->>R2: put raw blob
IN->>Q: enqueue {r2Key, meta}
Q->>IN: consume
IN->>IN: parse · resolve org/mailbox · DSN? → bounce
IN->>D1: upsert message (dedupe org+msgid)
IN->>D1: thread match/create · fan-out delivery rows
IN->>HUB: notify new mail
HUB-->>WEB: live tick (WebSocket)
Loading

Outbound (send + undo + provider events):

sequenceDiagram
autonumber
participant WEB as doota (web)
participant D1 as D1 DB
participant Q as outbound queue
participant JOBS as doota-mail-jobs
participant SEND as CF Email Sending
participant EV as events queue
participant HUB as MailEventHub (DO)
WEB->>D1: build message + submission(queued, idempotencyKey)
WEB->>Q: enqueue send (AFTER row written)
Note over WEB,Q: undo window — submission.undoUntil is source of truth
Q->>JOBS: consume (after undo delay)
JOBS->>D1: claim CAS (queued→sending, stamp lastAttemptAt)
JOBS->>D1: check suppression · charge send_counter
JOBS->>SEND: transmit (chunk ≤50 recipients)
JOBS->>D1: rollup submission + recipient status
JOBS->>HUB: live send-state tick
SEND-->>EV: delivery / bounce / complaint events
EV->>JOBS: consume
JOBS->>D1: update recipient status · suppress hard bounces
JOBS->>HUB: tick (delivered / failed)
HUB-->>WEB: live status to composer/thread
Loading

Getting started

Requires Node 22+, pnpm, and a Cloudflare account.

pnpm install
cp .env.example .env # then fill in the values (see below)
pnpm db:migrate:local # apply D1 migrations to the local database
pnpm dev # http://localhost:5173

Create the first admin (genesis) with the CLI:

pnpm reset-admin

Environment

See .env.example for the full list. The essentials:

  • ORIGIN — your app's URL (must match the dev port, or auth routes 404).
  • BETTER_AUTH_SECRET — 32+ chars, high entropy.
  • MAIL_DEK — the 32-byte (base64) data-encryption key for all mail content. Set as a Worker secret on web, mail-in, and mail-jobs — the same value on all three. See the warning below.
  • MAIL_SEARCH_KEY — HMAC key for blind note search tokens and signed image/resource tokens. (Message search is a plaintext FTS5 index and does not use this key.) Also a secret on all three Workers.

Caution

Back up MAIL_DEK before you store a single message — losing it is permanent and total data loss.

Every message, attachment, and sent copy is encrypted with MAIL_DEK. There is no plaintext path to your mail and no recovery: no support reset, no partial reconstruction, no backdoor. If the Worker secret is ever lost, rotated without the old value, or overwritten, every message you have ever received becomes permanently unreadable.

Generate it once, store the base64 value in a password manager / secrets vault outside Cloudflare, then set it as a secret on all three Workers:

# generate (32 random bytes, base64) — save the output somewhere safe FIRST
openssl rand -base64 32
wrangler secret put MAIL_DEK # doota (web)
wrangler secret put MAIL_DEK --config apps/mail-in/wrangler.jsonc
wrangler secret put MAIL_DEK --config apps/mail-jobs/wrangler.jsonc

The same rule applies to MAIL_SEARCH_KEY (lose it and search + inline images break, though mail stays readable). Treat both as unrecoverable-if-lost.

  • APP_CLOUDFLARE_ACCOUNT_ID / APP_CLOUDFLARE_API_TOKEN — a scoped API token (not the Global API Key), stored as a Worker secret in production.
  • MAIL_IN_WORKER_NAME — the deployed mail-in Worker the catch-all rule targets.
  • LOG_LEVEL — optional mail-pipeline log level (debug/info/warn/error, default info); set per Worker (web, mail-in, mail-jobs) as a plain var.

Deploy

pnpm db:migrate:remote # migrate the production D1 database
pnpm deploy # build + wrangler deploy

Repository layout

  • src/ — the Doota app (SvelteKit + Workers).
  • drizzle/ — database migrations.
  • landing/ — the standalone marketing site (its own SvelteKit project; pnpm --dir landing dev).

Useful scripts

ScriptDoes
pnpm checkauth-boundary check + svelte-check
pnpm testrun the Vitest suite
pnpm db:studioopen Drizzle Studio
pnpm auth:schemaregenerate the better-auth Drizzle schema
pnpm genregenerate Cloudflare binding types

Status

Doota is under development and moving fast. Star the repo to follow along until launch.

License & credits

An independent open-source project by Ethercorps.

Not affiliated with, endorsed by, or sponsored by Cloudflare. Cloudflare, Workers, R2, and D1 are trademarks of Cloudflare, Inc.

© 2026 Ethercorps