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.MailglassInboundRouterThe ingress plug is verify-first:
- read the exact request bytes from
conn.private[:raw_body] - verify the HMAC-SHA256 signature triple from the flat form fields
- reject replays (return
200— see Replay Protection below) - resolve tenant scope
- normalize into
%MailglassInbound.InboundMessage{} - persist one canonical row plus one raw evidence row
- 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:
- Navigate to Receiving → Routes → Create Route.
- Set a filter expression — for example:
- Match a specific recipient:
match_recipient("support@mg.example.com") - Catch all:
match_all()
- Match a specific recipient:
- Set the action to Forward with your endpoint URL:
https://your-app.example.com/inbound/YOUR_TENANT_ID/mailgun - 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_800The defaults match Mailgun's documented behavior and are suitable for production use.
Signing Key Rotation
To rotate the Mailgun webhook signing key:
- Generate a new signing key in the Mailgun dashboard.
- Update
signing_keyin 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:
| Field | Description |
|---|---|
timestamp | Unix timestamp (integer, as a string) when Mailgun generated the request |
token | Random 50-character nonce unique to this delivery attempt |
signature | HMAC-SHA256 of timestamp <> token hex-encoded lowercase |
The verification algorithm:
- Compute
HMAC-SHA256(signing_key, timestamp <> token). - Compare the result (hex-encoded, lowercase) to
signatureusing a constant-time comparison. - Reject the request if the timestamp is more than
timestamp_tolerance_secondsin the past or more thanfuture_skew_secondsin 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 OKimmediately (a no-op). - No second
InboundRecordis 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-text→text_bodybody-html/stripped-html→html_bodymessage-headers(JSON-encoded header pairs) → structuredheadersmaprecipient→envelope_recipientfrom,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.