tutorials
Email to task automation: turn inbound mail into actions
August 31, 2026

A customer emails
support@, a vendor sends an order confirmation, your monitoring tool fires an alert. Right now you read it, then copy-paste it into Linear, Notion, or a ticket queue by hand. The email already contains the action; you are just the slow transport layer.
Email to task automation is the practice of reading each inbound message, deciding whether it implies an action, extracting the fields that action needs, and creating the task (or firing the webhook) without a human retyping anything. The mail is structured data wearing a text costume. The job is to undress it once, reliably, and route it.
This tutorial builds the whole pipeline the manual way on Cloudflare, with real code and the failure modes that bite in production, then shows the version where an engine owns the plumbing so you only write the rule. If you want the shortcut, jump to the Tinbox section.
How do I automatically turn incoming emails into tasks?
Route inbound mail to a handler, parse it into clean fields, classify the action, then call your task system's API with an idempotency key so retries do not duplicate work. On Cloudflare that handler is a Worker email() entry point, invoked by Email Routing for each message to your domain. No SMTP server, no port 25, no inbound-parse SaaS in the middle.
The pipeline has four stages, and most of the difficulty is in the last three, not the first:
inbound mail → email() handler → parse MIME → classify action → create task (idempotent)
Receiving is easy. Parsing is fiddly. Classifying intent without paying for every message is the interesting part. And making task creation idempotent is the thing everyone forgets until they have 40 duplicate tickets from one retried queue job.
How do I receive the inbound message in a Worker?
Enable Cloudflare Email Routing on your domain and route an address to a Worker that exports an email() handler. The handler receives a ForwardableEmailMessage whose raw field is the full RFC 822 stream. You read that stream once into a string and hand it to a parser.
export default {
async email(message: ForwardableEmailMessage, env: Env): Promise<void> {
const raw = await streamToString(message.raw); // consume the stream once
const parsed = parseEmail(raw);
// The Message-ID is your idempotency anchor. Keep it.
const messageId = parsed.headers["message-id"] ?? `synthetic:${await sha256(raw)}`;
// Hand the structured message to the rest of the pipeline.
await handleInbound({ ...parsed, messageId, to: message.to }, env);
},
};
async function streamToString(stream: ReadableStream): Promise<string> {
const chunks: Uint8Array[] = [];
const reader = stream.getReader();
for (;;) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
}
const total = chunks.reduce((n, c) => n + c.length, 0);
const merged = new Uint8Array(total);
let offset = 0;
for (const c of chunks) {
merged.set(c, offset);
offset += c.length;
}
return new TextDecoder("utf-8").decode(merged);
}
The MIME parser (parseEmail) is the part that quietly triples the project: nested multipart trees, quoted-printable and base64 encodings, RFC 2047 encoded-word subjects, charsets beyond UTF-8. It is a whole subject on its own, covered in depth in parsing raw MIME email in JavaScript and turning inbound email into a webhook. For this article assume parseEmail gives you { headers, subject, text, html } and move on to the part that is specific to task automation: deciding what to do.
Should I use regex or an LLM to extract the action?
Use regex for deterministic shapes and an LLM only for ambiguous prose. Most actionable mail announces itself: a [TODO] subject prefix, a +task tag in the address, a vendor order-confirmation number, a monitoring alert with a fixed format. Those are pattern matches, not reasoning problems, and sending them to a model is slow and billed for no benefit.
Here is the split that holds up:
| Inbound message | Tool | Why |
|---|---|---|
Subject starts with [TODO] or [BUG] | regex | The intent is explicit in the header |
Address is you+task@domain | regex | The sender tagged it on purpose |
| Vendor order confirmation | regex | Confirmation mail has a stable shape |
| Monitoring/alert webhooks via email | regex | Machine-generated, fixed fields |
| "Can someone look at this when you get a chance?" | LLM | Intent is implied, not stated |
| A long customer thread that ends in a request | LLM | The action is buried in prose |
Write the deterministic rules as pure functions that return a structured action or null:
// automation/rules.ts
type Message = { from: string; to: string; subject: string; text: string };
type Action = {
type: "create_task";
title: string;
assignee?: string;
fields?: Record<string, string>;
} | null;
export function subjectPrefixRule(m: Message): Action {
const match = m.subject.match(/^\s*\[(TODO|BUG|TASK)\]\s*(.+)$/i);
if (!match) return null;
return { type: "create_task", title: match[2].trim() };
}
export function plusTagRule(m: Message): Action {
// you+task@domain or you+task.alice@domain
const tag = m.to.match(/\+task(?:\.([a-z0-9_-]+))?@/i);
if (!tag) return null;
return { type: "create_task", title: m.subject, assignee: tag[1] };
}
export function orderConfirmationRule(m: Message): Action {
if (!/\border (confirmation|#?\d)/i.test(m.subject)) return null;
const order = m.text.match(/\border\s*#?\s*([A-Z0-9-]{4,})/i);
if (!order) return null;
return {
type: "create_task",
title: `Fulfil order ${order[1]}`,
fields: { order_id: order[1], vendor: m.from },
};
}
Run them in order and take the first non-null result. This already covers the majority of genuinely actionable mail at zero cost and zero latency. The honest limit: regex cannot tell that a three-paragraph email from a customer politely ends with "would you be able to send over the updated invoice?" That is where a model earns its keep.
How do I extract the action from ambiguous prose with an LLM?
Call a model with a strict output schema so the result is parseable, and only reach it when the deterministic rules all returned null. Use the Vercel AI SDK so the provider is swappable and the schema is enforced by the SDK rather than by hopeful string parsing.
// automation/llm.ts
import { generateObject } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { z } from "zod";
const ActionSchema = z.object({
actionable: z.boolean(),
title: z.string().max(120),
// Only set when the writer named a deadline; never invent one.
due: z.string().nullable(),
});
export async function llmExtract(m: { subject: string; text: string }) {
const { object } = await generateObject({
model: anthropic("claude-haiku-4-5"),
schema: ActionSchema,
prompt:
`Does this email contain a request that needs a follow-up task? ` +
`Set actionable=false for newsletters, receipts, and FYI messages.\n\n` +
`Subject: ${m.subject}\n\n${m.text.slice(0, 4000)}`,
});
if (!object.actionable) return null;
return {
type: "create_task" as const,
title: object.title,
fields: object.due ? { due: object.due } : {},
};
}
Now chain them: cheap rules first, model only for the remainder.
// automation/extract.ts
import { subjectPrefixRule, plusTagRule, orderConfirmationRule } from "./rules";
import { llmExtract } from "./llm";
export async function extractAction(m) {
for (const rule of [subjectPrefixRule, plusTagRule, orderConfirmationRule]) {
const action = rule(m);
if (action) return action; // free, instant, deterministic
}
return llmExtract(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.
How do I stop duplicate tasks when the same email is reprocessed?
Derive an idempotency key from the message's Message-ID and use it as a unique key when you create the task, so a second attempt updates the existing task instead of creating a new one. This is the single most important non-obvious piece, because inbound pipelines reprocess constantly: a queue job retries, a redeploy replays backlog, a forwarded message arrives twice.
// automation/create-task.ts
export async function createTask(action, messageId: string, env: Env) {
// Idempotency key: stable per original message, survives retries.
const idempotencyKey = `email-task:${messageId}`;
const res = await fetch("https://api.linear.app/graphql", {
method: "POST",
headers: {
authorization: env.LINEAR_API_KEY,
"content-type": "application/json",
// Most task APIs honor an idempotency header; if not, dedupe locally first.
"idempotency-key": idempotencyKey,
},
body: JSON.stringify({
query: `mutation($title: String!) {
issueCreate(input: { title: $title, teamId: "${env.LINEAR_TEAM_ID}" }) {
issue { id url }
}
}`,
variables: { title: action.title },
}),
});
return res.json();
}
If your task system has no idempotency header (many do not), keep a tiny D1 table of seen message IDs and check it before creating:
const seen = await env.DB
.prepare("SELECT task_url FROM created_tasks WHERE message_id = ?")
.bind(messageId)
.first();
if (seen) return seen; // already handled this exact message, do nothing
const created = await createTask(action, messageId, env);
await env.DB
.prepare("INSERT INTO created_tasks (message_id, task_url) VALUES (?, ?)")
.bind(messageId, created.url)
.run();
Skip this and you will eventually demo the feature to your team by accidentally filing the same bug nineteen times.
The failure modes you now own
Wire this against a real inbound stream and the rough edges show up within a week:
You pay for every duplicate the LLM sees. Without a cache keyed on the message body, a resent campaign or a redeploy re-bills the model for identical mail. The fix is a content-hash cache, versioned on the prompt and schema so an edited rule auto-invalidates. (The full pattern is in building AI inbox triage, which is the same problem.)
It runs on the hot path. If you await extractAction() and await createTask() inside the email() handler, a slow model call or a sluggish task API delays accepting the message. Cloudflare Workers have a CPU budget, and an inbound handler that awaits two network round-trips is a timeout in waiting. Accepting the message and acting on it are different jobs with different latency budgets.
$ wrangler tail
email handler 502 exceeded CPU time <- waited on the LLM + Linear API inline
The fix is to enqueue on receipt and run the classify-and-create work in a separate consumer:
async email(message, env) {
const raw = await streamToString(message.raw);
const parsed = parseEmail(raw);
// Accept fast; do the slow work off the hot path.
await env.TASK_QUEUE.send({ parsed, messageId: parsed.headers["message-id"], to: message.to });
}
LLM output drifts. You tweak the prompt to fix one false positive and three other categories quietly start mis-firing. Without a fixture set you find out from a duplicate task, not from CI. You need an eval suite of labeled emails with expected actions, run on every prompt change. (The harness is its own subject: writing eval suites for an LLM email classifier.)
The agent oversteps. The moment you let the pipeline reply to the sender as well as file a task, a misclassification becomes an irreversible email to a real person. Creating a task is reversible; sending mail is not. Those two privileges should never share a code path without an approval gate. The full discussion is in letting an AI agent send email without it going rogue.
You can fix every one of these. Each fix is a few hundred lines of infrastructure (a queue, a cache table, an idempotency table, an eval harness, an approval gate) that has nothing to do with your actual automation logic, and you will maintain it forever.
How do I do all of this without building the plumbing?
Skip the queue, the cache, the idempotency table, and the eval harness, and declare your automation as a Tinbox interpreter that runs on your own Cloudflare account. An interpreter is exactly the rule this tutorial built by hand: a function that reads each inbound message and either matches deterministically or asks a model, then takes an action. The engine already owns the parser, the off-hot-path consumer, the content-hash cache, the dedupe, and the eval runner, so you write only the part that is yours.
npx tinbox init scaffolds the inbound handler, provisions D1, R2, and Queues, and prints the DNS records:
npx tinbox init yourdomain.com
Then you write a rule, not a pipeline. A deterministic action interpreter that fires a task webhook for tagged mail:
// app/interpreters/email-to-task.ts
import { defineInterpreter } from "tinbox/cf";
export default defineInterpreter({
name: "email-to-task",
// msg is already parsed: subject, text, html, from, to, messageId are clean.
match: (msg) => /^\s*\[(todo|task|bug)\]/i.test(msg.subject) || /\+task@/.test(msg.to),
run: async (msg, ctx) => {
// ctx.webhook is deduped by message id by the engine: retries never double-fire.
await ctx.webhook("https://api.yourtasks.dev/create", {
title: msg.subject.replace(/^\s*\[[a-z]+\]\s*/i, ""),
source: msg.from,
messageId: msg.messageId,
});
},
});
For the ambiguous prose, declare an LLM interpreter with a schema (the engine caches it by content hash and versions the key on your prompt, which was a whole section above) and map its output to an action:
// app/interpreters.config.ts
import { defineInterpreters, defaultInterpreters } from "tinbox/cf/interpreters";
import { z } from "zod";
export default defineInterpreters([
...defaultInterpreters(), // OTP, receipt, shipping, calendar-invite, etc. all free
{
id: "follow-up-detector",
label: "Follow-up detector",
kind: "llm",
appliesTo: "message",
instructions:
"Set actionable=true only when the sender asks for a follow-up that needs a human. " +
"Newsletters, receipts, and FYI messages are not actionable.",
output: z.object({ actionable: z.boolean(), title: z.string().max(120) }),
toVerdict: (out) =>
out.actionable ? { flags: ["needs_task"], extra: { task_title: out.title } } : {},
},
]);
What you get for free maps one-to-one onto the failure modes above. Algorithmic interpreters run before any LLM interpreter, so most mail never reaches the billed path. LLM verdicts are cached by content hash, so retries and redeploys stop re-billing. Interpreters run in an async consumer, so classification never delays accepting the message. ctx.webhook and task creation are deduped by message id, so reprocessing never double-files. And each interpreter ships with an eval fixture suite plus a playground where you paste a sample .eml, run the whole chain without persisting anything, and see every verdict, which 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 as a worker secret, so you still pay the provider for the ambiguous mail; interpreters cut that cost with caching and ordering, they do not make it zero. And an interpreter can write extra, set flags, propose a draft reply, and fire a webhook, but irreversible actions like sending mail to the original sender stay human-gated by design: task creation is reversible, so it automates safely; the reply does not, so it waits for a person.
For the bigger picture of why an owned, queryable inbox makes this kind of automation tractable in the first place, see treating your inbox as a database you can query. And if you want an assistant acting on these tasks rather than just filing them, the same parsed messages feed the MCP email server so Claude or another agent can read, triage, and follow up. For more ideas in this vein, ways to turn inbound email into action automatically and the email tasks worth automating with an agent are both lists of automations the interpreter pattern unlocks, and the interpreter recipes for smarter inbound handling collects ready-made rules to start from.
Summary
Email to task automation is a four-stage pipeline: receive the message, parse it into clean fields, classify the action (deterministic rules first, an LLM only for ambiguous prose), and create the task idempotently. You can build every stage yourself on Cloudflare, and now you know exactly what each one is for and where it breaks: the MIME parser, the per-email model cost, the hot-path timeout, the duplicate-task bug, and the irreversible-reply risk. Or you declare the rule as an interpreter and let the engine own the parser, the cache, the dedupe, the consumer, and the evals.
Deploy this yourself: npx tinbox init <domain>, then add your rule to app/interpreters/ and try it in the interpreters playground. The interpreter author's guide ships next to the code with the full verdict reference and the webhook recipe.
Frequently asked questions
How do I automatically turn incoming emails into tasks or actions?
Route inbound mail to a handler that parses the message, runs a classifier to decide if it is actionable and what kind, extracts the fields you need (assignee, due date, order number), and then calls your task system's API. On Cloudflare, use Email Routing to invoke a Worker email() handler, parse the MIME, and POST a normalized payload to the task backend. Add an idempotency key from the Message-ID so retries do not create duplicate tasks.
Should I use regex or an LLM to extract the action from an email?
Use regex for deterministic shapes: an order-confirmation number, a calendar invite, a [TODO] subject prefix, a +task tag in the address. Use an LLM only when the action is buried in free-form prose, like a customer email that implies a follow-up without stating one. Run the cheap deterministic rules first so most mail never reaches a billed model call.
How do I stop duplicate tasks when the same email is reprocessed?
Derive an idempotency key from the message's RFC 822 Message-ID (or a hash of the normalized body) and use it as a unique constraint or upsert key in your task store. Retried queue jobs, redeploys that reprocess backlog, and resent campaigns then map to the same task instead of creating a new one each time.
Does email to task automation send my mail to a third party?
It depends where the parsing and classification run. If you call a hosted parse-and-route SaaS, every message flows through their service first. If you run the email() handler on your own Cloudflare account and call task APIs directly, the mail stays in your infrastructure and only the fields you choose to send leave it. Algorithmic rules never leave your Worker at all.
Can an AI agent create the task and reply to the sender automatically?
Yes, but separate the two. Creating a task or draft is reversible and safe to automate. Sending a reply on the sender's behalf is irreversible and should stay human-gated or behind an explicit allowlist. A safe pattern is to create the task and prepare a draft reply, then let a person approve the send.


