The threading problem for agents
If you're building an email agent, you need to answer one question on every inbound message: which conversation does this reply belong to?
The standard answers are all fragile. Threading headers like Message-ID and In-Reply-To get rewritten, stripped, or lost by downstream mail servers and clients. Subject-line tokens ([#12345]) are readable to humans and therefore readable to attackers — anyone who sees one can forge a reply that looks like it belongs to that conversation. Plus-addressing (agent+12345@example.com) has the same problem with none of the cryptography.
For a human support inbox this is annoying. For an agent that takes actions based on reply content, it's a poisoning vector.
The mechanism
Wraps now ships signed reply-to addresses. When you send from a domain with reply threading enabled, each message can opt in to a Reply-To of the form:
<base64url-token>@r.mail.support.foo.comThe token encodes a version byte, key ID, conversationId, sendId, expiration, and an HMAC computed with a per-domain 32-byte secret. The secret is stored as a SecureString in SSM at /wraps/email/reply-secret/{domain} in your AWS account, KMS-encrypted at rest.
When a recipient replies, the message hits your inbound SES receipt rule for r.mail.{domain}. The inbound Lambda verifies the HMAC, checks expiration, and attaches a replyToken object to the existing email.received EventBridge event. Your handler reads the verified conversationId directly from the event and threads the reply without a database round-trip.
The signing secret lives in your AWS. Wraps doesn't have it and can't sign or verify tokens for you. The SDK reads the secret from SSM at send-time; the inbound Lambda reads it at verify-time. Both run under your IAM policies.
Using it
Enable it for one domain, or for every inbound-enabled domain at once:
# one domain
npx @wraps.dev/cli email reply init --domain support.foo.com
# every currently-enabled inbound domain
npx @wraps.dev/cli email reply init --allOn the send side, pass conversationId to opt in. The SDK derives the sending domain from params.from, fetches the secret, and sets ReplyToAddresses for you:
import { WrapsEmail } from "@wraps.dev/email";
const email = new WrapsEmail({
region: "us-east-1",
replyThreading: {}, // auto-reads /wraps/email/reply-secret/{domain}
});
const conversationId = email.replyThreading.newConversation();
await email.send({
from: "agent@support.foo.com",
to: "user@example.com",
subject: "Your ticket",
html: "<p>...</p>",
conversationId, // opt-in: turns on signing
});When the reply arrives, your EventBridge handler gets a verified token on the event detail:
{
"emailId": "inb_abc123",
"replyToken": {
"conversationId": "aGVsbG8gd29y",
"sendId": "MDEyMzQ1Njc",
"status": "valid"
},
"autoReply": false
}The filter you almost always want:
if (event.detail.replyToken?.status === "valid") {
// safe to thread on conversationId
await threadIntoConversation(event.detail.replyToken.conversationId);
}
if (event.detail.autoReply) {
// vacation responder or bulk mailer — don't respond
return;
}replyToken.status is a discriminated union — treat anything other than "valid" (including "expired", "invalid-signature", and "malformed") as unverified. The full status table is in the guide.
What a valid token actually proves
Verified token ≠ verified sender.
A status: "valid" token means someone replied to an address your domain previously generated. It does not prove who the sender is or that the reply came from the original recipient. Anyone with a copy of that Reply-To address can send mail to it.
If your agent takes consequential actions on reply content, combine replyToken with DKIM and SPF checks on the sender before trusting anything in the body.
What's not in v1
Being explicit about the surface area:
Replay defense is application-layer
A captured valid token can be resent until it expires or the secret rotates. Wraps gives you a stable sendId on every verified event. If you need at-most-once semantics, dedupe on sendId in a DynamoDB table with a TTL. The guide has a drop-in pattern.
No recipient binding
The token doesn't bind to the original To: address. If a recipient forwards your email, the forwarding party can reply to the token and it will verify. This is intentional (forwarded threads still work) but it's a tradeoff worth knowing about.
90-day default TTL, configurable per send
Tokens expire 90 days after they're generated. Override per send with replyTtlSeconds. You can set replyTtlSeconds: 0 for no expiration — which verifies forever until the secret rotates. That's a real forever. Use it carefully.
Rotation supports one previous key
wraps email reply rotate stores both the new and previous key IDs. Both the inbound Lambda and the SDK cache the secret for 5 minutes, and tokens signed with the old secret keep verifying during the cache-rollover window. Rotate as often as you want; there's no thundering-herd and no deploy coordination. But there is only one grace-window kid. A second rotation inside that window will drop still-in-flight tokens signed with the oldest secret.
Try it
Reply threading requires inbound email to be configured on the same domain first. If it isn't, the init command tells you to run wraps email inbound add first.
# upgrade the CLI
npm i -g @wraps.dev/cli@latest
# enable reply threading on an inbound-enabled domain
npx @wraps.dev/cli email reply init --domain support.foo.com
# verify what's deployed
npx @wraps.dev/cli email reply statusContinue reading
Signed threading, one command
The signing secret lives in your AWS account. Wraps never sees it. Your agent gets a verified conversationId on every reply.

