/ src / executables / MailServer.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\executables;

use seekquarry\yioop\configs as C;
use seekquarry\yioop\library as L;
use seekquarry\yioop\library\CrawlDaemon;
use seekquarry\yioop\library\mail\MailSiteFactory;

/*
 * When MAIL_SERVER_TEST_LOAD is defined, this file is being
 * included only so a unit test can reach the MailServer class
 * (for example to exercise the pure deriveListenConfig). In that
 * mode none of the executable top-level code runs: not the
 * request guard, not the configuration check, and not the daemon
 * dispatch at the foot of the file. The class is defined and
 * nothing is started, so loading it can never boot the daemon or
 * disturb a running web server. A normal "php MailServer.php
 * start" invocation does not define the constant and proceeds as
 * before.
 */
if (!defined("seekquarry\\yioop\\executables\\MAIL_SERVER_TEST_LOAD")) {
if (php_sapi_name() != 'cli' ||
    defined("seekquarry\\yioop\\configs\\IS_OWN_WEB_SERVER")) {
    echo "BAD REQUEST"; exit();
}
/** CRAWLING means don't try to use cache
 * @ignore
 */
$_SERVER["USE_CACHE"] = false;
/** for crawlHash and crawlLog and Yioop constants */
require_once __DIR__ . "/../library/Utility.php";
if (!C\PROFILE) {
    echo "Please configure the search engine instance by visiting" .
        "its web interface on localhost.\n";
    exit();
}
/*
 * We'll set up multi-byte string handling to use UTF-8
 */
mb_internal_encoding("UTF-8");
mb_regex_encoding("UTF-8");
}
/**
 * Long-running daemon that runs MailSite's SMTP and IMAP
 * listeners. Started and stopped through Manage Machines like
 * QueueServer, MediaUpdater, Mirror, and Fetcher; under the
 * hood that means a CrawlDaemon::start("MailServer") call
 * spawns this script in the background and a
 * CrawlDaemon::stop("MailServer") sends it the termination
 * signal. The instance can be present in two configurations
 * besides on/off:
 *
 *   - loopback only (MAIL_EXTERNAL = false): bind 127.0.0.1.
 *     Useful for in-Yioop webmail without exposing the mail
 *     ports to the network. Plaintext AUTH is acceptable on
 *     loopback (no eavesdropper).
 *
 *   - public interface (MAIL_EXTERNAL = true): bind 0.0.0.0.
 *     The listener accepts connections from the outside world.
 *     Plaintext AUTH is refused here; clients must STARTTLS,
 *     which depends on a certificate being configured in
 *     SERVER_CONTEXT. Without a cert, external mode listens but
 *     cannot serve AUTH'd traffic; inbound message-receipt still
 *     works because that path does not require AUTH.
 *
 * @author Chris Pollett
 */
class MailServer
{
    /**
     * Sets up the daemon. No constructor-time work needed
     * beyond the parameterless base; configuration flows
     * through MailSiteFactory::build at start() time so the
     * factory can re-read C\p('MAIL_DOMAINS') etc. after admin
     * edits between stop and start cycles without requiring a
     * full process restart.
     */
    public function __construct()
    {
    }
    /**
     * Entry point invoked by CrawlDaemon. Initializes the
     * daemon scaffolding (log file, PID file, signal handlers),
     * verifies MAIL_MODE includes mailsite (refusing to start
     * otherwise so a misconfigured Manage Machines toggle
     * cannot accidentally accept SMTP on a deployment that
     * was meant to be external-only or disabled), builds the
     * MailSite, and enters its event loop. The event loop
     * blocks until the process is killed or a fatal listener
     * error occurs; no return value is meaningful here.
     */
    public function start()
    {
        global $argv;
        if (C\nsdefined("MAIL_SERVER_MEMORY_LIMIT")) {
            ini_set('memory_limit', C\MAIL_SERVER_MEMORY_LIMIT);
        }
        CrawlDaemon::init($argv, "MailServer");
        L\crawlLog("\n\nInitialize logger..", "MailServer", true);
        $mail_mode = trim((string) C\p('MAIL_MODE'));
        if (!in_array($mail_mode, ['mailsite', 'both'])) {
            L\crawlLog("MAIL_MODE is '$mail_mode' which does not " .
                "include mailsite; MailServer will exit. Set " .
                "MAIL_MODE to 'mailsite' or 'both' under Server " .
                "Settings to run the local mail listeners.");
            return;
        }
        $mail_site = MailSiteFactory::build();
        $mail_site->onLog(function ($message) {
            L\crawlLog($message);
        });
        register_shutdown_function(function () use ($mail_site) {
            /* If the always-on mail server dies on a fatal error, a
               memory exhaustion most of all, record where the event
               loop was and how much memory and how many connections
               and parked fibers it held. A fatal leaves no other line,
               so without this a death can only be guessed at from the
               surrounding log; with it a crash is told apart at once
               from a clean stop. */
            $fatal = error_get_last();
            if ($fatal === null || !in_array($fatal['type'],
                [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) {
                return;
            }
            L\crawlLog("MailServer fatal in " .
                $mail_site->currentActivity() . ", memory " .
                memory_get_usage(true) . " peak " .
                memory_get_peak_usage(true) . ", " .
                $mail_site->pendingFiberCount() . " fibers parked, " .
                $mail_site->connectionCount() . " connections: " .
                $fatal['message']);
        });
        if (function_exists("pcntl_signal")) {
            /* A stop from an administrator or the operating system
               arrives as a signal; log which one before exiting so an
               outside kill is not mistaken for an unexplained death.
               A hard kill (the out-of-memory killer's SIGKILL) cannot
               be caught, and shows instead as memory near the ceiling
               in the last heartbeat above. */
            pcntl_async_signals(true);
            $log_signal = function ($signal) {
                L\crawlLog("MailServer received signal " . $signal .
                    ", stopping");
                exit(0);
            };
            pcntl_signal(SIGTERM, $log_signal);
            pcntl_signal(SIGINT, $log_signal);
            pcntl_signal(SIGHUP, $log_signal);
        }
        $server_context = C\nsdefined("SERVER_CONTEXT") ?
            C\SERVER_CONTEXT : [];
        $delivery_security = C\nsdefined("MAIL_DELIVERY_SECURITY") ?
            C\p('MAIL_DELIVERY_SECURITY') : 'insecure';
        $use_starttls = C\nsdefined("MAIL_USE_STARTTLS") &&
            C\p('MAIL_USE_STARTTLS');
        $external = C\nsdefined("MAIL_EXTERNAL") && C\p('MAIL_EXTERNAL');
        $ports = [
            'smtp' => intval(C\p('MAIL_SMTP_PORT')),
            'submission' => intval(C\p('MAIL_SUBMISSION_PORT')),
            'imap' => intval(C\p('MAIL_IMAP_PORT')),
            'smtps' => intval(C\p('MAIL_SMTPS_PORT')),
            'imaps' => intval(C\p('MAIL_IMAPS_PORT')),
        ];
        $config = self::deriveListenConfig($ports, $server_context,
            $delivery_security, $use_starttls, $external,
            self::serverName());
        $bind = $config['BIND'];
        L\crawlLog("MailServer binding SMTP on $bind:" .
            $config['SMTP_PORT'] . ", submission on $bind:" .
            $config['SUBMISSION_PORT'] . ", IMAP on $bind:" .
            $config['IMAP_PORT'] . ", SMTPS on $bind:" .
            $config['SMTPS_PORT'] . ", IMAPS on $bind:" .
            $config['IMAPS_PORT']);
        /* A light poll timer drives CrawlDaemon::processHandler so
           a CrawlDaemon stop request (for instance from Manage
           Machines, or the own-web-server restart) is noticed
           promptly rather than only when mail traffic arrives. */
        $mail_site->setTimer(self::PROCESS_POLL_INTERVAL,
                function () {
            CrawlDaemon::processHandler();
        });
        $start_time = time();
        $mail_site->setTimer(self::STATS_LOG_INTERVAL,
                function () use ($start_time, $mail_site) {
            $uptime = time() - $start_time;
            L\crawlLog("MailServer uptime: {$uptime}s, memory: " .
                memory_get_usage() . " (Peak: " .
                memory_get_peak_usage() . "), real: " .
                memory_get_usage(true) . ", pending fibers: " .
                $mail_site->pendingFiberCount() . ", connections: " .
                $mail_site->connectionCount());
        });
        $mail_site->setTimer(C\MAIL_OUTBOUND_DRAIN_INTERVAL,
                function () {
            MailSiteFactory::drainOutbound();
        });
        $mail_site->listen($config);
    }
    /**
     * Seconds between CrawlDaemon process-handler polls. One second
     * keeps stop requests snappy for the Ctrl-C-then-restart
     * developer workflow without flooding the loop.
     */
    const PROCESS_POLL_INTERVAL = 1;
    /**
     * Seconds between memory-and-uptime log lines. 30 gives
     * two heartbeat-style entries per minute -- enough to
     * confirm liveness by tailing mail.log without inspecting
     * processes, and enough resolution to spot memory growth
     * over hours/days. Mail listener traffic is light enough
     * that this cadence does not drown real events.
     */
    const STATS_LOG_INTERVAL = 30;
    /**
     * Derives the listener configuration array passed to
     * MailSite::listen from the mail settings, as a pure function
     * of its arguments so the policy can be unit-tested apart from
     * socket binding. The rules:
     *
     *  - TLS is available only when SERVER_CONTEXT carries an ssl
     *    block; without it no secure port binds and STARTTLS is not
     *    advertised.
     *  - The plaintext SMTP and IMAP listeners always bind, since
     *    port 25 must accept peer mail; listen advertises STARTTLS
     *    on them whenever TLS is available.
     *  - When STARTTLS is selected, the secure-port settings name
     *    those STARTTLS-capable plaintext listeners (so the secure
     *    port drives SMTP_PORT/IMAP_PORT) and no separate implicit-
     *    TLS socket opens. Otherwise the plaintext listeners keep
     *    the plaintext-port settings and the secure ports open
     *    additional implicit-TLS sockets when TLS is available.
     *  - Plaintext AUTH is allowed only under the 'insecure'
     *    delivery posture, or on a loopback bind that has no
     *    certificate (a developer rig that could not offer TLS
     *    anyway); the secure postures require an encrypted session
     *    before AUTH.
     *  - SERVER_CONTEXT is forwarded to listen only when TLS is
     *    available, so listen sees ssl exactly when a cert exists.
     *
     * @param array $ports map with integer 'smtp', 'imap',
     *      'smtps', 'imaps' port settings
     * @param array $server_context the SERVER_CONTEXT array, whose
     *      'ssl' key (when non-empty) means TLS is available
     * @param string $delivery_security posture, one of 'insecure',
     *      'spam', 'require'
     * @param bool $use_starttls whether secure ports negotiate via
     *      STARTTLS rather than implicit TLS
     * @param bool $external true to bind the public interface
     *      (0.0.0.0), false to bind loopback (127.0.0.1)
     * @param string $server_name hostname to advertise in banners
     * @return array the listen configuration array
     */
    public static function deriveListenConfig($ports,
        $server_context, $delivery_security, $use_starttls,
        $external, $server_name)
    {
        $bind = $external ? '0.0.0.0' : '127.0.0.1';
        $tls_available = !empty($server_context['ssl']);
        /* Always open the plaintext SMTP and IMAP ports and, when
           configured, the submission port. These upgrade in place
           with STARTTLS when a certificate is available, so a peer
           or client that wants TLS on 25/587/143 gets it there.
           The wrapped implicit-TLS ports (SMTPS 465, IMAPS 993) are
           opened in addition, not instead, whenever a certificate
           is available, so a client may choose either STARTTLS on
           the plaintext port or implicit TLS on the wrapped port.
           This replaces an earlier either/or in which use_starttls
           swapped the plaintext ports for the secure ones, which
           prevented a client wanting implicit TLS and a peer doing
           STARTTLS from both being served at once. use_starttls is
           retained in the signature for callers but no longer
           selects ports; STARTTLS is advertised by MailSite
           whenever a certificate is present. */
        $smtp_port = (int) $ports['smtp'];
        $imap_port = (int) $ports['imap'];
        $submission_port = isset($ports['submission']) ?
            (int) $ports['submission'] : 0;
        $smtps_port = $tls_available ? (int) $ports['smtps'] : 0;
        $imaps_port = $tls_available ? (int) $ports['imaps'] : 0;
        $allow_plaintext_auth =
            ($delivery_security === 'insecure') ||
            ($bind === '127.0.0.1' && !$tls_available);
        $config = [
            'SMTP_PORT' => $smtp_port,
            'SUBMISSION_PORT' => $submission_port,
            'IMAP_PORT' => $imap_port,
            'SMTPS_PORT' => $smtps_port,
            'IMAPS_PORT' => $imaps_port,
            'BIND' => $bind,
            'SERVER_NAME' => $server_name,
            'ALLOW_PLAINTEXT_AUTH' => $allow_plaintext_auth,
        ];
        if ($tls_available) {
            $config['SERVER_CONTEXT'] = $server_context;
        }
        return $config;
    }
    /**
     * Picks the SERVER_NAME advertised in the SMTP/IMAP banner and
     * EHLO replies. Delegates to MailSiteFactory::mailHostName so
     * the configurable MAIL_HOST_NAME wins, falling back to the
     * first configured mail domain, then php_uname('n'), then
     * 'localhost' so the banner is never empty.
     *
     * @return string the hostname to advertise
     */
    protected static function serverName()
    {
        return MailSiteFactory::mailHostName();
    }
}
/*
 * Instantiate and run the MailServer daemon, unless this file was
 * included only to define the class for a unit test.
 */
if (!defined("seekquarry\\yioop\\executables\\MAIL_SERVER_TEST_LOAD")) {
    $server = new MailServer();
    $server->start();
}
X