/ src / library / mail / DmarcCheck.php
<?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;
    }
}
X