<?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 Domain-based Message Authentication, Reporting, and
* Conformance (DMARC, RFC 7489) for an incoming message. DMARC binds
* the domain in the From header to the results of SPF and DKIM: the
* message passes when either an SPF pass or a DKIM pass is for a
* domain that aligns with the From domain. When neither aligns, the
* From domain's published policy says what to do -- nothing, treat as
* suspicious (quarantine), or refuse (reject).
*
* The evaluation takes the SPF and DKIM results that were computed
* for the message and the relevant domains, fetches the From domain's
* DMARC record (falling back to the organizational domain's record
* and its subdomain policy when the From domain itself publishes
* none), checks alignment under the record's relaxed or strict mode,
* and reports the outcome together with the policy to apply.
*
* The organizational domain is approximated as the registrable domain
* using a short list of common multi-label public suffixes rather
* than the full public suffix list; an unusual suffix may make
* alignment stricter than intended, which fails safe (toward applying
* policy) rather than wrongly passing a message.
*
* DMARC records are cached through MailRecordCache under TYPE_DMARC.
*
* @author Chris Pollett
*/
class DmarcCheck
{
/**
* The message aligned (an aligned SPF or DKIM pass); no policy
* action is needed.
*/
const PASS = 'pass';
/**
* The message did not align and the From domain publishes a
* DMARC record, so its policy applies.
*/
const FAIL = 'fail';
/**
* The From domain publishes no DMARC record, so DMARC expresses
* no opinion.
*/
const NONE = 'none';
/**
* Policy requesting no action on a failure (p=none); the message
* is delivered but the failure may be reported.
*/
const POLICY_NONE = 'none';
/**
* Policy requesting that a failing message be treated as
* suspicious (p=quarantine).
*/
const POLICY_QUARANTINE = 'quarantine';
/**
* Policy requesting that a failing message be refused
* (p=reject).
*/
const POLICY_REJECT = 'reject';
/**
* Common multi-label public suffixes, so a From domain such as
* example.co.uk yields the organizational domain example.co.uk
* rather than co.uk. This is a pragmatic subset of the public
* suffix list covering the suffixes seen most often; an
* unlisted multi-label suffix falls back to the last two
* labels.
*/
const MULTI_LABEL_SUFFIXES = ['co.uk', 'org.uk', 'gov.uk',
'ac.uk', 'co.jp', 'co.nz', 'co.za', 'com.au', 'net.au',
'org.au', 'com.br', 'com.cn', 'com.mx', 'co.in', 'co.kr'];
/**
* Evaluates DMARC for a message given its already-computed SPF
* and DKIM results. Returns a map describing the outcome:
* 'result' one of PASS, FAIL, NONE
* 'policy' the policy to apply on a fail (POLICY_NONE,
* POLICY_QUARANTINE, or POLICY_REJECT); POLICY_NONE
* when there is no record
* 'domain' the From-header domain that was evaluated
* 'dkim_aligned' whether an aligned DKIM pass was found
* 'spf_aligned' whether an aligned SPF pass was found
*
* @param string $from_domain the From-header domain
* @param string $dkim_status the DKIM verification status, as
* returned by DkimKey::verify ('pass' means verified)
* @param string $dkim_domain the DKIM signature d= domain
* @param string $spf_status the SPF result from SpfCheck
* @param string $spf_domain the domain SPF authenticated (the
* envelope MAIL FROM domain, or HELO for a null sender)
* @return array the outcome map described above
*/
public static function evaluate($from_domain, $dkim_status,
$dkim_domain, $spf_status, $spf_domain)
{
$from_domain = strtolower(trim((string) $from_domain));
$outcome = ['result' => self::NONE,
'policy' => self::POLICY_NONE, 'domain' => $from_domain,
'dkim_aligned' => false, 'spf_aligned' => false];
if ($from_domain === '') {
return $outcome;
}
$record = self::fetchPolicy($from_domain);
if ($record === '') {
return $outcome;
}
$tags = self::parsePolicy($record);
if (empty($tags['p'])) {
return $outcome;
}
$dkim_aligned = ($dkim_status === DkimKey::VERIFY_PASS) &&
self::aligned($dkim_domain, $from_domain,
($tags['adkim'] ?? 'r'));
$spf_aligned = ($spf_status === SpfCheck::PASS) &&
self::aligned($spf_domain, $from_domain,
($tags['aspf'] ?? 'r'));
$outcome['dkim_aligned'] = $dkim_aligned;
$outcome['spf_aligned'] = $spf_aligned;
if ($dkim_aligned || $spf_aligned) {
$outcome['result'] = self::PASS;
return $outcome;
}
$outcome['result'] = self::FAIL;
$outcome['policy'] = self::policyForDomain($tags,
$from_domain);
return $outcome;
}
/**
* Picks the policy that applies to the From domain: the
* subdomain policy sp= when the From domain is a subdomain of
* the domain that published the record and sp= is present,
* otherwise the main policy p=. Falls back to none for an
* unrecognized value.
*
* @param array $tags parsed DMARC tags
* @param string $from_domain the From-header domain
* @return string one of the POLICY_ constants
*/
private static function policyForDomain($tags, $from_domain)
{
$organizational = self::organizationalDomain($from_domain);
$policy = $tags['p'];
if ($from_domain !== $organizational &&
!empty($tags['sp'])) {
$policy = $tags['sp'];
}
if ($policy === self::POLICY_REJECT) {
return self::POLICY_REJECT;
}
if ($policy === self::POLICY_QUARANTINE) {
return self::POLICY_QUARANTINE;
}
return self::POLICY_NONE;
}
/**
* Tests whether an authenticated domain aligns with the From
* domain. Strict mode (mode 's') requires an exact match;
* relaxed mode (the default) requires the same organizational
* domain. An empty authenticated domain never aligns.
*
* @param string $authenticated the SPF or DKIM domain
* @param string $from_domain the From-header domain
* @param string $mode 's' for strict, anything else relaxed
* @return bool whether the domains align
*/
private static function aligned($authenticated, $from_domain,
$mode)
{
$authenticated = strtolower(trim((string) $authenticated));
if ($authenticated === '') {
return false;
}
if ($authenticated === $from_domain) {
return true;
}
if (strtolower(trim((string) $mode)) === 's') {
return false;
}
return self::organizationalDomain($authenticated) ===
self::organizationalDomain($from_domain);
}
/**
* Returns the organizational (registrable) domain of a host:
* the registrable label plus its public suffix. Recognizes a
* short list of common multi-label suffixes; otherwise treats
* the last two labels as the organizational domain.
*
* @param string $domain a host name
* @return string the organizational domain
*/
private static function organizationalDomain($domain)
{
$domain = strtolower(trim((string) $domain, " \t."));
if ($domain === '') {
return '';
}
$labels = explode('.', $domain);
$count = count($labels);
if ($count <= 2) {
return $domain;
}
$last_two = $labels[$count - 2] . '.' . $labels[$count - 1];
if (in_array($last_two, self::MULTI_LABEL_SUFFIXES)) {
return $labels[$count - 3] . '.' . $last_two;
}
return $last_two;
}
/**
* Fetches the DMARC record for a domain, using and populating
* the shared mail-record cache. Looks at _dmarc.<from-domain>
* first; when that publishes none and the organizational domain
* differs, looks at _dmarc.<organizational-domain>, as RFC 7489
* requires. Returns the record text, or the empty string when
* neither publishes one.
*
* @param string $from_domain the From-header domain
* @return string the DMARC record, or '' when none is published
*/
private static function fetchPolicy($from_domain)
{
$record = self::lookupDmarc($from_domain);
if ($record !== '') {
return $record;
}
$organizational = self::organizationalDomain($from_domain);
if ($organizational !== $from_domain) {
return self::lookupDmarc($organizational);
}
return '';
}
/**
* Reads the _dmarc TXT record for one domain through the cache,
* caching a found record or a miss. A record is recognized by
* its leading v=DMARC1 tag.
*
* @param string $domain domain to read _dmarc.<domain> for
* @return string the DMARC record, or '' when none is found
*/
private static function lookupDmarc($domain)
{
$name = '_dmarc.' . $domain;
$cache = MailRecordCache::getInstance();
$cached = $cache->get(MailRecordCache::TYPE_DMARC, $name);
if ($cached !== null) {
return $cached;
}
$record = '';
$time_to_live = 0;
$records = AsyncDnsResolver::query($name,
AsyncDnsResolver::TYPE_TXT);
if (!empty($records)) {
foreach ($records as $entry) {
$text = trim((string) ($entry['txt'] ?? ''));
if (stripos($text, 'v=DMARC1') === 0) {
$record = $text;
$time_to_live =
(int) ($entry['ttl'] ?? 0);
break;
}
}
}
$cache->put(MailRecordCache::TYPE_DMARC, $name, $record,
$time_to_live);
return $record;
}
/**
* Parses a DMARC record into its tag map, lowercasing tag names
* and the policy values. Only the tags this evaluator uses are
* meaningful; others are kept as-is.
*
* @param string $record the v=DMARC1 record text
* @return array map of tag name to value
*/
private static function parsePolicy($record)
{
$tags = [];
foreach (explode(';', $record) as $pair) {
$pair = trim($pair);
if ($pair === '' || strpos($pair, '=') === false) {
continue;
}
list($name, $value) = explode('=', $pair, 2);
$name = strtolower(trim($name));
$value = trim($value);
if ($name === 'p' || $name === 'sp' ||
$name === 'adkim' || $name === 'aspf') {
$value = strtolower($value);
}
$tags[$name] = $value;
}
return $tags;
}
}