Skip to main content

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:

ColumnWhat it does
UseSPFEvaluate SPF at all, 1 or 0
RejectFailedSPFRefuse the message when SPF returns a hard fail
AddSPFHeaderAdd 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.