/ 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
{
    /**
     * 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 behavior.
     *
     * @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;
    }
    /**
     * 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;
    /**
     * 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')) {
            /* The connection and handshake lines are the mail server's
               own operational output, so they go through the site's log
               channel, which the server sends to its MailServer log,
               rather than to a separate mail log file. The line each
               builds is unchanged; only where it lands moves. */
            $mail_site->onConnect(function ($info, $context)
                use ($mail_site) {
                $mail_site->logEvent(self::connectLogLine($info));
                return null;
            });
            $mail_site->onSecure(function ($info, $context)
                use ($mail_site) {
                $mail_site->logEvent(self::secureLogLine($info));
                return null;
            });
        }
        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,
     * and neither is one this server delivered to a mailbox of its
     * own, which the context names a source for: mail composed on
     * the site for a local address never crossed the network, so
     * there is no hop that could have been read in passing and
     * nothing for the posture to protect against. 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' and for the 'source' a message delivered
     *      within this server carries
     * @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']) ||
            !empty($context['source'])) {
            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. A message this server delivered to a
     * mailbox of its own, which the context names a source for, is
     * left alone: it never crossed the network, so there is no
     * connecting client for the check to weigh. 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), 'HELO', and the
     *      'source' a message delivered within this server carries
     * @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)
    {
        /* A message this server delivered to a mailbox of its own never
           crossed the network, so there is no connecting client to
           check a sending address against and nothing for the check to
           tell apart. Checking anyway would weigh a message composed
           here against records describing who may send from outside. */
        if (!empty($context['source'])) {
            return null;
        }
        $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 === '') {
            /* Nothing has been set, so the site takes mail for the name
               it is reached by. A site reached at 127.0.0.1 refused its
               own sender while this gave localhost alone. */
            $own = parse_url((string)C\NAME_SERVER, PHP_URL_HOST);
            return ($own === null || $own === false || $own === '') ?
                ['localhost'] : [$own];
        }
        $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;
    }
    /**
     * 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
     * @param string $sender the address this site sends its own mail
     *     from, read from the live settings when not given. A caller
     *     that knows the address passes it, so that its result does not
     *     depend on the settings of the machine it runs on
     * @return bool true when the name is the bot identity
     */
    public static function isBotUsername($username, $sender = null)
    {
        return strtok((string)$username, "@") ===
            self::botLocalPart($sender);
    }
    /**
     * 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;
    }
    /**
     * Says whether this Yioop is set up to send mail at all. A site whose
     * mail settings have never been filled in cannot offer to send
     * anything, so the parts of the site that would offer it ask here
     * first rather than sending into nothing.
     *
     * @return bool whether outgoing mail can be sent
     */
    public static function canSendMail()
    {
        if (C\p('MAIL_BOT_HANDLED')) {
            return true;
        }
        return trim(C\p('MAIL_SERVER') ?? "") !== "" &&
            trim(C\p('MAIL_SENDER') ?? "") !== "";
    }
    /**
     * Builds the mail client used to send the site's own outgoing mail
     * (registration and bulk mail). When the site sends with its own
     * client, the connection details are derived rather than read from
     * the manually entered mail-server fields: where this machine runs
     * a mail server, the client hands the message to it 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. Where mail services are turned off, no such server is
     * listening, and the client carries the message to the recipients'
     * own mail servers instead. 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) {
            /* The last two arguments say that this is the site's own
               client and whether this machine runs a mail server for it
               to hand a message to. Where mail services are turned off
               there is none, and the client carries the message to the
               recipients' own mail servers rather than opening a
               connection to a machine that is not listening. */
            $local_mail_server = in_array(C\p('MAIL_MODE'),
                ['mailsite', 'both']);
            return new SmtpClient(self::botAddress(), "localhost",
                SmtpClient::submissionPort(), self::botLocalPart(), "",
                "starttls", true, true, $local_mail_server);
        }
        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 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' => ('v=spf1 mx ~all')];
            $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;
    }
    /**
     * connectLogLine builds the one-line record of a new connection:
     * the protocol, the remote address and port, and whether the
     * connection arrived already wrapped in TLS. The mail server sends
     * this through its log channel, which stamps its own time, so no
     * time is added here.
     *
     * @param array $info connect details from MailSite: protocol,
     *      remote_addr, remote_port, tls_active
     * @return string the formatted line, ending in a newline
     */
    public static function connectLogLine($info)
    {
        return "MailSite connect " .
            ($info['protocol'] ?? '?') . " from " .
            ($info['remote_addr'] ?? '?') . ":" .
            ($info['remote_port'] ?? '?') .
            (empty($info['tls_active']) ? "" : " (implicit TLS)");
    }
    /**
     * secureLogLine builds the one-line record of a TLS handshake,
     * implicit or STARTTLS, recording the mode, the remote address and
     * port, and whether the handshake succeeded, with the error on
     * failure. This is what makes a client that opens a secure port but
     * never completes the handshake visible in the log. The log channel
     * stamps its own time, so none is added here.
     *
     * @param array $info handshake details from MailSite: protocol,
     *      remote_addr, remote_port, mode, ok, error
     * @return string the formatted line, ending in a newline
     */
    public static function secureLogLine($info)
    {
        $outcome = empty($info['ok']) ?
            ("failed: " . ($info['error'] ?? 'unknown')) : "ok";
        return "MailSite TLS " .
            ($info['mode'] ?? '?') . " " .
            ($info['protocol'] ?? '?') . " from " .
            ($info['remote_addr'] ?? '?') . ":" .
            ($info['remote_port'] ?? '?') . " " . $outcome;
    }
    /**
     * 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 recipients by reading the
     * bytes off the spool and handing them to the client's
     * delivery to each recipient's own mail server, which is the
     * same path a send takes when this machine's mail server is not
     * running. A message with nothing in it, or with nobody to
     * send it to, is failed here rather than tried.
     * @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'];
        }
        return SmtpClient::deliverToRecipientServers($from, $recipients,
            $bytes);
    }
    /**
     * 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" .
            "@" . (!empty(self::localDomains()[0]) ?
            self::localDomains()[0] : 'localhost') . ">" . $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']);
    }
}
X