A contact form is a small feature. "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 checks anything else. This wires up Mailgun as the trusted last-mile hop. Plus the three gotchas that ate most of the 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 is fixed.
Sending SMTP straight from your own server to a recipient's mail provider mostly does not work anymore. Residential IP ranges are pre-listed on blocklists like Spamhaus's PBL. Even a clean cloud VPS IP has no track record, and many providers reject the connection before checking SPF, DKIM or DMARC. A transactional email provider solves this by being the thing that has reputation. Mailgun, Postmark, SES. 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. Upgrade that account's plan, or stand up a genuinely
separate account for the new project.
Decide that up front rather than discovering it mid-setup. Check your existing account's plan limits before you assume a new domain will just work.
If you go the separate-account route instead of upgrading, know that Mailgun runs real anti-abuse checks on
fresh signups. A new account here got flagged and disabled before activation completed, with Your
account is temporarily disabled... Please contact support. Most likely tied to something in the
phone-verification step. There is no way to self-resolve it. It needs a support ticket, with no guarantee of
a fast turnaround. If you hit this, upgrading an existing account is often faster than fighting a fresh one
through support.
Once you have an account with room for it, go to the Mailgun dashboard → Sending → Domains → Add new
domain. Use a subdomain like mg.yourdomain.com, not the bare root domain. That keeps
this separate from any existing mail setup on the root, MX records or a different provider. 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. Doing it by hand avoids 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 sign outgoing mail, so receivers can verify it was not
tampered with in transit.email.mg.yourdomain.com pointing at
mailgun.org. Needed only for open and click tracking. Safe to skip._dmarc.mg.yourdomain.com. Tells receivers what to do
with mail that fails SPF or DKIM, and where to send aggregate reports.inbox.ondmarc.com, their optional OnDMARC integration, alongside their own
dmarc.mailgun.org. Trim that out if you would rather not send DMARC aggregate reports to a
second company you did not 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 DNS only. Proxying a CNAME meant for mail infrastructure through Cloudflare's HTTP proxy breaks it, and SPF, DKIM and DMARC TXT records are not 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 fine and the dashboard is slow to notice. A
real send attempt is a better signal than the badge. It fails with an explicit Domain ... is not
allowed to send: unverified and requires DNS configuration until Mailgun's side catches up, then
starts working with no other change.
Do not reuse an SMTP login from another project on the same account. Go to the Mailgun dashboard →
SMTP Credentials → Add new SMTP user. Give it a login scoped to what it is
for, such as [email protected], and let it auto-generate the password. Then a leaked
credential for one project cannot send as another.
smtp.mailgun.org:587 (STARTTLS)
user: [email protected]
pass: <generated>
A 200 and {"ok":true} from your own contact-form endpoint proves nothing if that
endpoint returns optimistically before confirming delivery. Check the real send result. Your app's own logs,
or Mailgun's Logs tab, for a clean accepted or delivered entry. Not an HTTP success from
your API. The two failure modes that show up here produce clearly different error text in the logs, so it is
easy to tell which gotcha you are looking at. A domain still unverified, or a bad or expired SMTP
credential.