Wraps Logo
Engineering6 min readWraps TeamApril 17, 2026

Signed Reply-Tofor Agents

Cryptographic conversation correlation for email agents. Ship in @wraps.dev/cli@2.19.0.

HMAC-signed reply-to
Per-domain secret in SSM
Wraps never sees the key

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.com

The 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 --all

On 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 status

Continue 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.

npx @wraps.dev/cli email reply init --all
Read the Guide