Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 23 additions & 24 deletions sections/inbound-email/_intro.md
Original file line number Diff line number Diff line change
@@ -1,38 +1,37 @@
# Inbound Email

Create and reply to tickets directly from incoming emails. Escalated supports **Mailgun**, **Postmark**, **AWS SES** webhooks, and **IMAP** polling as a fallback.
Create and reply to tickets directly from incoming emails. Escalated supports **Postmark** and **Mailgun** webhooks out of the box, with an open parser interface for plugging in additional providers.

## How It Works

1. Your email provider receives a message at your support address (e.g., `support@yourapp.com`)
2. The provider forwards it to your app via webhook (or IMAP polling fetches it)
3. Escalated normalizes the payload and checks for thread matches via subject reference (e.g., `[ESC-00001]`) or `In-Reply-To` headers
4. Matched emails add a reply; unmatched emails create a new ticket (or guest ticket for unknown senders)
1. Your email provider receives a message at your support address (e.g., `support@yourapp.com`).
2. The provider forwards it to your app via webhook.
3. Escalated normalizes the payload and resolves the message to an existing ticket using, in order: canonical `Message-ID` headers (`<ticket-{id}@{domain}>`), signed `Reply-To` verification (`reply+{id}.{hmac8}@{domain}`), and subject-reference tags (`[ESC-00001]`).
4. Matched emails add a reply; unmatched emails create a new ticket (or are skipped if they're noise — SNS subscription confirmations, empty body+subject).

## Configuration
## Webhook model

Enable inbound email and configure your adapter in admin settings, or via environment variables. Admin settings take priority over env/config values.
All greenfield framework ports (.NET, Spring, Go, Phoenix, Symfony) expose a **single unified webhook endpoint** and select the parser via the `adapter` query parameter or `X-Escalated-Adapter` header. Per-framework routes:

## Webhook URLs

Point your email provider's inbound webhook to these URLs. These routes require no authentication (they use signature verification instead).

| Provider | Webhook URL |
| Framework | Webhook URL |
| --- | --- |
| Mailgun | `POST /support/inbound/mailgun` |
| Postmark | `POST /support/inbound/postmark` |
| AWS SES | `POST /support/inbound/ses` |
| .NET | `POST /support/webhook/email/inbound?adapter=<postmark\|mailgun>` |
| Spring Boot | `POST /escalated/webhook/email/inbound?adapter=<postmark\|mailgun>` |
| Go | `POST /escalated/webhook/email/inbound?adapter=<postmark\|mailgun>` |
| Phoenix | `POST /support/webhook/email/inbound?adapter=<postmark\|mailgun>` |
| Symfony | `POST /escalated/webhook/email/inbound?adapter=<postmark\|mailgun>` |

The legacy host-app integrations (Laravel, Rails, Django, Adonis, Filament, WordPress) expose provider-specific endpoints — see the respective framework page for the exact URL.

## IMAP Polling
## Authentication

For email providers without webhook support, use the IMAP polling command on a schedule.
Webhooks are guarded by a shared secret. Your provider forwards it as `X-Escalated-Inbound-Secret`; the framework verifies it with a timing-safe comparison. The same secret is used to sign outbound `Reply-To` addresses (`reply+{id}.{hmac8}@{domain}`), so forged emails targeting a stolen reply address are rejected.

## Features

- **Thread detection** via subject reference and In-Reply-To / References headers
- **Guest tickets** for unknown senders with auto-derived display names
- **Auto-reopen** resolved or closed tickets when a reply arrives via email
- **Duplicate detection** via Message-ID headers to prevent duplicate processing
- **Attachment handling** with configurable size and count limits
- **Audit logging** -- every inbound email is recorded for debugging and compliance
- **Admin configurable** -- all settings manageable from the admin panel with env/config fallback
- **Thread detection** via canonical `Message-ID` / `In-Reply-To` / `References` headers, signed `Reply-To` verification, and subject-reference tags.
- **Signed Reply-To addresses** — outbound email embeds an HMAC so replies that strip threading headers still find the right ticket, and forged addresses are rejected.
- **Guest tickets** for unknown senders with auto-derived display names.
- **Noise filtering** — SNS subscription confirmations and empty body+subject messages are skipped rather than creating a ticket.
- **Attachment passthrough** — provider-hosted attachments (Mailgun's larger files) surface as `pending_attachment_downloads` for a background worker to fetch and persist out-of-band.
- **Audit logging** — every inbound email is recorded for debugging and compliance.
84 changes: 84 additions & 0 deletions sections/inbound-email/dotnet.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
### Webhook endpoint

The .NET bundle exposes a single webhook for all providers. Configure Postmark and/or Mailgun to POST inbound mail to:

```
POST /support/webhook/email/inbound?adapter=postmark
POST /support/webhook/email/inbound?adapter=mailgun
```

You can also pass the adapter via the `X-Escalated-Adapter` header instead of a query parameter.

### Configuration

Set the shared inbound secret and the mail domain (used for signed `Reply-To` and canonical `Message-ID` headers) in `appsettings.json` or via environment variables:

```json
{
"Escalated": {
"Mail": {
"Domain": "support.yourapp.com",
"InboundSecret": "a-long-random-value"
}
}
}
```

```bash
# .env
Escalated__Mail__Domain=support.yourapp.com
Escalated__Mail__InboundSecret=a-long-random-value
```

The `InboundSecret` is symmetric — it's used to sign outbound `Reply-To` addresses *and* to verify inbound webhook requests, so forged emails that target a stolen reply address are rejected via timing-safe HMAC comparison.

### Provider setup

Each provider signs its webhook and expects you to forward that signature via the `X-Escalated-Inbound-Secret` header.

**Postmark** — in your server settings under *Inbound → Webhook URL*:

```
https://yourapp.com/support/webhook/email/inbound?adapter=postmark
```

Add a custom header `X-Escalated-Inbound-Secret: <your secret>`.

**Mailgun** — under *Receiving → Routes*, create a "Forward" action pointing at:

```
https://yourapp.com/support/webhook/email/inbound?adapter=mailgun
```

Set the HMAC header the same way.

### Testing

```bash
curl -X POST \
-H "Content-Type: application/json" \
-H "X-Escalated-Inbound-Secret: <your secret>" \
-d '{
"FromFull": {"Email": "customer@example.com", "Name": "Customer"},
"To": "support@example.com",
"Subject": "Hello",
"TextBody": "Help please",
"MessageID": "<abc@mail>"
}' \
"https://yourapp.com/support/webhook/email/inbound?adapter=postmark"
```

The response shape:

```json
{
"inboundId": 42,
"status": "created",
"outcome": "created_new",
"ticketId": 7,
"replyId": null,
"pendingAttachmentDownloads": []
}
```

Provider-hosted attachments (Mailgun's larger files, for example) appear in `pendingAttachmentDownloads` so a background worker can fetch and persist them out-of-band.
88 changes: 88 additions & 0 deletions sections/inbound-email/go.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
### Webhook endpoint

The Go module exposes a single webhook handler for all providers. Configure Postmark and/or Mailgun to POST inbound mail to:

```
POST /escalated/webhook/email/inbound?adapter=postmark
POST /escalated/webhook/email/inbound?adapter=mailgun
```

You can also pass the adapter via the `X-Escalated-Adapter` header instead of a query parameter.

### Configuration

Set the shared inbound secret and mail domain (used for signed `Reply-To` and canonical `Message-ID` headers) via environment variables or directly on `email.Config`:

```bash
ESCALATED_MAIL_DOMAIN=support.yourapp.com
ESCALATED_INBOUND_SECRET=a-long-random-value
```

```go
cfg := email.Config{
MailDomain: os.Getenv("ESCALATED_MAIL_DOMAIN"),
InboundSecret: os.Getenv("ESCALATED_INBOUND_SECRET"),
}
```

The `InboundSecret` is symmetric — it signs outbound `Reply-To` addresses *and* verifies inbound webhook requests, so forged emails that target a stolen reply address are rejected via timing-safe HMAC comparison (`hmac.Equal`).

### Wiring

Register the handler on your `http.ServeMux`:

```go
svc := email.NewInboundEmailService(router, writer)
handler := handlers.NewInboundEmailHandler(svc, parsers, cfg.InboundSecret)
mux.Handle("POST /escalated/webhook/email/inbound", handler)
```

### Provider setup

Each provider signs its webhook and expects you to forward that signature via the `X-Escalated-Inbound-Secret` header.

**Postmark** — in your server settings under *Inbound → Webhook URL*:

```
https://yourapp.com/escalated/webhook/email/inbound?adapter=postmark
```

Add a custom header `X-Escalated-Inbound-Secret: <your secret>`.

**Mailgun** — under *Receiving → Routes*, create a "Forward" action pointing at:

```
https://yourapp.com/escalated/webhook/email/inbound?adapter=mailgun
```

Set the HMAC header the same way.

### Testing

```bash
curl -X POST \
-H "Content-Type: application/json" \
-H "X-Escalated-Inbound-Secret: <your secret>" \
-d '{
"FromFull": {"Email": "customer@example.com", "Name": "Customer"},
"To": "support@example.com",
"Subject": "Hello",
"TextBody": "Help please",
"MessageID": "<abc@mail>"
}' \
"https://yourapp.com/escalated/webhook/email/inbound?adapter=postmark"
```

The response shape:

```json
{
"status": "created",
"outcome": "created_new",
"ticket_id": 7,
"reply_id": null,
"pending_attachment_downloads": []
}
```

Provider-hosted attachments (Mailgun's larger files, for example) appear in `pending_attachment_downloads` so a background worker can fetch and persist them out-of-band.
87 changes: 87 additions & 0 deletions sections/inbound-email/phoenix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
### Webhook endpoint

The Phoenix library exposes a single webhook controller for all providers. Configure Postmark and/or Mailgun to POST inbound mail to:

```
POST /support/webhook/email/inbound?adapter=postmark
POST /support/webhook/email/inbound?adapter=mailgun
```

You can also pass the adapter via the `x-escalated-adapter` header instead of a query parameter.

### Configuration

Set the shared inbound secret and mail domain (used for signed `Reply-To` and canonical `Message-ID` headers) in `config/runtime.exs`:

```elixir
config :escalated,
mail_domain: System.get_env("ESCALATED_MAIL_DOMAIN", "support.yourapp.com"),
email_inbound_secret: System.fetch_env!("ESCALATED_INBOUND_SECRET"),
inbound_parsers: [
Escalated.Services.Email.Inbound.PostmarkParser,
Escalated.Services.Email.Inbound.MailgunParser
]
```

The `email_inbound_secret` is symmetric — it signs outbound `Reply-To` addresses *and* verifies inbound webhook requests, so forged emails that target a stolen reply address are rejected via timing-safe comparison (`Plug.Crypto.secure_compare/2`).

### Wiring

Register the route in your Phoenix router:

```elixir
scope "/support/webhook/email", Escalated.Controllers do
pipe_through :api
post "/inbound", InboundEmailController, :inbound
end
```

### Provider setup

Each provider signs its webhook and expects you to forward that signature via the `x-escalated-inbound-secret` header.

**Postmark** — in your server settings under *Inbound → Webhook URL*:

```
https://yourapp.com/support/webhook/email/inbound?adapter=postmark
```

Add a custom header `x-escalated-inbound-secret: <your secret>`.

**Mailgun** — under *Receiving → Routes*, create a "Forward" action pointing at:

```
https://yourapp.com/support/webhook/email/inbound?adapter=mailgun
```

Set the HMAC header the same way.

### Testing

```bash
curl -X POST \
-H "Content-Type: application/json" \
-H "x-escalated-inbound-secret: <your secret>" \
-d '{
"FromFull": {"Email": "customer@example.com", "Name": "Customer"},
"To": "support@example.com",
"Subject": "Hello",
"TextBody": "Help please",
"MessageID": "<abc@mail>"
}' \
"https://yourapp.com/support/webhook/email/inbound?adapter=postmark"
```

The response shape:

```json
{
"status": "created",
"outcome": "created_new",
"ticket_id": 7,
"reply_id": null,
"pending_attachment_downloads": []
}
```

Provider-hosted attachments (Mailgun's larger files, for example) appear in `pending_attachment_downloads` so a background worker can fetch and persist them out-of-band.
Loading