One-liner: MailKite Mail is a pnpm + Turborepo workspaces monorepo that factors one React/Vite SPA into
packages/ui(React) andpackages/core(API client, types, store adapter, webhook logic), then ships it fromapps/web— one Hono app that serves the SPA and its own API and runs on both Cloudflare Workers (assets + D1) and Node (@hono/node-server+ SQLite) — while the same UI also boots as Tauri 2 desktop (apps/desktop) and mobile (apps/mobile) shells; it ingests mail via MailKite's webhook into its own store and sends replies through/v1/send, never touching MailKite's internal database.
This doc defines the runtime, build, and repo shape for @mailkite/mail (standalone repo
mailkite/mail, hosted at mailn.app). The shared UI is in packages/ui + packages/core; the
web/server runtime lives in apps/web; the desktop/mobile Tauri shells live in apps/desktop /
apps/mobile. This doc owns the monorepo tooling and the apps/web web/server runtime (the
backend every shell points at); the cross-platform thin-client pattern, PWA, and the Tauri shells'
internals (plugins, push, signing, CI) are covered in depth in
platforms.md and repo-structure.md — here they appear only
as "how the shell loads the SPA."
It mirrors the existing customer dashboard's frontend stack (see
../../docs/architecture/dashboard.md) and the API Worker's
Hono pattern (see ../../docs/architecture/api.md), then fuses them
into a single deployable that also boots on plain Node and is reused by the native shells. It
supersedes the assets-only SPA sketch in
05-webmail-oss-and-whitelabel.md §4.
Companion docs: platforms.md (the shells around the SPA),
repo-structure.md (the workspaces layout + build matrix),
data-model.md (the D1/SQLite store + adapter), and
architecture.md (signature verification + ingest).
One repo (mailkite/mail), internally a pnpm + Turborepo workspaces monorepo. The single rule
that makes one codebase span web + desktop + mobile: packages/core is framework-agnostic,
packages/ui owns all React, and the apps/* shells are thin — they add only entry + native glue
and mount the shared <MailApp/>.
| Workspace | Package | What it is | Consumed by |
|---|---|---|---|
packages/core |
@mailkite/core |
API client, wire types, webhook verify + payload normalize, store adapter (MailRepo/SqlDriver/BlobStore), PlatformAdapter interface. No react dep. |
every package — UI imports the client surface; apps/web server imports the server surface |
packages/ui |
@mailkite/ui |
The React SPA: MailApp.tsx root, TanStack Router routes, shadcn/ui (new-york), Tailwind 4 tokens, hooks. Platform-blind (talks to a PlatformAdapter). |
all three apps |
apps/web |
@mailkite/web |
The backend. One Hono app (/api/* + /webhook + SPA serve) on Workers or Node, plus the Vite SPA build + PWA. |
deployed to mailn.app / self-host; the URL all shells point at |
apps/desktop |
@mailkite/desktop |
Tauri 2 desktop shell (macOS/Windows/Linux) that bundles the SPA and points it at a configured backend URL. | end users; thin client |
apps/mobile |
@mailkite/mobile |
Tauri 2 mobile shell (iOS + Android), same SPA, configured backend URL. | end users; thin client |
Why the backend can only live in
apps/web.email.receivedis an inbound HTTPS POST from MailKite that needs a stable public URL + thewhsec_*secret to verify, and the own store must be continuously reachable to ingest. A desktop/mobile device has neither a public URL nor a safe place formk_live_*— so the webhook receiver + store stay server-side and all shells are read/reply thin clients over/api/*. This is the load-bearing constraint behind the whole layout; seeplatforms.md§1.
mailkite-mail/ # repo root (github: mailkite/mail)
package.json # root scripts + devDeps (turbo, typescript, eslint)
pnpm-workspace.yaml # workspace globs
turbo.json # task pipeline + caching
tsconfig.base.json # shared compiler options; each pkg extends it
.npmrc # pnpm settings (node-linker=isolated)
packages/
core/ # @mailkite/core — framework-agnostic (NO react)
src/
index.ts # client surface (types + API client + PlatformAdapter)
client.ts # typed fetch wrapper over /api/* + /api/send
types.ts # Message, Thread, Attachment, AuthVerdict, SendInput…
platform.ts # PlatformAdapter interface (per-shell seam)
server/index.ts # server surface (webhook + store) — separate export
webhook.ts # HMAC verify + payload→row normalize (server-only)
store/
repo.ts # makeMailRepo(driver, blobs) -> MailRepo
d1.ts # D1 adapter (Workers)
sqlite.ts # libsql / better-sqlite3 adapter (Node)
schema.sql # portable schema (see data-model.md)
package.json # exports map enforces client/server split
tsconfig.json
ui/ # @mailkite/ui — all the React, none of the platform
src/
MailApp.tsx # SPA root: TanStack Router + Query + theme providers
routes/ # inbox, thread, compose, settings…
components/ # shadcn/ui (new-york) + mail components
styles/index.css # Tailwind 4 @theme tokens (lifted from website, §3.2)
hooks/ # useMessages, useThread, useSend (TanStack Query)
platform-context.tsx # React context surfacing the PlatformAdapter
components.json # shadcn config (Tailwind 4: empty config field)
package.json # deps: react, @tanstack/*, radix-ui, @mailkite/core
tsconfig.json
apps/
web/ # @mailkite/web — Vite SPA + Hono backend (web + server)
src/
app.ts # shared Hono app: /api/* + /webhook + SPA serve
worker.ts # Workers entry: export default app
node.ts # Node entry: serve() + serveStatic + SPA fallback
client/
main.tsx # mounts <MailApp/> with a WEB PlatformAdapter
sw.ts # service worker (vite-plugin-pwa injectManifest)
dist/client/ # Vite output: Workers assets.dir AND Node static root
migrations/ # D1/SQLite migrations (portable; data-model.md §6)
wrangler.jsonc
vite.config.ts # plugin-react + tailwind + vite-plugin-pwa
Dockerfile # the Node/Docker artifact
package.json
desktop/ # @mailkite/desktop — Tauri 2 desktop shell
src/main.tsx # mounts <MailApp/> with a TAURI adapter
vite.config.ts # builds the SPA shell Tauri embeds (port 5176)
dist/ # vite build output -> frontendDist
src-tauri/
tauri.conf.json # frontendDist -> ../dist; bundle targets; plugins
Cargo.toml # tauri + plugins (deep-link, notification, …)
capabilities/ # allow-list permission sets for the webview
src/lib.rs # Rust: register plugins, deep-link handler
package.json
mobile/ # @mailkite/mobile — Tauri 2 mobile (iOS + Android)
src/main.tsx # mounts <MailApp/> with a MOBILE adapter
vite.config.ts
src-tauri/
tauri.conf.json # + tauri.ios.conf.json / tauri.android.conf.json
gen/ # generated by `tauri ios init` / `tauri android init`
apple/ # Xcode project
android/ # Gradle project
Cargo.toml # + mobile push plugin (APNs/FCM)
capabilities/
package.json
docs/
The
apps/desktopandapps/mobilesrc/main.tsxare a few lines each — inject the rightPlatformAdapterand mount<MailApp/>from@mailkite/ui. All visual code lives inpackages/ui; the shells add only native glue. Full Tauri internals:platforms.md§4 andrepo-structure.md§§3, 5.
Decision (2026-06): pnpm workspaces + Turborepo. pnpm for fast, content-addressed installs and strict, non-hoisted
node_modules(which catches accidental cross-package imports); Turborepo for the task graph + caching acrosscore→ui→ the three apps. Shared packages are consumed asworkspace:*dependencies and built just-in-time by the task pipeline.
packages:
- "packages/*"
- "apps/*"# Strict, non-hoisted layout catches accidental cross-package imports early.
# Tauri/native toolchains occasionally need a hoist escape hatch; add it narrowly
# (public-hoist-pattern) rather than turning on shamefully-hoist.
node-linker=isolated
strict-peer-dependencies=falseTurborepo v2 uses a tasks key (the old pipeline key is gone). ^build (caret) means "build
this package's workspace dependencies first" — so core builds before ui, and ui before the
apps, with caching skipping anything unchanged.
{
"$schema": "https://turbo.build/schema.json",
"ui": "stream",
"globalDependencies": ["tsconfig.base.json", ".npmrc"],
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", "dist/client/**"]
},
"dev": {
"cache": false,
"persistent": true // long-running; don't block dependents
},
"lint": { "dependsOn": ["^build"] },
"typecheck": { "dependsOn": ["^build"], "outputs": [] },
"test": { "dependsOn": ["^build"], "outputs": ["coverage/**"] },
"deploy": {
"dependsOn": ["build"],
"cache": false // side-effecting; never cached
}
}
}| Turborepo concept | How MailKite Mail uses it |
|---|---|
dependsOn: ["^build"] |
core → ui → apps ordering, automatic from workspace:* deps |
| Output caching | dist/** and Vite dist/client/** are cached; an unchanged core never rebuilds |
persistent: true |
dev (Vite / wrangler / tauri dev) doesn't exit, so Turbo doesn't wait on it |
--filter=@mailkite/web |
build/run only one app + its deps (e.g. CI matrix legs) |
cache: false on deploy |
deploys are side-effecting; never served from cache |
| Remote cache (optional) | turbo login/link to share the cache across CI runs and machines |
Note — Tauri's Rust build is its own cache. Turbo caches the SPA outputs cleanly; the Rust/Cargo and Xcode/Gradle builds inside
src-tauri/have their own (large, slow) caches. Cache those at the CI layer (Rust target dir, CocoaPods, Gradle), not via Turbo outputs — listingsrc-tauri/target/**as a Turbo output is fragile. Seerepo-structure.md§6.
The SPA is React 19 + Vite 8 + TanStack Router + TanStack Query + shadcn/ui (new-york) + Tailwind
CSS 4, all in packages/ui. Versions are lifted verbatim from dashboard/package.json so the two
apps stay in lockstep.
| Concern | Package | Version |
|---|---|---|
| UI | react, react-dom |
^19.2.6 |
| Router | @tanstack/react-router |
^1.170.16 |
| Data | @tanstack/react-query |
^5.101.0 |
| Styling build | @tailwindcss/vite, tailwindcss |
^4.3.1 |
| shadcn primitives | radix-ui |
^1.6.0 |
| Variants / merge | class-variance-authority ^0.7.1, clsx ^2.1.1, tailwind-merge ^3.6.0 |
|
| Icons | lucide-react |
^1.21.0 |
| Toasts | sonner |
^2.0.7 |
| Animation (dev) | tw-animate-css |
^1.4.0 |
| Build | vite ^8.0.12, @vitejs/plugin-react ^6.0.1 |
|
| TS | typescript |
~6.0.2 |
| CF tooling | wrangler |
^4.95.0 |
@mailkite/ui exposes one root component, <MailApp platform={…}/>. It takes a PlatformAdapter
(from @mailkite/core) through React context (platform-context.tsx); a component that wants to fire
a notification calls usePlatform().notify(...), never navigator or a Tauri import. That single
indirection is what lets the identical component tree run in a browser, a desktop webview, and a
mobile webview. Each shell's entry is therefore trivial — inject the adapter, mount the root:
// apps/web/src/client/main.tsx
import { MailApp } from "@mailkite/ui";
import { webPlatform } from "./platform"; // Web Push, localStorage, window.open
createRoot(document.getElementById("root")!).render(<MailApp platform={webPlatform} />);// apps/desktop/src/main.tsx (apps/mobile/src/main.tsx is identical bar the adapter)
import { MailApp } from "@mailkite/ui";
import { tauriPlatform } from "./platform"; // OS notify, keychain, deep links
createRoot(document.getElementById("root")!).render(<MailApp platform={tauriPlatform} />);Consumption mechanics:
- Each app declares
"@mailkite/ui": "workspace:*"(which pulls@mailkite/coretransitively). pnpm symlinks them; Turbo's^buildbuilds them first. - With Vite + pnpm, importing
@mailkite/uisource directly works when it ships an ESM entry; add it tooptimizeDeps.includein each app'svite.config.tsso Vite pre-bundles the workspace package. - The client/server split in
coreis load-bearing.webhook.ts+store/*are server-only (they pullnode:crypto/SQLite) and live behind a separate@mailkite/core/serverexport. The browser/Tauri builds import only@mailkite/core(client surface), so Vite cannot bundle server code into a client. Detail:repo-structure.md§3.2.
Tailwind 4 has no tailwind.config.js. The @tailwindcss/vite plugin plus @import "tailwindcss"
in CSS does everything; the theme is an inline @theme block. shadcn's components.json reflects this
with an empty tailwind.config field. Copy these verbatim from dashboard/ into packages/ui:
// packages/ui/components.json
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": { "config": "", "css": "src/styles/index.css", "baseColor": "neutral", "cssVariables": true },
"iconLibrary": "lucide",
"aliases": { "components": "@/components", "utils": "@/lib/utils", "ui": "@/components/ui", "hooks": "@/hooks", "lib": "@/lib" }
}The brand tokens are framework-agnostic CSS vars. Lift the @theme + light override block straight
from website/src/styles/global.css (cited here so this doc is self-contained — do not re-derive the
hex values):
/* packages/ui/src/styles/index.css */
@import "tailwindcss";
@theme {
/* Default (dark) — also the values used to generate the color utilities. */
--color-bg: #0b0d12;
--color-panel: #11141b;
--color-border: #1e2430;
--color-text: #e6e9ef;
--color-muted: #8a93a6;
--color-accent: #6ea8fe;
--color-accent-2: #7c6cff;
--font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
Helvetica, Arial, sans-serif;
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
"Liberation Mono", monospace;
}
/* Light theme: override the same variables the utilities resolve at runtime. */
html[data-theme="light"] {
--color-bg: #ffffff;
--color-panel: #f6f8fc;
--color-border: #e3e8f0;
--color-text: #11141b;
--color-muted: #5a6473;
--color-accent: #2f6fe0;
--color-accent-2: #6a4dff;
}This yields the utility classes the components use directly: bg-bg, bg-panel, text-text,
text-muted, text-accent, border-border, plus brand helpers .text-gradient, .gradient-ring,
.eyebrow. Theme is selected with data-theme on <html>, set pre-paint in index.html and
toggled at runtime by a lib/theme.tsx — the exact same contract as the dashboard and marketing site.
If you also pull shadcn components, mirror the
:root/html[data-theme="light"]shadcn token remap fromdashboard/src/index.css(e.g.--background,--foreground,--primary) — it maps the same hex palette onto shadcn's variable names. Keep both blocks:--color-*for brand utilities, shadcn vars for primitives.
MailApp.tsx nests the providers: PlatformProvider → QueryClientProvider → ThemeProvider →
AuthProvider → RouterProvider, with <Toaster theme={theme} /> from sonner. The auth layer
holds the JWT used for /api/* calls; the platform layer surfaces the per-shell adapter.
// apps/web/vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [react(), tailwindcss()], // + vite-plugin-pwa (platforms.md §5)
build: { outDir: 'dist/client' }, // separate from any Worker/server build
optimizeDeps: { include: ['@mailkite/ui', '@mailkite/core'] }, // pre-bundle workspace deps
resolve: { alias: { '@': fileURLToPath(new URL('./src/client', import.meta.url)) } },
server: {
port: 5175, strictPort: true, // dashboard=5173, admin=5174, mail=5175
proxy: { '/api': 'http://localhost:8787', '/webhook': 'http://localhost:8787' },
},
})outDir: dist/client is load-bearing: it is simultaneously the Workers assets.directory and the
Node serveStatic root. One build path, two consumers. (The apps/desktop / apps/mobile
vite.config.ts builds the same SPA into dist/ for Tauri's frontendDist — see §7.)
The server is new Hono<{ Bindings: Env }>() in apps/web/src/app.ts, the same shape as
api/src/index.ts (app.use('/api/*', cors()), app.onError, app.notFound). Bindings (DB,
ASSETS, secrets) arrive on c.env under Workers and from process.env under Node — branched
once, at store construction (see §6). The store adapter itself lives in @mailkite/core/server,
so the server depends only on the MailRepo interface.
The whole backend is one process. A self-hoster runs it with a MailKite API key and a whsec_*
webhook secret — nothing else.
┌───────────────────────────────────────────┐
Shell (SPA) │ apps/web — one Hono app │
@mailkite/ui ─── GET / ───▶│ static assets ──▶ React/Vite SPA (dist) │
TanStack Router fetch │ │
│ /api/messages ▶│ GET /api/messages ┐ │
│ /api/messages/:id GET /api/messages/:id ├─▶ MailRepo │
│ POST /api/send ▶│ POST /api/send ────────┘ (own store) │
│ │ │ ▲ │
▼ │ ▼ │ │
reply composed │ ┌─────────────────┐│
│ │ D1 (Workers) ││
MailKite ──── webhook POST ─────▶│ POST /webhook ─────────│ SQLite (Node) ││
email.received x-mailkite- │ verify HMAC, └─────────────────┘│
signature │ ingestWebhookMessage │
└───────────────────────────────────────────┘
│ POST /v1/send (reply, JWT)
▼
MailKite API (api.mailkite.dev) ──▶ outbound SMTP
Three data flows:
| Flow | Path | Direction | Store touched |
|---|---|---|---|
| Read | Shell → /api/messages* → MailRepo |
inbound to UI | own store (read) |
| Ingest | MailKite → POST /webhook → MailRepo |
inbound to store | own store (write) |
| Reply | Shell → /api/send → MailKite /v1/send |
outbound | none (pure proxy) |
The app's store is the only mail database it reads. MailKite keeps its own copy in its D1
messages table, but the OSS portability story depends on MailKite Mail never reaching into it. The
single contract between them is the webhook payload and the /v1/send API.
| Route | Method | Auth | Purpose |
|---|---|---|---|
/webhook |
POST | HMAC sig | Receive email.received from MailKite, verify, ingest |
/api/messages |
GET | JWT | List stored messages, newest first |
/api/messages/:id |
GET | JWT | Full body, headers, deliveries, attachments |
/api/send |
POST | JWT | Proxy a reply to MailKite /v1/send |
/api/auth/* |
POST | — | Login / session (issues the JWT the SPA carries) |
/* (everything else) |
GET | — | Serve the SPA (index.html + assets) |
The signature is x-mailkite-signature: t=<unix_ms>,v1=<hex_hmac_sha256>, computed as
HMAC-SHA256(webhook_secret, "<t>." + rawBody) with a default 5-minute tolerance. You must verify
against the raw request body, not re-serialized JSON. Read it once with c.req.text() before any
JSON parsing, and reuse it:
import { verifyWebhook } from '@mailkite/core/server' // server-only surface
app.post('/webhook', async (c) => {
const raw = await c.req.text() // RAW body — required for HMAC
const sig = c.req.header('x-mailkite-signature') ?? ''
const secret = getEnv(c).WEBHOOK_SECRET // whsec_*
if (!verifyWebhook(sig, raw, secret /*, toleranceMs */)) {
return c.json({ error: 'bad signature' }, 401)
}
const payload = JSON.parse(raw) // { id, type:"email.received", from, to,
// subject, text, html, threadId, auth,
// attachments[] }
await getRepo(c).ingestWebhookMessage(payload, accountId)
return c.json({ ok: true })
})Gotcha — the single most common webhook bug. Any middleware that consumes or rewrites the body before this handler will break HMAC verification. Do not register a global body parser ahead of
/webhook. Read text once, verify, thenJSON.parsethe same string.
/api/send validates the JWT, then forwards to MailKite POST /v1/send with the account's API key.
Set inReplyTo to the message-id being answered — MailKite auto-derives the RFC5322 In-Reply-To +
References headers for correct threading. Body fields:
{ from, to, subject, html?, text?, replyTo?, cc?, bcc?, inReplyTo?, headers?, attachments? }.
/v1/send returns { id, status }. This route is a pure proxy — it never writes the local store;
the eventual delivery shows up later as an email.received webhook only if the account also receives
its own outbound, otherwise the SPA optimistically appends the sent message.
Attachment URLs in the webhook payload are MailKite signed 7-day links
(GET /att/:mid/:idx?exp=<s>&sig=<hex>, no auth) and the bytes behind them are deleted after 7 days.
Store the metadata (id/filename/contentType/size) and fetch-and-rehost the bytes at ingest into
the app's own blob store (R2 on Workers / filesystem on Node) — relying on the signed URL would lose
old mail's attachments after a week. See data-model.md §4.6.
The dashboard is a pure assets Worker (no main, no server). The API is a main-only Hono
Worker (no assets). apps/web is both at once in one config — first-class since Wrangler's
array-form run_worker_first (shipped 2025-06-17).
assets.run_worker_firstaccepts a boolean or an array of glob patterns. The array form is the right tool:["/api/*", "/webhook/*"]sends those paths to the Hono Worker first; everything else is served straight from the asset store — the Worker never runs, so static hits cost no Worker invocation and are fast.- Unmatched non-API GETs (deep links like
/inbox/123) fall tonot_found_handling: single-page-application, which servesindex.htmlso TanStack Router takes over. - The array form disables the automatic
Sec-Fetch-Mode: navigateSPA heuristic — giving an explicit, predictable split (API globs vs. everything-is-SPA), which is exactly what we want. - Negative globs are supported (e.g.
"!/api/docs/*"to carve out a subtree) — unused in v1, but available. - With
run_worker_first+not_found_handling, the Hono app does not need to callenv.ASSETS.fetch()for the SPA fallthrough — the platform serves assets for unmatched routes automatically.env.ASSETS.fetch(request)remains an escape hatch if you ever want the Worker to serve an asset programmatically (e.g. inject CSP headers). Don't reach for it by default.
// apps/web/src/worker.ts
import app from "./app"; // shared Hono app; routes mounted on /api/* and /webhook/*
export default app; // Workers calls app.fetch with (request, env, ctx)nodejs_compat is required because the webhook HMAC crypto touches Node APIs — the API Worker
already sets it.
The same apps/web/src/app.ts runs everywhere. Only the entry adapter and the storage backend
differ, and storage branches exactly once.
| Concern | Cloudflare Workers | Node self-host |
|---|---|---|
| Entry file | src/worker.ts (export default app) |
src/node.ts (serve({ fetch: app.fetch })) |
| HTTP adapter | Workers runtime (native Fetch) | @hono/node-server serve() |
| Static SPA | assets binding + not_found_handling |
@hono/node-server/serve-static + index.html fallback |
| Store | D1 (env.DB, async, remote) |
SQLite (@libsql/client or better-sqlite3, local) |
| Config / secrets | c.env (bindings) |
process.env |
| Migrations | wrangler d1 migrations apply |
schema.sql exec on boot |
| Shared | src/app.ts, all route handlers, @mailkite/core MailRepo, the SPA, the design tokens |
// apps/web/src/node.ts
import { serve } from "@hono/node-server";
import { serveStatic } from "@hono/node-server/serve-static";
import { fileURLToPath } from "node:url";
import app from "./app"; // SAME Hono app as the Worker
const root = fileURLToPath(new URL("./dist/client", import.meta.url));
// Register AFTER /api/* and /webhook/* are mounted on `app`, or static would shadow the API.
app.use("/*", serveStatic({ root }));
app.get("/*", serveStatic({ path: `${root}/index.html` })); // SPA fallback ≈ not_found_handling
serve({ fetch: app.fetch, port: Number(process.env.PORT ?? 3000) });Gotchas:
- Import paths are exact:
@hono/node-serverforserve,@hono/node-server/serve-staticforserveStatic. Do not usehono/cloudflare-workers'serveStaticon Node. serveStatic'srootis relative to cwd, not the module. Resolve it withfileURLToPath(new URL('./dist/client', import.meta.url))so the install works wherever the process is launched.- Order matters: mount
/api/*and/webhook/*onappbefore the static + fallback catch-alls. - The
serveStatic({ path: '.../index.html' })fallback is how Node replicates the Worker'snot_found_handling: single-page-application.
This works because app.fetch(request, env) is runtime-agnostic; serve() adapts Node's http to the
same Fetch API the Worker uses.
The MailRepo interface — listThreads, getMessage, ingestWebhookMessage, setRead, … — lives
in @mailkite/core over a pluggable SqlDriver (and a BlobStore for attachments); app.ts and
every route depend only on it. The full interface is defined in data-model.md
§5. Construct the concrete repo once per runtime:
- Workers: build from
c.env.DB(D1). Query API:env.DB.prepare(sql).bind(...).all(). - Node: build from a SQLite handle. Prefer
@libsql/clientfor an async API that matches D1's shape, so the adapter interface is uniform (async everywhere);better-sqlite3works too but is synchronous.
Branch on runtime only at construction — never inside a route handler. Keep schema.sql portable
SQL (D1 is SQLite-compatible, so this is natural): D1 applies it via migrations/, Node execs it on
boot. Detail lives in data-model.md.
apps/desktop and apps/mobile are Tauri 2 apps that bundle the shared SPA and point it at a
configured backend URL. They reuse the exact same @mailkite/ui + @mailkite/core packages — only
the PlatformAdapter differs. This doc covers how they load the SPA; the plugins, push, deep
links, secure storage live in platforms.md §4; the workspaces layout, signing, and
CI live in repo-structure.md §§3, 6.
Each Tauri app is its own pnpm workspace package whose Vite entry imports @mailkite/ui (see §3.1)
and injects a Tauri PlatformAdapter (OS notifications, OS keychain for the JWT, deep links). The
src-tauri/ Rust crate lives inside the app package; tauri.conf.json's
frontendDist/beforeBuildCommand are relative to src-tauri/, so they reach back up to the app's
own dist.
// apps/desktop/src-tauri/tauri.conf.json
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "MailKite Mail",
"identifier": "app.mailn.desktop",
"build": {
"beforeDevCommand": "pnpm --filter @mailkite/desktop dev",
"devUrl": "http://localhost:5176", // Vite dev server during `tauri dev` (HMR)
"beforeBuildCommand": "pnpm --filter @mailkite/desktop build",
"frontendDist": "../dist" // a PATH (embedded), not a remote URL
}
}frontendDist mode |
What it means | Verdict |
|---|---|---|
Path (e.g. ../dist) |
the built SPA is embedded in the binary, served over Tauri's internal protocol; the SPA then calls the configured API cross-origin | chosen — offline-capable, fast cold start, app-store-friendly, native plugin IPC available |
Remote URL (e.g. https://mailn.app) |
the app is a remote wrapper | rejected — needs network to launch, fights store "minimum functionality" rules, weakens the security model (remote-origin IPC), loses offline read |
Decision (2026-06): Tauri shells bundle the SPA and point it at a configured backend URL. Embed the built
@mailkite/ui; the SPA readsVITE_BACKEND_URL(defaulthttps://mailn.app, overridable for self-host via a first-run Server URL screen) andfetches/api/*cross-origin with theBearerJWT. The backend's CORS allow-list must include the fixed Tauri origins (tauri://localhoston macOS/iOS/Linux,https://tauri.localhoston Windows,http://tauri.localhoston Android). Full rationale + the per-install Server-URL seam:platforms.md§2 andrepo-structure.md§5.
apps/mobile adds tauri ios init / tauri android init, which scaffold native projects under
src-tauri/gen/apple (Xcode/Swift) and src-tauri/gen/android (Gradle/Kotlin). For a mail app that
needs push entitlements (APNs/FCM) and deep-link associated-domains, commit gen/ so those native
edits are reproducible. Dev: tauri ios dev / tauri android dev (HMR via devUrl); build:
tauri ios build --export-method app-store-connect → .ipa, tauri android build --aab → .aab.
Push wiring: platforms.md §4; signing and the CI matrix: repo-structure.md §6.
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "mailkite-mail",
"main": "src/worker.ts",
"compatibility_date": "2025-06-01",
"compatibility_flags": ["nodejs_compat"],
"observability": { "enabled": true },
"routes": [{ "pattern": "mailn.app", "custom_domain": true }],
"assets": {
"directory": "./dist/client",
"binding": "ASSETS",
"not_found_handling": "single-page-application",
"run_worker_first": ["/api/*", "/webhook/*"]
},
"d1_databases": [
{
"binding": "DB",
"database_name": "mailkite-mail",
"database_id": "<id>",
"migrations_dir": "migrations"
}
]
}{
"name": "@mailkite/web",
"type": "module",
"scripts": {
// --- shared SPA build ---
"build:client": "tsc -b && vite build", // -> dist/client
"build": "pnpm run build:client", // wrangler bundles src/worker.ts on deploy
// --- Cloudflare Workers target ---
"dev": "wrangler dev", // Worker + assets locally (:8787)
"dev:client": "vite", // SPA on :5175, proxying /api -> :8787
"deploy": "pnpm run build:client && wrangler deploy",
"db:migrate": "wrangler d1 migrations apply mailkite-mail --remote",
"db:migrate:local": "wrangler d1 migrations apply mailkite-mail --local",
// --- Node self-host target ---
"start": "node ./src/node.ts", // serves dist/client + API on $PORT
"start:dev": "tsx watch ./src/node.ts",
"typecheck": "tsc --noEmit",
"lint": "eslint ."
},
"dependencies": {
"@mailkite/core": "workspace:*",
"@mailkite/ui": "workspace:*",
"hono": "^4.9.0",
"@hono/node-server": "^1.13.0"
}
}Two dev modes: (a) pnpm --filter @mailkite/web dev (Worker target) + … dev:client (Vite HMR
proxying to it), mirroring the dashboard's setup; or (b) … start:dev for the Node target. From
the repo root, pnpm dev:web runs the Turbo dev task for this package + its workspace deps. For a
clean self-host binary you may tsc the server to JS and ship node dist/server/node.js;
tsx/Node type-stripping is fine for v1.
{
"name": "@mailkite/desktop",
"type": "module",
"scripts": {
"dev": "vite --port 5176", // Vite dev server Tauri's devUrl points at
"build": "tsc -b && vite build", // -> dist (Tauri frontendDist)
"tauri": "tauri", // `pnpm tauri build` / `tauri dev`
"typecheck": "tsc --noEmit",
"lint": "eslint ."
},
"dependencies": {
"@mailkite/ui": "workspace:*",
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-deep-link": "^2.0.0",
"@tauri-apps/plugin-notification": "^2.0.0"
},
"devDependencies": {
"@tauri-apps/cli": "^2.0.0",
"vite": "^8.0.12"
}
}apps/mobile/package.json is the same shape (identifier app.mailn.mobile, adds a mobile push
plugin); its tauri script drives tauri ios|android dev|build.
Dependencies apps/web adds beyond the dashboard set: hono ^4.9.0 (from api/),
@hono/node-server (Node entry), a SQLite driver (@libsql/client preferred, or better-sqlite3),
and optionally postal-mime ^2.4.0 if you ever parse stored raw MIME. The webhook verify + /v1/send
helpers live in @mailkite/core (no separate SDK dependency in apps/web).
The dashboard has no backend of its own — it calls api.mailkite.dev, so pure static assets suffice.
MailKite Mail is its own backend: it must receive webhooks and serve signed data endpoints. Fusing
the Hono server and the SPA into one apps/web deployable (one Worker, one Node process) means a
self-hoster runs a single binary, and the same app.fetch powers both targets. Factoring the UI into
packages/ui + packages/core then lets the same SPA ship as Tauri desktop/mobile shells without
forking the inbox.
Trade-offs that drove the design:
- Workspaces monorepo over one app: one component tree reused by web + desktop + mobile, with
platform differences isolated to a small
PlatformAdapterper shell and server code fenced off by the@mailkite/coreexportssplit. Cost: pnpm + Turbo tooling overhead; win: write the inbox once. - Combined
main+assetsover assets-only:apps/webneeds co-located server routes; the dashboard does not. The cost is one Worker bundle to maintain; the win is one process to deploy. run_worker_firstarray over default SPA detection: explicit globs disable theSec-Fetch-Modeheuristic, giving a predictable API-vs-SPA split. Static hits skip the Worker entirely — cheaper and faster.- D1 vs SQLite divergence: D1 is async/remote,
better-sqlite3is sync/local. Choosing@libsql/clientkeeps the adapter async on both runtimes so theMailRepointerface is uniform. - Tauri 2 over Electron/Capacitor: Electron is desktop-only and heavy; Capacitor can't do desktop. Tauri 2 is one toolchain across desktop + mobile with tiny binaries — see the decision below.
- Raw-body HMAC: verifying against
c.req.text()(not re-serialized JSON) avoids the most common webhook failure.
Decision (2026-06): workspaces monorepo + dual-runtime
apps/web+ Tauri 2 shells. One repo factors the UI intopackages/ui(React) +packages/core(framework-agnostic client/types/store/ webhook), built by pnpm + Turborepo (workspace:*deps,^buildordering, cached JS/TS).apps/webis the one Hono backend with two entry points —src/worker.ts(Cloudflare Workers,main+assetsbinding) andsrc/node.ts(@hono/node-server+serveStatic) — both serving the samesrc/app.ts; storage branches exactly once at construction (D1 on Workers, SQLite on Node). The same SPA also ships as Tauri 2 desktop (apps/desktop) and mobile (apps/mobile) shells that bundle@mailkite/uiand point it at a configured backend URL — chosen over Electron (desktop-only, heavy) and Capacitor (no desktop) for one unified toolchain across desktop + mobile. A self-hoster needs only a MailKite API key + awhsec_*webhook secret — never MailKite's internal DB. This is the OSS install story; the shells are thin clients on top of it.
platforms.md— the thin-client pattern in depth, the four targets, the PWA + Web Push, and the Tauri 2 desktop/mobile internals (plugins, push, deep links, secure storage)repo-structure.md— the pnpm + Turborepo workspaces layout, the client/serverexportssplit, and the 4-artifact build/release/CI matrix (signing included)data-model.md— the storage schema,MailRepo/SqlDriver/BlobStoreinterface (in@mailkite/core), and D1/SQLite adaptersarchitecture.md— signature verification + ingest pipeline + thesandbox=""mail iframe + JWT auth modelinstall.md— theapps/webserver self-host (APP_URL, secrets) that the shells' Server-URL seam points at../../docs/architecture/dashboard.md— the frontend stack being mirrored../../docs/architecture/api.md— the Hono/Workers backend pattern../../docs/architecture/webhook-signatures.md— thex-mailkite-signaturescheme05-webmail-oss-and-whitelabel.md— superseded plan stub (§4)
{ "name": "@mailkite/mail", "private": true, "type": "module", "engines": { "node": ">=22", "pnpm": ">=9" }, "packageManager": "pnpm@9.12.0", "scripts": { "build": "turbo run build", "dev": "turbo run dev", // persistent dev tasks, in parallel "dev:web": "turbo run dev --filter=@mailkite/web", "dev:desktop": "turbo run dev --filter=@mailkite/desktop", "dev:mobile:ios": "pnpm --filter @mailkite/mobile tauri ios dev", "lint": "turbo run lint", "typecheck": "turbo run typecheck", "test": "turbo run test" }, "devDependencies": { "turbo": "^2.5.0", "typescript": "~6.0.2", "eslint": "^9.0.0" } }