Guides
Build guide

Transactional email landing in spam

Transactional email in spam is a verdict the receiving server already wrote into the header. How to read it, and five causes to check in the order that settles fastest.

Dharmendra Jagodana11 min read

In short

Read the Authentication-Results header on the copy in spam before touching DNS. SPF or DKIM failing points at your records. DMARC failing while both pass means the From domain is not the one that was checked. All three passing means links or reputation. On BuildBase, first check which sender is the system default.

The receiving server has already told you why your sign-in email went to spam. It wrote the reason into the message, in a header almost nobody opens. And then the debugging starts somewhere else: a DNS record changed on a hunch, or a ticket to the email provider.

A marketing email in spam costs an open. A magic link in spam costs the sign-in, and the user decides your product is broken.

So read the header first. Then work down the five causes below and stop at the first one that is wrong. They are ordered by how fast each one settles, not by how often it is the answer, and the first two live in how BuildBase picks a sender and reports a domain. No generic deliverability checklist can find those for you.

Before you start

One copy of the message that actually landed in spam, ideally in a Gmail mailbox you control, plus console access to Messaging → Sender Accounts and the sending domain. If nobody kept a copy, make one: request a magic link or a password reset to that mailbox.

First, is it in spam at all?

"I never got the email" is two different bugs, and only one of them is about spam. Find the recipient in email history before you open a DNS panel.

A skipped row means the message never left. No usable sender, a missing template, a blocked or unsubscribed address, an address marked invalid after a bounce: each one is written on the row as the reason. A sent row means your provider accepted the message, and that is the last thing we can see. Where it went next, only the receiving server knows. The invitation troubleshooting guide walks through the skip reasons. This post starts where sent ends.

Read the verdict before you change anything

The receiving server records its checks in an Authentication-Results header, the format RFC 8601 defines. In Gmail, open the message, click More next to Reply, and choose Show original (Google's steps). Trimmed, with placeholder domains, the line you want looks like this:

Authentication-Results: mx.google.com;
       dkim=pass header.i=@mail.example.com;
       spf=pass smtp.mailfrom=mail.example.com;
       dmarc=fail header.from=example.org

Two passes and a fail. Both checks passed, for mail.example.com. The From header said example.org. DMARC asks whether at least one passing check belongs to a domain aligned with the From domain (RFC 7489, section 4.2), and here neither does. Gmail surfaces the same failure more bluntly: a question mark next to the sender's name means the message is not authenticated.

The header saysIt meansGo to
the passing domain is gmail.com or your SMTP host'sa different sender sent itcause 1
spf=fail, dkim=fail, or no DKIM result at allthe records are missing, wrong, or not live yetcause 2
spf=pass and dkim=pass, but dmarc=failsomething passed, but not for your From domaincause 3
all three pass, for your own domainthe message, its links, or the sending history4 and 5

1. Find out which sender actually sent it

This is the cause a generic checklist cannot find, because it lives in how we pick a sender.

System mail does not name one. The magic link, the verification code, the password reset code and the workspace invitation all go through whichever single sender in the org is flagged systemDefault: true. Which one is that? Usually the first sender anyone created. Every way of creating a sender, Mailgun, SMTP or Google, ends by calling updateDefaultSender, and that function only fills an empty default. It never moves one.

Here is how that plays out. Someone connects their own Google account during setup to check that email works. It does. Weeks later someone else adds mail.example.com, verifies it, creates a Mailgun sender on it and moves on. Every sign-in link since then has gone out through Gmail, from the address of the person who clicked connect first. A Google sender's From is the account's own address. Your verified domain never touched those messages, and every DNS record you fix will change nothing.

Two places to check. The email history row stores the from it used and the sender it went through. GET /api/emails/senders returns each sender with its systemDefault flag, but only the senders you own or that were shared with you. The default can easily be someone else's, and missing from your list. An org owner or admin can add filter[all]=true to list every sender in the org.

The fix is to move the default to the sender on your verified domain. That sender's owner can do it, and so can an org owner or admin:

curl -X PATCH https://api.console.buildbase.app/api/emails/senders/$SENDER_ID/default \
  -H "Authorization: $BUILDBASE_TOKEN"

Do it before you clean up. The delete route refuses the system default, so the old sender cannot be removed until another one holds the flag.

2. Check the domain is verified, not that verify returned 200

Adding a domain hands back the DNS records to publish, which come straight from Mailgun. The table below is the sending set from a real stored response, with the domain swapped for a placeholder:

TypeNameValueWhat it is
TXTmail.example.comv=spf1 include:mailgun.org ~allSPF
TXT<selector>._domainkey.mail.example.comk=rsa; p=...DKIM public key
CNAMEemail.mail.example.commailgun.orgMailgun tracking CNAME

MX records come back as a separate set, and you need them only if the domain should receive mail. The sending domains docs cover adding and verifying.

Now the part that matters. Verification is POST /api/emails/domains/:id/verify, which asks Mailgun to re-check DNS. Our docs say a record that has not propagated comes back as a 400. The code never reads the DNS result: the route returns 400 when the call to Mailgun fails, and treats any 200 from Mailgun as success. And Mailgun's own reference example for that call is a 200 whose body reads, trimmed:

{
  "message": "Domain DNS records have been updated",
  "domain": { "name": "example.com", "state": "unverified" }
}

So a 200 from us is not proof, and neither is the email.domain_verified event that fires after it. Read state on the domain document the call returns. A working domain reads active. Then read valid on each entry in sendingDnsRecords: any entry where it does not say valid is the record Mailgun could not find.

One gate does read state. A Mailgun sender cannot be created on a domain whose state is unverified; the route refuses with Domain is unverified. It checks once, at creation.

If a record reads invalid and propagation is not the explanation, rule out the two SPF mistakes that look fine in a DNS panel. A second v=spf1 record at the same name is a permanent error (RFC 7208, section 4.5), so if one already exists, add include:mailgun.org to it rather than publishing another. More than 10 DNS lookups while evaluating it, nested includes counted, is a permanent error too (section 4.6.4). Then ask DNS directly what it serves:

dig +short TXT mail.example.com
dig +short TXT <selector>._domainkey.mail.example.com
dig +short TXT _dmarc.example.com

3. DMARC is the record nobody hands you

Look at that table again. No DMARC record. Mailgun's reference describes its verify call as checking A, CNAME, SPF, DKIM and MX records, so a domain can verify cleanly with no DMARC record anywhere. You publish it yourself.

Does a transactional sender need it? Google requires DMARC from anyone sending more than 5,000 messages a day to personal Gmail accounts, accepts a policy of p=none, and requires the From domain to be aligned with either the SPF domain or the DKIM domain. Yahoo's sender requirements ask bulk senders for a DMARC policy of at least p=none and say DMARC must pass. Neither page sets a separate rule for transactional mail. Below that volume Google asks every sender for SPF or DKIM, not DMARC.

I would publish it at any volume. It is one TXT record, and at p=none it asks receivers to change nothing, while the aggregate reports from the receivers that send them show you who is mailing as your domain:

_dmarc.example.com.  TXT  "v=DMARC1; p=none; rua=mailto:dmarc-reports@example.com"

One record on the apex covers mail.example.com as well. A receiver that finds nothing at the subdomain falls back to the organizational domain (RFC 7489, section 6.6.3), and with no sp tag the p policy applies to subdomains.

Alignment is where the three kinds of sender differ:

  • Mailgun. The From is built from the verified domain when the sender is created, so it starts aligned. It does not have to stay that way. PATCH /api/emails/senders/:id accepts a new from and validates only that it is an address. Nothing compares its domain with the one Mailgun verified.
  • Custom SMTP. The From is whatever was typed. Saving the sender checks, by default, that the credentials connect. It proves nothing about whether your SMTP provider signs for that domain, which is set up at the provider, not with us.
  • Google. Mail goes out through Gmail as the connected account, from that account's address. Good for a test. Not your domain.

Relaxed alignment is the default (section 6.3) and only needs the organizational domains to match, so a DKIM signature for mail.example.com aligns with a From of hello@example.com. That is why the mail. subdomain the docs suggest costs you nothing at this step.

This is the cause you cannot fix from your side today, and I would rather you heard it from us.

Every message BuildBase sends, the magic link included, goes through a tracking rewrite on its way to the transport. The one exception is the sender test described below. Each href in the HTML part and each bare URL in the text part becomes <SERVER_URL>/api/mail-track/link/<token>, and a hidden 1x1 image from <SERVER_URL>/api/mail-track/blank-image/<token> is appended to the body. A unit test, email-send-funnel.test.ts, asserts that exactly one file hands mail to a transport, so no send path skips the rewrite. Your plan does not change it either. On a plan without open and click analytics the pixel still loads and the redirect still runs. Only the analytics row goes unwritten.

SERVER_URL is the tenant server's public address. On the managed service that is a buildbase.app host, and our own engineering notes say it plainly: customer link reputation lands directly on a buildbase.app host. Receivers do look at the domains inside a message. Spamhaus publishes a domain blocklist meant to be checked against "domains appearing in the mail headers and body e.g., URLs". So part of the verdict on your sign-in email is about a domain that is not yours and cannot be changed from the console. Per-org tracking domains are not built, and there is no setting that turns the rewrite off.

Two things you can do about it.

Isolate it. The console's Send test email is the one message that deliberately carries no tracking: no pixel, no rewritten links, no unsubscribe header. It always goes to your own account's address, so run the comparison there. Send a test through the system-default sender, then request a real magic link to the same address. If the test reaches the inbox and the sign-in email does not, from the same sender, your DNS is fine. The difference is in the message.

Self-host. On a self-hosted install SERVER_URL is your own host, so the links can sit under your own domain.

5. When all three pass: history, then content

SPF, DKIM and DMARC all pass for your own domain, and the right sender sent it. What is left has no header of its own: the domain's sending history, and the message itself. The test split from cause 4 tells you which: if both copies land in spam, look at history, and if only the real sign-in email does, look at the message.

History first. A domain you added last week has none for a receiver to go on. Gmail's Postmaster Tools reports the spam rate it measured for your domain, and Google asks every sender to keep it below 0.3%. Keep sign-in mail on its own subdomain, so a campaign that draws complaints cannot drag your magic links down with it.

That is how our own platform mail is set up. Organization invitations go out from noreply@mail.buildbase.app, on the mail. subdomain on purpose: it is the domain the relay signs for, so it is the domain DKIM aligns with, and keeping transactional mail off the root domain means an invitation cannot damage the reputation the marketing site sends from, or the other way round. Replies go to whoever sent the invitation, through Reply-To, so the recipient always has a person to answer.

Content goes last because nothing in the header points at it, so the only way to test it is by changing it. Google's sender guidelines say links "should be visible and easy to understand", and that recipients "should know what to expect when they click a link". The default magic-link template is a few short lines and one link labelled Sign in. If yours has grown into a newsletter, put the default back for a day and see where it lands.

Install

npm i @buildbase/sdk
email
deliverability
troubleshooting

Frequently Asked Questions

Why does my transactional email go to spam when SPF and DKIM both pass?

Check the DMARC result beside them. DMARC needs SPF or DKIM to pass for a domain aligned with your From domain, so a message signed for mail.example.com and sent from example.org fails DMARC with two passes in hand. If DMARC passes as well, look at links and reputation.

Do transactional emails need DMARC?

Google requires it from anyone sending more than 5,000 messages a day to personal Gmail accounts, and accepts a policy of p=none. Yahoo requires it from bulk senders. Neither page sets a separate rule for transactional mail, apart from one-click unsubscribe, which applies only to marketing and subscribed messages.

Does a password reset email need a List-Unsubscribe header?

No. Google and Yahoo require one-click unsubscribe for marketing and subscribed messages. On BuildBase the default sign-in, verification code and password reset templates belong to no unsubscribe group, so they go out without List-Unsubscribe headers. Templates in the workspace and billing groups carry them.

Why did the test email reach my inbox when the real one went to spam?

They are different messages. The console test goes through the sender you picked and carries no tracking pixel, no rewritten links and no unsubscribe header. Real system mail goes through the system-default sender with every link rewritten to the server tracking URL. Test the default sender to find which difference matters.

Ship it

Create a project and run this guide against your own workspace.

7-day free trialNo credit card requiredCancel anytime