
Email automation gets messy fast when you mix concerns. A system that fires transactional notifications needs different reliability guarantees than an agent that reads a reply and decides whether to escalate a support ticket. Conflating them in a single queue leads to dropped messages, duplicate sends, and agents acting on stale state.
This post draws clean lines between the three layers — triggers, routing, and actions — and shows you how to wire them together without the usual failure modes.
Transactional triggers: what they are and when to fire them
A transactional trigger is a deterministic outbound email fired in direct response to a system event — a user action, a state change, or a scheduled condition. The key property: the trigger logic is stateless and idempotent. The same event should produce the same email, and firing it twice should not create two distinct user-visible effects.
Common trigger patterns:
- Event-driven:
user.signup,payment.failed,invoice.created— fired synchronously or via an event bus like Kafka or SQS. - State-transition: sent when a record moves between states (e.g.,
order.statuschanges fromprocessingtoshipped). - Time-based: a cron or scheduled job queries for records in a given state and dispatches emails in batch.
- Threshold-based: a metric crosses a boundary — API rate limit approaching 90%, storage quota exceeded — and the system fires an alert.
The trigger layer should never contain routing logic or LLM calls. Its job is to serialize an event into an email payload and hand it to a sending service. Keep it thin.
Idempotency keys are non-negotiable
Every trigger dispatch should carry a deterministic idempotency key derived from the event, not generated at call time. A UUID generated fresh on each retry means you get duplicate emails when a Lambda retries after a timeout.
import hashlib
def make_idempotency_key(event_type: str, entity_id: str, timestamp_bucket: str) -> str:
raw = f"{event_type}:{entity_id}:{timestamp_bucket}"
return hashlib.sha256(raw.encode()).hexdigest()
from datetime import datetime, timezone
bucket = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H")
key = make_idempotency_key("payment.failed", "cust_abc123", bucket)
Store the key with a sent timestamp in your own database before dispatching, then check it before every send. Don't rely solely on the email provider's idempotency layer — providers have different key TTLs and none of them know your business logic.
When to use a queue vs. direct API call
For low-volume, latency-sensitive triggers (OTP codes, password resets), a direct synchronous API call with a timeout and retry budget is fine. For anything that can tolerate a few seconds of delay, putting the trigger on a queue decouples your application from the email provider's availability and lets you replay failed events cleanly. A standard pattern:
App Server → SQS/Pub-Sub → Worker → Email API → Provider SMTP relay
The worker owns retry logic, idempotency checks, and logging. The app server just enqueues and moves on.
Inbound routing: getting emails to the right handler
Inbound routing is the process of receiving raw MIME messages, parsing them into structured data, and dispatching them to the correct handler — whether that's a human inbox, a webhook, or an AI agent. The routing layer needs to be fast, deterministic, and not block on LLM inference.
A typical inbound pipeline looks like this:
sequenceDiagram
participant Sender
participant MX as MX Record
participant Parser as Inbound Parser
participant Classifier as Classifier
participant Router as Router
participant Agent as Agent Worker
Sender->>MX: SMTP delivery
MX->>Parser: Raw MIME
Parser->>Classifier: Structured payload
Classifier->>Router: Label plus confidence
Router->>Agent: Webhook POST
Agent->>Agent: Reason and act
The MX record points to your inbound processing infrastructure. The parser extracts headers, body, attachments, threading metadata (Message-ID, In-Reply-To, References), and authentication results (SPF/DKIM/DMARC pass/fail). That structured payload goes to a classifier, which assigns labels. The router uses those labels — plus envelope metadata like recipient address or subject patterns — to dispatch to the right worker.
Routing rules in practice
Routing logic should be explicit and fast. Common strategies:
Address-based routing: use subaddressing or dedicated addresses per workflow. support+billing@yourdomain.com routes to the billing agent, support+returns@yourdomain.com to the returns flow. Parse the local part with a regex before you ever touch the body.
Subject-line pattern matching: regex or simple string matching on Subject before invoking a classifier. A subject starting with Re: or containing a ticket ID like [TKT-1234] routes directly to the thread handler without classification overhead.
Header-based routing: automated senders often set X- headers or Precedence: bulk. Route these to a different pipeline than human-written email.
Fallback routing: always have an explicit fallback. Unmatched messages should go to a dead-letter queue, not silently drop.
Authentication gating
Before routing any inbound email to an agent, check authentication results. An agent that acts on spoofed email is a liability. Parse the Authentication-Results header:
def is_authenticated(auth_results_header: str) -> bool:
return (
"spf=pass" in auth_results_header and
"dkim=pass" in auth_results_header
)
This is a minimum bar. For high-trust workflows (financial approvals, account changes), also verify DMARC alignment — meaning the From domain aligns with the domain that passed SPF or DKIM. A message that passes DKIM for mailgun.net but has a From of yourbank.com should not be trusted.
Platforms like Mails.ai expose these authentication results as structured fields on the parsed inbound event, so you don't have to parse raw Authentication-Results headers yourself.
Agent actions: reasoning over email and deciding what happens next
Once a structured email payload reaches an agent worker, the agent needs to do three things: understand the message, decide on an action, and execute it safely. Each has distinct failure modes.
Structuring the payload for the LLM
Don't pass raw MIME to an LLM. Extract what matters and discard the rest:
def build_agent_context(parsed_email: dict) -> dict:
return {
"from": parsed_email["from"],
"subject": parsed_email["subject"],
"body_text": parsed_email["body_text"][:4000], # token budget
"thread_id": parsed_email["message_id"],
"in_reply_to": parsed_email.get("in_reply_to"),
"received_at": parsed_email["received_at"],
"classification": parsed_email["classification"],
"auth_pass": parsed_email["spf_pass"] and parsed_email["dkim_pass"],
}
Strip quoted reply history from the body before passing to the LLM — it reduces token usage and stops the model from acting on old context. Most quoted text starts with > or On [date] [sender] wrote:. A simple regex handles 90% of cases.
Action classes and their risk profiles
Agent actions after reading an email fall into four classes, ordered by risk:
| Action Class | Example | Risk | Reversibility |
|---|---|---|---|
| Read-only | Extract intent, log classification | None | N/A |
| Notification | Send an acknowledgement reply | Low | Can apologize |
| State mutation | Update a ticket, set a flag | Medium | Usually reversible |
| External effect | Trigger a refund, cancel a subscription | High | Often irreversible |
Design your agent to require explicit tool invocations for state mutation and external effects — never implicit side effects. The LLM should return a structured action object, and your code executes it. Never let the LLM call APIs directly without a validation layer.
from pydantic import BaseModel
from typing import Literal
class AgentAction(BaseModel):
action_type: Literal["reply", "update_ticket", "escalate", "noop"]
payload: dict
confidence: float # 0.0 to 1.0
reasoning: str
# Gate high-risk actions on confidence threshold
def should_execute(action: AgentAction) -> bool:
if action.action_type in {"update_ticket", "escalate"}:
return action.confidence >= 0.85
return True
For actions below the confidence threshold, route to a human review queue. This is the correct architecture for any system where mistakes have real consequences.
Threading and reply integrity
When the agent sends a reply, it must preserve thread state. That means:
- Set
In-Reply-Toto theMessage-IDof the email being replied to. - Set
Referencesto the full chain of priorMessage-IDvalues (space-separated). - Preserve the
Subjectwith the originalRe:prefix if present.
Failing to set these headers correctly causes replies to appear as new threads in Gmail, Outlook, and Apple Mail. From a UX perspective it looks broken. From an agent perspective, you lose the ability to reconstruct conversation history from header chains alone.
def build_reply_headers(original: dict) -> dict:
refs = original.get("references", "")
original_mid = original["message_id"]
new_refs = f"{refs} {original_mid}".strip()
return {
"In-Reply-To": original_mid,
"References": new_refs,
"Subject": original["subject"] if original["subject"].startswith("Re:")
else f"Re: {original['subject']}"
}
Connecting the three layers
The most common mistake is treating triggers, routing, and actions as one pipeline when they have different reliability, latency, and trust requirements.
- Triggers need idempotency and retry guarantees. They are write-only and stateless.
- Routing needs speed and determinism. Classification can be ML-assisted, but the dispatch logic should be rule-based and auditable.
- Actions need trust gating and confidence thresholds. The LLM is an advisor; your code is the executor.
Keep these as separate services or at minimum separate queue consumers. This lets you scale them independently, set different error budgets, and replay failures in one layer without affecting the others.
For teams building on top of an email API purpose-built for agents, the inbound webhook delivery, parsed MIME structure, and authentication result fields eliminate a significant amount of infrastructure you'd otherwise own yourself.
Frequently Asked Questions
How do I prevent duplicate agent actions when a webhook is delivered more than once?
Webhook delivery is at-least-once by design. Store the Message-ID of every processed inbound email in a database with a unique constraint before executing any action. On receipt, check for the ID before running the agent. This makes your handler idempotent regardless of how many times the webhook fires.
What's the right way to handle email threading when my agent sends proactive outbound messages that might get replies?
When you send the initial outbound message, store the Message-ID your sending service assigns (or generate one in the format <uuid@yourdomain.com> before sending). When a reply arrives, the In-Reply-To header on the inbound message will match that stored ID. Use that to load thread context before passing the conversation to the agent.
Should I classify email before or after routing it to the agent?
Classify before routing. Classification should run on every inbound message at the routing layer, assigning labels like support_request, billing_inquiry, automated_bounce, or spam. The router uses those labels to decide which agent handler — if any — should process the message. This prevents LLM inference costs on messages that should never reach an agent (bounces, out-of-office replies, marketing unsubscribes).
How do I handle inbound emails that fail SPF or DKIM?
Route them to a quarantine queue, not to your agent. Log the authentication failure with the full Authentication-Results header for auditing. For expected senders (partners, known services) that you've verified out-of-band, you can implement an allowlist keyed on the envelope sender domain — but make this an explicit exception, not the default.
What token budget should I use when passing email bodies to an LLM?
For most support and transactional workflows, 2,000–4,000 tokens of body text is sufficient. Strip quoted history from replies before counting. If you're processing attachments or long-form documents, handle those as separate structured extractions rather than dumping them into the main prompt. Keeping the core context window tight improves classification accuracy and reduces per-call cost.
When should an agent reply immediately vs. queue a response for human review?
Use a confidence gate on the action, not the classification. If the agent classifies the intent correctly but proposes an irreversible action (refund, cancellation, account change) below your confidence threshold, hold it for human review. If it's a low-risk reply (acknowledgement, status update, FAQ response) and confidence is high, reply immediately. The threshold values depend on your error tolerance — start conservative (0.90) and lower them as you validate real-world accuracy.