What you actually need from a helpdesk
Strip a Zendesk or a Freshdesk down to its bones and you find the same three pieces: a place where inbound mail lands, a row per customer message, and a way for a human (or another service) to react to that row. Everything else — macros, ticket tagging, SLAs, canned responses — is layered on top of that backbone. The Cloudflare-native version of the backbone fits in a single Worker.
The interesting part is what is not there: no SMTP relay to babysit, no separate SaaS to provision, and no per-seat bill. The whole pipeline runs on your Cloudflare account, the rows live in your D1, and the webhook you fire downstream is yours to own.
The three primitives
Cloudflare Email Routing. Activated per zone. Three catch-all rules — one for support@, one for billing@, one for abuse@ — each pointing at the same Worker action. The routing table decides the local part, the Worker decides what to do with it.
A Worker. The glue. It receives the forwarded message, normalizes the envelope, writes a row to D1, and fires the support webhook. It is also the natural place to enrich the row with the customer’s account id and plan tier — that metadata travels through the rest of the pipeline.
Cloudflare D1. The ticket store. One table per logical concept: ticket, message, customer. The schema is small enough to hold in your head and large enough to back a year of support volume for a small team.
The Worker, end to end
The receive-worker contract is email on the event payload. The handler inspects the envelope, decides which mailbox caught the message, and writes a row. Here is the production-grade skeleton, stripped of logging:
export default {
async email(message, env, ctx) {
const to = message.to; // "support@yourdomain.com"
const mailbox = to.split("@")[0]; // "support"
const from = message.from; // "alex@example.com"
const subject = message.headers.get("subject") ?? "";
const id = crypto.randomUUID();
await env.DB.prepare(
`INSERT INTO ticket (id, mailbox, from_email, subject, status, created_at)
VALUES (?, ?, ?, ?, 'open', datetime('now'))`,
).bind(id, mailbox, from, subject).run();
await fetch("https://your-ticketing.app/webhook", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ticketId: id, mailbox, from, subject }),
});
},
};That handler is the whole support pipeline. The mailbox column is your routing key — every query in the support UI filters on it first, which is why you want it indexed. The downstream webhook is your own service code; nothing in this pipeline depends on Flowmails staying up.
The D1 schema, before you grow into it
Three tables are enough to start. customer ties an email address to an account id and a plan tier; ticket is the open conversation; message is every inbound and outbound message that belongs to a ticket. The foreign-key shape is the same shape every helpdesk settles on once it has a thousand tickets.
CREATE TABLE customer (
email TEXT PRIMARY KEY,
account_id TEXT NOT NULL,
plan TEXT NOT NULL DEFAULT 'free'
);
CREATE TABLE ticket (
id TEXT PRIMARY KEY,
customer_email TEXT NOT NULL REFERENCES customer(email),
mailbox TEXT NOT NULL,
subject TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'open',
created_at TEXT NOT NULL
);
CREATE INDEX idx_ticket_mailbox_status ON ticket(mailbox, status);
CREATE TABLE message (
id TEXT PRIMARY KEY,
ticket_id TEXT NOT NULL REFERENCES ticket(id),
direction TEXT NOT NULL, -- 'in' | 'out'
body TEXT NOT NULL,
created_at TEXT NOT NULL
);
The composite index idx_ticket_mailbox_status is what keeps the support inbox fast once you cross the tens-of-thousands-of-tickets line. Add it before you need it; backfilling an index on a hot table is more painful than creating it on day one.
Where to add metadata before the row hits D1
The Worker runs on every message — that is the moment to enrich. Look the sender up in the customer table, attach the account id and plan tier to the ticket, and use the plan tier to decide whether to page the on-call. The downstream webhook receives a payload that already carries everything your CRM needs; your ticketing service code never has to call back into Flowmails.
This is the part SaaS helpdesks charge for. The Worker is yours to write, the metadata is yours to attach, and the ticket that lands in your service code already knows who the customer is and what they pay you.
When the simple shape stops being enough
You outgrow this pattern when one of three things happens: ticket volume exceeds what D1 is happy to scan in a single request, you need SLA timers that fire on their own without a Worker invocation, or you need cross-account delegation so a contractor can answer a ticket without seeing the rest of the inbox. None of those are blockers — they are upgrade steps that the existing shape was designed to absorb.
For most teams in the 10-100 tickets-a-day range, the shape above is the helpdesk. The SaaS tools earn their keep when you cross into thousands-per-day territory; before that line, you are paying for chrome.
Where to go from here
The Workers-native email routing tour walks through the runtime side of the same setup, and the email webhook playbook is the matching piece for the downstream side. If your team is the kind that would rather wire this up by hand than onboard a SaaS, the indie developer page is the rest of the tour.