This guide assumes you have completed the inbound-install.md setup. It covers only the Mailgun-specific configuration: mounting the ingress route, configuring your signing key, understanding the two payload modes, and what the package does with each verified request.

Mount Path

Mount the ingress plug on a dedicated Mailgun route in your Phoenix router:

forward "/inbound/:tenant_id/mailgun",
  MailglassInbound.Ingress.Plug,
  provider: :mailgun,
  router: MyApp.MailglassInboundRouter

The ingress plug is verify-first:

  1. read the exact request bytes from conn.private[:raw_body]
  2. verify the HMAC-SHA256 signature triple from the flat form fields
  3. reject replays (return 200 — see Replay Protection below)
  4. resolve tenant scope
  5. normalize into %MailglassInbound.InboundMessage{}
  6. persist one canonical row plus one raw evidence row
  7. dispatch mailbox execution only for newly inserted records

Plug.Parsers Wiring

Your endpoint must include the caching body reader from the install guide:

plug Plug.Parsers,
  parsers: [:urlencoded, :multipart, :json],
  pass: ["*/*"],
  json_decoder: Jason,
  body_reader: {MailglassInbound.Ingress.CachingBodyReader, :read_body, []}

Without this body_reader, verification fails immediately — the raw bytes that Mailgun signs are not available for HMAC computation.

Mailgun Route Setup

In the Mailgun dashboard, configure an inbound route to forward received mail to your endpoint:

  1. Navigate to Receiving → Routes → Create Route.
  2. Set a filter expression — for example:
    • Match a specific recipient: match_recipient("support@mg.example.com")
    • Catch all: match_all()
  3. Set the action to Forward with your endpoint URL: https://your-app.example.com/inbound/YOUR_TENANT_ID/mailgun
  4. Save the route.

Inbound Domain and MX Records

Mailgun inbound operates on a sending subdomain, not your primary domain. The conventional subdomain is mg.example.com — you configure this in the Mailgun dashboard under Sending → Domains and then set MX records on mg.example.com, not on example.com itself.

Attempting to use your main domain's MX records for Mailgun inbound will not work. Use the mg. subdomain (or whichever subdomain you configured in Mailgun) consistently in your Route filter expressions and forward actions.

Configuration

Add the Mailgun signing key to your application config:

# config/runtime.exs
config :mailglass_inbound, :mailgun,
  signing_key: System.get_env("MAILGUN_WEBHOOK_SIGNING_KEY")

Where to find the signing key: Mailgun Dashboard → Settings → API Security → HTTP Webhook Signing Key (labeled "Webhook Signing Key", distinct from your API key).

Optional Tuning Knobs

config :mailglass_inbound, :mailgun,
  signing_key: System.get_env("MAILGUN_WEBHOOK_SIGNING_KEY"),
  # How far in the past a timestamp is tolerated (default: 300 seconds / 5 minutes)
  timestamp_tolerance_seconds: 300,
  # How far in the future a timestamp is tolerated (default: 60 seconds)
  future_skew_seconds: 60,
  # How long replay tokens are retained in the cache (default: 28800 seconds / 8 hours)
  replay_cache_ttl_seconds: 28_800

The defaults match Mailgun's documented behavior and are suitable for production use.

Signing Key Rotation

To rotate the Mailgun webhook signing key:

  1. Generate a new signing key in the Mailgun dashboard.
  2. Update signing_key in your config and redeploy.

During deployment, Mailgun's 5-minute timestamp tolerance means requests signed with the old key continue to pass verification as long as they arrive within 300 seconds of the timestamp. There is no downtime gap during key rotation.

Verification

Mailgun inbound requests carry three flat form fields used for HMAC verification:

FieldDescription
timestampUnix timestamp (integer, as a string) when Mailgun generated the request
tokenRandom 50-character nonce unique to this delivery attempt
signatureHMAC-SHA256 of timestamp <> token hex-encoded lowercase

The verification algorithm:

  1. Compute HMAC-SHA256(signing_key, timestamp <> token).
  2. Compare the result (hex-encoded, lowercase) to signature using a constant-time comparison.
  3. Reject the request if the timestamp is more than timestamp_tolerance_seconds in the past or more than future_skew_seconds in the future.

Important: Mailgun inbound uses flat form fields, not the nested JSON %{"signature" => %{...}} envelope that Mailgun outbound webhooks use. The signing input is a direct string concatenation of timestamp and token, not a JSON document.

Replay Protection

After a request passes HMAC and timestamp verification, the token field is checked against an in-memory replay cache. If the same token has already been seen:

  • The plug returns 200 OK immediately (a no-op).
  • No second InboundRecord is created.
  • No mailbox execution is triggered.

The response is 200, not 401. This is deliberate: Mailgun retries on non-200 responses, which would create an infinite retry loop for a request the system has already processed. Returning 200 signals to Mailgun that the delivery succeeded.

The replay cache retains tokens for replay_cache_ttl_seconds (default: 8 hours). Tokens that arrive after the TTL expires are treated as new deliveries.

Two Payload Modes

Mailgun delivers inbound mail in one of two formats, detected automatically by the plug:

Raw MIME Mode

When the Mailgun route action includes the raw MIME option (the request carries a body-mime field), the plug parses the raw MIME bytes through the built-in MIME parser. This mode produces higher-fidelity normalization: attachment content-types, structured headers, and multi-part boundaries are all resolved from the original MIME structure.

Parsed Mode (Default)

When the Mailgun route sends parsed fields (no body-mime field), the plug normalizes from Mailgun's flat form fields:

  • body-plain / stripped-texttext_body
  • body-html / stripped-htmlhtml_body
  • message-headers (JSON-encoded header pairs) → structured headers map
  • recipientenvelope_recipient
  • from, to, cc, bcc, reply-to → address lists

No adopter configuration is needed to select a mode — the plug detects it from the presence of the body-mime field in the request.

Persistence Semantics

A verified Mailgun request writes two records before mailbox execution:

  • one canonical normalized row in mailglass_inbound_records
  • one linked raw evidence row in mailglass_inbound_evidence

The evidence row carries the full raw form payload, selected request headers, verification facts, parse warnings, and attachment blobs. If raw MIME mode was used, the evidence row also stores the original MIME bytes.

Duplicate requests (same tenant_id, provider, and provider_message_id) collapse on the canonical row — no second record is created and no second mailbox execution is dispatched.

Mailbox execution is dispatched after persistence commits. The Oban-backed path is the durable route. Without Oban, Task.Supervisor fallback is bounded best-effort only — no automatic retry on execution failure.