A self-hostable, serverless customer support platform, powered by Nuxt UI v4 and Cloudflare Workers.
smoke turns a Cloudflare account into a full support desk: customers email you or submit a request from a public portal, tickets thread automatically, and your team replies from a rich dashboard or straight from their own email inbox. Every piece of PII is envelope-encrypted, all state lives in D1 + KV + R2, and a drop-in setup wizard links your Cloudflare account and provisions Email Routing for you, so there is little to no dashboard clicking to get running. 🚀
- 📨 Email-Native Tickets - inbound mail (Cloudflare Email Routing or an IMAP/POP3 poll) opens
or threads tickets automatically, with a synchronous auto-acknowledgement and per-ticket reply
aliases (
support+t<id>@<domain>). - 💬 Unified Conversation View - the same rich thread on the public status page and the dashboard: markdown, attachments, link previews, per-message edit history with before/after diffs, and a GitHub-issue-style event timeline (status/priority/label/assignee/visibility/deadline changes, with "by <flow>" attribution for automated ones).
- 🏷️ Labels, Projects & Custom Fields - GitHub-style labels with inline create + color editing, multi-project membership, and typed custom fields (text, select, multiselect, account, ticket, customer, label, file).
- 👁️ Visibility, Locking & Retention - per-ticket Public / Internal / Private visibility, thread locking, auto-archive of aged closed tickets, and an opt-in purge - all policy-driven from Settings.
- 🎨 Ticket Identity - a per-ticket icon + color, with status/priority/visibility dropdowns that carry icons, colored chips, and descriptions.
- 🔁 Two-Way Agent Bridge - forward a ticket to an agent's own mailbox; they can reply from their email client (attributed back to their account) or from the UI. Replies can go out as the agent (name + avatar) or as an anonymous "Team" identity, per message.
- 📤 Provider-Agnostic Outbound - use Cloudflare Email Service with zero config, or point at any custom SMTP server (SES, Mailgun, Postmark, a self-hosted relay). Credentials are sealed at rest. (Workers can't SMTP to Cloudflare's own relay, so Cloudflare sending uses its REST API.)
- 📥 Optional IMAP/POP3 Polling - not on Cloudflare Email Routing? Enable a scheduled poll that pulls new mail from any mailbox and threads it into tickets.
- 👥 Participants - CC or forward extra addresses onto a ticket and they gain portal view + reply access, even on a private ticket (privacy gates the anonymous public view, not the conversation).
- 🔔 Event Notifications - the customer, assigned agents, and participants are emailed on new messages and on any state change (opened/closed/reopened/archived/status), with a global opt-out.
- 🧩 Flows - no-code automation: triggers (ticket / customer / label / assignee / agent lifecycle),
comparison operators, and nested all/any condition trees drive actions - set status/priority/labels,
assign, lock, draft or send a reply, or email the customer, with
{{placeholder}}templating. - 🤖 AI Replies - owner-enabled Cloudflare Workers AI drafts or auto-sends replies (reuses the linked account, off by default, hidden base prompt + a transparency trailer).
- 🌐 Public Portal - a landing page, a Turnstile-ready submission form, a tokenized status page, and a passwordless email-OTP customer session. Staff can create customers manually and mint reusable magic access links.
- 🧑💼 Role-Aware Dashboard - tickets, customers, labels, users, projects, audit, settings, and
analytics, gated by a granular permission model (
Agent<Manager<Admin) with a locked owner. - 🧭 Command Palette & Context Menus - a global Cmd/Ctrl+K palette plus right-click menus on rows, cards, and the page background - all identity-gated.
- 🧑🔧 Agent Onboarding & Recovery - manager/owner-issued invite links (TTL + max-uses) and an OTP-based forgot-password flow.
- 📝 Audit Log - a non-PII audit trail across every mutation, filterable and exportable (CSV / JSON / TXT), with its own retention policy.
- 🔐 Envelope Encryption - every PII payload is sealed with a per-row AES-256-GCM key wrapped by a
key derived from your
MASTER_KEY; emails are HMAC-indexed so they can be looked up without being stored in the clear. - 🛂 Turnstile Bot Protection - drop in a Cloudflare Turnstile site/secret key to guard the public submission form.
- 🖼️ BIMI Brand Logo - turn an Iconify icon into a BIMI-compliant SVG (SVG Tiny P/S), served at a
stable URL, and auto-provision the
default._bimiDNS record (with a DMARC-enforcement check, and an optional VMC link) through your Cloudflare token - a live-preview color/background/stroke customizer with transparency support lives in Branding. - 🎭 Branding & Avatars - instance name / description / theme color, a favicon (Iconify icon, upload, or URL), per-role default avatar icons + colors, and social links.
- ☁️ Drop-In Cloudflare Setup - the wizard links your account with an API token, then enables Email Routing, wires the catch-all email worker, registers the destination address, and surfaces (or creates) the exact DKIM / SPF / MX DNS records.
- 📊 Analytics - ticket volume over time, open vs resolved, response/resolution times, breakdowns by status/priority/label, per-agent load, and email-channel share, all computed from your own data.
- ⚡ Fast Cold Starts - a two-tier (in-isolate memory + KV) read-through cache, a KV-gated schema bootstrap that skips redundant DDL on warm deploys, and deterministic shard routing.
- 🌓 Light/Dark Mode, SEO, schema.org, sitemap, robots, and i18n out of the box.
- Nuxt 4 + Nuxt UI v4
- NuxtHub on Cloudflare Workers
- Cloudflare D1 (sharded via CollegeDB, Drizzle ORM), KV, R2
- edgeport - Workers-native SMTP/IMAP/POP3 (the mail transport)
- postal-mime + mimetext for parsing/building mail
- Pinia stores, Zod validation, Turnstile
- Playwright E2E (monocart coverage) + Vitest unit tests (GreenMail integration)
Only two secrets are required to boot; everything email/Cloudflare-related can be set later from the setup wizard or the Settings page (no redeploy needed).
| Variable | Required | Purpose |
|---|---|---|
MASTER_KEY |
✅ | >= 16 chars. Envelope-encrypts all PII. Deploy-time secret (gates the database). |
HMAC_SECRET |
✅ | Hex, >= 32 bytes (openssl rand -hex 32). HMACs emails for lookup. Deploy-time secret. |
CF_API_TOKEN |
⛅ | Cloudflare API token. Used as the SMTP password for Cloudflare Email Service and for account linking / Email Routing + Worker provisioning. Can be set in the wizard instead. (renamed from CF_EMAIL_TOKEN, which is still read as a fallback) |
SUPPORT_EMAIL |
⛅ | Base support address; reply aliases are support+t<id>@<domain>. Set in the wizard if omitted. |
NUXT_PUBLIC_SITE_URL |
— | Public site URL used in the auto-ack ticket link and as the BIMI logo host. |
SMTP_HOST / SMTP_PORT / SMTP_TLS / SMTP_USER / SMTP_PASS / SMTP_FROM |
— | Override the outbound transport (any SMTP server). Primarily for tests/self-hosting; the Settings UI is the normal path. |
Port note: smoke runs on port 4000 by default (an explicit pivot away from the Workers
8787/ occasional3000). Dev, e2e, and preview all use4000; the isolated setup-test server uses4001.
See .env.test for example values.
bun install
bun run dev # http://127.0.0.1:4000On first load you are redirected to /setup. The wizard:
- Admin Account - creates the first
Adminuser (with an optional avatar + PBKDF2 iteration count) and logs you in. - Email Channel - pick Cloudflare Email Service (just enter your support address; uses
CF_API_TOKEN) or Custom SMTP (host/port/TLS/user/password/from), plus an optional inbound receiving choice (Cloudflare Routing or an IMAP/POP3 mailbox). The SMTP password is sealed. - Cloudflare - optionally link your account and provision Email Routing + the inbound worker inline (skippable; also available later in Settings).
- Branding - optional site name / description / theme color / favicon.
- Finish - you land on the dashboard.
Everything email- and Cloudflare-related is KV-overridable with an env fallback, so you can also configure it later. Open Settings → Cloudflare to link your account (API token + account ID) and Provision: smoke enables Email Routing, wires the catch-all email worker, registers the destination address, onboards Cloudflare Email Sending (DKIM/SPF), and can publish a BIMI brand-logo record - each surfacing the exact DNS records (and creating them when the token allows). Point your support domain's catch-all Email Routing rule at this worker and you are receiving tickets.
- Inbound: Cloudflare Email Routing delivers to the worker → parsed → threaded into an existing
ticket (by reply alias, then
In-Reply-To/References) or a new one → one auto-ack reply. - Outbound: agent replies posted in the UI (or from a linked mailbox) are mirrored to the customer
through the resolved transport (Cloudflare Email Service or your custom SMTP), threaded with the
right
Reply-To/In-Reply-Toheaders and any attachments. - Agent bridge: assigned agents get a forwarded copy in their own inbox; replying there is ingested back into the thread and attributed to their account (matched by sender address).
- No Cloudflare Routing? Enable the IMAP/POP3 poll in Settings; a scheduled task pulls new mail.
bun run test # vitest unit tests (Cloudflare Workers pool)
bun run test:coverage # unit coverage (istanbul)
bun run test:e2e # Playwright (desktop + mobile) against a prod build/preview
bun run test:e2e:setup # the isolated first-run setup flow (fresh server on 4001)
# email round-trip integration (needs Docker; GreenMail SMTP/IMAP/POP3)
docker compose -f docker/compose.yml up -d --wait
bun run test:integration
docker compose -f docker/compose.yml down -vThe integration lane sends a real message through the outbound transport into GreenMail and reads it
back over IMAP with edgeport, asserting the ticket/KV mutations, headers, and attachments.
- Envelope encryption (
src/server/utils/encryption.ts) - per-row AES-256-GCM DEK wrapped by a KEK derived fromMASTER_KEY; algorithm/nonce/tag/version stored per row (Workers caps PBKDF2 at 100k, so iterations are configurable + versioned per row). - Sharding (
src/server/db/schema.ts) - CollegeDB spreads rows across theDB*D1 bindings;ensureCollegeDB(env)runs before any query, and a KVschema_readyflag skips redundant DDL on warm deploys. - KV-only settings + email state - global settings, sealed secrets, email threads, message-id, agent-mailbox, and participation indexes all live in KV (no schema changes needed to configure anything).
- Two-tier cache (
src/server/utils/cache.ts) - an in-isolate memoryMap(L1) over the KV binding (L2) with centralized keys + invalidators; hot paths (settings, current user, ticket lists) read through it. - Email engine (
src/server/utils/email.ts,email-poll.ts,src/server/plugins/email.ts). - Cloudflare provisioning (
src/server/utils/cloudflare.ts) - token verification, zone discovery, Email Routing enable, catch-all-to-worker, Email Sending onboarding, BIMI + DMARC records, and DNS create/upsert. - BIMI (
src/server/utils/bimi.ts) - a pure Iconify-to-SVG-Tiny-P/S transform served at/bimi/logo.svg, paired with thedefault._bimiDNS provisioning above. - Audit + notifications (
src/server/utils/audit.ts,notifications.ts) - a non-PII audit trail and best-effort email notifications on ticket events, both hooked into the mutation paths.
bun run build
npx wrangler --cwd .output deploySet MASTER_KEY and HMAC_SECRET as Worker secrets (wrangler secret put). Everything else is
configurable from the app. A cron trigger (*/15 * * * *) runs the optional inbound poll; it no-ops
unless you enable a mailbox in Settings.
Contributions are welcome! Please open an issue or submit a PR.
Made with ❤️ by Gregory Mitchell at The Earth App.
