/ src / library / mail / MailSiteFactory.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\atto\FileMailStorage;
use seekquarry\atto\MailSite;
use seekquarry\yioop\configs as C;
use seekquarry\yioop\models\MailAliasModel;
use seekquarry\yioop\models\MailSenderAllowModel;
use seekquarry\yioop\models\UserModel;

/**
 * Central wiring helper for MailSite. Returns a configured
 * instance with authenticator, storage backend, and local
 * domain list installed, ready for either of two consumers:
 *
 *   1. The long-running daemon under Manage Machines, which
 *      calls listen() on the returned instance to start its
 *      SMTP and IMAP listeners.
 *   2. The Yioop web layer, which calls the direct-API methods
 *      (listFolders, fetchMessage, appendMessage, and so on)
 *      against the same storage the daemon serves. Web requests
 *      never call listen(); they instantiate, operate, return.
 *
 * Both consumers point at the same WORK_DIRECTORY/mail tree so
 * a webmail UI and a Thunderbird IMAP session see consistent
 * mailbox state. The factory is intentionally a static helper
 * rather than an injected service because there is one set of
 * wiring decisions per Yioop install (storage path is a config
 * derivative, authenticator is fixed by the project's identity
 * model) and no test-time variation worth a DI layer.
 */
class MailSiteFactory
{
    /**
     * Folder name into which the Spam Insecure posture files mail
     * that arrived without TLS from an untrusted sender. Matches
     * the Junk folder MailSite creates and maps to the \Junk
     * special-use attribute.
     */
    const JUNK_FOLDER = 'Junk';
    /**
     * Local-part of the reserved mailbox that receives unsubscribe mail
     * (bot@<first-domain>). The username is reserved at registration so
     * no member can take it.
     */
    const UNSUBSCRIBE_MAILBOX = 'bot';
    /**
     * Test-only override. When set to a non-null MailSite, build()
     * returns it instead of constructing the production wiring.
     * Unit tests use this to substitute a RamMailStorage-backed
     * MailSite so they exercise MailSiteMailBackend's logic
     * without touching disk or needing a WORK_DIRECTORY.
     *
     * @var \seekquarry\atto\MailSite|null
     */
    protected static $test_override = null;
    /**
     * Installs (or clears, when $site is null) a test-only
     * MailSite that build() should return in place of the real
     * production wiring. Tests are expected to clear the
     * override in tearDown so subsequent tests see normal
     * factory behaviour.
     *
     * @param \seekquarry\atto\MailSite|null $site the test
     *      instance to return from build(), or null to clear
     */
    public static function setTestOverride($site)
    {
        self::$test_override = $site;
    }
    /**
     * Returns a handle to the local mail store on disk (the same tree
     * the MailSite server reads and writes), so callers such as the
     * unsubscribe reader can list and remove messages from a mailbox.
     *
     * @return object a FileMailStorage rooted at the mail directory
     */
    public static function storage()
    {
        /* FileMailStorage shares its source file with the MailSite
           server class, so the autoloader (which looks for a file
           named after the class) cannot reach it on its own; loading
           MailSite pulls in the whole file and defines it. */
        class_exists(MailSite::class);
        return new FileMailStorage(C\MAIL_DIR);
    }
    /**
     * Builds the MailSite server: the local mail store, the Yioop user
     * authenticator, the local domains it answers for, alias handling
     * and (when logging is on) connection hooks.
     *
     * @return object the configured MailSite, or a test override when
     *      one has been set
     */
    public static function build()
    {
        if (self::$test_override !== null) {
            return self::$test_override;
        }
        $mail_site = new MailSite();
        $mail_site->auth(new YioopUserAuthenticator());
        $mail_site->storage(self::storage());
        $mail_site->domains(self::localDomains());
        $mail_site->aliasResolver(self::resolveAliasOwner(...));
        if (C\p('MAIL_LOG_ENABLED')) {
            $mail_site->onConnect(self::logConnect(...));
            $mail_site->onSecure(self::logSecure(...));
        }
        if (C\p('MAIL_DELIVERY_SECURITY') === 'spam') {
            $mail_site->onMessage(self::junkInsecureMail(...));
        }
        if (C\p('MAIL_DMARC_ENFORCE')) {
            $mail_site->onMessage(self::dmarcEnforce(...));
        }
        if (!C\p('MAIL_TEST_MODE')) {
            $mail_site->onMailFrom(self::rejectIfNotFqdn(...));
            $mail_site->onRcptTo(self::rejectIfNotFqdn(...));
        }
        $mail_site->onOutbound(self::enqueueOutbound(...));
        return $mail_site;
    }
    /**
     * Resolves an alias local-part to the username of the Yioop
     * user who owns it, or the empty string when the local-part is
     * not a registered alias. Wired into MailSite as the alias
     * resolver so the atto-namespace server can route alias
     * recipients to the owner's mailbox without referencing a
     * Yioop model directly. The models are built per call; alias
     * resolution happens once per inbound recipient, not in a hot
     * loop, so the construction cost is not a concern.
     *
     * @param string $alias lowercased alias local-part to resolve
     * @param string $domain recipient domain the alias must belong
     *      to
     * @return string owning user's username, or '' when not an
     *      alias
     */
    public static function resolveAliasOwner($alias, $domain)
    {
        $alias_model = new MailAliasModel();
        $user_id = $alias_model->userIdForAlias($alias, $domain);
        if ($user_id <= 0) {
            return '';
        }
        $user_model = new UserModel();
        $username = $user_model->getUsername($user_id);
        return is_string($username) ? $username : '';
    }
    /**
     * onMessage hook for the "Spam Insecure" delivery posture.
     * When a message arrived without TLS and the receiving user
     * has not trusted its envelope sender, the message is filed in
     * Junk instead of the INBOX; otherwise delivery proceeds
     * normally. Messages that arrived over TLS are never diverted.
     * The trust list is consulted per receiving user, so the same
     * sender may be quarantined for one user and trusted by
     * another. Registered only when the posture is 'spam'.
     *
     * @param array $info delivery info with 'from', 'to', 'bytes'
     * @param array $context per-session context, read for
     *      'TLS_ACTIVE'
     * @return array|null a folder-redirect verdict routing to Junk,
     *      or null to deliver normally
     */
    public static function junkInsecureMail($info, $context)
    {
        if (!empty($context['TLS_ACTIVE'])) {
            return null;
        }
        $sender = (string) ($info['from'] ?? '');
        $recipient = (string) ($info['to'] ?? '');
        $user_id = self::userIdForRecipient($recipient);
        if ($user_id <= 0) {
            return null;
        }
        $allow_model = new MailSenderAllowModel();
        if ($allow_model->isAllowed($user_id, $sender)) {
            return null;
        }
        return ['folder' => self::JUNK_FOLDER,
            'flags' => ['\Recent']];
    }
    /**
     * onMessage hook that evaluates DMARC for an inbound message and
     * acts on a failure. It runs SPF against the connecting client
     * and the envelope-sender domain, verifies the message's DKIM
     * signature, then asks DmarcCheck whether either authenticates a
     * domain that aligns with the From-header domain. When the From
     * domain publishes a DMARC policy and neither aligns, a
     * quarantine policy files the message in Junk and a reject policy
     * refuses it; a none policy, and a From domain that publishes no
     * policy, deliver normally. Registered only when
     * C\p('MAIL_DMARC_ENFORCE') is true.
     *
     * @param array $info delivery info with 'from' (envelope
     *      sender), 'to', and 'bytes' (the full message)
     * @param array $context per-session context, read for
     *      'REMOTE_ADDR' (the connecting client) and 'HELO'
     * @return array|string|null 'reject' to refuse the message, a
     *      folder-redirect verdict routing to Junk, or null to
     *      deliver normally
     */
    public static function dmarcEnforce($info, $context)
    {
        $bytes = (string) ($info['bytes'] ?? '');
        if ($bytes === '') {
            return null;
        }
        list($header_block, ) = DkimKey::splitMessage($bytes);
        $headers = DkimKey::parseHeaders($header_block);
        $from_domain = DkimKey::fromDomain($headers);
        if ($from_domain === '') {
            return null;
        }
        $client_ip = (string) ($context['REMOTE_ADDR'] ?? '');
        $helo = (string) ($context['HELO'] ?? '');
        $sender = (string) ($info['from'] ?? '');
        $sender_domain = self::senderDomain($sender, $helo);
        $spf_status = SpfCheck::check($client_ip, $sender_domain,
            $helo);
        $dkim = DkimKey::verify($bytes);
        $dkim_status = $dkim['status'] ?? DkimKey::VERIFY_NONE;
        $dkim_domain = $dkim['domain'] ?? '';
        $outcome = DmarcCheck::evaluate($from_domain, $dkim_status,
            $dkim_domain, $spf_status, $sender_domain);
        if ($outcome['result'] !== DmarcCheck::FAIL) {
            return null;
        }
        if ($outcome['policy'] === DmarcCheck::POLICY_REJECT) {
            return 'reject';
        }
        if ($outcome['policy'] === DmarcCheck::POLICY_QUARANTINE) {
            return ['folder' => self::JUNK_FOLDER,
                'flags' => ['\Recent']];
        }
        return null;
    }
    /**
     * Picks the domain SPF should authenticate: the domain part of
     * the envelope sender, or the HELO name when the reverse path is
     * empty (a bounce or DSN), as RFC 7208 directs.
     *
     * @param string $sender envelope MAIL FROM address
     * @param string $helo the HELO/EHLO name the client gave
     * @return string the domain to evaluate SPF for
     */
    private static function senderDomain($sender, $helo)
    {
        $sender = trim($sender);
        $at = strrpos($sender, '@');
        if ($at === false) {
            return strtolower(trim($helo));
        }
        return strtolower(trim(substr($sender, $at + 1)));
    }
    /**
     * Resolves a recipient address to the id of the Yioop user
     * whose mailbox receives it, following the same direct-user-
     * then-alias order as MailSite::resolveLocalUser. Returns 0
     * when the local part is neither a user nor an alias. Used by
     * the Spam Insecure hook to pick whose trust list applies.
     *
     * @param string $recipient full recipient address
     * @return int owning user's id, or 0 when unresolved
     */
    protected static function userIdForRecipient($recipient)
    {
        $at = strrpos($recipient, '@');
        $local = ($at === false) ? $recipient :
            substr($recipient, 0, $at);
        $local = strtolower(trim($local));
        $domain = ($at === false) ? '' :
            strtolower(trim(substr($recipient, $at + 1)));
        if ($local === '') {
            return 0;
        }
        $user_model = new UserModel();
        $user = $user_model->getUser($local);
        if (is_array($user) && isset($user['USER_ID'])) {
            return (int) $user['USER_ID'];
        }
        $alias_model = new MailAliasModel();
        return $alias_model->userIdForAlias($local, $domain);
    }
    /**
     * Hook callback for MailSite's onMailFrom and onRcptTo. Reads
     * the relevant address out of the hook info array and returns
     * 'reject' when its domain part is not a fully-qualified
     * domain name. Registered only when C\p('MAIL_TEST_MODE') is
     * false (production); test rigs skip this hook so loopback
     * addresses work. The null reverse-path ('<>' for DSN/bounce,
     * which parseSmtpAddress reports as the empty string) is
     * accepted regardless -- RFC 5321 sec 4.5.5 requires servers
     * to accept it so non-delivery reports can flow.
     *
     * @param array $info hook payload; 'from' for MAIL FROM,
     *      'to' for RCPT TO
     * @return string|null 'reject' to refuse the address; null
     *      to pass through to the next hook / accept
     */
    public static function rejectIfNotFqdn($info)
    {
        $addr = $info['from'] ?? $info['to'] ?? '';
        if ($addr === '') {
            return null;
        }
        return self::isFqdnAddress($addr) ? null : 'reject';
    }
    /**
     * Returns true when the supplied SMTP address has a domain
     * part that looks like a fully-qualified domain name --
     * contains at least one dot, the last label starts with a
     * non-digit character, and the address is not an RFC-5321
     * address-literal (square-bracketed IP). Bare IP-literal
     * domains such as 127.0.0.1 fail the last-label check.
     * Addresses with no '@' fail outright.
     *
     * @param string $addr SMTP address text (already unwrapped
     *      from any angle brackets by parseSmtpAddress)
     * @return bool whether the domain part is a FQDN by the
     *      criteria above
     */
    public static function isFqdnAddress($addr)
    {
        $at_pos = strrpos($addr, '@');
        if ($at_pos === false) {
            return false;
        }
        $domain = substr($addr, $at_pos + 1);
        if ($domain === '' || $domain[0] === '[') {
            return false;
        }
        $dot_pos = strrpos($domain, '.');
        if ($dot_pos === false) {
            return false;
        }
        $tld = substr($domain, $dot_pos + 1);
        if ($tld === '' || ctype_digit($tld[0])) {
            return false;
        }
        return true;
    }
    /**
     * Parses C\p('MAIL_DOMAINS') into the array shape MailSite expects.
     * MAIL_DOMAINS is stored as a comma-separated string by the
     * server-settings form, so this splits, trims, and drops
     * empties. The empty-list case falls back to ['localhost']
     * to match MailSite's own internal default; this matters for
     * a fresh install where the admin has not yet visited the
     * Mail Services panel.
     *
     * @return array list of bare domain strings considered local
     *      to this MailSite instance for SMTP local delivery
     */
    public static function localDomains()
    {
        $raw = trim((string) C\p('MAIL_DOMAINS'));
        if ($raw === '') {
            return ['localhost'];
        }
        $domains = array_filter(array_map('trim',
            explode(',', $raw)));
        if (empty($domains)) {
            return ['localhost'];
        }
        return array_values($domains);
    }
    /**
     * The host name the mail server announces: in the inbound SMTP
     * and IMAP greeting banner, and as the EHLO/HELO name on
     * outbound delivery. Configurable through MAIL_HOST_NAME; when
     * that is empty it falls back to the first configured mail
     * domain, then the system host name, then localhost, so the
     * name is never empty. For best deliverability this should be
     * the sending IP's reverse-DNS (PTR) name and forward-confirm
     * back to that IP.
     *
     * @return string the host name to announce
     */
    public static function mailHostName()
    {
        $configured = trim((string) C\p('MAIL_HOST_NAME'));
        if ($configured !== '') {
            return $configured;
        }
        $raw = trim((string) C\p('MAIL_DOMAINS'));
        if ($raw !== '') {
            $first = trim(strtok($raw, ','));
            if ($first !== '') {
                return $first;
            }
        }
        $uname = trim((string) php_uname('n'));
        return $uname === '' ? 'localhost' : $uname;
    }
    /**
     * Works out the e-mail address that unsubscribe requests should be
     * sent to, named in the List-Unsubscribe header on bulk mail. When
     * this Yioop runs its own mail for one or more domains, replies go to
     * the reserved bot mailbox on the first such domain
     * (bot@<first-domain>). When it does not run its own mail there is no
     * local mailbox to receive them, so they are addressed to the site's
     * root account instead.
     *
     * @param string $root_email the root account e-mail address, used
     *      only when this site does not run its own mail
     * @return string the address unsubscribe mail should be sent to
     */
    public static function unsubscribeMailtoAddress($root_email)
    {
        $raw = trim((string) C\p('MAIL_DOMAINS'));
        if ($raw !== '') {
            return self::botAddress();
        }
        return (string) $root_email;
    }
    /**
     * Absolute path to the shared secret file the site's own mail
     * sender and the local mail server use to authenticate the
     * reserved bot account. The sender writes a fresh random secret
     * here just before it logs in, and the mail server reads it to
     * check the bot's password, so the site can relay its own mail
     * without an operator-stored bot password. It sits beside the
     * TLS key in the security directory, which is not web reachable.
     *
     * @return string absolute path to the bot secret file
     */
    public static function botSecretPath()
    {
        return C\SECURITY_DIR . "/bot_security.txt";
    }
    /**
     * The local part (mailbox name) of the site's bot identity. The
     * bot identity is the configured MAIL_SENDER: when MAIL_SENDER is
     * a bare name its local part is itself, and when it is a full
     * address the part before the "@" is used. An empty MAIL_SENDER
     * falls back to the reserved default name.
     *
     * @param string|null $sender the sender address to read; defaults
     *      to the MAIL_SENDER setting
     * @return string the bot mailbox name
     */
    public static function botLocalPart($sender = null)
    {
        if ($sender === null) {
            $sender = C\p('MAIL_SENDER');
        }
        $sender = trim((string)$sender);
        if ($sender === "") {
            return self::UNSUBSCRIBE_MAILBOX;
        }
        return strtok($sender, "@");
    }
    /**
     * The full e-mail address of the site's bot identity. When the
     * configured MAIL_SENDER is already a full address it is used as
     * is; when it is a bare name the first local mail domain is
     * appended so the bot has a deliverable address.
     *
     * @param string|null $sender the sender address to read; defaults
     *      to the MAIL_SENDER setting
     * @return string the bot e-mail address
     */
    public static function botAddress($sender = null)
    {
        if ($sender === null) {
            $sender = C\p('MAIL_SENDER');
        }
        $sender = trim((string)$sender);
        if (strpos($sender, "@") !== false) {
            return $sender;
        }
        $domains = self::localDomains();
        return self::botLocalPart($sender) . "@" . ($domains[0] ?? "");
    }
    /**
     * Reports whether a login name is the site's bot identity. The
     * test is on the local part, so both the bare bot name and the
     * full bot address count as the bot.
     *
     * @param string $username the login name to test
     * @return bool true when the name is the bot identity
     */
    public static function isBotUsername($username)
    {
        return strtok((string)$username, "@") === self::botLocalPart();
    }
    /**
     * Reports whether a member account may not use a given email address
     * because it would collide with the site's own mail. The site's bot
     * address is always refused; every other address on a managed mail
     * domain is refused only when MAIL_REFUSE_LOCAL_DOMAIN_ACCOUNTS is
     * turned on, which it is not by default, so an ordinary person on
     * the same domain (for example someone@example.com at an in-house
     * example.com install) can still register. This reads the live
     * settings and hands them to accountEmailRefused, which holds the
     * actual rule.
     *
     * @param string $email the address a member wants to use
     * @return bool true when the address must be refused
     */
    public static function isForbiddenAccountEmail($email)
    {
        return self::accountEmailRefused($email, self::botLocalPart(),
            self::localDomains(),
            (bool)C\MAIL_REFUSE_LOCAL_DOMAIN_ACCOUNTS);
    }
    /**
     * The rule behind isForbiddenAccountEmail, written as a plain
     * decision over its inputs so it can be reasoned about and tested
     * without the live configuration. An address is refused when it sits
     * on one of the managed mail domains and either its mailbox name is
     * the bot's or the caller asked to refuse all addresses on those
     * domains. The localhost placeholder used by a site with no mail
     * domains configured never counts as managed, so on such a site no
     * address is refused. All comparisons ignore letter case.
     *
     * @param string $email the address a member wants to use
     * @param string $bot_local the bot's mailbox name
     * @param array $managed_domains bare domains the site handles mail
     *      for
     * @param bool $refuse_local_domain whether to refuse every address
     *      on a managed domain, not only the bot's
     * @return bool true when the address must be refused
     */
    public static function accountEmailRefused($email, $bot_local,
        $managed_domains, $refuse_local_domain)
    {
        $at = strrpos((string)$email, "@");
        if ($at === false) {
            return false;
        }
        $local = strtolower(trim(substr((string)$email, 0, $at)));
        $domain = strtolower(trim(substr((string)$email, $at + 1)));
        if ($domain === "" || $domain === "localhost") {
            return false;
        }
        $is_managed = false;
        foreach ($managed_domains as $managed) {
            if (strtolower(trim((string)$managed)) === $domain) {
                $is_managed = true;
                break;
            }
        }
        if (!$is_managed) {
            return false;
        }
        if ($local === strtolower(trim((string)$bot_local))) {
            return true;
        }
        return (bool)$refuse_local_domain;
    }
    /**
     * Builds the mail client used to send the site's own outgoing mail
     * (registration and bulk mail). When the site sends as the bot
     * through its own MailSite, the connection details are derived
     * rather than read from the manually entered mail-server fields:
     * the client connects to the local MailSite on the submission port
     * as the bot identity over STARTTLS, sending from the bot address
     * (the configured MAIL_SENDER). No password is supplied here
     * because the client mints a one-time secret for the bot at login
     * time. The local certificate is accepted without name checking
     * because the connection is to the same machine on localhost.
     * Otherwise the client is built from the configured external relay
     * fields.
     *
     * @param bool|null $bot_handled whether the bot/MailSite route is
     *      in use; defaults to the MAIL_BOT_HANDLED setting
     * @return SmtpClient a client ready to send outgoing mail
     */
    public static function outboundSmtpClient($bot_handled = null)
    {
        if ($bot_handled === null) {
            $bot_handled = C\p('MAIL_BOT_HANDLED');
        }
        if ($bot_handled) {
            return new SmtpClient(self::botAddress(), "localhost",
                C\p('MAIL_SUBMISSION_PORT'), self::botLocalPart(), "",
                "starttls", true);
        }
        return new SmtpClient(C\p('MAIL_SENDER'), C\p('MAIL_SERVER'),
            C\p('MAIL_SERVERPORT'), C\p('MAIL_USERNAME'), C\p('MAIL_PASSWORD'),
            C\p('MAIL_SECURITY'));
    }
    /**
     * Maps a delivery-security posture to the MTA-STS policy mode
     * to publish. 'spam' publishes a 'testing' policy (senders
     * report TLS failures but still deliver) and 'require'
     * publishes 'enforce' (senders refuse cleartext delivery). The
     * 'insecure' posture publishes no policy, signalled here by the
     * empty string, so the route returns a 404 rather than an
     * empty or misleading file.
     *
     * @param string $delivery_security posture, one of 'insecure',
     *      'spam', 'require'
     * @return string 'testing', 'enforce', or '' for no policy
     */
    public static function mtaStsMode($delivery_security)
    {
        if ($delivery_security === 'require') {
            return 'enforce';
        }
        if ($delivery_security === 'spam') {
            return 'testing';
        }
        return '';
    }
    /**
     * Builds the body of the MTA-STS policy file (RFC 8461) for one
     * policy domain. The mx lines cover both a bare apex MX
     * (mx: domain) and any subdomain MX (mx: *.domain) so the
     * policy matches whichever host the domain's MX record names
     * without hard-coding a specific mail hostname; this pairs with
     * a wildcard-or-SAN certificate covering the domain and its
     * subdomains. The version and max_age follow the RFC; max_age
     * is one week, a conservative rollout value above the one-day
     * floor some senders impose. Returns the empty string when the
     * mode is empty (no policy), so callers can treat that as
     * "nothing to serve".
     *
     * @param string $mode policy mode, 'testing' or 'enforce'
     *      (an empty mode yields no policy)
     * @param string $domain policy domain whose MX hosts the
     *      policy authorizes
     * @return string the policy file body, or '' when no policy
     */
    public static function mtaStsPolicy($mode, $domain)
    {
        $domain = trim((string) $domain);
        if ($mode === '' || $domain === '') {
            return '';
        }
        $lines = [
            'version: STSv1',
            'mode: ' . $mode,
            'mx: *.' . $domain,
            'mx: ' . $domain,
            'max_age: ' . C\ONE_WEEK,
        ];
        return implode("\r\n", $lines) . "\r\n";
    }
    /**
     * Resolves the policy domain for an MTA-STS request from the
     * Host header. In production the policy is served from the
     * dedicated host mta-sts.<domain>, so the leading "mta-sts."
     * label is stripped and the remainder must be a configured
     * local mail domain. As a loopback testing convenience, a
     * request whose host is localhost or 127.0.0.1 (optionally
     * with a port) resolves to the first configured local domain
     * so the operator can fetch the file without standing up the
     * mta-sts subdomain. Any other host yields the empty string,
     * meaning no policy should be served.
     *
     * @param string $host the request Host header value
     * @param array $domains configured local mail domains
     * @return string the policy domain, or '' when the host does
     *      not correspond to a served policy
     */
    public static function mtaStsDomainForHost($host, $domains)
    {
        $host = strtolower(trim((string) $host));
        $colon = strpos($host, ':');
        if ($colon !== false) {
            $host = substr($host, 0, $colon);
        }
        if ($host === '') {
            return '';
        }
        if (($host === 'localhost' || $host === '127.0.0.1') &&
            !empty($domains)) {
            return $domains[0];
        }
        if (strncmp($host, 'mta-sts.', 8) === 0) {
            $candidate = substr($host, 8);
            foreach ($domains as $domain) {
                if (strcasecmp($candidate, trim($domain)) === 0) {
                    return $domain;
                }
            }
        }
        return '';
    }
    /**
     * Builds the suggested _mta-sts policy-discovery TXT record
     * (RFC 8461) for a domain. Sending servers look up this record
     * to learn that the domain publishes an MTA-STS policy and to
     * detect when it changes; the id is bumped whenever the policy
     * changes so senders re-fetch it. A timestamp-derived id is a
     * common convention. Returns an empty string when no policy is
     * being published (mode empty), since the record should not be
     * advertised without a policy to back it.
     *
     * @param string $mode policy mode, 'testing' or 'enforce'
     *      (empty mode yields no record)
     * @param int $policy_id integer used to derive the record id,
     *      typically a timestamp
     * @return string the suggested TXT record value, or '' when no
     *      policy is published
     */
    public static function mtaStsTxtRecord($mode, $policy_id)
    {
        if ($mode === '') {
            return '';
        }
        return 'v=STSv1; id=' . (int) $policy_id;
    }
    /**
     * Builds a suggested SPF TXT record for a domain. The record
     * authorizes the hosts named by the domain's own MX records to
     * send for it (mx mechanism) and asks receivers to soft-fail
     * mail from anywhere else (~all), a conservative starting
     * point that an operator tightens to -all once confident.
     *
     * @return string the suggested SPF TXT record value
     */
    public static function spfTxtRecord()
    {
        return 'v=spf1 mx ~all';
    }
    /**
     * Builds a suggested DMARC TXT record (published at
     * _dmarc.<domain>). It starts in the report-only "none" policy
     * so an operator can watch aggregate reports before tightening
     * to quarantine or reject; rua names the address that receives
     * those aggregate reports.
     *
     * @param string $report_address mailbox to receive aggregate
     *      DMARC reports (the rua target)
     * @return string the suggested DMARC TXT record value
     */
    public static function dmarcTxtRecord($report_address)
    {
        $record = 'v=DMARC1; p=none';
        $report_address = trim((string) $report_address);
        if ($report_address !== '') {
            $record .= '; rua=mailto:' . $report_address;
        }
        return $record;
    }
    /**
     * Assembles the suggested DNS records an operator should
     * publish for each configured mail domain, for display in the
     * Server Settings DNS helper. Each entry names the record host
     * (relative to the domain), its type, and the suggested value.
     * The MTA-STS records are included only when the delivery
     * posture publishes a policy. The mx host patterns mirror the
     * MTA-STS policy: a wildcard and an apex entry so the operator
     * can point the domain's MX at the apex or any subdomain
     * covered by a wildcard-or-SAN certificate, without this code
     * inventing a specific mail hostname.
     *
     * A listed domain that is the www. or mta-sts. host of another
     * listed domain is skipped, since it is not an independent mail
     * domain: the apex's own set already carries the mta-sts. host
     * and policy records, and a www. host needs no mail records.
     *
     * @param array $domains configured local mail domains
     * @param string $delivery_security posture, used to decide
     *      whether MTA-STS records are suggested
     * @param int $policy_id id for the _mta-sts record
     * @param string $report_address rua target for DMARC, may be
     *      empty
     * @param string $dkim_selector DKIM selector label; with a
     *      record, drives the <selector>._domainkey.<domain> host
     * @param string $dkim_record DKIM public-key TXT value; when
     *      empty no DKIM record is suggested
     * @return array map of domain to a list of records, each a map
     *      with 'host', 'type', and 'value'
     */
    public static function suggestedDnsRecords($domains,
        $delivery_security, $policy_id, $report_address,
        $dkim_selector = '', $dkim_record = '')
    {
        $mode = self::mtaStsMode($delivery_security);
        $listed = [];
        foreach ($domains as $listed_domain) {
            $listed_domain = trim($listed_domain);
            if ($listed_domain !== '') {
                $listed[$listed_domain] = true;
            }
        }
        $suggestions = [];
        foreach ($domains as $domain) {
            $domain = trim($domain);
            if ($domain === '') {
                continue;
            }
            /* A www. or mta-sts. host of a domain already in the
               list is not an independent mail domain: www is a web
               alias and mta-sts is that domain's MTA-STS policy host,
               whose records the apex's own set already carries.
               Emitting a full mail record set for it would produce
               nonsense such as _mta-sts.mta-sts.<apex> or
               mta-sts.www.<apex>, so skip it. A bare www./mta-sts.
               host whose apex is not listed is left alone, since
               nothing else covers it. */
            if ((strncmp($domain, 'www.', 4) === 0 &&
                !empty($listed[substr($domain, 4)])) ||
                (strncmp($domain, 'mta-sts.', 8) === 0 &&
                !empty($listed[substr($domain, 8)]))) {
                continue;
            }
            $records = [];
            $records[] = ['host' => $domain, 'type' => 'TXT',
                'value' => self::spfTxtRecord()];
            $records[] = ['host' => '_dmarc.' . $domain,
                'type' => 'TXT',
                'value' => self::dmarcTxtRecord($report_address)];
            $sts_txt = self::mtaStsTxtRecord($mode, $policy_id);
            if ($sts_txt !== '') {
                $records[] = ['host' => '_mta-sts.' . $domain,
                    'type' => 'TXT', 'value' => $sts_txt];
                $records[] = ['host' => 'mta-sts.' . $domain,
                    'type' => 'A/CNAME',
                    'value' => 'this Yioop web server'];
            }
            if ($dkim_record !== '' && $dkim_selector !== '') {
                $records[] = [
                    'host' => $dkim_selector . '._domainkey.' .
                        $domain,
                    'type' => 'TXT', 'value' => $dkim_record];
            }
            $suggestions[$domain] = $records;
        }
        return $suggestions;
    }
    /**
     * Connection-logging hook. Writes one mail.log line recording
     * the protocol, remote address and port, and whether the
     * connection arrived already wrapped in TLS. Wired only when
     * MAIL_LOG_ENABLED is on; the write itself also goes through
     * SmtpClient::appendLog, which is a no-op when logging is off,
     * so this never writes unexpectedly. Returns null so the
     * connection is never refused by the act of logging.
     *
     * @param array $info connect details from MailSite: protocol,
     *      remote_addr, remote_port, tls_active
     * @param array $context per-session context (unused)
     * @return null always, so the session proceeds
     */
    public static function logConnect($info, $context)
    {
        $line = date('r') . " MailSite connect " .
            ($info['protocol'] ?? '?') . " from " .
            ($info['remote_addr'] ?? '?') . ":" .
            ($info['remote_port'] ?? '?') .
            (empty($info['tls_active']) ? "" : " (implicit TLS)") .
            "\n";
        SmtpClient::appendLog($line);
        return null;
    }
    /**
     * TLS-handshake-logging hook. Writes one mail.log line for each
     * handshake attempt, implicit or STARTTLS, recording the mode,
     * the remote address and port, and whether the handshake
     * succeeded; on failure the handshake error is included. This
     * is what makes a client that opens a secure port but never
     * completes the handshake visible in the log. Returns null;
     * the return value is ignored for this hook regardless.
     *
     * @param array $info handshake details from MailSite: protocol,
     *      remote_addr, remote_port, mode, ok, error
     * @param array $context per-session context (unused)
     * @return null always
     */
    public static function logSecure($info, $context)
    {
        $outcome = empty($info['ok']) ?
            ("failed: " . ($info['error'] ?? 'unknown')) : "ok";
        $line = date('r') . " MailSite TLS " .
            ($info['mode'] ?? '?') . " " .
            ($info['protocol'] ?? '?') . " from " .
            ($info['remote_addr'] ?? '?') . ":" .
            ($info['remote_port'] ?? '?') . " " . $outcome . "\n";
        SmtpClient::appendLog($line);
        return null;
    }
    /**
     * Absolute path of the outbound spool directory, where
     * messages awaiting background direct-MX delivery are queued.
     * Lives beside the mailbox tree under MAIL_DIR.
     * @return string absolute outbound spool directory path
     */
    public static function outboundSpoolDir()
    {
        return C\MAIL_DIR . "/outbound";
    }
    /**
     * onOutbound hook: queues one message for background outbound
     * delivery. Writes two files into the outbound spool named by a
     * unique base: a ".eml" holding the raw RFC 5322 bytes, and an
     * ".envelope" holding a JSON record with the envelope sender,
     * the remote recipient list, an attempt counter, and the time
     * of the next delivery attempt. Writes are done to a temporary
     * name and renamed into place so the drainer never reads a
     * half-written file. Returns null; the hook's return value is
     * ignored.
     * @param array $info outbound details from MailSite: 'from',
     *      'recipients' (remote addresses), 'bytes' (message)
     * @param array $context per-session context (unused)
     * @return null always
     */
    public static function enqueueOutbound($info, $context)
    {
        $recipients = $info['recipients'] ?? [];
        if (empty($recipients)) {
            return null;
        }
        $spool = static::outboundSpoolDir();
        if (!is_dir($spool)) {
            @mkdir($spool, 0700, true);
        }
        $base = $spool . "/" . uniqid("out_", true);
        $envelope = [
            'from' => (string) ($info['from'] ?? ''),
            'recipients' => array_values($recipients),
            'attempts' => 0,
            'next_attempt' => time(),
        ];
        $message = (string) ($info['bytes'] ?? '');
        $eml_tmp = $base . ".eml.tmp";
        if (@file_put_contents($eml_tmp, $message) === false) {
            SmtpClient::appendLog(date('r') .
                " MailSite outbound queue write failed\n");
            return null;
        }
        @rename($eml_tmp, $base . ".eml");
        $envelope_tmp = $base . ".envelope.tmp";
        @file_put_contents($envelope_tmp,
            json_encode($envelope));
        @rename($envelope_tmp, $base . ".envelope");
        SmtpClient::appendLog(date('r') .
            " MailSite outbound queued " . count($recipients) .
            " recipient(s) from " . $envelope['from'] . "\n");
        return null;
    }
    /**
     * Sweeps the outbound spool and attempts delivery of every
     * message whose next-attempt time has arrived. Called on a
     * timer by MailServer so delivery happens off the event loop.
     * Each message is delivered by direct MX (see
     * deliverOutboundFile); on success its spool files are
     * removed, on a transient failure its attempt counter is
     * incremented and a later retry is scheduled, and once the
     * attempt cap (MAIL_SCHEDULED_MAX_ATTEMPTS) is reached the
     * message is bounced back to its local sender and removed.
     * Returns counts for logging/testing.
     * @param int|null $now current time (defaults to time()), for
     *      tests
     * @return array{sent:int, retried:int, bounced:int} outcome
     *      counts
     */
    public static function drainOutbound($now = null)
    {
        if ($now === null) {
            $now = time();
        }
        $stats = ['sent' => 0, 'retried' => 0, 'bounced' => 0];
        $spool = static::outboundSpoolDir();
        if (!is_dir($spool)) {
            return $stats;
        }
        $envelopes = glob($spool . "/*.envelope");
        if (empty($envelopes)) {
            return $stats;
        }
        $max_attempts = (int) C\MAIL_SCHEDULED_MAX_ATTEMPTS;
        foreach ($envelopes as $envelope_path) {
            $base = substr($envelope_path,
                0, strlen($envelope_path) - strlen(".envelope"));
            $eml_path = $base . ".eml";
            $envelope = json_decode(
                (string) @file_get_contents($envelope_path), true);
            if (!is_array($envelope) || !is_file($eml_path)) {
                continue;
            }
            if ((int) ($envelope['next_attempt'] ?? 0) > $now) {
                continue;
            }
            $result = static::deliverOutboundFile($envelope,
                $eml_path);
            if ($result['ok']) {
                @unlink($eml_path);
                @unlink($envelope_path);
                $stats['sent']++;
                continue;
            }
            $envelope['attempts'] =
                (int) ($envelope['attempts'] ?? 0) + 1;
            if ($envelope['attempts'] >= $max_attempts) {
                static::bounceOutbound($envelope, $eml_path,
                    $result['error']);
                @unlink($eml_path);
                @unlink($envelope_path);
                $stats['bounced']++;
                continue;
            }
            $envelope['next_attempt'] = $now +
                $envelope['attempts'] * (int) C\MAIL_AGGREGATION_TIME;
            $envelope_tmp = $envelope_path . ".tmp";
            @file_put_contents($envelope_tmp,
                json_encode($envelope));
            @rename($envelope_tmp, $envelope_path);
            SmtpClient::appendLog(date('r') .
                " MailSite outbound retry " . $envelope['attempts'] .
                " for " . implode(', ', $envelope['recipients']) .
                ": " . $result['error'] . "\n");
            $stats['retried']++;
        }
        return $stats;
    }
    /**
     * Delivers one queued message to its remote recipients by
     * direct MX, grouping recipients by domain and trying each
     * domain's MX hosts in priority order. Speaks SMTP as a peer
     * MTA (no AUTH), opportunistic STARTTLS, with a port-25 then
     * submission-port attempt so a network that blocks outbound 25
     * still has a path. A per-domain failure leaves the whole
     * message unsent so a single retry covers all recipients.
     * @param array $envelope decoded envelope record (from,
     *      recipients)
     * @param string $eml_path path to the raw message bytes
     * @return array{ok:bool, error:string} delivery outcome
     */
    protected static function deliverOutboundFile($envelope,
        $eml_path)
    {
        $from = (string) ($envelope['from'] ?? '');
        $recipients = $envelope['recipients'] ?? [];
        $bytes = (string) @file_get_contents($eml_path);
        if ($bytes === '' || empty($recipients)) {
            return ['ok' => false, 'error' => 'empty message'];
        }
        $by_domain = [];
        foreach ($recipients as $rcpt) {
            $at = strrpos($rcpt, '@');
            if ($at === false) {
                return ['ok' => false,
                    'error' => "no domain in $rcpt"];
            }
            $domain = strtolower(substr($rcpt, $at + 1));
            $by_domain[$domain][] = $rcpt;
        }
        $ports = [25];
        $submission = (int) C\p('MAIL_SUBMISSION_PORT');
        if ($submission > 0 && $submission !== 25) {
            $ports[] = $submission;
        }
        foreach ($by_domain as $domain => $domain_recipients) {
            $mx_hosts = SmtpClient::resolveMxHosts($domain);
            if (empty($mx_hosts)) {
                return ['ok' => false,
                    'error' => "no MX record for $domain"];
            }
            $accepted = false;
            $last_error = '';
            foreach ($mx_hosts as $mx_host) {
                foreach ($ports as $port) {
                    $smtp = new SmtpClient($from, $mx_host, $port,
                        '', '', 'opportunistic', false);
                    if ($smtp->deliverBytes($from,
                            $domain_recipients, $bytes)) {
                        $accepted = true;
                        break 2;
                    }
                    $last_error = trim($smtp->getLastError());
                    /* try the submission port only on a TCP-layer
                       failure; an SMTP-level rejection is
                       definitive for that MX. */
                    if (stripos($last_error,
                            'could not connect') === false) {
                        break;
                    }
                }
            }
            if (!$accepted) {
                return ['ok' => false, 'error' =>
                    "$domain: " . ($last_error !== '' ?
                    $last_error : 'no MX accepted')];
            }
        }
        return ['ok' => true, 'error' => ''];
    }
    /**
     * Generates a delivery-failure notice for a message that could
     * not be delivered after the attempt cap and posts it to the
     * sender's local mailbox. Outbound relay is permitted only from
     * a local sender, so the sender always has a local mailbox to
     * receive the bounce; the failure is also written to the log.
     * @param array $envelope decoded envelope record (from,
     *      recipients)
     * @param string $eml_path path to the original message bytes
     * @param string $error the final delivery error
     * @return void
     */
    protected static function bounceOutbound($envelope, $eml_path,
        $error)
    {
        $from = (string) ($envelope['from'] ?? '');
        $recipients = implode(', ',
            (array) ($envelope['recipients'] ?? []));
        SmtpClient::appendLog(date('r') .
            " MailSite outbound bounced to $from for $recipients: " .
            $error . "\n");
        $original = (string) @file_get_contents($eml_path);
        $boundary = "bounce_" . bin2hex(random_bytes(8));
        $eol = "\r\n";
        $notice = "From: Mail Delivery System <postmaster" .
            "@" . self::primaryLocalDomain() . ">" . $eol .
            "To: <$from>" . $eol .
            "Subject: Undelivered Mail Returned to Sender" . $eol .
            "Content-Type: multipart/mixed; boundary=\"" .
            $boundary . "\"" . $eol . $eol .
            "--" . $boundary . $eol .
            "Content-Type: text/plain; charset=utf-8" . $eol . $eol .
            "Delivery to the following recipient(s) failed " .
            "permanently:" . $eol . $eol .
            "    " . $recipients . $eol . $eol .
            "Reason: " . $error . $eol . $eol .
            "--" . $boundary . $eol .
            "Content-Type: message/rfc822" . $eol . $eol .
            $original . $eol .
            "--" . $boundary . "--" . $eol;
        $site = self::build();
        $site->deliverMail("<>", $from, $notice,
            ['source' => 'outbound-bounce']);
    }
    /**
     * The server's primary local mail domain, used as the host
     * part of the postmaster address on a bounce notice. Falls
     * back to 'localhost' when no domain is configured.
     * @return string a local domain name
     */
    protected static function primaryLocalDomain()
    {
        $domains = self::localDomains();
        return !empty($domains[0]) ? $domains[0] : 'localhost';
    }
}
X