One-liner: A deliberately small "read + reply" inbox where threading, sender-auth badges, and spam score arrive pre-computed in the webhook — so the hard parts of an IMAP client are nearly free, and we spend our budget on a fast, keyboard-driven, privacy-leaning UI instead.
This doc is the feature map: what MailKite Mail does, how each feature works given the webhook-only architecture, and which tier it lands in. It supersedes the feature sketch in ../../docs/plan/05-webmail-oss-and-whitelabel.md and assumes the locked decisions in 00-overview.md and install.md: own store (SQLite/D1) populated only by the email.received webhook, outbound only via POST /v1/send, no IMAP/POP/SMTP-receive, never touch MailKite's internal DB.
The whole design follows from one fact: we own every bit of mailbox state. Read/unread, stars, labels, trash, snooze — none of it is two-way-synced against an IMAP server. There is no \Seen flag to reconcile, no conflict resolution, no folder hierarchy to mirror. That is why labels, arbitrary saved views, and HEY-style piles are cheap, and why this client can stay thin and forkable (the Nylas Mail lesson: decouple from platform internals so the OSS community can carry it).
Before the inventory, the framing. Three features that are genuinely hard in a traditional IMAP client are handed to us pre-computed in every webhook payload (see the payload shape in 00-overview.md and ../../api/src/index.ts):
| Pre-computed field | What it normally costs an IMAP client | What it costs us |
|---|---|---|
threadId |
Parsing References/In-Reply-To/Message-ID headers and subject-normalizing to build conversations |
Group rows by threadId. Done. |
auth: { spf, dkim, dmarc, spam } |
Running your own SPF/DKIM/DMARC validation, or trusting opaque Authentication-Results |
Render a trust badge straight from the payload. |
auth.spam (score) |
Bolting on SpamAssassin/rspamd and a training loop | Route high scores to a Spam view at ingest. |
Decision (2026-06): lead with the webhook's pre-computed fields. "No IMAP" is not a limitation to apologize for — it is why threading, sender-auth display, and spam routing are basically free here. The MVP builds directly on
threadIdandauth.*rather than re-deriving them.
- Simple. One mailbox, one model. We pick labels over folders, one curated set of keyboard shortcuts (Gmail's), and one scheduling primitive. Fewer concepts beats more features.
- Modern. React 19 + Vite + TanStack Router + shadcn/ui + Tailwind CSS 4, the same stack as
dashboard/. Design tokens are lifted verbatim from../../website/src/styles/global.css(dark--color-bg #0b0d12, light#ffffff, accent#6ea8fe); theme viahtml[data-theme="light"]. Seestack.md. - Fast. Mail lives in our own store, so list, search, and read are local DB reads — no network round-trip per open. A command palette (
Cmd+K) plus Gmail keybindings is the primary interaction surface. - Keyboard-friendly. Adopt Gmail's bindings verbatim for muscle memory; surface them through a discoverable palette so no one has to memorize 30 keys.
- Private by default. Remote images blocked until you ask, mandatory HTML sanitization, sender-auth badges, and no outbound open-tracking, ever (see § Security). On-brand for an OSS, minimal client.
Tier legend: V1 = ships in the minimal MVP · V2 = fast-follow, feasible with today's payload · Later = nontrivial, lower value, or blocked-on-platform (needs MailKite to extend the webhook payload — see the decision record below).
How-it-works tags: Native (we own the data / pure UI) · Local-derived (computed from our stored mail) · Send-path (works through /v1/send) · Platform-dep (needs a new payload field/endpoint) · Infeasible (conflicts with a locked constraint).
| Feature | Notes / how it works here | Tier |
|---|---|---|
| Threaded conversation view | Local-derived. Group by the threadId handed to us in the payload; no header parsing. The single biggest win, essentially free. |
V1 |
| 3-pane reading layout | Native UI mirroring the dashboard shadcn layout; collapses to list+detail on narrow widths. | V1 |
| Read / unread | Native. Our own boolean column — no \Seen sync. Mark-on-open + bulk mark. |
V1 |
| Quote collapse / trim history | Local-derived. Strip trailing quoted blocks from text/html for a clean thread view (sanitization required regardless). |
V2 |
| Stars / flags | Native boolean. Trivial since state is ours; single star for V1, colors later. | V2 |
| Snooze | Local-derived snooze_until column + the shared scheduler. Un-snoozes back to Inbox. No platform dependency. |
V2 |
| Mute thread | Native per-thread flag; future webhook messages on the thread skip Inbox. | V2 |
| List density toggle (comfortable/compact) | Native CSS + setting. | Later |
| Auto-advance after archive/delete | Native UI nicety. | Later |
| HEY-style Screener (approve first-time senders) | Local-derived. Sender allowlist table; unknown from.address → held in a Screener view instead of Inbox. Strong fit — we control ingest routing. |
Later |
| HEY-style Reply Later / Set Aside piles | Local-derived. Each is just a reserved label + a curated view. Cheap differentiator. | Later |
| HEY-style Paper Trail (receipts) | Local-derived heuristic; needs a classifier. Punt until a rules engine exists. | Later |
| Feature | Notes / how it works here | Tier |
|---|---|---|
| Reply (with threading) | Send-path. Pass the original message id as inReplyTo; /v1/send auto-sets In-Reply-To + References. Exactly what the API was built for. |
V1 |
| Quote original on reply | Local-derived. Prepend the stored text/html client-side. |
V1 |
| Rich-text (HTML) compose | Send-path. Lightweight editor (Tiptap/contentEditable) → sanitized HTML → html field. |
V1 |
| Plaintext compose | Send-path. Always send text alongside html for deliverability. |
V1 |
| cc / bcc on send | Send-path. /v1/send supports cc/bcc — settable outbound even though they are not visible inbound (see §4). |
V1 |
| Attachments on send | Send-path. Upload path → encode/host → attachments field. |
V2 |
| Forward | Send-path. Re-send stored body via /v1/send. |
V2 |
| Drafts | Local-derived drafts table with autosave. Never touches an IMAP Drafts folder. |
V2 |
| Signatures (per identity) | Local-derived setting injected at compose. | V2 |
| Schedule send / Send Later | Local-derived. Hold draft, the shared scheduler calls /v1/send at fire time. |
V2 |
| Reply-all | Platform-dep. The payload gives to[] but not cc, so we cannot reconstruct the full original recipient set. V1 replies to sender only; true reply-all is blocked on the webhook gaining cc. |
Later |
| Undo send | Local-derived. Buffer 5–30s before actually calling /v1/send. |
Later |
| Reply-To override | Send-path replyTo field. |
Later |
| Snippets / canned responses | Local-derived text blobs inserted at compose. | Later |
| Custom headers | Send-path headers field; power feature. |
Later |
| AI compose / instant replies | Send-path + external LLM. MailKite has an agent surface, but keep the webmail lean. | Later |
| Read receipts / open tracking (outbound) | Infeasible / anti-goal. Requires a tracking pixel + callback; conflicts with our privacy stance. Will not ship. | Never |
| Feature | Notes / how it works here | Tier |
|---|---|---|
| Archive | Native state flag — drop from Inbox view, keep in All Mail. | V1 |
| Trash (soft-delete + restore) | Native soft-delete column + retention purge. | V1 |
| Labels (multi, overlapping) | Native join table — one message ↔ many labels. The primary organization model. | V2 |
| Spam view | Native. Seeded from auth.spam in the payload — classification is partly done upstream. |
V2 |
| Reserved "system" labels as folders | Native. Inbox / Sent / Archive / Trash / Spam are presented as folders but are really reserved labels — one model, two names. | V2 |
| Sweep (bulk by sender) | Local-derived bulk action over local mail. | Later |
| Focused / Split inbox views | Local-derived saved-query views (e.g. VIP split). | Later |
| VIP / important sender | Local-derived allowlist; pairs with Screener and Splits. | Later |
| Rules / filters engine | Local-derived, run in the webhook receiver before insert: match from/to/subject/auth.spam → label/archive/snooze/screener. App-level only — not server Sieve. Header/cc conditions are Platform-dep. See §4 and §5. |
Later |
| Feature | Notes / how it works here | Tier |
|---|---|---|
| Recipient autocomplete | Local-derived from seen from/to addresses + manual entries. High value, easy. |
V2 |
| Address book | Local-derived, auto-populated from incoming from.address. No CardDAV. |
Later |
| vCard import / export | Native file handling; OSS parity, low MVP value. | Later |
| Avatars / enrichment | Local-derived initials or opt-in Gravatar only — avoid third-party leaks. | Later |
| Feature | Notes / how it works here | Tier |
|---|---|---|
| List + download attachments | Native. Payload gives {id,filename,contentType,size,url}. Gotcha: url is a signed 7-day link — it expires. See the durability decision below. |
V1 |
Inline images (cid:) |
Local-derived. Rewrite cid: refs in html to stored attachment URLs. Payload does not map cid explicitly → match by filename/contentType. Partial. |
V2 |
| Attachment preview (image/PDF) | Native once bytes are local. | Later |
| Drag-out / save | Native. | Later |
Decision (2026-06): fetch-and-store attachments at ingest. MailKite's
attachments[].urlare signed 7-day URLs (GET /att/:mid/:idx?exp=&sig=). If we only store the URL, mail "rots" after a week. The portable choice is to fetch the bytes at webhook time and persist them (R2 on Workers, filesystem/SQLite blob on Node). Re-minting via MailKite is Platform-dep and breaks the "own store" portability story, so we don't rely on it.
| Feature | Notes / how it works here | Tier |
|---|---|---|
| Full-text search | Local-derived. Node: better-sqlite3 + FTS5 virtual table. Gotcha: D1 has no FTS5 — on Workers use LIKE or a maintained tokenized column. We index only what we store (subject/from/text/html). |
V1 |
| Search operators (Gmail subset) | Local-derived. Supported: from:, to:, subject:, has:attachment, filename:, is:unread/read/starred/spam/snoozed, label:/in:, before:/after:/older_than:/newer_than:, "phrase", -exclude, OR/AND/(). |
V2 |
cc: / bcc: / list: / rfc822msgid: |
Platform-dep. These fields are not in the payload. to: works but won't include cc'd recipients. See §4. |
Later |
| Feature | Notes / how it works here | Tier |
|---|---|---|
| Dark / light theme | Native. Tokens lifted from ../../website/src/styles/global.css; html[data-theme] + system preference + manual toggle. |
V1 |
| Single mailbox (one account) | Native. One API key + one whsec_* = one mailbox. The locked self-host story. |
V1 |
| Signature editor | Native setting (also §3.2). | V2 |
| Density / reading-pane position / layout | Native settings. | Later |
| Multiple identities / send-as aliases | Send-path. from is settable on /v1/send; inbound for an alias just arrives via webhook. |
Later |
| Custom themes / accent | Native CSS-var override — cheap given the token system. | Later |
| Vacation responder / auto-reply | Send-path + ingest trigger. Doable but loop-risk; gate carefully. | Later |
| Unified inbox across accounts | Local-derived if we ingest N webhook sources, but multiplies config (N keys/secrets). Keep V1 single-account. | Later |
| Masked / alias generation | Platform-dep. MailKite owns address creation (/api/domains/subdomain); out of webmail scope. |
Later |
| Feature | Notes / how it works here | Tier |
|---|---|---|
| Core Gmail shortcuts | Native. c compose, r reply, e archive, j/k navigate, Enter/o open, u back, / search, ? help overlay. |
V1 |
Command palette (Cmd+K) |
Native. Superhuman/Fastmail pattern; the primary discoverability surface — one palette beats memorizing 30 keys. | V2 |
| Full shortcut set | Native. a reply-all, f forward, # delete, b snooze, s star, z undo, x select, g i/t/d/a go-to views, Cmd/Ctrl+Enter send. |
V2 |
One React UI ships as four shells — Hono-served web + installable PWA, Tauri 2 desktop, and Tauri 2 mobile (iOS + Android) — all thin clients to the server-side backend. Full architecture in platforms.md; feature tiers here.
| Feature | Notes / how it works here | Tier |
|---|---|---|
| Responsive layout | Native. shadcn/Tailwind responsive: degrade 3-pane → list+detail. | V1 |
| PWA (installable, offline read) | Native. The free baseline (vite-plugin-pwa). Offline read via SW precache + Query persistence; offline-compose queue flushes to /v1/send on reconnect. |
V2 |
| Tauri 2 desktop app | Native shell. Bundles the SPA, points at a configured backend URL; OS notifications, deep links, tray badge, keychain token. | V2 |
| Tauri 2 mobile app (iOS + Android) | Native shell. Same SPA in WKWebView / Android System WebView; native push (APNs/FCM), deep links, app-icon badge, Keychain/Keystore token. | Later |
| Push notifications | Native + one ingest seam (§6). Two impls behind one PlatformAdapter: Web Push (VAPID) on web/PWA; native APNs/FCM on Tauri mobile (WKWebView has no service workers). Fired at webhook ingest. |
V2 (web) / Later (native) |
Almost every feature in §3 is UI in the SPA, so it runs identically on all four shells (browser, PWA, Tauri desktop, Tauri mobile) — the SPA is the same bundle everywhere (platforms.md). The table below covers only the handful of features that touch native OS capabilities, where availability differs by shell. These are injected through the PlatformAdapter interface (defined in packages/core), so packages/ui stays platform-blind: the web build wires a Web adapter (Notifications API, localStorage, Web Push), the Tauri builds wire a Tauri adapter (OS plugins + keychain).
| Native capability | Web (browser) | PWA (installed) | Desktop (Tauri 2) | Mobile (Tauri 2) | How |
|---|---|---|---|---|---|
| OS notifications | Notifications API | Notifications API | tauri-plugin-notification |
tauri-plugin-notification (Android: create channel first) |
One adapter, two impls. |
| Push (new-mail alert) | Web Push (VAPID) | Web Push (VAPID); iOS Safari requires home-screen install, 16.4+ | n/a (use local notification + poll) | native APNs/FCM (WKWebView has no service workers) | Fired from the one ingest seam (§6). |
| Badge count (unread) | tab title only | tab title / limited | dock / taskbar overlay | app-icon badge | app.setBadgeCount(n) on native; Linux support is DE-dependent. |
Deep links (mailkite://thread/:id) |
URL routing | URL routing | tauri-plugin-deep-link + single-instance |
tauri-plugin-deep-link (associated domains / app links) |
Routes into TanStack Router. |
| Offline read cache | SW precache + Query persistence | SW precache + Query persistence | tauri-plugin-sql (SQLite) |
tauri-plugin-sql (SQLite) |
Read mirror only — the server own store stays canonical. |
| Secure token storage | localStorage (sandboxed) |
localStorage (sandboxed) |
OS keychain (tauri-plugin-keyring) |
iOS Keychain / Android Keystore | Native shells never use localStorage for the JWT. |
Decision (2026-06): native capabilities are progressive enhancement, not gates. Every shell loads the same SPA and the full feature inventory; only the six capabilities above degrade gracefully per shell (e.g. badge → tab title in a plain browser, push → native APNs/FCM on mobile). No feature is desktop- or mobile-only — the heavy lifting (webhook receiver, own store,
/v1/send) is server-side, and the shells are thin clients (platforms.md). Web/PWA is the baseline; desktop and mobile add the native niceties.
| Feature | Notes / how it works here | Tier |
|---|---|---|
| Webhook signature verification | Native, security-critical. Verify x-mailkite-signature (t=<ms>,v1=<hmac>) via MailKite.verifyWebhook(sig, rawBody, secret, toleranceMs) from the mailkite SDK — HMAC-SHA256 over "<t>." + rawBody, default 5-min tolerance, per-account whsec_*. Reject on failure. This is the front door of the whole app. |
V1 |
| HTML sanitization | Native, non-negotiable. Sanitize every html body (DOMPurify-class) — strip scripts, event handlers, dangerous CSS — and render in a sandboxed iframe. |
V1 |
| SPF / DKIM / DMARC badge | Native. Render straight from auth.{spf,dkim,dmarc}. Almost no other client hands the UI pre-computed auth results — a free, high-trust win. |
V2 |
| Spam score display / routing | Native from auth.spam (also §3.3). |
V2 |
| Block remote images by default | Native. Default-block remote <img>/CSS backgrounds to defeat tracking pixels; per-sender "load images" allow. |
V2 |
| Image proxying (hide IP) | Native — a /img-proxy?url= route in the Hono app fetches server-side. Trade-off: server bandwidth, and it still registers an "open." |
Later |
| Link safety / strip trackers | Native. Annotate/strip known tracker params; warn on redirect domains. | Later |
One gap shapes the "Later" column more than anything else. The email.received webhook delivers from, to[], subject, text, html, threadId, auth.*, and attachments[] — but not: cc/bcc recipients, raw RFC822 headers, Message-ID, Date, Reply-To, or List-* headers.
Decision (2026-06): degrade, don't depend on MailKite internals. MailKite's own
GET /api/messages/:iddoes exposeheaders_json+deliveries, but reaching into MailKite's store would break the OSS portability contract (own store, two secrets, nothing else — seeinstall.md). So for the OSS build we use only the webhook fields and explicitly degrade anything that needs more. The fix is a single platform ask: extend the webhook payload withcc,bcc, and selected headers (Message-ID,Reply-To,List-Unsubscribe).
What the gap degrades (everything else is feasible locally):
| Feature | Why it's blocked | Unblocked by |
|---|---|---|
| True reply-all | Can't reconstruct the original recipient set without cc |
cc in payload |
cc: / bcc: search operators |
Fields absent | cc/bcc in payload |
list: search, List-Unsubscribe one-click |
No List-* headers |
header subset in payload |
rfc822msgid: search |
No Message-ID |
Message-ID in payload |
| Header-condition rules | No raw headers to match | header subset in payload |
to: search and display do work (the payload has to[]) — they just won't include cc'd recipients. Surface that caveat in the UI.
Being opinionated about the "no" list is how V1 stays small.
| Not building | Why |
|---|---|
| IMAP/POP/SMTP-receive of any kind | Locked architecture: mail arrives only via webhook. No mail server, no folder sync, no \Seen reconciliation, no two-way conflict resolution. This constraint is the product. |
| Folders as an IMAP-synced hierarchy | We pick labels as the one model; "folders" (Inbox/Sent/Archive/Trash/Spam) are just reserved labels. One model = simpler OSS UX and no nested-mailbox sync to maintain. |
| A full filter/Sieve engine in V1 | Server-side Sieve is IMAP-world and unavailable to us. Our rules run app-level in the webhook receiver, limited to payload fields — shipped as Later, not V1, to keep the MVP focused on read + reply. |
| Reaching into MailKite's internal DB | Breaks the portability contract (two secrets, own store). The headers_json/deliveries MailKite exposes stay off-limits for the OSS build. |
| Outbound open-tracking / read receipts | Anti-goal. Requires a tracking pixel + callback; conflicts with the privacy posture. Will not ship. |
| AI compose / smart-reply in V1 | MailKite has an agent surface, but the webmail stays lean; revisit as Later. |
| Masked-email / alias generation | MailKite owns address creation; minting addresses is a platform concern, not a webmail one. |
| Multi-account unified inbox in V1 | Feasible but multiplies config (N keys/secrets). V1 is one account; multi is documented as advanced/Later. |
| A webhook receiver / store inside the desktop or mobile app | A device has no public HTTPS URL for email.received and must never hold the mk_live_* key. Native apps are thin clients to the server-side backend (platforms.md). |
| Electron (desktop) or a second mobile toolchain | Electron is desktop-only and heavy; we use Tauri 2 for one toolchain across desktop and mobile. Capacitor was considered and rejected for splitting the toolchain. |
Two observations that collapse many features into one piece of work each — build the primitive once and the features fall out:
- One scheduler. Snooze, schedule-send, send-later, undo-send, and the vacation responder are all "do a thing at time T." Build one scheduling primitive — a Durable Object alarm or Cron Trigger on Workers,
node-cron/setIntervalon Node — and all of them become thin features on top. No platform dependency. - One ingest seam. Rules, spam routing, Screener, attachment fetch-and-store, and push notifications all hook the same point: the verified webhook receiver, before the row is inserted. Get verification + the rule pass right once.
See stack.md for where these live in src/index.ts.
Keep it small. V1 is read + reply + portable ingest, nothing more:
- Verified webhook ingestion —
MailKite.verifyWebhook, reject on bad signature (the front door). - Own store populated by
email.received— SQLite on Node, D1 on Workers. - Threaded view grouped by
threadId. - 3-pane reading layout (degrades to list+detail on mobile).
- Read / unread, archive, trash (soft-delete).
- HTML sanitization + sandboxed render (non-negotiable).
- Full-text search (FTS5 on Node,
LIKE/tokenized column on D1). - Reply with
inReplyTothreading + quoted original. - Rich-text & plaintext compose, with cc/bcc on send.
- Attachment list/download with fetch-and-store at ingest.
- Dark/light theme from the locked design tokens.
- Core keyboard shortcuts +
?help overlay.
Everything in §3 tagged V2 or Later is out of V1 by design. When in doubt, cut it.
00-overview.md— what MailKite Mail is and the webhook/sendboundary.platforms.md— the four shells (web/PWA/desktop/mobile), thin-client pattern, and per-platform notification/push/badge seams.install.md— the dual-target install + own-store decision.stack.md— Hono + React/Vite stack and where ingest/scheduler live.../../api/src/index.ts— the verified API surface:/api/ingest,GET /api/messages,POST /v1/send,GET /att/:mid/:idx.