# Reply Threading Guide

Source: https://wraps.dev/docs/guides/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

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

terminal.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:

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

send.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:

event.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".

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

## Filter Pattern

The single check you almost always want:

handler.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 `true` when 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:

handler.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:

terminal.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:

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

terminal.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:

send.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:

handler.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](https://wraps.dev/docs/quickstart/email/inbound)

EventBridge Events

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

[Event Reference](https://wraps.dev/docs/infrastructure/events)
