An Inbox That Answers Itself
A fully serverless AI email responder built entirely on Cloudflare. It reads inbound mail, answers from one bounded subject, replies inside the original thread, and costs under $0.0035 per interaction to run.
- Project type
- Full-stack serverless AI application (inbound email automation)
- Role
- Solo developer and architect — product, MIME parsing, AI integration, delivery, cost model
- Created
- November 2026
- Status
- Working system; live on [email protected], answering real inbound email end to end
- Trigger
- Inbound email to a single Cloudflare Email Routing address
- Codebase
- ~210 lines of JavaScript, one file, zero npm dependencies, zero build step
// 01 — The problem
Enterprises answer the same email a thousand times, and a server is the wrong place to do it.
A large share of inbound business email is repetitive: the same handful of questions, asked by different people, at unpredictable times of day. Hiring a person to answer them doesn't scale with volume. Standing up a traditional backend — a mail processor, a queue, a worker fleet — to automate it means paying for capacity around the clock for traffic that arrives in bursts, if it arrives at all.
And an LLM sitting behind a public-facing email address has its own failure mode: asked a question it was never scoped to answer, it will answer anyway, fluently and off-brand, unless something upstream of the model stops it.
Four constraints shaped the build:
| Constraint | Consequence |
|---|---|
| Inbound email is unstructured, raw MIME | Subject, body, encoding and threading headers all have to be parsed by hand before anything useful can happen |
| A real inbox expects a real reply | A bare acknowledgement isn't enough — the reply has to land in the sender's existing thread, from a sendable address, inside normal reply latency |
| A mailbox can't justify a server | Running a mail-answering service behind a VM or container means paying around the clock for traffic that arrives in bursts, if at all |
| An open-ended LLM will answer anything | An assistant living at a personal or brand email address has to refuse to become a general chatbot the moment someone asks it something unrelated |
So the goal wasn't "wire an LLM to an inbox." It was: answer real email, inside the real thread, from a single bounded subject, on infrastructure that costs nothing while nobody is emailing.
// 02 — My responsibilities
- → Defined the product: a single email address that reads inbound mail, answers from a bounded topic, and replies inside the same thread.
- → Chose Cloudflare's email stack end to end — Email Routing for the trigger, an Email Worker for compute, AI Search for retrieval and generation — over a self-hosted mail processor.
- → Built a hand-rolled MIME parser from scratch: subject and Message-ID/References extraction, multipart boundary walking, quoted-printable and base64 decoding, RFC 2047 encoded-header decoding.
- → Designed the email threading model — carrying In-Reply-To and References forward and minting a fresh Message-ID per outbound send — so multi-turn conversations stay in one thread instead of starting over each reply.
- → Scoped the assistant at the prompt layer: every call frames the question as a lookup against one subject and instructs the model to decline anything else, rather than trusting the sender's message to stay on topic.
- → Wired the chat completions call to a Cloudflare AI Search instance, treating it as the single source of grounded knowledge rather than the model's own pretraining.
- → Designed the identity split between the sending address and the reply-to address, so the domain that sends mail and the address people actually reply to can be managed and reputation-scored independently.
- → Implemented the dual-send delivery: the sender's reply and an internal BCC audit copy as two independent envelope sends, since Email Workers has no native BCC header behaviour.
- → Implemented failure handling via message.setReject so a broken AI call or malformed MIME bounces with a reason instead of disappearing silently.
- → Modelled the full cost per interaction across Workers compute, Email Routing, and the AI Search call, and verified it against Cloudflare's published pricing.
- → Documented every binding, secret and permission needed to run the Worker from the dashboard alone — no wrangler, no CLI deploy step required.
// 03 — The approach
Seven stages. None of them run on a server.
Every stage is either a Cloudflare primitive or plain JavaScript inside a single Worker. There is no intermediate service, no database, and no process that has to stay running between emails.
-
01
Receive — Email Routing hands the message straight to compute.
Mail addressed to [email protected] is routed by Cloudflare Email Routing directly into the Worker's email() export. There is no inbox to poll, no webhook relay, and no queue sitting between the SMTP transaction and the code that handles it.
-
02
Parse — turn raw MIME into a subject, a thread, and a question.
Read the message's raw stream to text, pull the Subject, Message-ID and References headers, walk the MIME boundaries to find the first text/plain part, and decode whatever transfer encoding it was sent with. The output is just a subject string and a body string, plus the two headers needed to keep the thread intact.
-
03
Scope — frame every question as a lookup, not an open prompt.
The system prompt and the user turn both hard-code the same constraint: find information about one named subject and answer only from that, declining politely otherwise. The sender's wording never gets to redefine what the assistant is allowed to talk about.
-
04
Generate — one HTTP call to a managed retrieval-and-generation endpoint.
The scoped prompt is sent to an AI Search chat completions endpoint — a Cloudflare-managed instance that retrieves relevant context and produces the reply in a single request. No vector database, no retrieval code, and no model hosting live in this project at all.
-
05
Compose — build the reply as a real, threaded email.
Assemble a raw outbound MIME message: a Re: subject, a From address distinct from the Reply-To address, a fresh Message-ID, and In-Reply-To / References headers pointing back at the message just received, so the reply nests into the existing conversation in Gmail, Outlook and Apple Mail.
-
06
Deliver — two independent sends, not one message with extra headers.
The composed reply is sent to the original sender through the Email Workers send-email binding. A second, differently-labelled copy is built and sent to an internal address for audit visibility — a real BCC header is not meaningful to a send binding, so the copy is its own delivery.
-
07
Guard — reject loudly, scale by doing nothing extra.
Any failure — a bad AI response, a malformed message, a send error — calls message.setReject() with the reason, so the inbound message bounces instead of vanishing. There is no fleet to patch and no capacity to provision; the next email just triggers another isolated invocation.
inbound SMTP to [email protected] │ ▼ [1] Cloudflare Email Routing ──► triggers the Worker directly, no inbox │ ▼ [2] email() handler ──► raw MIME → subject, Message-ID, References, text body │ ▼ [3] scoped prompt ──► "find info about <subject> and nothing else" │ ▼ [4] AI Search /chat/completions ──► retrieval + generation, one HTTP call │ ▼ [5] compose reply ──► Re: subject, fresh Message-ID, In-Reply-To, References │ ▼ [6] send_email binding ──► reply to sender + audit copy to internal address │ ▼ [7] on any failure ──► message.setReject(reason) — bounce, don't vanish
What makes it reusable across mailboxes
| Per-mailbox | Shared by every deployment |
|---|---|
| The system prompt's one allowed subject | MIME parsing, decoding and threading-header handling |
| The From / Reply-To address pair | The chat-completions call and its error handling |
| The BCC audit address | The dual-send delivery pattern |
| The AI Search instance and model name | The whole Cloudflare account — no new infrastructure to provision per deployment |
Standing up a second address for a different subject means changing a prompt string and two addresses — not re-architecting the parsing, threading, or delivery layer underneath it.
// 04 — Technical insights
Parsing email is the unglamorous 80% of the work
A raw inbound message is a stream of bytes with no guarantees. It might be single-part plain text, or multipart/alternative with an HTML sibling, or quoted-printable, or base64, or carry a subject line encoded under RFC 2047 because it contains a non-ASCII character. None of that is visible until it's parsed.
None of it needed a library. Walking MIME boundaries, decoding quoted-printable and base64 bodies, and pulling four headers out of a text block is well within what a few dozen lines of plain JavaScript can do — which matters here, because Workers has no npm install step for a hand-pasted file.
| Parsing decision | Value | Reasoning |
|---|---|---|
| Body extraction | First text/plain MIME part found | Most clients send multipart/alternative with HTML as a courtesy copy; the plain-text sibling is what the model should read |
| Header decoding | RFC 2047 UTF-8 base64 | Non-ASCII subject lines arrive encoded; reading them literally would corrupt both the reply subject and the thread |
| Thread anchor | In-Reply-To + References | Mail clients group conversations by these two headers, not by matching subject text |
| Identity split | From [email protected], Reply-To [email protected] | Isolates the sending subdomain's deliverability reputation from the address a human actually replies to |
Threading is three headers, not a feature
The first version of this Worker replied with a rewritten subject line and nothing else. Every reply landed in the sender's inbox as a brand-new conversation, because mail clients don't group by subject text — they group by Message-ID, In-Reply-To, and References.
The fix was to carry the incoming Message-ID forward as In-Reply-To, append it to whatever References chain already existed, and mint a fresh Message-ID for the outbound reply so the next reply in the thread has something correct to point back to.
| Component | Finds | Misses |
|---|---|---|
| Email Worker (compute) | Every inbound trigger, MIME parsing, threading, the reject path | Any notion of what the reply should say |
| AI Search (chat completions) | A grounded, on-topic answer in natural language | Delivery, threading, retries, or an audit trail |
Keep it inside Cloudflare
There's no separate mail processing service, no message queue, and no hosted model. The trigger, the compute, and the AI call are three Cloudflare products behind three bindings in one account.
| Benefit | Detail |
|---|---|
| One account, one dashboard | Email Routing, the Worker, and AI Search are bindings inside one Cloudflare account, not three vendors to bill, monitor and secure separately |
| No always-on compute | Workers execute per request on V8 isolates and bill in CPU-milliseconds; there is no idle server charge between emails |
| No message broker | Email Routing hands the trigger directly to the Worker's email() export — no queue, webhook relay, or polling inbox to maintain |
| No secret store to stand up | The AI Search bearer token lives in the Worker's own encrypted secret bindings, set from the dashboard |
| Nothing to patch | No OS, no container base image, no cron job keeping a process alive |
Scope is a prompt problem, not a model problem
Keeping the assistant from answering outside its lane doesn't require a classifier or a second model call. It requires framing the question the same way, every time, before the model ever sees it: find information about one named subject relevant to this message, and reply to it — nothing else is on the table.
| Input shape | What happens | Would otherwise produce |
|---|---|---|
| Unrelated question ("what's the weather today") | The system prompt instructs the model to decline anything outside the one allowed subject | A fluent, confident answer about something the assistant was never meant to speak to |
| Empty or whitespace-only body | Falls back to a literal placeholder string before the AI call is made | A wasted AI call answering nothing in particular |
| HTML-only email, no text/plain part | The parser returns an empty body rather than guessing at markup | An answer generated from an empty or malformed question |
| A long forwarded thread with quoted history | Only the newest message's raw body reaches the model | A reply that assumes context from earlier turns it never actually saw |
// 05 — Results
A real inbox, a real thread, and a cost per email measured in fractions of a cent.
| Measure | Result |
|---|---|
| Fabricated answers about unrelated topics | Scoped out at the prompt level on every single call — the assistant is told what it may discuss before it sees the question |
| Thread fragmentation | Fixed — In-Reply-To and References are carried through on every reply, so multi-turn conversations stay in one thread |
| Servers to patch, scale, or pay for at idle | Zero — the entire path is Cloudflare Workers, Email Routing, and AI Search, all billed per use |
| Time to first reply | A few seconds end to end: trigger → parse → AI call → compose → send |
| Deployment footprint | One JavaScript file, no build step, editable directly from the dashboard |
| Audit trail | Every outbound reply is mirrored to an internal address as an independent send |
| Lines of code | ~210, zero external dependencies |
| Cost per interaction | Under $0.0035, all in |
What this looks like for an enterprise considering the same pattern:
- Automated first-response coverage for a known category of inbound email, with no on-call rotation required to keep it running.
- A measurable reduction in manual reply volume for the subset of email that's genuinely repetitive.
- A cost structure that tracks actual usage rather than provisioned capacity — a quiet month costs nothing beyond the odd email that does arrive.
- An audit copy of every automated reply, landing in an internal mailbox the moment it's sent, with no separate logging infrastructure.
- A deployment that's one file: reviewable, pasteable, and auditable without a build pipeline standing between the source and what's running.
Cost breakdown per interaction
| Component | Cost |
|---|---|
| Workers invocation (parse + compose + two outbound sends) | ~$0.0003 at Workers' paid-plan request and CPU-time pricing |
| Email Routing (inbound trigger, outbound sends) | $0 — no per-message fee on Cloudflare's own routing |
| AI Search chat completion (retrieval + generation) | ~$0.002–0.003 per call, depending on context size and model |
| Total, worst case | Under $0.0035 per end-to-end interaction |
| At 10,000 emails a month | Under $35 total — no autoscaling group, no idle server, no on-call rotation |
// 06 — What I learned
The AI call is the easy part. Email is the hard part.
The hard part was never the AI call.
Generating the reply is one fetch to a chat completions endpoint. Everything that took real engineering sat around it: parsing raw MIME by hand, decoding headers correctly, carrying threading state forward, and sending a second copy for audit. The AI is the narrowest step in the pipeline, not the widest.
Scope has to live in the prompt, not in hope.
Nothing about an inbound email guarantees the sender stays on topic. Framing every question as 'find information about this one subject, and nothing else' at the top of every call — not as a one-time instruction — is what keeps an email address from quietly becoming a general-purpose chatbot.
Email threading is a protocol, not a UI trick.
Mail clients don't group conversations by matching subject lines; they match Message-ID against In-Reply-To and References. Get those three headers right once and every client renders a coherent thread for free.
From and Reply-To are different jobs.
The address that sends mail and the address a human replies to don't have to be the same, and often shouldn't be: splitting them keeps a sending subdomain's deliverability reputation separate from the identity people actually see and respond to.
BCC isn't a header here, it's a second send.
A send-email binding delivers to exactly the envelope recipients it's called with. Getting a visible copy to an internal address meant building and sending a second, differently labelled message — not adding a header and hoping.
Serverless billing matches the shape of email traffic.
Inbound mail arrives in bursts, not a steady load. Billing per request and per CPU-millisecond, instead of per provisioned hour, means the cost curve already matches how the traffic actually behaves — there's no capacity to size for a peak that mostly doesn't happen.
Reject loudly, don't fail silently.
A broken AI call or malformed message calling message.setReject() bounces with a reason the sender and the mail logs can both see. Swallowing the error would have looked fine right up until someone asked why their email vanished.
A single file is itself a feature.
No bundler, no package.json, no CI step — the entire system is one ES module that can be pasted into the Cloudflare dashboard and run. For a project this size, that's not a limitation; it's the deployment story.
// Technologies used
| Layer | Technology | Why it was chosen |
|---|---|---|
| Compute | Cloudflare Workers (V8 isolates, ES modules) | Runs the email() handler with no server, container, or VM to provision or keep warm |
| Inbound trigger | Cloudflare Email Routing | Routes mail addressed to [email protected] straight into the Worker's email() export — no inbox, no polling |
| Outbound delivery | Email Workers send_email binding | Delivers the reply and the audit copy as two independent, explicitly-addressed sends |
| Knowledge & generation | Cloudflare AI Search — chat completions endpoint | A managed retrieval-and-generation instance; one HTTP call replaces a vector store, a retrieval layer, and a hosted model |
| MIME parsing | Hand-rolled JavaScript, no dependency | Subject, Message-ID/References and body extraction, plus quoted-printable and base64 decoding, in well under 150 lines |
| Secrets | Worker encrypted secret bindings | The AI Search bearer token is stored outside source and injected at runtime |
| Deployment | Single ES module file, no build step | Edited and shipped directly from the Cloudflare dashboard; nothing to compile, bundle, or npm install |
// Appendix — system at a glance
| Metric | Value |
|---|---|
| Source file | worker.js, 1 file |
| Lines of code | ~210 |
| External dependencies | 0 |
| Cloudflare products used | 4 — Workers, Email Routing, Email Workers send binding, AI Search |
| Secrets configured | 1 — AI_API_TOKEN |
| Outbound sends per inbound email | 2 — reply to sender, audit copy via BCC-style second send |
| Median cost per interaction | Under $0.0035 |
| Idle cost | $0 |