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):
| Priority | Value |
|---|---|
| 10 | mxa.eu.mailgun.org |
| 10 | mxb.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.onlineRoutes — 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 $credCreate 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 $credWebhook payload (when forwarding to n8n/CF Worker)
Mailgun POSTs multipart/form-data with these fields:
| Field | Content |
|---|---|
sender | From address |
recipient | To address matched by the route |
subject | Subject line |
body-plain | Plain text body |
body-html | HTML body (if present) |
From, To, Date | Raw headers |
timestamp, token, signature | HMAC-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.netandmxa/mxb.eu.mailgun.orgMX records