> ## Documentation Index
> Fetch the complete documentation index at: https://docs.lettr.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Building a Support Inbox with Inbound Email

> Turn customer emails into tickets with Lettr inbound: set up MX records and a forwarding URL, parse the relay payload, filter spam yourself and reply in-thread.

This guide builds a support inbox on Lettr: customers email an address such as `support@yourcompany.com`, each message reaches your application as a ticket, and agents' answers go back into the same conversation. Inbound email posts each message to your application as JSON and the send API sends the reply, so there is no mail server to run and no IMAP mailbox to poll.

***

## How the Flow Works

```
Customer → MX records → Lettr inbound servers → Forwarding URL (JSON POST) → Your app (ticket)
Your app (agent reply) → Send API → Customer
```

Lettr only moves mail: it accepts a message for your inbound domain, posts it to your forwarding URL and stops there. Everything else happens in your handler:

| Concern | Who handles it |
| - | - |
| Receiving mail over SMTP | Lettr |
| Delivering the message as JSON | Lettr |
| Spam and abuse filtering | Your handler |
| Creating the ticket, threading, assignment | Your handler |
| Storing attachments | Your handler (they are inside the raw message) |
| Sending the reply | Lettr send API |

> [!WARNING]
>
> Lettr does no inbound spam filtering. There is no spam score in the payload and no setting in the dashboard. Every message accepted for your inbound domain reaches your endpoint, spam included.

***

## Step 1: Pick an Address Layout

Use a dedicated subdomain for the support inbox, such as `support.example.com`. Mail to any address at an inbound domain goes to the same forwarding URL, so `help@`, `billing@` and `anything@` all arrive at one endpoint and are routed in code.

A subdomain also keeps your root domain's MX records pointing at your regular mailbox provider (Google Workspace, Microsoft 365), so staff email is unaffected.

> [!TIP]
>
> If customers should see `support@example.com` rather than `support@support.example.com`, forward from your existing mailbox to an address on the inbound domain, or point the root domain's MX records at Lettr only if that domain doesn't need a regular mailbox.

***

## Step 2: Build the Receiving Endpoint First

The forwarding URL is required. When you create the inbound domain, Lettr tests it with a `POST` request and refuses to create the domain unless your endpoint answers with a `2xx` status. The endpoint therefore has to exist and be publicly reachable before the domain is created in the dashboard.

Start with an endpoint that verifies the auth token and acknowledges the request:

```javascript theme={null}
import crypto from 'node:crypto';
import express from 'express';

const app = express();

function verifyToken(req, res, next) {
  const token = req.get('X-MessageSystems-Webhook-Token') ?? '';
  const expected = process.env.INBOUND_AUTH_TOKEN ?? '';

  const ok =
    token.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expected));

  return ok ? next() : res.sendStatus(401);
}

app.post('/webhooks/inbound', verifyToken, express.json({ limit: '50mb' }), async (req, res) => {
  for (const event of req.body) {
    const relay = event.msys?.relay_message;
    if (!relay) continue;

    await handleSupportEmail(relay);
  }

  res.sendStatus(200);
});
```

> [!NOTE]
>
> An endpoint behind a firewall or WAF that challenges unknown clients (a Cloudflare `403`, for example) fails the test: the dashboard shows the status your endpoint returned and the domain is not created. Allow server-to-server `POST` requests on that path. Lettr's mail infrastructure publishes no fixed source IP addresses, so rely on the auth token rather than an IP allowlist. See [Security](https://docs.lettr.com/learn/inbound/security.md#verify-the-auth-token).

***

## Step 3: Create the Inbound Domain and MX Records

**1. Create the inbound domain**

In the dashboard, go to **Domains** → **Inbound** and click **Create Inbound Domain**. Enter the **Domain Name** (`support.example.com`), your **Forwarding URL** (`https://yourapp.com/webhooks/inbound`) and an **Auth token**. The token is sent to your endpoint in the `X-MessageSystems-Webhook-Token` header.

**2. Add the MX records**

Add the three MX records the dashboard shows to your DNS. For a subdomain, set the host to `support`.

| Type | Priority | Value |
| - | - | - |
| MX | 10 | `rx1.sparkpostmail.com` |
| MX | 10 | `rx2.sparkpostmail.com` |
| MX | 10 | `rx3.sparkpostmail.com` |

All three are required.

**3. Verify**

Click **Verify MX Records** on the domain's page. When both the MX records and the forwarding URL are in place, the page shows **This domain is receiving mail**.

A domain with MX records but no forwarding URL shows **No forwarding URL**, and mail sent to it is accepted and then discarded. Check the status before you announce the address.

To look at incoming mail before DNS is set up, the [inbound sandbox](https://docs.lettr.com/learn/inbound/sandbox.md) gives every team an address for inspecting what arrives, but sandbox mail is never forwarded to your endpoint. To test the handler end to end, create a real inbound domain on a test subdomain.

Full details are in the [Inbound Email Setup](https://docs.lettr.com/learn/inbound/setup.md) guide.

***

## Step 4: Turn a Relay Message into a Ticket

Each request body is a JSON array. Every element carries one message in `msys.relay_message`:

| Field | What you use it for |
| - | - |
| `msg_from` | Envelope sender. Can be a bounce address for lists and bulk senders, so don't use it as the customer's address |
| `friendly_from` | `From` header with display name, usually the person who wrote |
| `rcpt_to` | Which address on your domain was written to (`support@`, `billing@`) |
| `content.subject` | Ticket title |
| `content.text` / `content.html` | Body. Either can be missing, so handle both. |
| `content.headers` | Array of single-key objects, including `Message-ID`, `In-Reply-To`, `References` |
| `content.email_rfc822` | The complete raw message, base64-encoded when `content.email_rfc822_is_base64` is `true` |

There is no attachments array. Attachments live only inside `email_rfc822`, so parse it with a MIME library such as `mailparser` when you need them. See [Attachments](https://docs.lettr.com/learn/inbound/attachments.md).

```javascript theme={null}
// Headers arrive as an array of single-key objects
function getHeader(headers = [], name) {
  const wanted = name.toLowerCase();
  for (const header of headers) {
    for (const [key, value] of Object.entries(header)) {
      if (key.toLowerCase() === wanted) return value;
    }
  }
  return undefined;
}

// "Name <a@b.com>" or "a@b.com" -> "a@b.com"
function extractAddress(value) {
  const match = value?.match(/<([^>]+)>/);
  return (match ? match[1] : value ?? '').trim();
}

// Reply-To header first, then the From header, then the envelope sender
function customerAddress(relay) {
  const replyTo = getHeader(relay.content.headers, 'Reply-To');
  return extractAddress(replyTo) || extractAddress(relay.friendly_from) || relay.msg_from;
}

async function handleSupportEmail(relay) {
  const { rcpt_to, content } = relay;
  const customer = customerAddress(relay);

  if (await isSpam(relay)) return; // still answer 2xx, see Step 5

  const messageId = getHeader(content.headers, 'Message-ID');
  const references = getHeader(content.headers, 'References');

  // Reuse the ticket if this message belongs to an existing conversation
  let ticket = await findTicketByThread({
    inReplyTo: getHeader(content.headers, 'In-Reply-To'),
    references,
  });

  if (!ticket) {
    ticket = await createTicket({
      from: customer,
      mailbox: rcpt_to.toLowerCase(),
      subject: content.subject,
    });
  }

  await addMessage(ticket.id, {
    from: customer,
    body: content.text || content.html,
    messageId,
    references,
    subject: content.subject,
  });
}
```

Resolve the customer's address in that order: the `Reply-To` header, then the address in `friendly_from`, with `msg_from` only as a last fallback. Store each inbound `Message-ID`, because Step 6 needs it to reply in-thread. Lettr's send API returns a `request_id`, not the `Message-ID` of the email it sent, so the stored IDs come from the customer's messages. For the complete field list see [Email Parsing](https://docs.lettr.com/learn/inbound/parsing.md).

***

## Step 5: Filter Spam in Your Handler

A public support address receives spam, bounce notices and auto-replies, and all of it reaches the handler. Three rules keep the queue clean:

* **Always answer `2xx`**, including for messages you drop. The mail was already accepted by the time it reaches you, so an error status cannot reject it back to the sender. It only makes the delivery to your endpoint look failed.
* **Treat sender fields as claims.** `msg_from` and `friendly_from` are set by whoever sent the message. When the receiving server added an `Authentication-Results` header, a `dmarc=fail` is a strong signal. A missing header means "unknown", not "pass".
* **Skip automatic mail.** Don't open tickets for auto-replies, and never auto-reply to them, or two autoresponders can loop.

```javascript theme={null}
async function isSpam(relay) {
  const { content } = relay;

  // Auto-replies and bulk mail
  const autoSubmitted = (getHeader(content.headers, 'Auto-Submitted') ?? 'no').toLowerCase();
  if (autoSubmitted !== 'no') return true;

  const precedence = (getHeader(content.headers, 'Precedence') ?? '').toLowerCase();
  if (['bulk', 'junk', 'list'].includes(precedence)) return true;

  // Failed authentication, when the header is present
  const authResults = getHeader(content.headers, 'Authentication-Results');
  if (authResults && /dmarc=fail/i.test(authResults)) return true;

  // Add your own checks: blocked senders, rate per sender, a third-party filter
  return false;
}
```

More patterns, including quarantining instead of dropping, are in [Spam Filtering](https://docs.lettr.com/learn/inbound/spam-filtering.md).

***

## Step 6: Reply with the Send API

Replies written in your ticket UI go out through `POST /emails`. Three details make the reply land in the customer's existing conversation:

* **`from`** must be an address on a verified sending domain. The inbound domain is for receiving. Sending is configured separately under [sending domains](https://docs.lettr.com/learn/domains/sending-domains.md).
* **`reply_to`** is the support address on your inbound domain, so the customer's next message comes back to your endpoint. To tie the reply to a ticket, put the ticket ID in it with a plus-style address such as `support+t-4821@support.example.com`, then read it from `rcpt_to`. Use lowercase letters, digits and hyphens in the ID.
* **`headers`** can carry `In-Reply-To` and `References`. The send API allows both: it blocks only a fixed list of headers it manages itself (such as `From`, `To`, `Subject`, `Message-ID`, `Reply-To` and `List-Unsubscribe`) and accepts up to 10 custom headers, each a string up to 998 characters. Set `In-Reply-To` to the customer's last `Message-ID` and `References` to their `References` value followed by that `Message-ID`.

```bash theme={null}
curl -X POST https://app.lettr.com/api/emails \
  -H "Authorization: Bearer lttr_your_api_key" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: ticket-4821-reply-17" \
  -d '{
    "from": "support@example.com",
    "from_name": "Example Support",
    "to": ["customer@example.net"],
    "reply_to": "support+t-4821@support.example.com",
    "subject": "Re: Help with my order",
    "text": "Hi Alex, your order shipped this morning. Tracking: ...",
    "headers": {
      "In-Reply-To": "<abc123@example.net>",
      "References": "<first-message@example.net> <abc123@example.net>"
    },
    "metadata": { "ticket_id": "4821" }
  }'
```

The `Idempotency-Key` header means a retry after a network error doesn't email the customer twice. Keep the `Re:` prefix and the original subject too: some clients thread by subject when headers are missing or rewritten.

> [!NOTE]
>
> Threading with `In-Reply-To` and `References` depends on the customer's mail client. Most honor it, but Lettr can't guarantee how a given client groups messages. The reply address and subject are the reliable parts. Don't depend on custom headers such as `X-Ticket-ID` for matching: clients don't copy them into replies.

If the same `from` address will receive customer replies too, don't send replies from `no-reply@`. Use a monitored address, and set `reply_to` so answers reach the inbox you built.

***

## Common Problems

> **The domain won't create: 'Your forwarding URL answered SparkPost with HTTP 403'**
>
> A firewall or WAF is blocking the test `POST`. Allow server-to-server requests on the forwarding URL's path and try again. The domain is created only after your endpoint answers `2xx`.

> **MX records verify, but no tickets appear**
>
> Check the domain's status. **No forwarding URL** means mail is accepted and discarded. Open the **Mail Forwarding** card and add one. Also confirm your handler returns `2xx` and doesn't throw on messages with only an HTML part.

> **Mail to the domain goes to the wrong server**
>
> A domain without MX records falls back to its A record, so mail lands on your web host. Add all three MX records and re-run **Verify MX Records**. Allow time for DNS propagation.

> **Replies start new tickets instead of joining the thread**
>
> Match on more than one signal: the ticket ID in the `reply_to` address first, then `In-Reply-To` and `References` against stored `Message-ID` values, then a subject token. Customers edit subjects and some clients drop headers.

***

## Related Topics

> **[Inbound Email Setup](https://docs.lettr.com/learn/inbound/setup.md)**
>
> Create the inbound domain, MX records and forwarding URL

> **[Reply Tracking](https://docs.lettr.com/learn/inbound/reply-tracking.md)**
>
> Match replies to the emails you sent

> **[Spam Filtering](https://docs.lettr.com/learn/inbound/spam-filtering.md)**
>
> Build spam handling into your forwarding URL

> **[Inbound Security](https://docs.lettr.com/learn/inbound/security.md)**
>
> Verify the auth token and validate content


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.