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.
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 [email protected];
spf=pass smtp.mailfrom=mail.example.com;
dmarc=fail header.from=example.orgTwo 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 says | It means | Go to |
|---|---|---|
the passing domain is gmail.com or your SMTP host's | a different sender sent it | cause 1 |
spf=fail, dkim=fail, or no DKIM result at all | the records are missing, wrong, or not live yet | cause 2 |
spf=pass and dkim=pass, but dmarc=fail | something passed, but not for your From domain | cause 3 |
| all three pass, for your own domain | the message, its links, or the sending history | 4 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:
| Type | Name | Value | What it is |
|---|---|---|---|
| TXT | mail.example.com | v=spf1 include:mailgun.org ~all | SPF |
| TXT | <selector>._domainkey.mail.example.com | k=rsa; p=... | DKIM public key |
| CNAME | email.mail.example.com | mailgun.org | Mailgun 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.com3. 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:[email protected]"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/:idaccepts a newfromand 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 [email protected]. That is why the
mail. subdomain the docs suggest costs you nothing at this step.
4. Every link points at our host, not yours
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 [email protected], 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