Reply Threading Guide
Thread inbound replies to the original conversation using HMAC-signed reply-to addresses. Verified conversationId, per-domain secrets, and auto-reply detection on email.received events.
HMAC-signed reply-to addresses give you a verified conversationId on inbound email events when a recipient replies to a message you sent. The token is embedded in the reply-to address and signed with a per-domain secret that lives in your AWS account.
When you enable reply threading on a sending domain (e.g. support.foo.com), every outbound message can opt in to a signed reply-to address of the form <token>@r.mail.support.foo.com. When the recipient hits reply, their client addresses that token. Wraps' inbound Lambda verifies the HMAC and surfaces a replyToken object on the email.received event so you can thread replies into the right conversation without a database lookup.
send → signed reply-to → recipient replies → inbound Lambda verifies → event with replyToken
Verified token ≠ verified sender
replyToken.status === "valid" means the token verified cryptographically — someone replied to a message you previously sent from this domain. It does not prove who the sender is. Anyone with a copy of the reply-to address can send a message to it. Combine with DKIM and SPF to authenticate the sender before trusting reply content.
Reply threading is enabled per sending domain. Inbound receiving must already be set up for the same domain first.
npx @wraps.dev/cli email reply init --domain support.foo.comIf inbound is not yet enabled for support.foo.com, run wraps email inbound add support.foo.com first.
npx @wraps.dev/cli email inbound add support.foo.comEnable reply threading on every currently-enabled inbound domain in one command:
npx @wraps.dev/cli email reply init --allWhat gets deployed
/wraps/email/reply-secret/{domain} holding a 32-byte HMAC secretr.mail.{domain}r.mail.{domain}ssm:GetParameter to the prefix onlyOnce reply threading is enabled for a domain, opt in per-send by passing a conversationId. The SDK pulls the signing secret from SSM, generates the signed reply-to address, 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: this turns on signing});// Later, any reply to that message arrives with// replyToken: { conversationId, sendId, status: "valid" }Generate a new conversationId at the start of each conversation and reuse it on every outbound message in that thread. The SDK derives the sending domain from params.from, so one WrapsEmail instance handles any number of enabled domains.
Inbound replies arrive on the standard email.received EventBridge event with two new fields:
{ "emailId": "inb_abc123", "replyToken": { "conversationId": "aGVsbG8gd29y", "sendId": "MDEyMzQ1Njc", "status": "valid" }, "autoReply": false}replyToken.status is a discriminated union. Pattern-match on it explicitly — do not treat "present" as "trusted".
| Status | Meaning | Recommended Action |
|---|---|---|
"valid" | HMAC verified, token within TTL, kid known. | Use replyToken.conversationId to thread. |
"invalid-signature" | Token present but HMAC does not verify against current or previous kid. | Drop. Likely spoofed, corrupted in transit, or the SDK cached a secret from before a rotation it hasn't seen yet. |
"expired" | exp is in the past. | Drop, or send a "conversation expired, please start a new one" auto-reply. |
"unsupported-version" | Lambda saw a token version byte it does not recognize (future-proofing). | Drop. Consider upgrading the CLI + Lambda. |
"malformed" | Local-part looked like a reply address but was not decodable. | Drop. Not a threading reply. |
"unknown-domain" | Recipient domain is not enabled for reply threading (likely misrouted). | Drop. Check wraps email reply status. |
null (the field) | Recipient wasn't under r.mail.*. Not a threading reply at all. | Handle as a regular inbound email. |
The single check you almost always want:
if (event.detail.replyToken?.status === "valid") { // safe to trust conversationId for threading await threadIntoConversation(event.detail.replyToken.conversationId);}Any other value — including a non-null replyToken with a non-valid status — should be treated as an unverified reply and fall back to whatever logic you use for plain inbound mail.
Every inbound event includes an autoReply boolean. Lambda flags it truewhen the message looks like a vacation responder or bulk mailer — specifically, if any of these headers are present:
Auto-Submitted: auto-repliedPrecedence: bulkX-AutoreplyUse this to avoid response loops:
if (event.detail.autoReply) { // vacation responder, bulk mailer, or other auto-reply // skip triggering any response to avoid loops return;}Rotate the signing secret for a domain at any time:
npx @wraps.dev/cli email reply rotate --domain support.foo.com5-minute grace window
Both the inbound Lambda and the SDK cache the SSM secret for 5 minutes. After rotation, tokens signed with the old secret continue to verify against previousKid until both caches roll over. This means rotation is safe to run at any time — there is no thundering-herd problem and no need to coordinate deploys.
Decode a reply-to address locally without touching AWS. Useful when support receives a screenshot and needs to identify the conversation:
npx @wraps.dev/cli email reply decode <token>@r.mail.support.foo.comPrints the decoded version, kid, conversationId, sendId, exp, HMAC (hex), and the sending domain. The HMAC is not verified— decode is a pure format-level operation.
Audit every enabled domain in one command. Verifies the SSM parameter exists, the r.mail.{domain} recipient is present in the catch-all receipt rule, and the MX record resolves.
npx @wraps.dev/cli email reply statusSigned reply-to addresses expire 90 days after they are generated by default. Tokens that arrive after their exp return status: "expired". Override per-send if your workflow needs a longer window:
// Default: 90-day TTL on every signed reply-to addressawait email.send({ from, to, subject, html, conversationId });// Opt-in: custom TTL (in seconds)await email.send({ from, to, subject, html, conversationId, replyTtlSeconds: 60 * 60 * 24 * 365, // 1 year});// Opt-in: never expires (use with caution)await email.send({ from, to, subject, html, conversationId, replyTtlSeconds: 0,});Infinite TTL is a real infinite
A token issued with replyTtlSeconds: 0 verifies forever until the signing secret is rotated. Treat captured infinite-TTL tokens as permanently-valid until rotation.
Each sending domain has its own 32-byte HMAC secret. Leaking one domain's key does not compromise tokens signed for another domain in the same AWS account.
Secrets are stored as SecureString parameters in SSM, KMS-encrypted at rest. Wraps never sees them; they are read only by the inbound Lambda and the SDK, both running in your AWS account under your IAM policies.
Rotation updates the single SSM parameter for that domain in one PutParameter call with { currentKid, previousKid } so both the new and old keys verify during the cache rollover window.
Wraps does not provide built-in replay protection. A captured valid token can be resent until its exp or the secret rotates. If your workflow needs at-most-once semantics, pair sendId with a DynamoDB TTL idempotency table in your own application:
// Application-layer replay defense (optional, not provided by Wraps)import { DynamoDBClient, PutItemCommand,} from "@aws-sdk/client-dynamodb";const ddb = new DynamoDBClient({});async function handleReply(event) { const { replyToken } = event.detail; if (replyToken?.status !== "valid") return; // Reject if we've already processed this sendId try { await ddb.send( new PutItemCommand({ TableName: "reply-sendids", Item: { sendId: { S: replyToken.sendId }, ttl: { N: String(Math.floor(Date.now() / 1000) + 90 * 86_400) }, }, ConditionExpression: "attribute_not_exists(sendId)", }) ); } catch (err) { if (err.name === "ConditionalCheckFailedException") { // replayed sendId — drop return; } throw err; } await threadIntoConversation(replyToken.conversationId);}This is application-layer, not Wraps-provided. Dedupe on sendId, not conversationId — you want many replies per conversation but each specific outbound message replied to at most once.
Set up inbound email receiving so replies flow into your AWS account and trigger email.received events.
Full payload reference for email.received and every outbound SES event type.