How to receive email with Mailgun

Updated

Short answer

To receive email with Mailgun, point the domain's MX records at Mailgun (mxa.mailgun.org and mxb.mailgun.org in the US region, mxa.eu.mailgun.org and mxb.eu.mailgun.org in the EU) and create a route. A route pairs a filter, such as match_recipient(".*@reply.acme.com"), with actions: forward() posts the message to your URL, store(notify="...") keeps it for 3 days and notifies you with a link to fetch it, and stop() skips the routes after it. Every post is signed with your HTTP webhook signing key. HelloBox sets up the route for you and keeps its own copy.

Point the domain at Mailgun

Add the domain in Mailgun, choosing its region, then add the MX records from its DNS page. For a US domain:

reply.acme.com.  MX  10 mxa.mailgun.org
reply.acme.com.  MX  10 mxb.mailgun.org

EU domains use mxa.eu.mailgun.org and mxb.eu.mailgun.org. If the main domain already receives mail elsewhere, use a subdomain so its MX records stay as they are. (Mailgun: domains, regions)

Routes: filters and actions

A route is a filter plus one or more actions. Filters are match_recipient(), match_header() and catchall(). Actions are forward() to an address or URL, store() and stop(). Each route has a priority, and lower numbers run first. Every matching route runs, even after a higher-priority one matched, unless a route ends with stop().

curl -s --user 'api:YOUR_API_KEY' https://api.mailgun.net/v3/routes \
  -F priority=0 \
  -F description='Replies' \
  -F expression='match_recipient(".*@reply.acme.com")' \
  -F action='store(notify="https://acme.com/mailgun/inbound")'

Filter and action fields can't be longer than 4k characters. Your plan sets how many routes you get: Mailgun's pricing page lists 1 inbound route on Free, 5 on Basic and full inbound routing from Foundation. (Mailgun: routes)

store() and notify

store(notify="url") keeps the message and POSTs a notification to your URL. The notification carries parsed fields (sender, recipient, subject, body-plain, stripped-text, body-html, attachments, message-headers) and a message-url to fetch the stored message.

Mailgun's routes reference says stored messages are kept for 3 days. Its pricing page lists separate message retention per plan, so check what yours keeps, and save your own copy as soon as the notification arrives. To get the original MIME, fetch the message-url with Accept: message/rfc2822 and read body-mime:

curl -s --user 'api:YOUR_API_KEY' \
  -H 'Accept: message/rfc2822' \
  'https://api.mailgun.net/v3/domains/reply.acme.com/messages/MESSAGE_KEY'

(Mailgun: storing and retrieving messages)

US and EU regions

One Mailgun account can have domains in the US and EU regions, and message data never leaves the region it's processed in. Domains, stored messages, event logs, suppressions and routes belong to a region, while API keys are shared. Call the API at the base URL for the domain's region:

RegionAPI base URLInbound MX
UShttps://api.mailgun.netmxa.mailgun.org, mxb.mailgun.org
EUhttps://api.eu.mailgun.netmxa.eu.mailgun.org, mxb.eu.mailgun.org

(Mailgun: API overview)

Verify every notification

Posts from forward() and store(notify=...) carry timestamp, token and signature fields. The signature is a hex HMAC-SHA256 of the timestamp and token joined together, keyed with the HTTP webhook signing key from Settings, API Security. That's not your API key.

import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyMailgun(signingKey, { timestamp, token, signature }) {
  const expected = createHmac("sha256", signingKey).update(timestamp + token).digest("hex");
  return expected.length === signature.length
    && timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}

Mailgun also suggests refusing a token you've already seen and checking the timestamp isn't too old, without being too strict since posts can be delayed. (Mailgun: securing webhooks)

Read the mail in HelloBox

  1. In Mailgun, open Settings, then API Security, and copy your API key and the HTTP webhook signing key.
  2. In HelloBox, choose Connect a provider, then Mailgun, and paste both. HelloBox works out the region from where your domains are, and asks only if it can't tell.
  3. Turn receiving on for a domain. HelloBox adds a priority 0 route that stores its mail and notifies HelloBox, without stop(), so your existing routes keep running. It shows the MX records, or adds them if your DNS is on Cloudflare.

HelloBox checks each signature, refuses notifications older than 15 minutes, then fetches the original message and saves it before answering Mailgun. It also reads Mailgun's logs for anything stored in the last 3 days that a notification missed. Mail shows up as threaded conversations your team can answer through your own Mailgun account. For the webhook approach, see receiving email with Postmark.

Frequently asked questions

What are Mailgun's MX records?
mxa.mailgun.org and mxb.mailgun.org for US domains, with priority 10, and mxa.eu.mailgun.org and mxb.eu.mailgun.org for EU domains.
How long does Mailgun store received email?
Mailgun's routes reference says messages kept with store() are held for 3 days. Its pricing page lists retention per plan, so save your own copy when the notification arrives.
What does stop() do in a Mailgun route?
Mailgun runs every matching route by default, even after a higher-priority one matched. Ending a route with stop() keeps the routes after it from running.
Are Mailgun routes tied to a region?
Yes. Domains, routes and stored messages belong to the US or EU region, so use the API base URL for the region your domain is in.
How do I verify Mailgun inbound webhooks?
Compute HMAC-SHA256 of timestamp plus token with your HTTP webhook signing key and compare the hex digest to the signature field.