# Inbound Mail

## Overview

Inbound routes allow you to receive emails and process them programmatically via webhooks. Every inbound route gets a unique email address, and you can optionally configure custom domains for branded receiving addresses.

:::info
Inbound routes are available on **Starter plans and above**. Check your plan's feature availability in the dashboard.
:::

## How Inbound Mail Works

1. **Email arrives** at your inbound route address
2. **Spam filtering** checks the message against your configured threshold
3. **Email parsing** extracts headers, body, attachments, and metadata
4. **Webhook delivery** sends the complete email data to your endpoint
5. **Your application** processes the email and takes action

## Getting Started

### Step 1: Create an Inbound Route

1. Navigate to your project in the dashboard
2. Go to **Routes** → **Create Route**
3. Select **Inbound** as the route type
4. Configure your spam filtering preferences
5. Save the route

### Step 2: Get Your Inbound Address

Once created, your route receives a unique email address:

```
{route-uuid-without-dashes}@inbound.lettermint.co
```

Example: `a1b2c3d4e5f67890abcdef1234567890@inbound.lettermint.co`

:::tip
You can find your inbound address on the route details page in your dashboard. Copy it to start receiving emails immediately.
:::

### Step 3: Configure Webhooks

To process received emails, set up a webhook endpoint:

1. In your route settings, go to **Webhooks**
2. Create a new webhook
3. Enter your endpoint URL
5. Save and test the webhook

See [Webhook Events](/platform/webhooks/events#messageinbound) for the complete payload structure.

## Custom Domains

Use your own domain for inbound email addresses instead of the default `@inbound.lettermint.co` domain. Custom domains act as a **catch-all** — any email sent to your domain is routed to your webhook, regardless of what comes before the `@`.

For example, with the custom domain `support.acme.com`, all of these addresses deliver to the same inbound route:

```
help@support.acme.com
billing@support.acme.com
anything-you-want@support.acme.com
```

This makes custom domains ideal for support systems, reply tracking, and any workflow where you need flexible receiving addresses. Combined with [subaddress support](#subaddress-support), you can use `+` tags to route and categorize emails within your application.

### Setting Up a Custom Domain

### Configure custom domain

In your inbound route settings, enter your desired custom domain (e.g., `support.acme.com`)
  ### Add MX record

Add an MX record to your DNS pointing to Lettermint's inbound servers:

    | Type | Hostname | Priority | Value |
    |------|----------|----------|-------|
    | MX | `support.acme.com` | 10 | `inbound.lettermint.co` |
  ### Verify domain

Return to your route settings and click **Verify MX Record**. Verification typically completes within minutes.
  ### Start receiving

Once verified, emails sent to `anything@support.acme.com` will be routed to your webhook.
  :::warning
Custom domain MX records must point exclusively to Lettermint. If you need to receive emails at the same domain through multiple providers, use a subdomain like `inbound.yourdomain.com`.
:::

### DNS Propagation

DNS changes can take time to propagate globally:

```bash
# Check if your MX record is set correctly
dig MX support.acme.com

# Expected output should show:
# support.acme.com.  300  IN  MX  10 inbound.lettermint.co.
```

**Troubleshooting MX verification:**
- DNS changes typically propagate in 5-60 minutes
- Some DNS providers cache records for up to 24 hours
- Ensure your MX record points to `inbound.lettermint.co`
- Check that the hostname is correct (not doubled like `support.acme.com.acme.com`)

## Spam Filtering

Lettermint automatically scans all inbound emails using Rspamd spam filtering. You can configure how strictly emails are filtered.

### Spam Threshold

The spam threshold determines what happens to messages based on their spam score:

**`threshold`** (number, default: `5.0`)

Spam score threshold. Messages with a higher score are marked as spam.

- **5.0** (default): Standard filtering, rejects obvious spam
- **10.0**: Lenient, allows most emails through
- **2.0**: Strict, may flag legitimate emails as spam
- **null**: Spam filtering OFF, accepts all emails, but will still scan and report spam scores

:::warning
Setting the threshold to `null` (OFF) will accept all emails including obvious spam. Only use this for testing or when you implement your own spam filtering.
:::

### Spam Detection Results

Every inbound webhook includes spam scoring information:

```json
{
  "is_spam": false,
  "spam_score": 2.4,
  "spam_symbols": [
    {
      "name": "DKIM_VALID",
      "score": -0.1,
      "options": [],
      "description": "Message has valid DKIM signature"
    },
    {
      "name": "SPF_PASS",
      "score": -0.1,
      "options": [],
      "description": "SPF check passed"
    },
    {
      "name": "BAYES_HAM",
      "score": -3.0,
      "options": [],
      "description": "Bayesian classifier identified message as non-spam"
    }
  ]
}
```

Your application can decide how to handle emails based on these values.

## Attachment Delivery Mode

Choose how attachments are delivered in webhook payloads:

**`attachment_delivery`** (string, default: `"inline"`)

How attachments are delivered in webhooks.

- **`inline`** (default): Attachments include base64-encoded `content` in the payload
- **`url`**: Attachments include a signed `url` for downloading (no `content` field)

### Inline Mode (Default)

Attachments are embedded directly in the webhook payload as base64-encoded content:

```json
{
  "attachments": [{
    "filename": "document.pdf",
    "content": "JVBERi0xLjQK...",
    "url": null,
    "content_type": "application/pdf",
    "size": 45678,
    "content_id": null
  }]
}
```

### URL Mode

Attachments are stored and a signed download URL is provided instead:

```json
{
  "attachments": [{
    "filename": "document.pdf",
    "content": null,
    "url": "https://storage.lettermint.co/inbound/attachments/abc123/0?expires=...&signature=...",
    "expires_at": "2025-10-30T14:30:00+00:00",
    "content_type": "application/pdf",
    "size": 45678,
    "content_id": null
  }]
}
```

:::info
**URL expiration:** Download URLs are valid for **28 days** (matching message retention). The `expires_at` field contains an ISO 8601 timestamp indicating exactly when the URL becomes invalid. After expiration, the attachment is no longer accessible.
:::

### When to Use URL Mode

- **Large attachments**: Reduces webhook payload size significantly
- **Deferred processing**: Download attachments only when needed
- **Bandwidth optimization**: Avoid transferring attachments you may not need

### Configuration

Configure via the dashboard in your inbound route settings, or via the [Team API](/platform/teams/team-api/introduction):

```bash
curl -X PUT "https://api.lettermint.co/v1/routes/{routeId}" \
  -H "Authorization: Bearer lm_team_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "inbound_settings": {
      "attachment_delivery": "url"
    }
  }'
```

## Subaddress Support

Lettermint supports email subaddressing (also known as plus addressing) for routing and filtering:

```
johndoemail+lm@support.acme.com
└──┬──┘└┬┘
    user    tag
```

### Use Cases

**Support ticket categorization:**
```
support+billing@acme.com  → Route to billing team
support+technical@acme.com → Route to tech support
```

**User identification:**
```
orders+user123@acme.com → Link to user account #123
```

**A/B testing email addresses:**
```
newsletter+variant-a@acme.com
newsletter+variant-b@acme.com
```

### Webhook Payload

Subaddresses are parsed and included in the webhook payload:

```json
{
  "from": {
    "email": "customer@example.com",
    "subaddress": null
  },
  "recipient": "support+billing@acme.com",
  "subaddress": "billing"
}
```

## Webhook Payload

When an email is received, Lettermint sends a `message.inbound` webhook with complete email data.

### Example Payload

```json
{
  "id": "abcdf1234-asdf",
  "event": "message.inbound",
  "created_at": "2025-10-02T14:30:00.000Z",
  "data": {
    "route": "support-inbox",
    "message_id": "msg_a1b2c3d4",
    "envelope": {
      "remote_ip": "203.0.113.45",
      "remote_hostname": "mail.example.com",
      "helo": "mail.example.com",
      "mail_from": "customer@example.com"
    },
    "from": {
      "email": "customer@example.com",
      "name": "John Doe",
      "subaddress": null
    },
    "to": [
      {
        "email": "support@acme.com",
        "name": null,
        "subaddress": null
      }
    ],
    "cc": [],
    "recipient": "support@acme.com",
    "subaddress": null,
    "reply_to": "customer@example.com",
    "subject": "Question about my order",
    "date": "2025-10-02T14:30:00.000Z",
    "body": {
      "text": "Hi, I have a question...",
      "html": "<p>Hi, I have a question...</p>"
    },
    "tag": null,
    "headers": [
      {
        "name": "Message-ID",
        "value": "<abc123@mail.example.com>"
      }
    ],
    "attachments": [
      {
        "filename": "receipt.pdf",
        "content": "JVBERi0xLjQKJ...",
        "url": null,
        "content_type": "application/pdf",
        "size": 45678,
        "content_id": null
      }
    ],
    "is_spam": false,
    "spam_score": 1.2,
    "spam_symbols": [
      {
        "name": "DKIM_VALID",
        "score": -0.1,
        "options": [],
        "description": "Message has valid DKIM signature"
      },
      {
        "name": "SPF_PASS",
        "score": -0.1,
        "options": [],
        "description": "SPF check passed"
      }
    ]
  }
}
```

### Payload Fields

| Field | Type | Description |
|-------|------|-------------|
| `route` | string | Route slug/identifier |
| `message_id` | string | Unique message identifier |
| `envelope.remote_ip` | string\|null | IP address of the sending server |
| `envelope.remote_hostname` | string\|null | Hostname of the sending server (from rDNS lookup) |
| `envelope.helo` | string\|null | HELO/EHLO hostname from SMTP handshake |
| `envelope.mail_from` | string\|null | SMTP envelope sender (MAIL FROM) |
| `from.email` | string | Sender email address |
| `from.name` | string\|null | Sender display name |
| `from.subaddress` | string\|null | Parsed subaddress from sender |
| `to` | array | List of TO recipients |
| `cc` | array | List of CC recipients |
| `recipient` | string | Primary recipient (envelope TO) |
| `subaddress` | string\|null | Parsed subaddress from recipient |
| `reply_to` | string\|null | Reply-To address |
| `subject` | string | Email subject |
| `date` | string | ISO 8601 timestamp |
| `body.text` | string\|null | Plain text body |
| `body.html` | string\|null | HTML body |
| `tag` | string\|null | Custom tag from X-LM-Tag header |
| `headers` | array | All email headers (excluding standard ones) |
| `attachments` | array | File attachments (see [Attachment Delivery Mode](#attachment-delivery-mode)) |
| `is_spam` | boolean | Whether message exceeded spam threshold |
| `spam_score` | number | Calculated spam score |
| `spam_symbols` | array | Array of spam rule objects with `name`, `score`, `options`, and `description` |

:::info
Attachment content is base64-encoded. Decode it before saving or processing files.
:::

## Processing Attachments

Handle attachments based on your route's [delivery mode](#attachment-delivery-mode):

<CodeTabs>

```javascript title="Node.js"
const fs = require('fs');
const https = require('https');

async function saveAttachment(attachment, filename) {
  if (attachment.content) {
    // Inline mode: decode base64 content
    const buffer = Buffer.from(attachment.content, 'base64');
    fs.writeFileSync(filename, buffer);
  } else if (attachment.url) {
    // URL mode: check expiry, then download from signed URL
    if (new Date(attachment.expires_at) < new Date()) {
      throw new Error('Attachment URL has expired');
    }
    const response = await fetch(attachment.url);
    const buffer = Buffer.from(await response.arrayBuffer());
    fs.writeFileSync(filename, buffer);
  }
}

// Usage
const attachment = data.attachments[0];
await saveAttachment(attachment, attachment.filename);
```

```python title="Python"
import base64
import requests
from datetime import datetime, timezone

def save_attachment(attachment, filename):
    if attachment.get('content'):
        # Inline mode: decode base64 content
        content = base64.b64decode(attachment['content'])
        with open(filename, 'wb') as f:
            f.write(content)
    elif attachment.get('url'):
        # URL mode: check expiry, then download from signed URL
        expires_at = datetime.fromisoformat(attachment['expires_at'])
        if expires_at < datetime.now(timezone.utc):
            raise ValueError('Attachment URL has expired')
        response = requests.get(attachment['url'])
        with open(filename, 'wb') as f:
            f.write(response.content)

# Usage
attachment = data['attachments'][0]
save_attachment(attachment, attachment['filename'])
```

```php title="PHP"
function saveAttachment(array $attachment, string $filename): void
{
    if (!empty($attachment['content'])) {
        // Inline mode: decode base64 content
        $content = base64_decode($attachment['content']);
        file_put_contents($filename, $content);
    } elseif (!empty($attachment['url'])) {
        // URL mode: check expiry, then download from signed URL
        $expiresAt = new DateTimeImmutable($attachment['expires_at']);
        if ($expiresAt < new DateTimeImmutable()) {
            throw new RuntimeException('Attachment URL has expired');
        }
        $content = file_get_contents($attachment['url']);
        file_put_contents($filename, $content);
    }
}

// Usage
$attachment = $data['attachments'][0];
saveAttachment($attachment, $attachment['filename']);
```

</CodeTabs>

## Best Practices

### Security

- **Validate webhook signatures**: Always verify webhook authenticity using [signed webhooks](/platform/webhooks/signing)
- **Sanitize email content**: Email bodies can contain malicious HTML or scripts
- **Scan attachments**: Run virus scans on attachments before processing
- **Rate limiting**: Implement rate limits to prevent abuse

### Performance

- **Async processing**: Process emails in background jobs, not during webhook request
- **Return 200 quickly**: Acknowledge webhook receipt within 5 seconds
- **Handle retries**: Lettermint retries failed webhooks, design idempotent handlers

## Use Cases

### Support Ticket System

Receive support emails and automatically create tickets:

```javascript
// Webhook handler
app.post('/webhooks/inbound', async (req, res) => {
  const email = req.body.data;

  // Create support ticket
  await createTicket({
    from: email.from.email,
    subject: email.subject,
    body: email.body.text,
    attachments: email.attachments,
    category: email.subaddress // Use subaddress for routing
  });

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

### Email to Task Converter

Parse emails and create tasks in your project management system:

```python
@app.route('/webhooks/inbound', methods=['POST'])
def handle_inbound():
    email = request.json['data']

    # Parse email subject for task details
    task = parse_task_from_subject(email['subject'])

    # Create task in your system
    create_task(
        title=task['title'],
        description=email['body']['text'],
        assignee=email['subaddress'],  # Use subaddress for assignment
        attachments=email['attachments']
    )

    return '', 200
```

### Reply Tracking

Track replies to your outbound emails:

```php
// Send email with reply tracking
$client->email
    ->from('noreply@acme.com')
    ->to('customer@example.com')
    ->replyTo('replies+order-123@acme.com')  // Unique reply address
    ->subject('Your order #123')
    ->send();

// Process reply
Route::post('/webhooks/inbound', function (Request $request) {
    $email = $request->input('data');
    $orderId = $email['subaddress']; // Extract "order-123"

    // Link reply to order
    Order::find($orderId)->addReply($email);
});
```

## Limitations

- **Maximum email size**: 25 MB (including attachments)
- **Webhook timeout**: 30 seconds
- **Delivery attempts**: Failed deliveries are retried automatically with exponential backoff. See [Webhook delivery and retries](/platform/webhooks/introduction#delivery-and-retries)
- **Custom domains**: Requires exclusive MX record pointing to Lettermint

## Next Steps

- [**Webhook Events**](/platform/webhooks/events#messageinbound) - Complete `message.inbound` payload reference
- [**Signed Webhooks**](/platform/webhooks/signing) - Verify webhook authenticity
- [**Routes Overview**](/platform/projects-and-routes/routes) - Learn about other route types
