tutorials
AI inbox triage: automatically sort and prioritize your email
June 11, 2026

Your inbox is full and the signal-to-noise ratio is terrible. You want something to read every message, decide what it is, and surface what matters, without shipping years of mail to a triage SaaS.
AI inbox triage is the practice of running a classifier over every inbound message and writing back structured conclusions: a category like promotions or support, a priority flag, and any extracted fields like an order number or a one-time code. The hard part is not the model call. It is doing it cheaply, deterministically where you can, and without your classifier silently regressing every time you tweak a prompt.
This tutorial builds the whole thing the manual way first, with real code and real failure modes, then shows the version where the engine handles caching, ordering, and evals for you. If you only want the shortcut, jump to the Tinbox section.
What is AI inbox triage and what should it actually decide?
AI inbox triage is per-message classification that produces three kinds of output: a category (which tab or folder this belongs in), one or more flags (is it urgent, is it automated), and extracted fields (the OTP code, the order total). A good triage system decides each of these with the cheapest tool that can do the job reliably.
The mistake most people make is reaching for an LLM on every message. That is slow, it costs money per email, and it is non-deterministic, so the same receipt classified twice can land in two different folders. The better mental model is a chain: deterministic rules run first and free, and only the genuinely ambiguous messages reach a model.
Here is the split that holds up in practice:
| Case | Tool | Why |
|---|---|---|
| One-time codes, password resets | regex | A fixed pattern is exact and instant |
| Receipts, shipping notices | regex | Confirmation mail has stable shapes |
| Unsubscribe link extraction | regex | The link is in the body, not the intent |
| Complaint detection, urgency | LLM | Intent is ambiguous, patterns miss it |
| Multi-language sentiment | LLM | No regex survives free-form prose |
How do I classify the deterministic cases with regex?
Write pure functions that take a parsed message and return a verdict or null. They run instantly, cost nothing, and never touch a network. Start with the cases that are insulting to send to an LLM: extracting a one-time code is a \b\d{6}\b near the word "code", not a reasoning problem.
// triage/rules.ts
type Message = { from: string; subject: string; text: string };
type Verdict = {
category?: string;
flags?: string[];
extra?: Record<string, string>;
} | null;
export function otpRule(m: Message): Verdict {
const looksLikeCode =
/\b(verification|one[- ]?time|security)\b.*\bcode\b/i.test(m.subject + " " + m.text);
if (!looksLikeCode) return null;
const match = m.text.match(/\b(\d{6})\b/);
if (!match) return null;
return {
category: "updates",
flags: ["realtime_important"],
extra: { otp_value: match[1] },
};
}
export function receiptRule(m: Message): Verdict {
if (!/\b(receipt|order confirmation|your order)\b/i.test(m.subject)) return null;
// Don't fire on an unpaid invoice: "amount due" is not a receipt.
if (/amount due/i.test(m.text)) return null;
const total = m.text.match(/\b(?:total|charged)[:\s]*\$?([\d,]+\.\d{2})/i);
return {
category: "updates",
extra: total ? { order_total: total[1] } : {},
};
}
Run them in order and take the first non-null verdict for a given concern. This is already useful: it sorts the bulk of automated mail and pulls out the OTP code with zero cost and zero latency. The honest limitation is that regex cannot tell you whether a one-paragraph email from a customer is a routine question or a furious complaint about to churn. That is where you need a model.
How do I add an LLM classifier for the ambiguous mail?
Call a model with a strict output schema so the result is parseable and stable, then map that output to a category and a flag. Use the Vercel AI SDK so the provider is swappable and the schema is enforced by the SDK rather than by hopeful string parsing.
// triage/llm.ts
import { generateObject } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { z } from "zod";
const TriageSchema = z.object({
category: z.enum(["support", "sales", "promotions", "personal", "other"]),
urgent: z.boolean(),
reason: z.string().max(140),
});
export async function llmTriage(m: { subject: string; text: string }) {
const { object } = await generateObject({
model: anthropic("claude-haiku-4-5"),
schema: TriageSchema,
prompt:
`Classify this email. Mark urgent=true only if it needs a human reply today.\n\n` +
`Subject: ${m.subject}\n\n${m.text.slice(0, 4000)}`,
});
return {
category: object.category,
flags: object.urgent ? ["important"] : [],
extra: { triage_reason: object.reason },
};
}
Now chain them: run the cheap rules, and only fall through to the model when nothing deterministic matched.
// triage/run.ts
import { otpRule, receiptRule } from "./rules";
import { llmTriage } from "./llm";
export async function triage(m) {
for (const rule of [otpRule, receiptRule]) {
const v = rule(m);
if (v) return v; // free, instant, deterministic
}
return llmTriage(m); // billed, only for the ambiguous remainder
}
This works on the first run. It is also where the pain begins, and pretending otherwise would be dishonest.
The failure modes you now own
Run this against a real inbound stream and you will hit all of these within a week:
You pay for every duplicate. A campaign that resends the same body, a retried queue job, a redeploy that reprocesses backlog: each one re-bills the model for an identical message. There is no cache, so your model bill scales with retries, not with unique mail.
$ wrangler tail
POST /triage 200 llm call (cache miss)
POST /triage 200 llm call (cache miss) <- same body, billed again
POST /triage 200 llm call (cache miss) <- redeploy reprocessed it, billed again
Prompt edits silently regress. You tweak the prompt to fix one misclassification, ship it, and three other categories quietly start landing wrong. You have no fixture set, so you find out from a user, not from CI.
It runs on the hot path. If you call triage() inside your inbound handler, a slow model call delays accepting the message. Cloudflare Workers have a CPU budget, and an inbound email handler that awaits an LLM is a timeout waiting to happen. Inbound acceptance and classification are different jobs with different latency budgets, and they need to be separated.
The model is non-deterministic. Without a strict schema and a stable cache, the same email classified twice can disagree. A user who sorts by category sees mail shuffle between folders on reprocess.
You can fix every one of these. Caching needs a content hash, a key that versions on the prompt, and a D1 table. Off-hot-path needs a queue and a consumer. Evals need a fixture harness. That is a few hundred lines of infrastructure that has nothing to do with your actual triage logic, and you will maintain it forever.
How do I cache LLM verdicts so retries stop re-billing?
Hash the normalized body and store the verdict keyed by (interpreter_id, version, content_hash), where version is a hash of the prompt plus the output schema. Re-runs hit the cache; an edited rule changes the version and auto-invalidates. This is the single highest-leverage fix because it decouples your model bill from your retry and redeploy behavior.
// A content-hash cache turns "billed per delivery" into "billed per unique body".
const contentHash = await sha256(normalize(m.text));
const version = await sha256(PROMPT + JSON.stringify(TriageSchema.shape));
const key = `${interpreterId}:${version}:${contentHash}`;
const cached = await db
.prepare("SELECT verdict FROM triage_cache WHERE key = ?")
.bind(key)
.first();
if (cached) return JSON.parse(cached.verdict); // free, deterministic, stable
const verdict = await llmTriage(m);
await db
.prepare("INSERT OR REPLACE INTO triage_cache (key, verdict) VALUES (?, ?)")
.bind(key, JSON.stringify(verdict))
.run();
return verdict;
If your ingest pipeline already computes a SHA-256 of the body (most do, for deduplication), reuse it instead of hashing twice. Note that the cache is only sound for deterministic-ish inputs: if your LLM rule calls external tools whose results change over time, a cached verdict can go stale, and you should bypass the cache for those. That nuance is exactly the kind of thing you have to get right by hand.
How do I keep the classifier from regressing on a prompt change?
Keep a suite of labeled fixture emails with expected verdicts and run it on every prompt or schema change. An eval is just a table of inputs and expectations; the value is running it automatically so a "small" prompt tweak cannot ship a silent regression.
// triage/evals.ts
const fixtures = [
{ name: "angry refund demand", email: angryRefund, expect: { category: "support", urgent: true } },
{ name: "routine pricing question", email: pricingQ, expect: { category: "sales", urgent: false } },
{ name: "newsletter blast", email: newsletter, expect: { category: "promotions", urgent: false } },
];
for (const f of fixtures) {
const got = await triage(f.email);
const ok = got.category === f.expect.category && (got.flags?.includes("important") ?? false) === f.expect.urgent;
console.log(`${ok ? "PASS" : "FAIL"} ${f.name}`);
}
To run evals deterministically in CI without burning tokens, stub the model so each fixture returns a fixed output. Now you have the full hard-way stack: ordered rules, an LLM fallback, a content-hash cache, an off-hot-path consumer, and an eval harness. It works. It is also a meaningful amount of plumbing to own, and none of it is your triage logic.
How do I do all of this without building the plumbing?
Skip all of the plumbing above and declare your triage rules as Tinbox interpreters, which run on your own Cloudflare account. An interpreter is the same idea this tutorial built by hand: a rule that reads each inbound message and writes a verdict (category, flags, extracted extra, optionally a proposed draft). The difference is that the engine already owns the caching, the algorithmic-first ordering, the off-hot-path consumer, and the eval runner, so you only write the pure classification logic.
The two flavors map exactly to what you built. Algorithmic interpreters are pure functions, the regex rules: instant, free, deterministic, never cached because recomputing a regex beats a lookup. LLM interpreters are an instruction string plus a Zod output schema: billed, so the engine caches them by content hash and versions the key on your prompt and schema, which is the fix that took a full section above.
You declare them in a git-owned config file. A bad shape fails the build, never runtime:
// app/interpreters.config.ts
import { defineInterpreters, defaultInterpreters, complaint, newsletter } from "tinbox/cf/interpreters";
import { z } from "zod";
export default defineInterpreters([
// The built-ins you'd otherwise hand-write: OTP, receipt, unsubscribe,
// shipping, calendar-invite, flight. All algorithmic, all free.
...defaultInterpreters(),
// Tune a built-in LLM rule with a factory instead of forking it.
complaint({
instructions: "Flag a thread as urgent only if the customer is threatening to churn or churn-adjacent.",
}),
// Your own LLM triage rule, schema-pinned like generateObject above.
{
id: "priority-triage",
label: "Priority triage",
kind: "llm",
appliesTo: "message",
instructions: "Classify intent and mark urgent=true only if it needs a human reply today.",
output: z.object({
category: z.enum(["support", "sales", "promotions", "personal", "other"]),
urgent: z.boolean(),
}),
toVerdict: (out) => ({
category: out.category,
flags: out.urgent ? ["important"] : [],
}),
},
]);
The engine runs all algorithmic interpreters before any LLM interpreter, so a message settled by the OTP or receipt rule never reaches the billed path: the cost optimization you would have wired by hand is the default. Verdicts land in the extra column the inbox UI already renders, so a category becomes a tab, a flag becomes a badge, and an extracted otp_value becomes a click-to-copy component, with no extra UI work. Because interpreters run in an async consumer, classification never touches the ingest hot path, so a slow model call can never delay accepting the message.
The part the hard way made painful, evals, is built in. Each interpreter ships with a fixture suite, and there is a playground where you paste a sample .eml, run the whole chain without persisting anything, and see every verdict plus the rendered component. That is the regression guard from the previous section, as a page instead of a script you maintain.
Two honest caveats. LLM interpreters require a model API key set as a worker secret, so you are still paying the model provider for the ambiguous mail; interpreters reduce that cost with caching and ordering, they do not make it zero. And an interpreter can write extra, set flags, set a category, and propose a draft reply for a human to review, but it can never send on its own: triage is reversible by design, and anything irreversible stays human-gated.
For the broader picture of why a queryable, owned inbox makes this kind of automation tractable, see treating your inbox as a database you can query. If your triage needs to pull a specific value out of a message rather than sort it, detecting and extracting OTP codes walks the algorithmic-versus-LLM tradeoff in depth, and writing eval suites for an LLM email classifier goes deeper on the regression-guarding harness. If you want an agent acting on the triaged mail rather than just sorting it, letting an AI agent send email without it going rogue covers the guardrails. For concrete rules to start with, see ten AI triage rules that tame a busy inbox and the ways an LLM can classify your incoming email.
Summary
AI inbox triage is a classification chain, not a single model call. Run deterministic regex rules first for the cases they nail (codes, receipts, unsubscribe links), fall through to a schema-pinned LLM only for genuinely ambiguous mail, cache LLM verdicts by content hash so retries stop re-billing, run the whole thing off the ingest hot path, and guard prompt changes with an eval suite. You can build every piece of that yourself, and now you know exactly what each piece is for. Or you can declare the rules and let the engine own the plumbing.
Deploy this yourself: npx tinbox init <domain>, then add your rules to app/interpreters.config.ts and try them in the interpreters playground. The interpreter author's guide ships next to the code with the full verdict reference and the eval recipe.
Frequently asked questions
How can I automatically triage my email inbox with AI?
Run a classifier over each inbound message that outputs structured fields: a category (e.g. promotions, support), a priority flag, and any extracted values. Cheap deterministic cases (OTP codes, receipts) should use plain regex; ambiguous cases (is this complaint urgent?) go to an LLM with a fixed output schema. Persist the verdict to your message store so the inbox UI can sort and badge on it.
Should I use regex or an LLM to classify email?
Use regex for deterministic, free, instant cases: one-time codes, password resets, unsubscribe links, receipts. Use an LLM only where intent is genuinely ambiguous and a fixed pattern would miss it: complaint detection, urgency scoring, multi-language sentiment. Running the algorithmic rules first lets you skip the billed LLM call when a cheap rule already settled the message.
How do I keep an LLM email classifier from drifting?
Pin the output to a strict schema (e.g. a Zod object), cache verdicts by content hash so re-runs are free and stable, and version the cache key on the prompt plus schema so an edited rule auto-invalidates. Then keep an eval suite of labeled fixture emails and run it on every prompt change to catch regressions before they ship.
Does AI inbox triage send my email to a third party?
It depends where the classifier runs. If you call a hosted triage SaaS, your mail flows through their service. If you run the model call from your own Cloudflare Worker with your own API key, only the bytes you choose to send reach the model provider, and the verdicts stay in your own D1 database. Algorithmic rules never leave your infrastructure at all.
What does AI inbox triage cost per email?
Algorithmic rules cost nothing per message. LLM rules cost one model call per unique message body, but a content-hash cache means retries, redeploys, and duplicate sends never re-bill. Order algorithmic rules first so most mail never reaches the model, and the marginal cost of triage drops to a small fraction of your volume.


