cbpolicyd SPF Checking on Zimbra
SPF says which servers are allowed to send mail for a domain. The cbpolicyd checkspf module evaluates it during the SMTP session and can refuse a failure before the message is accepted. That is earlier than Zimbra's own filtering, which sees SPF results only as a header after amavis has already taken the message.
It is also the module most likely to lose you legitimate mail if you turn the strict setting on without thinking it through.
Part of the series that starts with cbpolicyd on Zimbra: An Introduction.
Enable it
zmprov ms $(zmhostname) zimbraCBPolicydCheckSPFEnabled TRUE
zmmtactl restart
The table
Three settings, and that is the whole module:
| Column | What it does |
|---|---|
UseSPF | Evaluate SPF at all, 1 or 0 |
RejectFailedSPF | Refuse the message when SPF returns a hard fail |
AddSPFHeader | Add a Received-SPF header recording the result |
NULL means inherit rather than off, which matters when you have more than one policy
carrying SPF settings.
Start with header only
The safe first configuration evaluates SPF, records the answer and refuses nothing:
INSERT INTO policies (Name, Priority, Description, Disabled)
VALUES ('SPF inbound', 30, 'SPF evaluation for inbound mail', 0);
INSERT INTO policy_members (PolicyID, Source, Destination, Disabled)
VALUES ((SELECT ID FROM policies WHERE Name = 'SPF inbound'),
'$-', -- unauthenticated only
'%internal_domains',
0);
INSERT INTO checkspf (PolicyID, Name, UseSPF, RejectFailedSPF, AddSPFHeader, Disabled)
VALUES ((SELECT ID FROM policies WHERE Name = 'SPF inbound'),
'Inbound SPF',
1, -- evaluate
0, -- but do not reject
1, -- write the header
0);
Run that for a few weeks and read the headers on real mail:
grep -h 'Received-SPF' /opt/zimbra/data/... | sort | uniq -c | sort -rn | head
or simply open a handful of messages in the web client with Show Original. What you are
looking for is how many fail results land on mail you actually wanted. In most
organisations the answer is "more than you expect", and that is the whole argument for not
starting with rejection.
Scoping to $- keeps your own authenticated users out of it. SPF is a statement about
which servers may send for a domain, and your users submitting through your own server
authenticated is not what it was designed to judge.
Turning on rejection
Once you have looked at the data:
UPDATE checkspf SET RejectFailedSPF = 1 WHERE Name = 'Inbound SPF';
zmcbpolicydctl restart
This refuses only a hard fail, which means the domain owner published a policy explicitly
saying this server is not authorised. It does not act on softfail, neutral or none.
Why this breaks forwarded mail
This is the part worth understanding before you switch rejection on.
When someone forwards mail to you - an alias at their university, a personal address pointing at your server, a mailing list - the message arrives from their server while still carrying the original sender's envelope address. The original domain's SPF record does not list the forwarder, so SPF fails, and the mail was entirely legitimate.
This is not an edge case. It is how a large amount of real mail reaches people, and it is the reason SRS exists and the reason DMARC treats SPF alignment the way it does.
Practical consequences:
- Expect complaints about mail from ex-employees' forwarded addresses, from alumni accounts, and from anyone whose address is really a redirection.
- Mailing lists that do not rewrite the envelope sender will fail.
- Adding an exemption policy at a lower priority for known forwarders is possible, but it is a list you now have to maintain.
If you want sender authentication with fewer false positives, DMARC with a reject policy
on your own domain protects your brand, and evaluating DKIM alongside SPF avoids
punishing forwarded mail that still carries a valid signature. cbpolicyd does neither of
those. It is SPF only.
What Zimbra already does
Zimbra's amavis and SpamAssassin already evaluate SPF and feed the result into the spam score. That means a soft SPF failure already makes a message more likely to be scored as spam without you configuring anything.
The value cbpolicyd adds is timing rather than detection: it can refuse during the SMTP session, so a rejected message never enters your queue, never gets scanned, and never generates a bounce from your server. That is genuinely better for obvious forgeries and genuinely worse for anything ambiguous, because there is no folder to recover it from.
A reasonable position for most servers is to leave RejectFailedSPF at 0, let the score
handle the ambiguous cases, and let
Sieve file the result by spam score.
Checking what is configured
SELECT p.Priority, p.Name AS Policy, m.Source, m.Destination,
c.UseSPF, c.RejectFailedSPF, c.AddSPFHeader
FROM checkspf c
JOIN policies p ON p.ID = c.PolicyID
LEFT JOIN policy_members m ON m.PolicyID = p.ID
WHERE c.Disabled = 0
ORDER BY p.Priority;
Next in this series
cbpolicyd HELO Checking, which catches a different class of forgery: senders claiming to be you.