Wraps Logo
Guide

Reply Threading

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.

Overview

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.

Flow

send → signed reply-to → recipient replies → inbound Lambda verifies → event with replyToken

Trust Model

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.

Enable Per Domain

Reply threading is enabled per sending domain. Inbound receiving must already be set up for the same domain first.

One domain

GNU Bashterminal.sh
npx @wraps.dev/cli email reply init --domain support.foo.com

If inbound is not yet enabled for support.foo.com, run wraps email inbound add support.foo.com first.

GNU Bashterminal.sh
npx @wraps.dev/cli email inbound add support.foo.com

Every inbound domain

Enable reply threading on every currently-enabled inbound domain in one command:

GNU Bashterminal.sh
npx @wraps.dev/cli email reply init --all

What gets deployed

  • A SecureString SSM parameter at /wraps/email/reply-secret/{domain} holding a 32-byte HMAC secret
  • An SES receipt-rule recipient for r.mail.{domain}
  • MX and SPF records for r.mail.{domain}
  • An IAM policy statement on the inbound Lambda scoping ssm:GetParameter to the prefix only

Signing From the SDK

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

TypeScriptsend.ts
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.

Event Shape

Inbound replies arrive on the standard email.received EventBridge event with two new fields:

JSONevent.detail.json
{  "emailId": "inb_abc123",  "replyToken": {    "conversationId": "aGVsbG8gd29y",    "sendId": "MDEyMzQ1Njc",    "status": "valid"  },  "autoReply": false}

Status Values

replyToken.status is a discriminated union. Pattern-match on it explicitly — do not treat "present" as "trusted".

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

Filter Pattern

The single check you almost always want:

TypeScripthandler.ts
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.

Auto-Reply Detection

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-replied
  • Precedence: bulk
  • X-Autoreply

Use this to avoid response loops:

TypeScripthandler.ts
if (event.detail.autoReply) {  // vacation responder, bulk mailer, or other auto-reply  // skip triggering any response to avoid loops  return;}

Rotation

Rotate the signing secret for a domain at any time:

GNU Bashterminal.sh
npx @wraps.dev/cli email reply rotate --domain support.foo.com

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

Debugging

Decode a reply-to address locally without touching AWS. Useful when support receives a screenshot and needs to identify the conversation:

GNU Bashterminal.sh
npx @wraps.dev/cli email reply decode <token>@r.mail.support.foo.com

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

Status

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.

GNU Bashterminal.sh
npx @wraps.dev/cli email reply status

Longer-Lived Tokens

Signed 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:

TypeScriptsend.ts
// 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.

Security

Per-domain secrets

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 stay in your 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.

Atomic, per-domain rotation

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.

Replay Defense (Optional)

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:

TypeScripthandler.ts
// 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.

Next Steps

Inbound Email

Set up inbound email receiving so replies flow into your AWS account and trigger email.received events.

Inbound Quickstart
EventBridge Events

Full payload reference for email.received and every outbound SES event type.

Event Reference