# Inbound email parsing: receive, thread, and reply
URL: https://samva.dev/guides/inbound-email-parsing
> Build an inbound email path that verifies signed webhooks, reads the parsed reply in its conversation, and sends a threaded response.
- Published: 2026-09-19



Inbound email matters when a reply changes what your product does next. A support reply can update a
ticket, a customer response can unblock an approval, and a forwarded message can become an
application event. The useful integration is a small, verified path from a received email to the
conversation your application already understands.

This page is the minimum working path with the TypeScript SDK. The complete, runnable recipe lives in
the [inbound email cookbook](https://github.com/SamvaHQ/samva/blob/HEAD/cookbooks/inbound-email.md),
including the full example, durable deduplication, and production hardening.

To start from code, use the [inbound replies example](https://github.com/SamvaHQ/samva/tree/main/examples/inbound-email-replies).
It includes a webhook handler and local tests that verify requests without sending email. Replace
the example's in-memory deduplication set with durable storage before using it in production.

## Configure receiving [#configure-receiving]

Start with a verified domain and a public HTTPS endpoint. Creating the endpoint returns a signing
secret; enabling receiving with that endpoint ID starts routing mail for the domain and reports the
recipient addresses it accepts. Publish the root MX record Samva proposes before mail can route, and use a
dedicated subdomain when an existing mailbox owns your primary MX route.

```ts title="configure-inbound.ts"
import { createClient } from "samva";

const samva = createClient({ apiKey: process.env.SAMVA_API_KEY! });

const endpoint = await samva.webhooks.create({
  name: "Inbound replies",
  url: "https://app.example.com/webhooks/samva",
  eventTypes: ["message.received"],
  channels: ["email"],
});

await samva.email.enableReceiving({
  id: process.env.SAMVA_DOMAIN_ID!,
  domain: "replies.example.com",
  catchAll: true,
  endpointId: endpoint.endpoint.id,
});
```

Endpoint names are unique per organization. If enabling receiving fails after you create the
endpoint, list endpoints and reconcile the existing one with `webhooks.update` instead of creating a
second. A signing secret is shown only once; rotate it with `webhooks.regenerateSecret` if the first
response was lost.

### Choose a destination [#choose-a-destination]

A catch-all domain delivers to a webhook endpoint, to forwarding rules, or to both. Pass `endpointId`
to deliver `message.received` to a webhook. Leave it out to deliver through forwarding rules alone;
that needs at least one active rule that covers the domain, and the request fails without one. Mail
that matches neither a rule nor an endpoint is rejected as an unknown recipient, so Samva never
accepts a message it has nowhere to send. A forwarding-only domain publishes no `message.received`.

## Move a domain's receiving to Samva [#move-a-domains-receiving-to-samva]

Moving receiving from another provider, such as Cloudflare Email Routing, changes the domain's MX
record. Sending setup never touches it. The domain's **Overview** lists what is left, in order, with
the live state of each step:

1. **Sending verified.** Settings appear after the domain's DNS verifies for sending.
2. **Destination.** Choose a webhook or add a forwarding rule in Settings.
3. **Receiving on.** Enable catch-all in Settings.
4. **Inbound MX.** Samva looks up the domain's MX record and names the target that currently takes
   the mail. Replace it with the record shown, or let your DNS provider add it when one-click setup
   is available. The record appears only after the first three steps hold.

Read the same status from the API with
[`GET /v1/email/domains/:id/receiving-status`](/docs/api-reference/email/getReceivingStatus), or from
the CLI with `samva email receiving status <domain-id>`.

## Verify, read the thread, and reply [#verify-read-the-thread-and-reply]

`message.received` carries parsed fields including `from`, `to`, `subject`, `conversationId`,
`hasAttachments`, and `isAutoReply`, plus a parsed body when available. Verify the raw request before
parsing JSON, then read the conversation and reply on the same thread.

```ts title="webhooks/samva.ts"
import { createClient } from "samva";
import { verifyRequest } from "samva/webhooks";

const samva = createClient({ apiKey: process.env.SAMVA_API_KEY! });

export async function POST(request: Request) {
  const verified = await verifyRequest(request, process.env.SAMVA_WEBHOOK_SECRET!);
  if (verified.event.type !== "message.received") return new Response(null, { status: 204 });

  const reply = verified.event.data as {
    messageId: string;
    conversationId: string;
    from: string;
    subject?: string;
    isAutoReply?: boolean;
  };

  if (reply.isAutoReply) return new Response(null, { status: 204 });

  const conversation = await samva.conversations.getById({ id: reply.conversationId });

  await samva.email.send(
    {
      conversationId: conversation.id,
      to: reply.from,
      subject: reply.subject ? `Re: ${reply.subject}` : "Re: your message",
      html: "<p>Thanks for your reply. We will follow up shortly.</p>",
      inReplyToMessageId: reply.messageId,
    },
    { headers: { "idempotency-key": verified.id } },
  );

  return new Response(null, { status: 204 });
}
```

Filter `isAutoReply` so an out-of-office response never triggers another reply. Deduplicate on
`webhook-id`, the event id that is stable across retries, and pass it as the `idempotency-key` header
so a concurrent retry cannot double-send while a transient failure stays retryable. Keep
`conversationId` and `inReplyToMessageId` so your application and the recipient see one continuous
conversation.

## Read the full email [#read-the-full-email]

The webhook carries the fields a router needs. When the handler needs more, read the message's email details with its `messageId`. They include the addresses beyond `from` and `to`, the parsed threading headers, `receivedAt` (when Samva's receiving server accepted the email, not the sender's `Date` header), and signed, time-limited links to the HTML, the text, and the raw MIME:

```ts title="read-email.ts"
import { createClient } from "samva";

const samva = createClient({ apiKey: process.env.SAMVA_API_KEY! });

export async function readEmail(messageId: string) {
  const email = await samva.messages.getEmail({ id: messageId, includeDownloadUrls: "true" });

  console.log(email.fromEmail, email.toEmails, email.ccEmails, email.replyToEmails);
  console.log(email.messageIdHeader, email.inReplyTo, email.references);
  console.log(email.isAutoReply, email.autoReplyReason);

  return email;
}
```

`isAutoReply` is a boolean in the webhook, and `autoReplyReason` here says why: `out-of-office`, `auto-response`, `delivery-status`, or `read-receipt`. Treat all four as machine mail, and never answer a `delivery-status` with another email. The [API reference](/docs/api-reference/messages/getEmail) lists every field.

## Download attachments [#download-attachments]

`hasAttachments` tells you whether to look. List the attachments for the message, then ask for a download link for each one. The link expires, so fetch the file when you need it and store your own copy if you must keep it.

```ts title="download-attachments.ts"
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { createClient } from "samva";

const samva = createClient({ apiKey: process.env.SAMVA_API_KEY! });

export async function saveAttachments(messageId: string, directory: string) {
  await mkdir(directory, { recursive: true });
  const { attachments } = await samva.attachments.list({ messageId });

  for (const attachment of attachments) {
    if (attachment.isInline) continue; // images embedded in the HTML body
    const { url } = await samva.attachments.downloadUrl({ id: attachment.id });
    const response = await fetch(url);
    if (!response.ok) throw new Error(`Download failed: ${response.status}`);
    // Name the file by its id: the sender chose `attachment.filename`, so never build a path from it.
    await writeFile(join(directory, attachment.id), Buffer.from(await response.arrayBuffer()));
  }
}
```

Each attachment has an `id`, `filename`, `contentType`, `size`, and `isInline`, with a `contentId` for inline images. Check the content type and size before you open a file, and keep the sender's filename as display text only.

## How a reply finds its conversation [#how-a-reply-finds-its-conversation]

Samva places each inbound email in a conversation using the headers the sender's mail client preserves. The reply's `In-Reply-To` and `References` headers point at the email it answers, and Samva checks them first. An email that carries no such headers is matched by its subject and participants instead. An email whose headers name a parent Samva cannot find starts a new conversation and is not matched by subject, so a reply to an email sent from another system can open a separate thread.

Outbound email carries the headers replies need, so a customer who answers in their mail client lands in the right conversation without any work from you. The other direction is the part your app owns. A send joins a conversation only when you pass the conversation or the message it replies to, so keep `conversationId` and `inReplyToMessageId` from the inbound event and send them with the answer. Keep the `Re:` subject as well, so the customer sees one thread in their inbox.

Samva does not strip quoted history or signatures from the body. You get what the sender wrote, including the earlier messages the client quoted below it.

## Receive on a subdomain [#receive-on-a-subdomain]

Receiving moves the root MX records for the domain you enable. If your team's mailboxes already run on `example.com`, enable receiving on a subdomain such as `reply.example.com` instead, and set the `reply-to` of your outbound email to an address there. Customer replies reach your app, and your team's inbox stays untouched.

The [inbound email API](/features/inbound-email#inbound-compare) page puts this path next to a parse webhook, and its [FAQ](/features/inbound-email#inbound-faq) answers what the webhook contains. Support apps that file the reply on a ticket start at [support email](/solutions/support). An agent that should own the address starts at [mailboxes](/features/mailboxes).

The [cookbook](https://github.com/SamvaHQ/samva/blob/HEAD/cookbooks/inbound-email.md)
covers the durable deduplication store, MX and subdomain handling, and the runnable
[example](https://github.com/SamvaHQ/samva/tree/HEAD/examples/inbound-email-replies).

For the event schema, see the [webhook event catalog](/docs/platform/webhook-events). For endpoint
verification and retry behavior, see [receive webhooks](/docs/platform/webhooks).

## FAQ

### What does an inbound email webhook contain?

A message.received event includes the sender, recipient, subject, conversation ID, attachment and auto-reply flags, plus a parsed body when available. Verify the signature before reading it.

### How do I get the attachments from an inbound email?

The webhook sets hasAttachments. List the attachments for the message id, then request a time-limited download link for each one by id. Each attachment carries its filename, content type, size, and whether it is inline.

### Why did my reply start a new conversation?

A send joins a conversation only when you pass the conversation or the message it replies to. Send with conversationId and inReplyToMessageId from the inbound event, and keep the Re: subject.

### How do I reply in the same email thread?

Fetch the conversation from the inbound event, then send with its conversationId and the inbound message ID as email.inReplyToMessageId.
## Related

- [Design email for humans and AI agents](/blog/design-email-for-humans-and-agents): Write the ask as text, then let an agent reply on the same thread.
- [Migrate SendGrid Inbound Parse](/guides/sendgrid-inbound-migration): Map payloads, verify webhooks, retrieve attachments, and plan an MX cutover.
- [Migrate from Resend](/guides/migrate-from-resend): Map receiving, conversations, and the MX cutover from Resend.
- [Inbound email API](/features/inbound-email): Compare a parse webhook with the inbound email API, then read the FAQ.
- [inbound.new alternative](/compare/inbound-new): How Samva and inbound.new each receive mail, thread replies, and send.
- [Support replies](/solutions/support): Put a customer reply on the ticket it answers.
- [Email for AI agents](/features/mailboxes): An address your agent owns, with a scoped key, approvals, and quarantine.
- [Receive webhooks](/docs/platform/webhooks): Register an endpoint and verify each signed delivery.
