<?php
/**
* SeekQuarry/Yioop --
* Open Source Pure PHP Search Engine, Crawler, and Indexer
*
* Copyright (C) 2009 - 2026 Chris Pollett chris@pollett.org
*
* LICENSE:
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see
* <https://www.gnu.org/licenses/>.
*
* END LICENSE
*
* @author Chris Pollett chris@pollett.org
* @license https://www.gnu.org/licenses/ GPL3
* @link https://www.seekquarry.com/
* @copyright 2009 - 2026
* @filesource
*/
namespace seekquarry\yioop\library\mail;
use seekquarry\yioop\configs as C;
/**
* Evaluates Sender Policy Framework (SPF, RFC 7208) records to decide
* whether a connecting client address is authorized to send mail for
* a domain. Given the connecting IP, the envelope-sender domain, and
* the HELO name, it fetches the domain's v=spf1 TXT record and walks
* its mechanisms, returning one of the standard SPF results.
*
* This implementation covers the mechanisms in everyday use: all,
* ip4, ip6, a, mx, include, and exists, the redirect= modifier, and
* the +, -, ~, and ? qualifiers. It enforces the RFC 7208 limit of
* ten DNS-querying mechanisms to bound work and prevent abuse,
* returning permerror when that limit is exceeded. Macro expansion
* beyond the common %{d}, %{s}, %{l}, %{o}, and %{i} forms is not
* performed; a record relying on uncommon macros may evaluate to
* permerror, which DMARC treats as a non-aligned result rather than
* a pass, so the failure is safe.
*
* Looked-up SPF records are cached through MailRecordCache so the
* same domain is not re-queried on every message.
*
* @author Chris Pollett
*/
class SpfCheck
{
/**
* The connecting client passed the domain's policy.
*/
const PASS = 'pass';
/**
* The policy explicitly disallows the client (-all reached, or a
* matched mechanism with a fail qualifier).
*/
const FAIL = 'fail';
/**
* The policy disallows the client only softly (~all).
*/
const SOFTFAIL = 'softfail';
/**
* The policy is neutral about the client (?all or no match).
*/
const NEUTRAL = 'neutral';
/**
* The domain publishes no SPF record.
*/
const NONE = 'none';
/**
* A DNS lookup failed temporarily; the check could be retried.
*/
const TEMPERROR = 'temperror';
/**
* The record could not be evaluated: malformed, too many DNS
* lookups, or relying on an unsupported construct.
*/
const PERMERROR = 'permerror';
/**
* Maximum number of DNS-querying mechanisms and modifiers a
* single evaluation may perform, per RFC 7208 4.6.4.
*/
const MAX_LOOKUPS = 10;
/**
* Evaluates SPF for a connecting client. Fetches the sender
* domain's policy and walks it, returning one of the result
* constants.
*
* @param string $ip the connecting client's IP address
* @param string $domain the envelope-sender domain (the part
* after @ in MAIL FROM, or the HELO name when MAIL FROM
* has an empty reverse path)
* @param string $helo the HELO/EHLO name the client gave, used
* to expand macros and as the sender for a null reverse
* path
* @return string one of the SpfCheck result constants
*/
public static function check($ip, $domain, $helo = '')
{
$ip = trim((string) $ip);
$domain = strtolower(trim((string) $domain));
if ($ip === '' || $domain === '') {
return self::NONE;
}
$client = self::parseIp($ip);
if ($client === null) {
return self::PERMERROR;
}
$lookups = 0;
return self::evaluateDomain($domain, $client, $domain,
(string) $helo, $lookups);
}
/**
* Fetches and evaluates the SPF record for one domain. Used for
* the initial domain and, recursively, for include and redirect
* targets. The sender and helo are carried through unchanged so
* macros expand against the original message; only the domain
* whose record is being read changes.
*
* @param string $domain domain whose v=spf1 record to evaluate
* @param array $client parsed connecting address from parseIp
* @param string $sender_domain original envelope-sender domain
* @param string $helo original HELO name
* @param int &$lookups running count of DNS-querying terms,
* shared across recursion to enforce the limit
* @return string one of the SpfCheck result constants
*/
private static function evaluateDomain($domain, $client,
$sender_domain, $helo, &$lookups)
{
$record = self::fetchSpfRecord($domain);
if ($record === null) {
return self::TEMPERROR;
}
if ($record === '') {
return self::NONE;
}
$terms = preg_split('/\s+/', trim($record));
$redirect = '';
foreach ($terms as $term) {
if ($term === '' || strtolower($term) === 'v=spf1') {
continue;
}
if (stripos($term, 'redirect=') === 0) {
$redirect = substr($term, strlen('redirect='));
continue;
}
if (stripos($term, 'exp=') === 0) {
continue;
}
$parsed = self::parseMechanism($term);
if ($parsed === null) {
return self::PERMERROR;
}
list($qualifier, $name, $value) = $parsed;
$matched = self::matchMechanism($name, $value, $client,
$domain, $sender_domain, $helo, $lookups);
if ($matched === self::PERMERROR ||
$matched === self::TEMPERROR) {
return $matched;
}
if ($matched === true) {
return self::qualifierResult($qualifier);
}
}
if ($redirect !== '') {
if ($lookups >= self::MAX_LOOKUPS) {
return self::PERMERROR;
}
$lookups++;
return self::evaluateDomain(strtolower($redirect),
$client, $sender_domain, $helo, $lookups);
}
return self::NEUTRAL;
}
/**
* Splits a mechanism term into its qualifier, name, and value.
* The qualifier is one of +, -, ~, ? and defaults to + when
* absent. The name is the lowercased mechanism word; the value
* is whatever followed a colon or slash, used by ip4, ip6, a,
* mx, include, and exists.
*
* @param string $term one whitespace-delimited SPF term
* @return array|null [qualifier, name, value], or null when the
* term is not a recognized mechanism
*/
private static function parseMechanism($term)
{
$qualifier = '+';
$first = substr($term, 0, 1);
if ($first === '+' || $first === '-' || $first === '~' ||
$first === '?') {
$qualifier = $first;
$term = substr($term, 1);
}
if ($term === '') {
return null;
}
$value = '';
$separator = strcspn($term, ':/=');
if ($separator < strlen($term)) {
$name = substr($term, 0, $separator);
$value = substr($term, $separator);
$lead = substr($value, 0, 1);
if ($lead === ':' || $lead === '=') {
/* The grammar uses ip4:1.2.3.4; some published
records mistakenly write ip4=1.2.3.4. Accept the
equals form for ip4/ip6 so a common typo does not
turn into a permerror, which would otherwise fail
an authorized sender. */
$value = substr($value, 1);
}
} else {
$name = $term;
}
$name = strtolower($name);
$known = ['all', 'ip4', 'ip6', 'a', 'mx', 'include',
'exists', 'ptr'];
if (!in_array($name, $known)) {
return null;
}
return [$qualifier, $name, $value];
}
/**
* Decides whether one mechanism matches the connecting client.
* DNS-querying mechanisms increment the shared lookup count and
* may return PERMERROR when the limit is hit or TEMPERROR on a
* failed query; non-matching mechanisms return false and
* matching ones return true.
*
* @param string $name mechanism name from parseMechanism
* @param string $value mechanism value from parseMechanism
* @param array $client parsed connecting address
* @param string $domain domain whose record is being evaluated
* @param string $sender_domain original envelope-sender domain
* @param string $helo original HELO name
* @param int &$lookups running DNS-lookup count
* @return bool|string true on match, false on no match, or a
* PERMERROR/TEMPERROR result constant on a lookup problem
*/
private static function matchMechanism($name, $value, $client,
$domain, $sender_domain, $helo, &$lookups)
{
if ($name === 'all') {
return true;
}
if ($name === 'ip4' || $name === 'ip6') {
return self::matchIpMechanism($name, $value, $client);
}
if ($name === 'ptr') {
/* ptr is deprecated and discouraged; treat as no match
rather than performing its reverse lookups. */
return false;
}
if ($lookups >= self::MAX_LOOKUPS) {
return self::PERMERROR;
}
$lookups++;
$target = ($value === '') ? $domain :
strtolower(self::expandMacros($value, $sender_domain,
$client, $helo));
if ($name === 'a') {
return self::matchAddressRecords($target, $client);
}
if ($name === 'mx') {
return self::matchMxRecords($target, $client, $lookups);
}
if ($name === 'include') {
$result = self::evaluateDomain($target, $client,
$sender_domain, $helo, $lookups);
if ($result === self::PASS) {
return true;
}
if ($result === self::TEMPERROR ||
$result === self::PERMERROR) {
return $result;
}
return false;
}
if ($name === 'exists') {
$records = AsyncDnsResolver::query($target,
AsyncDnsResolver::TYPE_A);
return !empty($records);
}
return false;
}
/**
* Matches an ip4 or ip6 mechanism: parses the value as an
* address with an optional CIDR prefix and tests whether the
* connecting client falls in that range and is of the same
* family.
*
* @param string $name 'ip4' or 'ip6'
* @param string $value address or address/prefix from the term
* @param array $client parsed connecting address
* @return bool whether the client is within the range
*/
private static function matchIpMechanism($name, $value, $client)
{
$prefix = null;
$slash = strpos($value, '/');
if ($slash !== false) {
$prefix = (int) substr($value, $slash + 1);
$value = substr($value, 0, $slash);
}
$family = ($name === 'ip4') ? 4 : 6;
$range = self::parseIp($value);
if ($range === null || $range['family'] !== $family ||
$client['family'] !== $family) {
return false;
}
if ($prefix === null) {
$prefix = ($family === 4) ? 32 : 128;
}
return self::inRange($client['packed'], $range['packed'],
$prefix, $family);
}
/**
* Matches an a mechanism by resolving the target's A and AAAA
* records and testing the client against each.
*
* @param string $target host name to resolve
* @param array $client parsed connecting address
* @return bool whether the client equals one of the addresses
*/
private static function matchAddressRecords($target, $client)
{
$type = ($client['family'] === 4) ? AsyncDnsResolver::TYPE_A :
AsyncDnsResolver::TYPE_AAAA;
$field = ($client['family'] === 4) ? 'ip' : 'ipv6';
$records = AsyncDnsResolver::query($target, $type);
if (empty($records)) {
return false;
}
foreach ($records as $record) {
$address = $record[$field] ?? '';
$parsed = self::parseIp($address);
if ($parsed !== null &&
$parsed['packed'] === $client['packed']) {
return true;
}
}
return false;
}
/**
* Matches an mx mechanism by resolving the target's MX hosts and
* then each host's address records. The MX resolution and each
* host resolution are bounded by the shared lookup count.
*
* @param string $target domain whose MX hosts to resolve
* @param array $client parsed connecting address
* @param int &$lookups running DNS-lookup count
* @return bool|string true on match, false on none, or a
* PERMERROR result when the lookup limit is exceeded
*/
private static function matchMxRecords($target, $client,
&$lookups)
{
$hosts = SmtpClient::resolveMxHosts($target);
foreach ($hosts as $host) {
if ($lookups >= self::MAX_LOOKUPS) {
return self::PERMERROR;
}
$lookups++;
if (self::matchAddressRecords($host, $client)) {
return true;
}
}
return false;
}
/**
* Fetches a domain's v=spf1 TXT record, using and populating the
* shared mail-record cache. Returns the record text when found,
* the empty string when the domain publishes none, or null on a
* DNS lookup failure (so the caller can report temperror).
*
* @param string $domain domain to read the SPF record for
* @return string|null the v=spf1 record, '' for none, or null
* on lookup failure
*/
private static function fetchSpfRecord($domain)
{
$cache = MailRecordCache::getInstance();
$cached = $cache->get(MailRecordCache::TYPE_SPF, $domain);
if ($cached !== null) {
return $cached;
}
$records = AsyncDnsResolver::query($domain,
AsyncDnsResolver::TYPE_TXT);
$record = '';
$time_to_live = 0;
foreach ($records as $entry) {
$text = trim((string) ($entry['txt'] ?? ''));
if (stripos($text, 'v=spf1') === 0) {
$record = $text;
$time_to_live = (int) ($entry['ttl'] ?? 0);
break;
}
}
$cache->put(MailRecordCache::TYPE_SPF, $domain, $record,
$time_to_live);
return $record;
}
/**
* Expands the common SPF macros in a mechanism value: %{d} the
* domain, %{s} the sender, %{l} the sender local part, %{o} the
* sender domain, and %{i} the client IP. Other macro letters are
* left untouched, which for an unsupported macro yields a target
* that will not resolve -- a safe non-match rather than a wrong
* pass.
*
* @param string $value mechanism value possibly containing
* macros
* @param string $sender_domain original envelope-sender domain
* @param array $client parsed connecting address
* @param string $helo original HELO name
* @return string the value with known macros expanded
*/
private static function expandMacros($value, $sender_domain,
$client, $helo)
{
if (strpos($value, '%') === false) {
return $value;
}
$local = 'postmaster';
$sender = $local . '@' . $sender_domain;
$replacements = ['%{d}' => $sender_domain,
'%{s}' => $sender, '%{l}' => $local,
'%{o}' => $sender_domain, '%{i}' => $client['display'],
'%{h}' => $helo, '%%' => '%', '%_' => ' '];
return strtr($value, $replacements);
}
/**
* Parses an IP address string into a family marker, its packed
* binary form for range tests, and a normalized display form.
* Returns null when the string is not a valid IPv4 or IPv6
* address.
*
* @param string $ip address text
* @return array|null ['family'=>4|6, 'packed'=>binary,
* 'display'=>string], or null when invalid
*/
private static function parseIp($ip)
{
$ip = trim($ip);
if ($ip === '') {
return null;
}
$packed = @inet_pton($ip);
if ($packed === false) {
return null;
}
$family = (strlen($packed) === 4) ? 4 : 6;
return ['family' => $family, 'packed' => $packed,
'display' => $ip];
}
/**
* Tests whether two packed addresses share their leading prefix
* bits, the comparison behind a CIDR range match.
*
* @param string $client packed client address
* @param string $range packed range base address
* @param int $prefix number of leading bits that must match
* @param int $family 4 or 6, to bound the prefix
* @return bool whether the client lies in the range
*/
private static function inRange($client, $range, $prefix,
$family)
{
$width = ($family === 4) ? 32 : 128;
if ($prefix < 0 || $prefix > $width) {
return false;
}
$whole = intdiv($prefix, 8);
if ($whole > 0 && strncmp($client, $range, $whole) !== 0) {
return false;
}
$bits = $prefix % 8;
if ($bits === 0) {
return true;
}
$mask = 0xff << (8 - $bits) & 0xff;
$client_byte = ord($client[$whole]);
$range_byte = ord($range[$whole]);
return ($client_byte & $mask) === ($range_byte & $mask);
}
/**
* Maps a matched mechanism's qualifier to its SPF result.
*
* @param string $qualifier one of +, -, ~, ?
* @return string the corresponding result constant
*/
private static function qualifierResult($qualifier)
{
if ($qualifier === '-') {
return self::FAIL;
}
if ($qualifier === '~') {
return self::SOFTFAIL;
}
if ($qualifier === '?') {
return self::NEUTRAL;
}
return self::PASS;
}
}