Skip to content

Security

An honest threat model — what's encrypted, what isn't, who can read what, and how to protect the keys.

Updated View as Markdown

Doota is an email app with at-rest encryption. Over-claiming here is the fastest way to get someone hurt, so this page is deliberately precise about what the code actually does.

What is encrypted, and what is not

Data Where At rest
Subject, text body, HTML body D1 (*_enc columns) Encrypted — AES-256-GCM with MAIL_DEK.
Raw RFC 5322 message + attachments R2 (MAIL_RAW) Encrypted — AES-256-GCM with MAIL_DEK, written and read through the same envelope. Reads fail closed on a non-encrypted blob.
Outbound message body + send-log payloads R2 / D1 (dataCipher, TTL) Encrypted.
Routing + threading metadata (from, to, message-id, thread, timestamps) D1 Cleartext by design — the hot path and threading never decrypt.
Search index (subject + body) D1 (message_search, FTS5) Readable plaintext by design — a deliberate exception (see below).
Note search tokens D1 (note_fts) HMAC blind tokens (MAIL_SEARCH_KEY) — reveals no plaintext.
Draft-staged attachments (file attached while composing, before send) R2 (draft/…) Cleartext — a plaintext content path (see below).
Local thread-list mirror (subjects, snippets, sender names) Client-side SQLite (OPFS or IndexedDB, per-user, per-device) Plaintext on the device — same posture as any native mail client (Apple Mail, Outlook). Cleared on logout and account switch. Zero-access-at-rest is a server property; the local mirror is intentional and local only.
Local full-timeline mirror (messages, internal notes, and system events for every opened thread) Client-side SQLite (same store as above, per-user, per-device) Plaintext on the device. The mirror holds every item type in the thread timeline: message bodies (plain text and server-rendered HTML), internal notes (private team collaboration data), and system events. Internal notes are worth calling out explicitly — they are private to your team on the server, but once a thread has been opened, the note text sits in the local store alongside the rest of the timeline. Cleared on logout and account switch, and never synced back to the server. Zero-access-at-rest is a server property; the local mirror is intentional, local-only, and per-user.
Cached app shell (the /app page HTML) Service-worker Cache Storage (doota-shell-*, per-device) Plaintext on the device. Cached so the app can boot with no connection (offline reading); it contains the signed-in user’s name and email (session-rendered identity), never mail content — the list and bodies come from the local mirror. Refreshed from the network on every online visit; cleared on logout and account switch alongside the mirror.

Who can read what

  • You (the user) — your own mail, decrypted in the app after login.
  • Teammates on a shared mailbox — mail in that mailbox (that’s the point of a shared inbox).
  • The operator — anyone with the Worker secrets (MAIL_DEK) can decrypt all content in D1 and R2. This is intended for operator oversight.
  • A D1-only leak — sees encrypted subjects/bodies (useless without MAIL_DEK) and cleartext routing metadata + thread structure — plus the readable message_search index, which recovers the words of subjects and bodies for search-indexed mailboxes (not formatting or attachments).
  • An R2-only leak — sees ciphertext (useless without MAIL_DEK), except attachments on open, unsent drafts.

Content safety

  • Attachments are scanned on your device. Every attachment is checked against known-dangerous patterns in the browser (yara-x compiled to WASM) — the file is never uploaded to a scanning service. A Threat found or Couldn’t check result forces a confirm before the file opens or downloads.
  • Previews run in an isolated frame. Rich previews (PDF, images, Office docs, archives…) render in a sandboxed, session-gated viewer that can’t reach your account, cookies, or the rest of the page — so opening a file to look at it doesn’t expose your session to it.
  • Remote images load through Doota. Images in a message are proxied, so a sender can’t use them to see your IP or when you opened their mail.

Key management

Two instance secrets, both Worker secrets, never in D1:

  • MAIL_DEK — content encryption key (AES-256-GCM). Versioned, key-id-tagged envelope (v1.<keyId>.<iv>.<ciphertext>) so rotation is additive.
  • MAIL_SEARCH_KEY — HMAC key for blind note search tokens + signed image/resource tokens. Distinct from the DEK. (Message search is a readable FTS5 index and does not use this key.)

Cloudflare API token

Doota uses a scoped API token (Bearer) for domain onboarding — never the Global API Key, which can’t be scoped and would expose your whole account. The required permission set and rotation guidance are in Cloudflare API token.

Authentication hardening

  • Recovery only ever goes to a verified external address — never the Doota address (which is unreadable pre-login), closing the circular-recovery hole.
  • Forgot-password returns a generic response and backgrounds the send, so timing and existence can’t be probed. Rate limited.
  • A password reset does not clear 2FA — a compromised recovery inbox can’t become a full 2FA bypass. Lost authenticators recover via backup codes.
  • TOTP is mandatory for admins/super-admins; passkeys count as two factors on their own.
  • Every privileged action routes through one authorization chokepoint (can()).

Dependency risk

Outbound is sent through Cloudflare Email Sending, which is in public beta. It’s the only supported sender — Doota runs on Cloudflare end to end, with no third-party mail provider. Factor its beta status into your risk assessment.

Reporting a vulnerability

Found a security issue? Report it privately to the Ethercorps maintainers rather than opening a public issue, and allow time for a fix before disclosure.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close