A contact form is a small feature, but "actually deliver the email" is the whole hard part — a residential or cloud-VPS IP has zero sending reputation and gets blocked before a receiving server even checks anything else. This is the walkthrough for wiring up Mailgun as the trusted last-mile hop, plus three real gotchas that ate most of the actual time: a per-account domain limit, a brand-new account getting auto-disabled, and a dashboard that lies about DNS state for several minutes after it's actually fixed.
Sending SMTP straight from your own server to a recipient's mail provider mostly doesn't work anymore. Residential IP ranges are commonly pre-listed on blocklists like Spamhaus's PBL, and even a clean cloud VPS IP has no track record — many providers reject the connection before ever checking SPF/DKIM/DMARC. A transactional email provider like Mailgun (or Postmark, SES, etc.) solves this by being the thing that actually has reputation: your server authenticates to them over SMTP, and their IP does the real delivery. Your app still owns message content and recipients; the provider is only the trusted final hop.
Domain <name> can't be created. Domains limit of 1 has been
exceeded for the account. The fix is either upgrading that account's plan, or standing up a
genuinely separate account for the new project.
Worth deciding this up front rather than discovering it mid-setup — check your existing account's plan limits before assuming a new domain will just work.
If you go the separate-account route instead of upgrading, be aware Mailgun runs real anti-abuse checks on
fresh signups — a new account here got flagged and disabled (Your account is temporarily disabled...
Please contact support) before activation even completed, most likely tied to something in the
phone-verification step. There's no visible way to self-resolve this; it needs a support ticket, and there's
no guarantee of a fast turnaround. If you hit this, it's often faster to fall back to upgrading an existing
account than to fight a fresh one through support.
Once you have an account with room for it: Mailgun dashboard → Sending → Domains → Add new
domain. Use a subdomain (e.g. mg.yourdomain.com) rather than the bare root domain —
keeps this fully separate from any existing mail setup (MX records, a different provider, etc.) on the root.
Mailgun then gives you the exact DNS records to add.
Add these directly in your DNS provider (Cloudflare, in this case) — Mailgun's "automatic setup" option can log into some providers for you, but doing it manually keeps you from granting a new OAuth connection for a one-time task:
v=spf1 include:mailgun.org ~all<selector>._domainkey.mg.yourdomain.com — the public
half of a signing key Mailgun uses to cryptographically sign outgoing mail, letting receivers verify it
wasn't tampered with in transit.email.mg.yourdomain.com → mailgun.org —
needed only if you want open/click tracking; safe to skip if you don't._dmarc.mg.yourdomain.com — tells receivers what to do with
mail that fails SPF/DKIM, and where to send aggregate reports.inbox.ondmarc.com, their optional OnDMARC integration) alongside their own
(dmarc.mailgun.org). Worth trimming that out if you'd rather not send DMARC aggregate reports
to a second company you didn't choose — a conservative, monitor-only record with just Mailgun's own address
is enough to get real verification working:
v=DMARC1; p=none; rua=mailto:<your-mailgun-id>@dmarc.mailgun.org;
None of these need Cloudflare's orange-cloud proxy — leave every one of them DNS only. Proxying a CNAME meant for mail infrastructure through Cloudflare's HTTP proxy breaks it; SPF/DKIM/DMARC TXT records aren't proxyable at all.
dig +short TXT mg.yourdomain.com
dig +short TXT krs._domainkey.mg.yourdomain.com
dig +short TXT _dmarc.mg.yourdomain.com
dig +short CNAME email.mg.yourdomain.com
If all four resolve correctly against real DNS, the records are genuinely fine — the dashboard is just
slow to notice. A real send attempt is a better verification signal than the badge; it'll fail with an
explicit Domain ... is not allowed to send: unverified and requires DNS configuration error
until Mailgun's own side actually catches up, then start working with no other change needed.
Don't reuse an SMTP login from another project on the same account. Mailgun dashboard →
SMTP Credentials → Add new SMTP user — give it a login scoped to what
it's actually for (e.g. [email protected]), and let it auto-generate the password.
That keeps a compromised or leaked credential for one project from being usable to send as another.
smtp.mailgun.org:587 (STARTTLS)
user: [email protected]
pass: <generated>
A 200/{"ok":true} from your own app's contact-form endpoint isn't proof by
itself if that endpoint returns optimistically before confirming delivery. Check the actual send result —
your app's own logs, or Mailgun's Logs tab in the dashboard — for a clean accepted/delivered
entry, not just an HTTP success from your API. The two failure modes that show up here (domain still
unverified, or a bad/expired SMTP credential) both produce clearly different error text in the logs, so
it's easy to tell which gotcha you're actually looking at if delivery still isn't working.