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