How to Keep Transactional Email Out of the Spam Folder
Transactional email deliverability is mostly decided before the message is ever composed. Order confirmations, password resets and receipts get filtered because the receiving server cannot verify that your sending host was authorised to use your domain, or because the message looks like it came from a machine that sends bulk marketing rather than a system replying to a specific user action. The fix is not a clever subject line. It is DNS, authentication and a sending path you control.
Think about what a mailbox provider actually does when your message arrives. It opens a connection, reads the envelope, then scores the message against a long list of signals: does the sending IP have a reputation, does the domain publish a policy saying which hosts may send for it, does the cryptographic signature in the headers validate against that policy, and does the content match what the user expects. Fail the first three and the fourth rarely matters.
The three records that decide transactional email deliverability
Every domain that sends mail should publish four things in DNS: an MX record so replies have somewhere to go, a SPF record, a DKIM public key, and a DMARC policy. The first two are old and well understood. The last two are where most setups quietly break.
SPF is a TXT record listing which hosts may send mail for the domain. It is checked against the envelope sender, not the visible From: header, which is why a message can pass SPF and still be forged. Keep it short. Every lookup in the record costs a DNS query, and receivers cap the total at ten. A record that includes three third party senders, each of which includes two more, is a record that fails intermittently.
dig +short TXT example.com
"v=spf1 include:_spf.provider.example -all"
"v=DMARC1; p=none; rua=mailto:[email protected]"
Note the two TXT records on one name. That is legal and common, but it means a naive parser sees them as one string. If you are debugging, query the name and read the raw answer rather than trusting a summary tool.
DKIM is the part people skip because it requires generating a key pair and pasting the public half into DNS. Do it anyway. The private key stays on your sending host, the public key goes into a TXT record at a selector name such as mail._domainkey.example.com, and every outgoing message carries a DKIM-Signature header. The signature covers selected headers and the body hash, so any relay that rewrites the body invalidates it.
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
d=example.com; s=mail;
h=from:to:subject:date:message-id;
bh=...; b=...
The h= tag lists which headers are signed. If From is not in that list, the signature is close to worthless, because an attacker can rewrite the visible sender while the signature still validates. Check the tag before you trust a signing setup you did not configure yourself.
Alignment, not just authentication
DMARC ties the two together. It asks the receiver to check that the domain in the visible From: header matches the domain that passed SPF or DKIM. This is called alignment, and it is the single most misunderstood part of the whole system. You can pass SPF with envelope-from set to a bounce domain and still fail DMARC, because the visible From: is your own domain and neither check aligned with it.
For transactional mail the practical rule is simple: sign with the same domain you put in From:. If your application sends through a relay, configure the relay to use your domain as the signing domain, not the relay's own. If the relay will not do that, use a subdomain for sending and put that subdomain in From: so alignment holds.
Start with p=none and a reporting address. That tells receivers to apply the policy but still deliver the mail, and to send you aggregate reports. Read those reports. They will show you which hosts are sending as your domain, including ones you forgot about. Only move to p=quarantine once the reports show that everything legitimate is passing. Moving too early is how password reset emails start landing in spam for a subset of users and nobody can reproduce it locally.
Bounces, complaints and the addresses you must not ignore
Transactional mail is defined by the fact that it is expected. That expectation is enforced through feedback loops. When a message hard bounces, the receiving server returns a permanent failure. If your application keeps sending to that address, you accumulate a reputation for sending to addresses that do not exist, and that reputation follows the sending IP, not the domain.
Handle bounces at the application layer. Parse the enhanced status code from the SMTP reply, not the human readable text, because the text varies between providers.
550 5.1.1 <[email protected]>: Recipient address rejected:
User unknown in local recipient table
The 5.1.1 is the part to act on. A leading 5 means permanent, a leading 4 means temporary and you should retry with backoff. Suppress hard bounces permanently. Suppress soft bounces after a bounded number of attempts, then stop.
Complaints are the other signal. A user marking a password reset as spam is unusual but it happens, often because the message arrived long after the request or because it looks identical to a phishing attempt. Make reset links short lived and single use, and put the domain in the visible text so the user can see where the link goes before clicking.
Content matters less than people think, but a few things reliably hurt. A message with no plain text alternative, a link whose visible text is a bare IP address, a From: display name that does not match the domain, and a subject line that is entirely a tracking number with no context. None of these are fatal on their own. Together they push a borderline message over the line.
What to do next
Pick one domain you send transactional mail from and audit it this week. Query its TXT records, confirm the From: domain aligns with what SPF and DKIM actually authenticate, set a DMARC record at p=none with a reporting address, and read the first batch of reports before you change anything else. Then fix your bounce handling so permanent failures stop the send rather than repeating it. Everything after that is tuning.
