/ src / library / mail / MailRecordCache.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;
use seekquarry\yioop\library\LRUCache;

/**
 * Cross-user cache of the DNS-derived records used to check mail
 * security, keyed by a record type and a lookup name. Checking an
 * incoming message can require several remote lookups -- a signer's
 * DKIM public key, a sender domain's DMARC policy, and so on -- each
 * published in DNS, the same for every recipient, and rarely changed.
 * Caching them avoids repeating those lookups on each message view.
 *
 * The key has two components, the record type (one of the TYPE_
 * constants) and the lookup name, so different kinds of record for
 * the same domain do not collide. Values are opaque strings: a DKIM
 * key body, a DMARC policy string, and so on; the empty string marks
 * a lookup that found nothing, cached briefly so a missing record is
 * not re-queried every view.
 *
 * The cache is a process-wide singleton wrapping an LRUCache, so when
 * Yioop runs under its own WebSite server (a long-running process)
 * the records are shared across requests and across users for free.
 * It is also backed by a file under CACHE_DIR, so the records survive
 * a restart and are shared when Yioop runs under a web server that
 * handles requests in separate processes. Each entry records an
 * expiry; the DNS record's own TTL is honored but clamped to a
 * configured range.
 *
 * @author Chris Pollett
 */
class MailRecordCache
{
    /**
     * The single shared instance, created on first use.
     * @var MailRecordCache|null
     */
    private static $instance = null;
    /**
     * In-memory store of key => [value, expiry] entries.
     * @var LRUCache
     */
    private $entries;
    /**
     * Absolute path of the file the cache is persisted to.
     * @var string
     */
    private $file;
    /**
     * Whether the persistent file has been read into memory yet.
     * @var bool
     */
    private $loaded;
    /**
     * Record type for a signer's DKIM public key, looked up at
     * <selector>._domainkey.<domain>.
     */
    const TYPE_DKIM = 'dkim';
    /**
     * Record type for a sender domain's DMARC policy, looked up at
     * _dmarc.<domain>.
     */
    const TYPE_DMARC = 'dmarc';
    /**
     * Record type for a domain's SPF policy, the v=spf1 TXT record
     * looked up at the domain itself.
     */
    const TYPE_SPF = 'spf';
    /**
     * Number of records kept before the least recently used are
     * evicted.
     */
    const SIZE = 200;
    /**
     * File the cache is persisted to, within CACHE_DIR.
     */
    const CACHE_FILE = 'mail_records.json';
    /**
     * Sets up an empty in-memory store and notes the file the cache
     * persists to. The file is read lazily on first access.
     */
    public function __construct()
    {
        $this->entries = new LRUCache(self::SIZE);
        $this->file = C\CACHE_DIR . "/" . self::CACHE_FILE;
        $this->loaded = false;
    }
    /**
     * Returns the shared cache instance, creating it on first call.
     *
     * @return MailRecordCache the process-wide cache
     */
    public static function getInstance()
    {
        if (self::$instance === null) {
            self::$instance = new self();
        }
        return self::$instance;
    }
    /**
     * Replaces the shared instance with one that persists to a given
     * file, or clears it when passed null so the next getInstance
     * rebuilds the default. Used by tests to point the cache at a
     * throwaway file rather than the real CACHE_DIR; not used in
     * normal operation.
     *
     * @param string|null $file path to persist to, or null to reset
     */
    public static function setInstanceFile($file)
    {
        if ($file === null) {
            self::$instance = null;
            return;
        }
        $cache = new self();
        $cache->file = $file;
        self::$instance = $cache;
    }
    /**
     * Looks up a cached record by its type and name. Returns the
     * stored value when the entry is present and unexpired: the
     * record body for a found record, or the empty string for a
     * cached miss. Returns null when there is no usable entry, which
     * tells the caller to perform a fresh DNS lookup.
     *
     * @param string $type one of the TYPE_ constants
     * @param string $name the DNS name the record was looked up at
     * @return string|null the cached value, or null on a miss
     */
    public function get($type, $name)
    {
        $this->load();
        $entry = $this->entries->get(self::composeKey($type, $name));
        if (!is_array($entry)) {
            return null;
        }
        list($value, $expiry) = $entry;
        if ($expiry < time()) {
            return null;
        }
        return $value;
    }
    /**
     * Stores a looked-up record under its type and name with an
     * expiry computed from the time to live. A found record uses the
     * DNS record's TTL clamped to the configured min and max; an
     * empty value (nothing found) uses the shorter miss time. The
     * whole cache is written back to the persistent file so other
     * processes see the entry.
     *
     * @param string $type one of the TYPE_ constants
     * @param string $name the DNS name the record was looked up at
     * @param string $value the record body, or '' for a miss
     * @param int $time_to_live the DNS record's TTL in seconds; used
     *      only for a found record, and clamped to the configured
     *      range
     */
    public function put($type, $name, $value, $time_to_live)
    {
        $this->load();
        if ($value === '') {
            $lifetime = C\MAIL_RECORD_CACHE_MISS;
        } else {
            $lifetime = max(C\MAIL_RECORD_CACHE_MIN,
                min(C\MAIL_RECORD_CACHE_MAX, (int) $time_to_live));
        }
        $this->entries->put(self::composeKey($type, $name),
            [$value, time() + $lifetime]);
        $this->save();
    }
    /**
     * Joins a record type and name into the single string the
     * underlying store is keyed by. A null byte separates the two
     * components so neither can be confused for the other.
     *
     * @param string $type one of the TYPE_ constants
     * @param string $name the DNS name the record was looked up at
     * @return string the composite store key
     */
    private static function composeKey($type, $name)
    {
        return $type . "\x00" . $name;
    }
    /**
     * Reads the persistent file into the in-memory store on first
     * access. Expired entries are dropped as they are read. A
     * missing or unreadable file leaves the cache empty, which is
     * harmless: lookups simply fall through to DNS.
     */
    private function load()
    {
        if ($this->loaded) {
            return;
        }
        $this->loaded = true;
        if (!file_exists($this->file)) {
            return;
        }
        $contents = @file_get_contents($this->file);
        if ($contents === false || $contents === '') {
            return;
        }
        $decoded = json_decode($contents, true);
        if (!is_array($decoded)) {
            return;
        }
        $now = time();
        foreach ($decoded as $key => $entry) {
            if (!is_array($entry) || count($entry) != 2) {
                continue;
            }
            if ($entry[1] < $now) {
                continue;
            }
            $this->entries->put($key, $entry);
        }
    }
    /**
     * Writes the in-memory store back to the persistent file. The
     * write goes to a temporary file that is then renamed over the
     * target, so a concurrent reader never sees a half-written file.
     * Failure to write is ignored: the cache still works in memory
     * for the life of the process.
     */
    private function save()
    {
        $directory = dirname($this->file);
        if (!is_dir($directory)) {
            return;
        }
        $encoded = json_encode($this->entries->getAll());
        if ($encoded === false) {
            return;
        }
        $temporary = $this->file . '.' . getmypid() . '.tmp';
        if (@file_put_contents($temporary, $encoded) === false) {
            return;
        }
        @rename($temporary, $this->file);
    }
}
X