Sending transactional email from your application
Set up app email that reaches the inbox: a sending subdomain, aligned SPF, DKIM and DMARC, bounce and complaint handling, safe retries and testing.
By Exmoor Software7 min read
Transactional email is the mail your application sends because a user did something: a sign-up confirmation, a password reset, a login code, a receipt. People expect it within seconds, and when it lands in spam they open a support ticket — or give up.
Most of what makes it reliable is set up once: the domain you send from, authentication aligned with that domain, and code that handles failures without sending duplicates. Everything below applies to any provider — an email API, an SMTP relay or your own mail server; only the names of the settings differ.
1. Give application mail its own subdomain
Send application mail from a dedicated subdomain such as notify.example.com, marketing from another one such as news.example.com, and keep example.com for people’s mailboxes.
From: Example <account@notify.example.com> ← password resets, receipts
From: Example News <hello@news.example.com> ← newsletters, promotions
From: Anna Novak <anna@example.com> ← people
- Separate DNS records. Each subdomain has its own SPF record, DKIM keys and, if you want, DMARC policy. Changing the marketing tool doesn’t touch the records your login emails depend on.
- Separate reputation, to a degree. Mailbox providers can assess a subdomain on its own, so a campaign that draws complaints is less likely to drag down password resets. It is not a firewall: the subdomains share the organizational domain, and Gmail, for example, counts messages from the same primary domain together towards its 5,000-a-day bulk-sender threshold.
- Clear streams. Filters, recipients and your logs can tell the two kinds of mail apart.
Don’t move transactional mail to a newly registered domain such as example-mail.com: it has no reputation and teaches users to trust lookalike domains.
2. Authenticate with your domain, not the provider’s
DMARC passes when SPF or DKIM passes for a domain aligned with the From domain — in the default relaxed mode, one with the same organizational domain (DMARC alignment). Out of the box, many providers authenticate with their own domain: SPF and DKIM pass, DMARC fails. Three settings fix that.
Custom DKIM
Have the provider sign with d=notify.example.com (or d=example.com). Usually you publish a CNAME pointing a selector at a key the provider hosts, so the provider can rotate it:
s1._domainkey.notify.example.com. 3600 IN CNAME s1.dkim.esp.example.
Some providers give you a TXT record with the public key instead. Check the result with the DKIM checker: 2048-bit RSA is the usual choice, and verifiers must reject keys shorter than 1024 bits (RFC 8301). This is the most important of the three settings, because DKIM survives forwarding and SPF doesn’t.
Custom Return-Path (bounce domain)
SPF checks the envelope sender — the MAIL FROM address of the SMTP session, recorded by the receiver as Return-Path — not the From header. If the provider uses its own bounce domain, SPF passes for the provider and isn’t aligned. Configure a bounce subdomain of yours:
bounce.notify.example.com. 3600 IN CNAME bounces.esp.example.
With the CNAME, the SPF record of that name (and an MX record, where the provider needs one) is the provider’s, and bounces reach the provider. bounce.notify.example.com shares the organizational domain example.com with the From domain, so SPF is aligned in relaxed mode. A CNAME can’t coexist with other records at the same name, so use a name reserved for this.
SPF include — only where SPF is checked
Some providers skip the CNAME and ask you to add their include: to the SPF record of the domain they use as the envelope sender:
bounce.notify.example.com. 3600 IN TXT "v=spf1 include:spf.esp.example -all"
Put it in the record of the domain that actually appears in Return-Path. Every name has its own SPF record: an include added to example.com “just in case” does nothing for mail sent with a subdomain as envelope sender, yet it counts towards that record’s limit of 10 DNS lookups (SPF: too many DNS lookups). The SPF checker shows the count.
DMARC
If notify.example.com has no DMARC record of its own, receivers apply the record of example.com — its sp tag if present, otherwise p. You can also publish one for the subdomain:
_dmarc.notify.example.com. 3600 IN TXT "v=DMARC1; p=reject; rua=mailto:dmarc-reports@example.com"
If you aren’t sure that everything sending as the subdomain is aligned, start with p=none, read the aggregate reports for a few weeks, then move to p=quarantine or p=reject (how to set up DMARC). A subdomain only your application uses is often the easiest place to reach p=reject, because you know exactly what sends from it. Check it with the DMARC checker.
3. Handle bounces and complaints
An undeliverable message is either rejected during the SMTP session or answered with a bounce — a delivery status notification (RFC 3464) — sent to the Return-Path address. Your provider receives bounces at the bounce domain and reports them to you, usually as webhook events. Complaints (“report spam” clicks) arrive through the feedback loops of mailbox providers that offer them, often in the ARF format (RFC 5965); Gmail instead shows complaint rates in aggregate in Postmaster Tools.
- Verify and store first, process later. Check the webhook signature as your provider documents it, store the raw event, answer 2xx quickly and process from a queue. Providers retry webhooks, so expect duplicates and events out of order; deduplicate on the provider’s event ID.
- Hard bounces — permanent failures such as enhanced status code
5.1.1(no such mailbox): add the address to a suppression list and stop sending to it. Ask the user in the app to correct their address. See email bounce codes. - Soft bounces — temporary failures (
4.x.x), such as a full mailbox: the provider retries. If an address keeps soft-bouncing for days, treat it as a hard bounce for non-essential mail. - Complaints: stop sending optional mail to that address. Keep suppression per stream — a newsletter complaint shouldn’t block tomorrow’s password reset, while a hard bounce applies everywhere.
- Check suppression in your own code before calling the provider, so you know why a message wasn’t sent.
4. Send idempotently and retry safely
Most incidents are messages never sent because something failed halfway, or sent twice because something retried.
- Don’t send from the request handler. Write the intent to send in the same database transaction as the business event (an “outbox” row: order 1042 paid → send receipt) and let a background worker deliver it. A rollback sends nothing; a crashed worker leaves the row waiting.
- Use an idempotency key derived from the event, such as
receipt:order-1042. Pass it to the provider if its API accepts one, and enforce it with a unique constraint on your table of sent messages. - Retry only transient failures: timeouts, connection errors, HTTP 429 and 5xx, or
4yzreplies from an SMTP relay — RFC 5321 treats4yzas transient and5yzas permanent. Use exponential backoff with jitter and a cap on attempts. - Treat a timeout as “maybe sent”. The provider may have accepted the message just before the connection dropped — exactly the case the idempotency key covers.
- Respect expiry. A login code that arrives after it expired is worse than none; drop the job and let the user ask again.
- Keep identifiers. Store the
Message-IDyou generate (unique per message, RFC 5322) and the provider’s message ID to match webhook events to messages.
5. Don’t mix transactional and marketing mail
A receipt with a promotion block is, to filters and many recipients, a marketing message. Google’s sender guidelines say not to mix different types of content in one message (no promotions in receipts), to use the same From address for messages of the same category and, if you send from several IPs, a different IP for each type of message. So keep separate subdomains, separate streams or IP pools where your provider offers them, separate DKIM selectors, and separate suppression lists.
6. List-Unsubscribe only where it fits
Gmail and Yahoo require one-click unsubscribe (RFC 8058) for marketing and subscribed messages from bulk senders; Google’s FAQ says transactional messages are excluded.
| Message | List-Unsubscribe |
|---|---|
| Password reset, login code, security alert | no — nothing to unsubscribe from |
| Receipt, invoice, order status | no |
| Optional notifications (digests, weekly reports) | yes — switching off that notification only |
| Newsletters, announcements | yes, one-click, sent from the marketing stream |
Details in one-click unsubscribe. Also consider Auto-Submitted: auto-generated (RFC 3834): it marks the message as machine-generated, and automatic responders such as out-of-office replies should not answer it. If users may reply, set a Reply-To that someone reads.
7. Test in staging and read the headers
- Development and automated tests: use a local mail catcher or the provider’s sandbox mode, so tests never reach real customers.
- Staging: send through the real provider, with the production subdomain or one set up identically, to test mailboxes you control at the major mailbox providers.
- In your deployment pipeline: check SPF, DKIM and DMARC of the sending subdomain with the correct.email API after DNS changes, and fail the build when
result.statusisfail. - After every DNS or provider change: paste the full headers of a received message into the email header analyzer. It runs entirely in your browser and shows the delivery path and the verdicts the receiver recorded. To read them yourself, see how to read email headers.
A correctly set-up message looks like this:
Return-Path: <b-8f2c1e@bounce.notify.example.com>
Authentication-Results: mx.example.net;
spf=pass smtp.mailfrom=bounce.notify.example.com;
dkim=pass header.d=notify.example.com header.s=s1;
dmarc=pass header.from=notify.example.com
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=notify.example.com; s=s1;
h=from:to:subject:date:message-id; bh=…; b=…
From: Example <account@notify.example.com>
Message-ID: <20261023091402.7f3a91@notify.example.com>
Auto-Submitted: auto-generated
Check on every test message:
spf=passfor your bounce subdomain,dkim=passwithheader.dat your domain,dmarc=passfor the From domain.Return-Pathon your bounce subdomain, not the provider’s domain.- One From header, a Date header and a unique Message-ID.
- No List-Unsubscribe on resets and receipts; a working one on optional notifications.
- TLS into the receiving server —
with ESMTPSin its Received header.
References
- RFC 5321 — Simple Mail Transfer Protocol
- RFC 5322 — Internet Message Format
- RFC 7208 — SPF
- RFC 6376 — DKIM Signatures
- RFC 8301 — Cryptographic Algorithm and Key Usage Update to DKIM
- RFC 9989 — DMARC
- RFC 3464 — Delivery Status Notifications
- RFC 5965 — An Extensible Format for Email Feedback Reports
- RFC 3834 — Recommendations for Automatic Responses to Electronic Mail
- RFC 8058 — Signaling one-click functionality for list email headers
- Google — Email sender guidelines
- Google — Email sender guidelines FAQ
- Yahoo — Sender best practices