diff --git a/sections/inbound-email/_intro.md b/sections/inbound-email/_intro.md index 330af41..f228c82 100644 --- a/sections/inbound-email/_intro.md +++ b/sections/inbound-email/_intro.md @@ -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 (``), 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=` | +| Spring Boot | `POST /escalated/webhook/email/inbound?adapter=` | +| Go | `POST /escalated/webhook/email/inbound?adapter=` | +| Phoenix | `POST /support/webhook/email/inbound?adapter=` | +| Symfony | `POST /escalated/webhook/email/inbound?adapter=` | + +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. diff --git a/sections/inbound-email/dotnet.md b/sections/inbound-email/dotnet.md new file mode 100644 index 0000000..9a1fed4 --- /dev/null +++ b/sections/inbound-email/dotnet.md @@ -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: `. + +**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: " \ + -d '{ + "FromFull": {"Email": "customer@example.com", "Name": "Customer"}, + "To": "support@example.com", + "Subject": "Hello", + "TextBody": "Help please", + "MessageID": "" + }' \ + "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. diff --git a/sections/inbound-email/go.md b/sections/inbound-email/go.md new file mode 100644 index 0000000..51a4135 --- /dev/null +++ b/sections/inbound-email/go.md @@ -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: `. + +**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: " \ + -d '{ + "FromFull": {"Email": "customer@example.com", "Name": "Customer"}, + "To": "support@example.com", + "Subject": "Hello", + "TextBody": "Help please", + "MessageID": "" + }' \ + "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. diff --git a/sections/inbound-email/phoenix.md b/sections/inbound-email/phoenix.md new file mode 100644 index 0000000..0b73d69 --- /dev/null +++ b/sections/inbound-email/phoenix.md @@ -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: `. + +**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: " \ + -d '{ + "FromFull": {"Email": "customer@example.com", "Name": "Customer"}, + "To": "support@example.com", + "Subject": "Hello", + "TextBody": "Help please", + "MessageID": "" + }' \ + "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. diff --git a/sections/inbound-email/spring.md b/sections/inbound-email/spring.md new file mode 100644 index 0000000..7597af0 --- /dev/null +++ b/sections/inbound-email/spring.md @@ -0,0 +1,80 @@ +### Webhook endpoint + +The Spring Boot starter exposes a single webhook 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) in `application.yml`: + +```yaml +escalated: + mail: + domain: support.yourapp.com + inbound-secret: ${ESCALATED_INBOUND_SECRET} +``` + +Or via environment variables: + +```bash +ESCALATED_MAIL_DOMAIN=support.yourapp.com +ESCALATED_INBOUND_SECRET=a-long-random-value +``` + +The `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 HMAC comparison (`MessageDigest.isEqual`). + +### 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: `. + +**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: " \ + -d '{ + "FromFull": {"Email": "customer@example.com", "Name": "Customer"}, + "To": "support@example.com", + "Subject": "Hello", + "TextBody": "Help please", + "MessageID": "" + }' \ + "https://yourapp.com/escalated/webhook/email/inbound?adapter=postmark" +``` + +The response shape: + +```json +{ + "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. diff --git a/sections/inbound-email/symfony.md b/sections/inbound-email/symfony.md new file mode 100644 index 0000000..0bcde6f --- /dev/null +++ b/sections/inbound-email/symfony.md @@ -0,0 +1,91 @@ +### Webhook endpoint + +The Symfony bundle exposes a single webhook controller 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) under the `escalated:` bundle config: + +```yaml +# config/packages/escalated.yaml +escalated: + mail_domain: '%env(ESCALATED_MAIL_DOMAIN)%' + inbound_secret: '%env(ESCALATED_INBOUND_SECRET)%' +``` + +```bash +# .env +ESCALATED_MAIL_DOMAIN=support.yourapp.com +ESCALATED_INBOUND_SECRET=a-long-random-value +``` + +The `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 (`hash_equals`). + +### 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: `. + +**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: " \ + -d '{ + "FromFull": {"Email": "customer@example.com", "Name": "Customer"}, + "To": "support@example.com", + "Subject": "Hello", + "TextBody": "Help please", + "MessageID": "" + }' \ + "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. + +### Adding a custom parser + +The bundle discovers inbound parsers by the `escalated.inbound_parser` tag. To add a new one, implement `Escalated\Symfony\Mail\Inbound\InboundEmailParser` and autoconfigure: + +```yaml +# config/services.yaml +services: + _instanceof: + Escalated\Symfony\Mail\Inbound\InboundEmailParser: + tags: ['escalated.inbound_parser'] +```