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