Running an email agent on ordinary Node infrastructure
Plenty of teams are not on Workers and are not going to be. An email agent runs perfectly well as an ordinary Node service, and the design is the same shape with different mechanisms: something receives webhooks, something queues work, something serialises per thread, and the model call happens away from the request.
4 min read
The endpoint
Verify the signature over the raw body before parsing, acknowledge immediately, and put the work on a queue. In Express the trap is that body-parsing middleware will have consumed the raw body before you can hash it, so capture it first.
import express from 'express';
import { verifyWebhook } from '@mcpmailer/sdk';
const app = express();
app.post(
'/webhooks/mcpmailer',
express.raw({ type: 'application/json' }), // raw first, or the HMAC will not match
async (req, res) => {
// Checks the HMAC against the raw bytes and rejects a stale timestamp, so a
// captured delivery cannot be replayed at you later.
const event = await verifyWebhook(
req.body.toString(),
req.headers,
process.env.WEBHOOK_SECRET!
);
if (!event) return res.status(401).send('bad signature');
await queue.add('handle', event, { jobId: event.id }); // dedupes retries
res.status(200).send('ok');
}
);That jobId is doing real work: webhook deliveries retry, and keying the job on the delivery id means a duplicate delivery collapses into the job that already exists rather than producing a second reply. Every retry of the same event carries the same id, which is exactly what makes it usable as the key. It is the cheapest idempotency you will get, and it is not sufficient on its own, which the next section covers.
The worker, and per-thread serialisation
Two messages on one thread arriving seconds apart must not be handled concurrently, or both runs read the thread as it was and answer without seeing each other. In Postgres, an advisory lock keyed on the thread id is the least machinery that works.
async function handle(event: { message_id: string; thread_id: string }) {
const key = hashToBigInt(event.thread_id);
await db.transaction(async (tx) => {
await tx.execute(sql`SELECT pg_advisory_xact_lock(${key})`); // one run per thread
if (await alreadyHandled(tx, event.message_id)) return;
const thread = await mail.getThread(event.thread_id);
const [contact] = await mail.lookupContact(thread.from);
const decision = await yourModel({ thread, contact }); // the slow part
if (decision.action === 'escalate') return escalate(tx, event, decision);
await mail.replyAll(event.message_id, decision.body);
await markHandled(tx, event.message_id);
});
}One caution worth stating plainly: holding a transaction open across a model call ties up a database connection for seconds. At low volume it is fine and it is the simplest correct thing. At higher volume, take the lock, record intent, commit, then do the model call outside the transaction and reconcile afterwards, or move the lock to Redis. The reasoning behind serialising at all is in what happens when forty messages arrive at once.
Long waits without holding a process
wait_for_reply blocks server-side, which suits a worker process fine for waits measured in minutes. For waits measured in days, do not hold a job open. End the run, and let the next message.received webhook resume it by rebuilding context from the thread and the contact record, per long-running email conversations.
The state you keep between those runs is small: handled ids, workflow position keyed on thread id, and nothing else, per where each piece of an agent's state belongs.
Deployment details that actually matter
- A public URL for webhooks. Obvious in production, annoying locally, which is what the tunnel is for:
connect({ handle, target })gives you a stable hostname that survives restarts, per tunnels. - Graceful shutdown. Stop accepting jobs, finish the ones in flight, then exit. A container killed mid-send is how you get a reply with no record that it happened.
- Timeouts on every outbound call. A model provider having a slow day should not exhaust your worker pool.
- Retries with backoff, and a dead letter queue. Then actually read the dead letter queue, which is where the interesting failures accumulate.
- The key in the environment, never in the image or the repository, per keys, scope, and what to do the day one leaks.
When Node is the right call
When the rest of your product is already there, when you need a library that does not run on the edge, or when your team's operational knowledge is containers rather than Workers. None of those are compromises: the API surface is identical, the limits are identical, and the audit trail is identical.
What you take on is the plumbing that a Durable Object gives you for free, which is per-thread state and locking. That is a queue and an advisory lock, so it is a day of work rather than a project. The edge version, for comparison, is in build an email agent on the Cloudflare Agents SDK.
Questions
- Can I run an email agent on plain Node?
- Yes. A webhook endpoint, a queue, a worker with per-thread locking, and the SDK or REST API is the whole design. Nothing about the platform's limits or audit trail changes.
- Why does my HMAC check fail in Express?
- Because body-parsing middleware consumed the raw body before you hashed it. Use
express.rawon that route and compute the signature over the exact bytes received. - How do I stop two runs answering the same thread?
- A Postgres advisory lock keyed on the thread id, plus a handled-message check. The queue's job id gives you deduplication of retries, which is related but not the same problem.
- Should I hold a transaction open during the model call?
- Not at volume. It ties up a connection for seconds. Take the lock, record intent, commit, then call the model and reconcile.
- How do I handle waits of several days?
- End the run and resume from the next webhook, rebuilding context from the thread and contact. Only hold
wait_for_replyopen for waits measured in minutes. - What is the most commonly missed detail?
- Graceful shutdown. A container killed mid-send produces a reply that your database has no record of, which then gets sent again on the retry.
Give your agent an address it can answer from.
Create an inbox