Mailgun Email Receiving Playbook

Overview

Mailgun receiving is free — no quota, no cost. Works by configuring MX records for a domain to point to Mailgun, then defining routes that match inbound messages and forward/store/webhook them.

Current sending domain: ai.pinbox24.com Receiving can use the same domain or a dedicated subdomain (e.g. mail.zintegrowana.online).

API base: https://api.eu.mailgun.net (EU region)

How it works

Inbound email → Mailgun MX → Route match → Action
                                              ├── forward() → another email address
                                              ├── store()   → Mailgun storage (fetch via API)
                                              └── forward() → webhook URL (POST with parsed email)

Routes are evaluated top-to-bottom by priority (lower number = higher priority). First match wins unless stop() is omitted (then evaluation continues).

Prerequisites — MX records

For each receiving domain, add MX records via Cloudflare (DNS-only / grey cloud):

PriorityValue
10mxa.eu.mailgun.org
10mxb.eu.mailgun.org
# Add via Cloudflare API (zone: zintegrowana.online)
$env:SOPS_AGE_KEY_FILE = "C:\Users\konar\.age\p24-infra-keys.txt"
$rawMon = sops --decrypt --input-type dotenv --output-type dotenv C:\code_2026\p24-infra\secrets\monitoring.env.sops
$env:CF_TOKEN = ($rawMon | Where-Object { $_ -match "^CF_API_TOKEN=" } | Select-Object -First 1).Split("=",2)[1]
$zone = "57cb3d8f24c7cc319fb703394edc7b87"
 
foreach ($mx in @("mxa.eu.mailgun.org", "mxb.eu.mailgun.org")) {
    Invoke-RestMethod -Uri "https://api.cloudflare.com/client/v4/zones/$zone/dns_records" `
        -Method POST -Headers @{"Authorization"="Bearer $($env:CF_TOKEN)"; "Content-Type"="application/json"} `
        -Body (@{ type="MX"; name="subdomain.zintegrowana.online"; content=$mx; priority=10; ttl=300; proxied=$false } | ConvertTo-Json)
}
$env:CF_TOKEN = ""
# Verify (allow 5 min propagation):
# nslookup -type=MX subdomain.zintegrowana.online

Routes — CRUD via API

# Helper — load Mailgun key
$env:SOPS_AGE_KEY_FILE = "C:\Users\konar\.age\p24-infra-keys.txt"
$rawMon = sops --decrypt --input-type dotenv --output-type dotenv C:\code_2026\p24-infra\secrets\monitoring.env.sops
$env:MG_KEY = ($rawMon | Where-Object { $_ -match "^MAILGUN_API_KEY=" } | Select-Object -First 1).Split("=",2)[1]
$cred = [System.Management.Automation.PSCredential]::new("api", (ConvertTo-SecureString $env:MG_KEY -AsPlainText -Force))

List all routes

Invoke-RestMethod -Uri "https://api.eu.mailgun.net/v3/routes" -Credential $cred

Create a route — forward to webhook

Invoke-RestMethod -Uri "https://api.eu.mailgun.net/v3/routes" -Method POST -Credential $cred -Body @{
    priority    = "10"
    description = "Forward alerts@ai.pinbox24.com to n8n webhook"
    expression  = "match_recipient('alerts@ai.pinbox24.com')"
    action      = @(
        "forward('https://n8n.bms-4.infra.zintegrowana.online/webhook/email-inbound')",
        "stop()"
    )
}

Create a route — forward to another email

Invoke-RestMethod -Uri "https://api.eu.mailgun.net/v3/routes" -Method POST -Credential $cred -Body @{
    priority    = "20"
    description = "Forward anything @ai.pinbox24.com to radieu@gmail.com"
    expression  = "match_recipient('@ai.pinbox24.com')"
    action      = @(
        "forward('radieu@gmail.com')",
        "stop()"
    )
}

Create a route — store + forward (for later API retrieval)

Invoke-RestMethod -Uri "https://api.eu.mailgun.net/v3/routes" -Method POST -Credential $cred -Body @{
    priority    = "10"
    description = "Store and webhook"
    expression  = "match_recipient('inbox@ai.pinbox24.com')"
    action      = @(
        "store(notify='https://your-webhook-url')",
        "stop()"
    )
}

Update a route

$routeId = "ROUTE_ID_HERE"
Invoke-RestMethod -Uri "https://api.eu.mailgun.net/v3/routes/$routeId" -Method PUT -Credential $cred -Body @{
    priority    = "10"
    description = "Updated description"
    expression  = "match_recipient('new@ai.pinbox24.com')"
    action      = "forward('radieu@gmail.com')"
}

Delete a route

Invoke-RestMethod -Uri "https://api.eu.mailgun.net/v3/routes/$routeId" -Method DELETE -Credential $cred

Webhook payload (when forwarding to n8n/CF Worker)

Mailgun POSTs multipart/form-data with these fields:

FieldContent
senderFrom address
recipientTo address matched by the route
subjectSubject line
body-plainPlain text body
body-htmlHTML body (if present)
From, To, DateRaw headers
timestamp, token, signatureHMAC-SHA256 verification fields

Verify webhook signature (n8n Code node or CF Worker)

// Node.js / n8n Code node
const crypto = require('crypto');
const { timestamp, token, signature } = $input.first().json;
const MAILGUN_WEBHOOK_SIGNING_KEY = $env.MAILGUN_WEBHOOK_SIGNING_KEY;
const hash = crypto.createHmac('sha256', MAILGUN_WEBHOOK_SIGNING_KEY)
    .update(timestamp + token)
    .digest('hex');
if (hash !== signature) throw new Error('Invalid Mailgun signature');

Webhook signing key is separate from the API key — find it in Mailgun dashboard → Webhooks → HTTP webhook signing key. Store as MAILGUN_WEBHOOK_SIGNING_KEY in secrets/monitoring.env.sops.

Test receiving

# Send a test to a receiving address, then check Mailgun logs
$env:MG_KEY = "..."   # from SOPS as above
$cred = [System.Management.Automation.PSCredential]::new("api", (ConvertTo-SecureString $env:MG_KEY -AsPlainText -Force))
 
# Send test inbound (from external address)
Invoke-RestMethod -Uri "https://api.eu.mailgun.net/v3/ai.pinbox24.com/messages" -Method POST -Credential $cred -Body @{
    from    = "test@gmail.com"
    to      = "alerts@ai.pinbox24.com"
    subject = "Receiving test"
    text    = "If you see this, receiving works."
}
 
# Check logs
Invoke-RestMethod -Uri "https://api.eu.mailgun.net/v3/ai.pinbox24.com/events?event=stored" -Credential $cred
$env:MG_KEY = ""

Cost

  • Receiving: free — no limits on inbound volume
  • Sending: $35/month — 50,000 emails/month included
  • Receiving routes and storage (7-day retention) are included in the free tier

Notes

  • Resend is currently used for BrandPilot transactional email — Mailgun could replace it but no change planned
  • MX records must be DNS-only (grey cloud) — Cloudflare proxying breaks MX
  • Mailgun stores inbound messages for 3 days by default; use store() action to retain and fetch via API
  • EU region only: always use api.eu.mailgun.net and mxa/mxb.eu.mailgun.org MX records