<?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);
}
}