Guides

Sending email that actually arrives

Connect Mailgun, set a sender address that passes DMARC, and read the delivery signals that actually mean something.

Created 22 Aug 2026

Mailgun is the only way this system sends mail — no SMTP, no second provider — so "connecting email" and "connecting Mailgun" are the same job.

Two decisions before you click anything

Your sending domain — what you register at Mailgun and publish DNS records for. A subdomain such as mg.yourdomain.com keeps your main domain's reputation separate, and that is the usual choice. Do not read the field's placeholder as advice: it shows mg.yourdomain.com only while no sender address exists yet, and once one does it echoes that sender's own domain and offers "Suggestion: Use (from your Contact Email)" — which, if your sender is [email protected], suggests your apex. Decide this yourself rather than accepting what the field offers.

Your sender address — the From address on outgoing mail. It must sit on the sending domain or on a parent or child of it; an unrelated domain, a Gmail address for instance, is a DMARC alignment failure and the save is refused. Simplest is to put it exactly on the sending domain: [email protected] with sending domain mg.yourdomain.com. A parent-domain sender works too — see the alignment note in step 5.

Order matters: the Mailgun dialog will not save until a sender address already exists.

Step 1 — Set your sender identity

Admin → General Settings → Branding tab. ("General Settings" is the sidebar entry under Settings; Branding is a tab on that page.) Four fields decide how mail is addressed:

  • Email Sender Name — the From name. Edit this to change how the sender appears; editing Business Name instead renames your business everywhere.
  • Email Sender Address — the From address, placeholder [email protected]. Fill it in.
  • Contact Email — your public contact address. Used as the From address only when Email Sender Address is empty.
  • Business Name — the From name only when Email Sender Name is empty.

They autosave when you click out of the field; there is no Save button.

Leaving Email Sender Address empty is the common mistake: the fallback is Contact Email, usually on your main domain rather than your sending domain, and that mismatch is what gets your mail rewritten later. Both values are read fresh on every send, so this screen shows what actually goes out.

Step 2 — Set up the domain at Mailgun

Create the account, add your sending domain, publish every DNS record Mailgun lists — SPF, DKIM, tracking CNAME — and wait for Mailgun to mark the domain verified. Note whether the account is US or EU.

Step 3 — Connect Mailgun

Admin → Integrations → Email tab → Mailgun. Four fields: API Key (stored encrypted, never shown back), Region, Sending Domain, and Webhook Signing Key — the app tries to fetch that one from Mailgun on save; if it cannot, copy it from Mailgun → API Security. From Email and From Name are read-only here, with a link back to Branding.

Three different things stop the save:

  • No sender address — Save stays disabled under an amber note, "Contact Email must be configured in Branding Settings to send emails".
  • A sender on the wrong domain — a red panel names both domains, and the save is rejected.
  • Demo mode — an install running with DEMO_MODE on refuses credential changes while still reporting success. A normal install never sets it.

A successful save registers eight webhooks automatically at your-site/api/webhooks/mailgun — accepted, delivered, opened, clicked, permanent failure, temporary failure, complained, unsubscribed. They populate every delivery statistic in the product.

Do not skip the signing key

With no signing key stored, the webhook endpoint accepts events without verifying the signature — it logs a warning and processes the payload anyway. Anyone who finds the URL can forge delivery, complaint and unsubscribe events, and unsubscribe events change contact records. Fill it in, then check the webhook card reads "Signature verification: Enabled".

Step 4 — Confirm it is really connected

After saving, an Email Tracking Webhooks card appears. It asks Mailgun now rather than trusting what was stored at setup — an earlier version showed a green "Configured" badge while Mailgun held no webhooks at all and every message sat at QUEUED. Read three things.

  • The badge, backed by a Live at Mailgun: n/8 events count. Active means all eight event types are live and pointing here. Missing at Mailgun or Incomplete means they are not — press Reconfigure. Unverified in amber means Mailgun did not answer: "could not tell", not "broken".
  • A red line naming event types. They exist at Mailgun but point at a different URL — the dangerous case, because nothing looks broken while the events go elsewhere.
  • Webhook Endpoint URL. Confirm it is your real public address. It comes from the site address this install is configured with; if that was never set, a hosting platform's own name (something like yourapp.fly.dev) is used instead, and unsubscribe links point there too. If it is wrong, set APP_URL in your environment and press Reconfigure.

Then send a test. The Send Test Email field appears only once the integration is connected, so you will not find it while filling the dialog in for the first time — save, leave the dialog open, and it is there. It builds its From from the address stored at the last save, while real sends use the live Email Sender Address: a pass proves the key, region and domain work, not the address customers see. If it fails, suspect the region first — a US key against the EU host, or the reverse, surfaces only as "Failed to send test email. Please check your API key and domain." — which never mentions the region. Mailgun's real error goes to the server log, not the screen.

Before Mailgun is connected, sends silently report success

With no Mailgun configuration present, the send function reports success, logs a warning to the console and sends nothing — in production exactly as in development. Nothing is recorded and every part of the app believes the mail went out: test a signup flow before connecting and you get "check your inbox" for a code that was never sent. The same applies if you later switch the integration off with its enable toggle. That is not a pause on tracking; it makes every send a silent no-op.

Step 5 — Read the health page

Admin → Email Health, under Administration; the same card also sits on the Branding tab under the sender fields. It does not check anything when you open it: the card starts empty and you press Run check (Re-check afterwards), because each run does live DNS plus a Mailgun API call. An empty Email Deliverability card is the normal starting state, not a broken page. Nothing is cached — what it reports is what DNS says at that moment:

  • SPF — fails on zero records, and also on more than one. Two SPF records is not twice the protection; it invalidates both.
  • DKIM — probes the selectors s1, mg2048, google, k1, mail. A key under any other selector reads as "no key found" even though DKIM works; check Mailgun's dashboard before chasing it.
  • DMARC — your policy, and whether a rua= address exists.
  • MX, From alignment, and the gateway state Mailgun reports for the domain.

unknown means DNS did not answer, not that the record is missing. The tracking CNAME is not checked here.

Expect one inconsistency. This page's alignment check is stricter than the rule that gates saving. Sending as [email protected] through mg.yourdomain.com saves, sends unmodified and reads OK on System Status, but Email Health calls From alignment a failure — it accepts only a From on the sending domain itself or below it. The mail is fine; use a sender on the sending domain exactly if you would rather not carry a permanent red mark.

If the sender drifts out of alignment

Alignment is enforced when you save, not when you send. Edit Branding after connecting and nothing fails — the From is silently rewritten to noreply@your-sending-domain, and a notification titled "Email sender address is misconfigured" is raised at most once an hour. Customers then get mail from an address you did not choose, and replies go to a mailbox nobody watches: no Reply-To header is set anywhere in this product, so the From address is the reply address. System Status flags it too.

Both guards run on a Mailgun connection made through the admin screens; an install wired up through MAILGUN_* environment variables bypasses them.

The reputation numbers

The rest of the page combines your own send outcomes with Mailgun's suppression lists. The thresholds behind the colours are fixed: complaints are a hard problem above 0.3%, bounces above 5%, delivery below 95%. Counts marked "at least" are a floor, because the list is read one page of 1000 at a time.

Two limits. Mailgun's suppression list is display-only, never written back into your contacts. And a complaint or an unsubscribe sets the contact to UNSUBSCRIBED, but a hard bounce changes nothing about the contact — Mailgun refuses that address itself, so it costs you nothing further, but the contact still looks healthy in your list.

Double opt-in — what is real and what is not

Admin → General Settings → Contacts tab, card titled "Double opt-in". Four switches: Newsletter signups, Widget & lead forms, Event registrations, Purchases. Newsletter and Widgets default on; Events and Purchases default off and carry a caution, because demanding a code before someone gets a joining link or a receipt turns confirmation into a missed booking.

The card says unconfirmed contacts are created but not mailed. That is not what happens today:

  • Only the Newsletter switch does anything. The other three save and display correctly, but no signup path reads them.
  • Only one form asks for confirmation — the newsletter signup site component. Contacts created through widgets, events, purchases, the admin screens, the API or an import are never asked.
  • Nothing checks confirmation at send time. Someone who was emailed a code and never entered it still receives marketing and newsletter broadcasts.

Treat it as a way to collect confirmations, not to suppress anyone, and do not rely on it for a legal requirement without verifying that yourself.

Where it does run, a 6-digit code is emailed and entered on the public /confirm page, valid 24 hours and dead after five wrong attempts. The code is in the subject line deliberately, so it can be read from a phone notification, and that email's wording cannot be edited in the template builder.

Unsubscribes

An unsubscribe footer is appended automatically to marketing and newsletter mail only; system and transactional mail gets none. The link leads to a preference page with four choices: take a break (7, 14, 30, 60 or 90 days, 30 preselected), no newsletters, no marketing at all, or unsubscribe from everything.

Custom code that sends without naming a template type gets no footer, an empty unsubscribe link and no opt-out checking, silently. The shipped templated send paths all forward their template's type, so this bites custom code rather than anything you get out of the box.

There is no one-click unsubscribe header. No List-Unsubscribe header is sent and there is no one-click endpoint, which Gmail's and Yahoo's bulk sender rules require. At bulk volume that is a real deliverability gap, and not one the admin screens can configure away.

Newsletters

Broadcasts go out from Admin → Newsletter, which handles sequences and one-shot sends. Recipients are filtered to subscribed contacts with no marketing opt-out, no newsletter opt-out and no active pause, and go in batches of up to 1000 per Mailgun call. There is no throttling or retry, so Mailgun's rate limits apply as they come.

Messages stuck at QUEUED have two very different causes. Broadcast rows stuck at QUEUED while ordinary single messages progress is a known display fault in the batch path — those rows can never be matched to incoming events, and their events can also create duplicate rows. The mail was sent; judge a broadcast by the reputation numbers and Mailgun's own logs. But if everything is at QUEUED, password resets and receipts included, webhooks are not reaching this install: go back to the webhook card in step 4.

Optional extras

DMARC reports and complaint feedback loops have setup panels on Email Health, which appear while their section has no data. Both use the same ingest token, shown exactly once — regenerating it breaks any Cloudflare Worker holding the old one, for both feeds. Use a separate domain for ingestion: Cloudflare Email Routing replaces the apex MX and SPF of whatever zone you enable it on, so doing this on your sending domain breaks inbound mail and outbound authorisation at once.

Google Postmaster Tools connects separately and shows Gmail's own view of your sending — but Google publishes nothing below roughly 100–200 messages a day to distinct Gmail recipients, so below that expect an empty panel indefinitely.

One thing that can quietly stop all mail

Your Mailgun API key and webhook signing key are encrypted in the database. If ENCRYPTION_KEY is not set, that encryption key is derived from AUTH_SECRET. Rotating AUTH_SECRET then makes the stored keys permanently unreadable — mail stops with a decryption failure rather than an obvious configuration error. If you rotate it, re-encrypt the stored credentials in the same operation with scripts/reencrypt-secrets.ts, or re-enter both keys in the admin UI afterwards.

Before real customers see any of it

Admin → Email Builder → Email Templates tab holds the ten system templates that ship with the install: password reset, event registration, cancellation and reschedule, account creation, abandoned checkout, purchase confirmation, payment receipt, and two invitation templates. Every one reaches a customer eventually — read them before that happens.

One editing note: a template's preheader is injected as hidden text at the top of the message, so write it as the inbox preview snippet, not as body copy.

Tracking is switched off automatically for credential-bearing mail — password resets, invitations, account creation, and anything tagged verification or magic-link. Those carry no open or click tracking regardless of your other settings.

Was this helpful?

Comments