<?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 as A;
use seekquarry\yioop\configs as C;
use seekquarry\yioop\library as L;
use seekquarry\yioop\library\AnalyticsManager;
use seekquarry\yioop\library\MediaConstants;
/**
* Sends an email by talking to an outgoing mail server, the way a
* mail program does. It opens a connection to the server, secures it
* if asked, logs in if the server needs that, names the sender and
* the recipients, and hands over the message. It knows the back-and-
* forth a mail server expects but does not build the message itself —
* that is MimeMessage's job; this class takes finished message text
* and gets it delivered. Using it avoids the setup headaches of
* relying on PHP's built-in mail() function and a mail server running
* on the local machine. Here is an example:
*
* $server = new SmtpClient('someone@somewhere.com', 'somewhere.com',
* 587, 'someone', 'pword', 'starttls');
* $to = "cool@place.com";
* $from = "someone@somewhere.com";
* $subject = "Test Mail";
* $message = "This is a test";
* $server->send($subject, $from, $to, $message);
*
* @author Chris Pollett
*/
class SmtpClient implements MediaConstants
{
/**
* The name this client announces itself by when it greets a mail
* server. It is one steady name for the whole site rather than a
* different name per message.
* @var string
*/
public $sender_host;
/**
* The open network connection to the mail server while a message
* is being sent.
* @var resource
*/
public $connection;
/**
* A running record of anything that went wrong during the current
* send, built up as the steps run and read back through
* getLastError.
* @var array
*/
public $messages;
/**
* The finished text of the most recently sent message — its
* headers, a blank line, then the body. It does not include the
* little end-marker the mail server uses to know the message is
* over, since that is part of the conversation, not the message.
* Code uses this to file a copy of a just-sent message into the
* sender's Sent folder.
* @var string
*/
public $last_wire_message;
/**
* A word-for-word record of the most recent exchange with the
* mail server: every line this client sent (marked with >>>) and
* every line the server replied (marked with <<<). The lines that
* carry the login name and password are blanked out so the saved
* record never holds credentials. It is written to mail.log on
* every send, success or failure, so an administrator reading the
* log can see exactly what passed between client and server.
* @var string
*/
public $transcript;
/**
* The full text of the server's most recent reply, kept after the
* short numeric status code has been read so the rest of the reply
* can still be examined. The greeting step reads it to see whether
* the server offered to upgrade the connection to an encrypted one
* (the opportunistic-encryption path in startSession).
* @var string
*/
public $last_response_body;
/**
* Filename, relative to LOG_DIR, that writeTranscript appends
* to on every send.
*/
const MAIL_LOG_NAME = "mail.log";
/**
* End of line string for an SMTP server
*/
const EOL = "\r\n";
/**
* How long before timeout when making a connection to an SMTP server
*/
const SMTP_TIMEOUT = 10;
/**
* Local address the outbound socket binds to so server-to-server
* delivery leaves over IPv4. This host's IPv4 address has a matching
* reverse-DNS (PTR) record, while its temporary IPv6
* autoconfiguration address does not, and receivers such as Gmail
* reject mail whose sending address lacks forward-confirmed reverse
* DNS. Binding the IPv4 wildcard forces the connection onto IPv4; the
* zero port lets the operating system choose the source port.
*/
const IPV4_ANY_BIND = "0.0.0.0:0";
/**
* Length of an SMTP response code
*/
const SMTP_CODE_LEN = 3;
/**
* TLS versions allowed for the STARTTLS handshake: TLS 1.2 and TLS
* 1.3 only. The bare STREAM_CRYPTO_METHOD_TLS_CLIENT also permits the
* long-obsolete TLS 1.0 and 1.1, so it is spelled out here instead.
*/
const TLS_CRYPTO_METHOD = STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT |
STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT;
/**
* Service ready for requests
*/
const SERVER_READY = 220;
/**
* SMTP last action okay
*/
const OKAY = 250;
/**
* authentication successful
*/
const GO_AHEAD = 235;
/**
* Send next authentication item
*/
const CONT_REQ = 334;
/**
* Ready for the actual mail input
*/
const START_INPUT = 354;
/**
* Sets up a sender pointed at a particular outgoing mail server,
* remembering how to reach it and how to log in. Nothing is
* connected yet; that happens when a message is actually sent.
*
* @param string $sender_email the address to use as the sender
* when a caller does not give one
* @param string $server the mail server's domain name
* @param int $port the port the mail server listens on
* @param string $login the username to log in with, or "" when
* the server needs no login
* @param string $password the password to log in with, or ""
* when the server needs no login
* @param mixed $secure how to secure the connection: false (or
* "plain") for no encryption; "starttls" (older name "tls")
* to start in the clear and then upgrade to an encrypted
* connection on the submission port; "smtps" (older name
* "ssl") to be encrypted from the moment of connecting
* @param bool $allow_self_signed when true, accept a server whose
* security certificate is self-issued and skip the usual
* name check. Needed for a self-hosted mail server that
* presents its own certificate; should stay false for the
* big public providers (Gmail, Fastmail, Yahoo, and so on),
* where a properly issued certificate is expected and
* accepting a self-issued one would quietly weaken security
* against an impostor in the middle.
*/
public function __construct(public $sender_email, public $server,
public $port, public $login, public $password,
public $secure = false, public $allow_self_signed = false)
{
/* Announce one consistent EHLO/HELO identity (the
configured MAIL_HOST_NAME, else the first mail domain,
else the system host name) rather than the per-message
sender domain, so the announced name matches the sending
IP's reverse DNS (PTR) across every From address. */
$this->sender_host = MailSiteFactory::mailHostName();
$this->connection = null;
$this->messages = "";
$this->transcript = "";
$this->last_wire_message = "";
$this->last_response_body = "";
}
/**
* Generates a fresh random secret for the reserved bot account
* and writes it to the shared secret file the local mail server
* reads when checking the bot's password, then returns it to be
* sent as this session's password. Writing it right before the
* login keeps the window in which the file holds a usable secret
* short. The file is made readable only by its owner because it
* is a live credential. An empty string is returned when the
* secret cannot be written, which leaves the login step to fail
* cleanly rather than send a secret the server will not have.
*
* @return string the secret just written, or "" on write failure
*/
protected function refreshBotSecret()
{
$secret = bin2hex(random_bytes(32));
$path = MailSiteFactory::botSecretPath();
if (file_put_contents($path, $secret, LOCK_EX) === false) {
return "";
}
chmod($path, 0600);
return $secret;
}
/**
* Opens the connection to the mail server and gets it ready to
* accept a message: connects, secures the connection when that
* was asked for (or when the server offers it and this client is
* set to take the offer), introduces itself, and logs in if a
* username and password were given. Returns whether the server is
* ready to receive a message.
*
* @return bool true when the connection is open and ready, false
* when any step failed (the reason is added to the running
* message log readable through getLastError)
*/
public function startSession()
{
$context = $this->tlsContext($this->server,
$this->allow_self_signed);
/* Send over IPv4 so the sending address has matching reverse
DNS; see IPV4_ANY_BIND. */
stream_context_set_option($context, "socket", "bindto",
self::IPV4_ANY_BIND);
$implicit_tls = ($this->secure == 'ssl' ||
$this->secure == 'smtps');
$wrapper = $implicit_tls ? 'tls://' : 'tcp://';
$errno = 0;
$errstr = '';
/* The @ suppresses the connection-failure warning that
stream_socket_client emits to output; the failure is
reported through $errno and $errstr instead, which is the
intended error channel and which we fold into the logged
message below. Without this the warning leaks to the
browser when a peer MTA refuses the port (for example a
receiver that blocks port 25 from this address). */
$this->connection = @stream_socket_client(
$wrapper . $this->server . ':' . $this->port,
$errno, $errstr, self::SMTP_TIMEOUT,
STREAM_CLIENT_CONNECT, $context);
if (!$this->connection) {
$reason = ($errstr !== '') ? $errstr : "errno $errno";
$this->messages .=
"Could not connect to smtp server " . $this->server .
":" . $this->port . " - " . $reason . "\n";
return false;
}
if ($this->readResponseGetCode() != self::SERVER_READY) {
$this->messages .= "SMTP error\n";
return false;
}
$hostname = self::bracketIpLiteralHost($this->sender_host);
$this->helloHandshake($hostname);
$mandatory_tls = ($this->secure == 'tls' ||
$this->secure == 'starttls');
$opportunistic_tls = ($this->secure == 'opportunistic');
$peer_advertised = (stripos($this->last_response_body,
'STARTTLS') !== false);
$want_tls = $mandatory_tls ||
($opportunistic_tls && $peer_advertised);
if ($want_tls) {
$starttls_code = $this->smtpCommand('STARTTLS');
if ($starttls_code != self::SERVER_READY) {
if ($mandatory_tls) {
$this->messages .= "Cannot start TLS\n";
return false;
}
$this->messages .=
"STARTTLS refused; continuing plaintext\n";
} else if (!stream_socket_enable_crypto(
$this->connection, true,
self::TLS_CRYPTO_METHOD)) {
/* the context attached to the socket at connect
time carries the allow_self_signed and peer-
verification settings, so the enable call
inherits them. Mandatory mode treats handshake
failure as fatal; opportunistic falls through
to plaintext. */
if ($mandatory_tls) {
$this->messages .= "TLS handshake failed\n";
return false;
}
$this->messages .=
"TLS handshake failed; continuing plaintext\n";
} else if ($this->helloHandshake($hostname) !=
self::OKAY) {
if ($mandatory_tls) {
$this->messages .= "TLS greeting error\n";
return false;
}
$this->messages .=
"Post-TLS EHLO failed; continuing plaintext\n";
}
}
if (MailSiteFactory::isBotUsername($this->login)) {
$this->password = $this->refreshBotSecret();
}
if ($this->login != "" && $this->password != "") {
if ($this->smtpCommand('AUTH LOGIN') != self::CONT_REQ) {
$this->messages .= "Authentication Error Auth Login\n";
return false;
}
if ($this->smtpCommand(base64_encode($this->login))
!= self::CONT_REQ) {
$this->messages .= "Authentication Error Username Transition\n";
return false;
}
if ($this->smtpCommand(base64_encode($this->password)) !=
self::GO_AHEAD) {
$this->messages .= "Authentication Error Password Transition\n";
return false;
}
}
return true;
}
/**
* Politely tells the mail server the conversation is over and
* closes the connection.
*/
public function endSession()
{
$this->smtpCommand('QUIT');
fclose($this->connection);
}
/**
* Pulls the plain email address out of a recipient entry. A
* recipient can be written as just an address ("a@b.com") or as a
* name plus address ("Display Name <a@b.com>"); the mail server
* needs only the address part. This returns that bare address.
* It is used when sending so a caller can put the friendly "name
* <address>" form in the visible To and Cc lines while the server
* is given only the address.
*
* @param string $spec one recipient entry
* @return string the bare address, trimmed of surrounding spaces;
* the entry itself (trimmed) is returned when it has no
* angle brackets
*/
protected static function extractBareAddress($spec)
{
$open = strrpos($spec, '<');
$close = strrpos($spec, '>');
if ($open !== false && $close !== false &&
$close > $open) {
$bare = substr($spec, $open + 1,
$close - $open - 1);
} else {
$bare = $spec;
}
return trim($bare);
}
/**
* Prepares the encryption settings used when securing the
* connection — both for a connection that is encrypted from the
* start and for one that starts in the clear and upgrades. When
* self-issued certificates are allowed, the usual checks that the
* server's certificate is properly issued and matches its name
* are turned off; this is needed for a self-hosted mail server
* presenting its own certificate. Otherwise the normal checks
* apply, which require a properly issued certificate whose name
* matches the server being connected to.
*
* @param string $host the server name, used to check the
* certificate's name when checking is on
* @param bool $allow_self_signed whether to relax the certificate
* checks
* @return resource the prepared encryption settings
*/
protected function tlsContext($host, $allow_self_signed)
{
$verify = !$allow_self_signed;
return stream_context_create([
'ssl' => [
'peer_name' => $host,
'SNI_enabled' => true,
'verify_peer' => $verify,
'verify_peer_name' => $verify,
'allow_self_signed' => $allow_self_signed,
],
]);
}
/**
* Reads the server's reply and returns its short status number.
* A reply can span several lines; this keeps reading until the
* last line arrives, records the whole reply in the conversation
* log, and hands back the three-digit number the server uses to
* say whether the last step worked.
*
* @return string the three-digit status number
*/
public function readResponseGetCode()
{
$data = "";
while($line = fgets($this->connection)) {
$data .= $line;
if ($line[self::SMTP_CODE_LEN] == ' ') {
break;
}
}
foreach (preg_split('/\r?\n/', rtrim($data, "\r\n"))
as $log_line) {
$this->transcript .= "<<< $log_line\n";
}
$this->last_response_body = $data;
return substr($data, 0, self::SMTP_CODE_LEN);
}
/**
* Sends one instruction to the mail server and returns the
* server's status number in reply. It adds both the instruction
* and the reply to the conversation log. The two lines that carry
* the login name and password (the ones right after the
* "log me in" instruction) are written to the log as "(redacted)"
* so the saved log never contains the actual credentials.
*
* @param string $command the instruction to send
* @return string the three-digit status number the server
* replied with
*/
public function smtpCommand($command)
{
static $auth_pending = 0;
$display = $command;
$upper = strtoupper($command);
if ($auth_pending > 0) {
$display = "(redacted)";
$auth_pending--;
} else if (str_starts_with($upper, 'AUTH LOGIN')) {
/* the two follow-up commands (username and password)
carry base64-encoded credentials and must be
redacted in the log. */
$auth_pending = 2;
}
$this->transcript .= ">>> $display\n";
fputs($this->connection, $command . self::EOL);
return $this->readResponseGetCode();
}
/**
* Introduces this client to the mail server by name. It is sent
* twice over a session: once as the opening greeting after
* connecting, and again after the connection is upgraded to an
* encrypted one (the server expects a fresh greeting over the
* secure channel). It prefers the modern greeting word, which
* also invites the server to list the features it supports, and
* falls back to the older greeting word on the rare server that
* refuses the modern one, so the session can still go ahead.
*
* @param string $hostname the name this client announces itself
* as
* @return string the three-digit status number from whichever
* greeting the server accepted
*/
public function helloHandshake($hostname)
{
$code = $this->smtpCommand("EHLO $hostname");
if ($code != self::OKAY) {
$code = $this->smtpCommand("HELO $hostname");
}
return $code;
}
/**
* Sends an email — either right away, or by handing it to the
* background mail process to send shortly, depending on how the
* site is configured. It works without needing a mail server set
* up on the local machine, the way PHP's built-in mail() would.
*
* @param string $subject the subject line
* @param string $from the sender address
* @param string $to the recipient address
* @param string $message the body text
* @param array $extra_headers optional further headers as a
* name => value map (for example a List-Unsubscribe header);
* carried through to the built message whether the mail is
* sent now or queued for the media updater to send later
*/
public function send($subject, $from, $to, $message,
$extra_headers = [])
{
$start_time = microtime(true);
if ($from == "") {
$from = $this->sender_email;
}
if (C\p('SEND_MAIL_MEDIA_UPDATER') == "true") {
$this->sendQueue($subject, $from, $to, $message,
$extra_headers);
} else {
$this->sendImmediate($subject, $from, $to, $message, [],
$extra_headers);
}
if (C\QUERY_STATISTICS) {
$current_messages = AnalyticsManager::get("MAIL_MESSAGES");
if (!$current_messages) {
$current_messages = [];
}
$total_time = AnalyticsManager::get("MAIL_TOTAL_TIME");
if (!$total_time) {
$total_time = 0;
}
$elapsed_time = L\changeInMicrotime($start_time);
$total_time += $elapsed_time;
$current_messages[] = [
"QUERY" => "<p>Send Mail</p>".
"<pre>" . wordwrap($this->messages, 60, "\n", true) .
"</pre>",
"ELAPSED_TIME" => $elapsed_time
];
AnalyticsManager::set("MAIL_MESSAGES", $current_messages);
AnalyticsManager::set("MAIL_TOTAL_TIME", $total_time);
}
}
/**
* Builds a message from its parts and sends it to the mail server
* right now, then reports whether it went through. The message
* text is assembled by MimeMessage so all message-building lives
* in one place; this method's job is the conversation with the
* server (naming the sender and recipients, handing over the
* message). A copy of the exact text sent is kept in
* last_wire_message so a caller can file it into the Sent folder.
*
* @param string $subject the subject line
* @param string $from the sender address
* @param mixed $to the recipient. Either a single address as a
* string (the form every existing caller uses), or a list
* under keys 'to', 'cc', and 'bcc', each a list of plain
* addresses. With the list form: the To line comes from the
* to-list, the Cc line from the cc-list (left off when
* empty), blind-copy recipients are never shown in the
* message but are still delivered to, and every address gets
* handed to the server as a recipient.
* @param string $message the body text
* @param array $attachments optional files to attach, each
* described by its filename, content type, and bytes
* @param array $extra_headers optional further headers as a
* name-to-value list (for example the "in reply to" and
* "references" headers used when sending a reply). Control
* characters are stripped from each value so a value cannot
* smuggle in extra header lines.
* @return bool true when the message was accepted, false when any
* step failed (connecting, securing, logging in, naming the
* sender or recipients, or handing over the message). The
* reason for a false result is in the running message log,
* readable through getLastError.
*/
public function sendImmediate($subject, $from, $to, $message,
$attachments = [], $extra_headers = [])
{
if (\Fiber::getCurrent() !== null) {
return $this->sendImmediateViaWorker($subject, $from, $to,
$message, $attachments, $extra_headers);
}
$eol = self::EOL;
$this->messages = "";
$this->transcript = "";
$this->last_wire_message = "";
/* normalize $to: a bare string is the single-recipient form
used by most callers; an array gives separate to, cc, and
bcc lists. The To and Cc lines shown in the message come
from the to-list and cc-list; blind-copy recipients are
delivered to but never shown in the message. */
if (is_array($to)) {
$to_list = $to['to'] ?? [];
$cc_list = $to['cc'] ?? [];
$bcc_list = $to['bcc'] ?? [];
$to_header = implode(", ", $to_list);
$cc_header = implode(", ", $cc_list);
$rcpts = array_map([self::class,
'extractBareAddress'],
array_merge($to_list, $cc_list, $bcc_list));
$transcript_to = $to_header;
} else {
$to_header = (string) $to;
$cc_header = '';
$rcpts = [self::extractBareAddress((string) $to)];
$transcript_to = (string) $to;
}
/* Build the whole message through MimeMessage so that all
message construction lives in one place. It fills in the
date, a unique message id, the sender, recipients, subject,
the housekeeping headers that tell a receiver how to read
the body, and any extra headers, and lays out attachments
as separate parts when there are any. The copy kept for the
Sent folder is the finished text; the marker that tells the
server the message is over is added only to the copy that
goes on the wire. */
$built_to = ($cc_header === '') ? $to_header :
['to' => $to_header, 'cc' => $cc_header];
/* Sign the built message the same way deliverBytes does, so
mail sent through this path (registration notices, group-
activity notifications, and the bulk-mail job) carries the
same DKIM signature as mail delivered directly, and is not
rejected by receivers that require one. Signing is a no-op
when DKIM is not set up. The signed text is what both the
wire copy and any Sent copy use. */
$this->last_wire_message = self::dkimSign(MimeMessage::build(
$from, $built_to, $subject, $message, $attachments,
$extra_headers));
$mail = $this->last_wire_message . $eol . ".";
$wire_from = self::bracketIpLiteralDomain($from);
$commands = [
"MAIL FROM: <$wire_from>" => self::OKAY,
];
foreach ($rcpts as $rcpt) {
$wire_rcpt = self::bracketIpLiteralDomain($rcpt);
$commands["RCPT TO: <$wire_rcpt>"] = self::OKAY;
}
$commands["DATA"] = self::START_INPUT;
$commands[$mail] = self::OKAY;
if (!$this->startSession()) {
$this->writeTranscript($from, $transcript_to, false);
return false;
}
$success = true;
foreach ($commands as $command => $good_response) {
$response = $this->smtpCommand($command);
if ($response != $good_response) {
$this->messages .=
"$command failed!! $response $good_response\n";
$success = false;
break;
}
}
$this->endSession();
$this->writeTranscript($from, $transcript_to, $success);
return $success;
}
/**
* Sends one message by handing the whole exchange to a short-lived
* helper process and waiting on its reply without freezing the event
* loop. This is used when a send happens inside the cooperative web
* server (that is, inside a fiber): the helper process does the
* connect, the TLS handshake, and the back-and-forth with the mail
* server while this method hands the loop back until the helper
* answers, so one slow or unreachable mail server cannot stall every
* other connection the single-process server is handling. The pattern
* is the same one the password hash uses to get off the loop. The
* helper's success flag and conversation log are copied back onto this
* object so the caller sees exactly what a direct send would have left
* behind.
*
* @param string $subject subject line of the message
* @param string $from envelope-from address
* @param mixed $to recipient address, or an array of to/cc/bcc lists
* @param string $message the already-assembled message body
* @param array $attachments files to attach, as the direct send takes
* @param array $extra_headers additional headers to include
* @return bool true if the helper reported the message was sent
*/
protected function sendImmediateViaWorker($subject, $from, $to,
$message, $attachments, $extra_headers)
{
$this->messages = "";
$this->transcript = "";
$this->last_wire_message = "";
$request = json_encode([
"sender_email" => $this->sender_email,
"server" => $this->server,
"port" => $this->port,
"login" => $this->login,
"password" => $this->password,
"secure" => $this->secure,
"allow_self_signed" => $this->allow_self_signed,
"subject" => $subject,
"from" => $from,
"to" => $to,
"message" => base64_encode($message),
"attachments" => $attachments,
"extra_headers" => $extra_headers
]);
$config = realpath(__DIR__ . "/../../configs/Config.php");
$worker = 'require ' . var_export($config, true) . '; ' .
'\seekquarry\yioop\library\mail\SmtpClient::runSendWorker();';
$descriptors = [0 => ["pipe", "r"], 1 => ["pipe", "w"],
2 => ["pipe", "w"]];
$pipes = [];
$process = proc_open([PHP_BINARY, "-r", $worker], $descriptors,
$pipes);
if (!is_resource($process)) {
$this->messages .= "Could not start mail worker\n";
return false;
}
fwrite($pipes[0], $request);
fclose($pipes[0]);
stream_set_blocking($pipes[1], false);
$output = "";
while (!feof($pipes[1])) {
$reads = [$pipes[1]];
$writes = null;
$excepts = null;
if (stream_select($reads, $writes, $excepts, 0) > 0) {
$chunk = fread($pipes[1], 4096);
if ($chunk === false) {
break;
}
$output .= $chunk;
} else if (\Fiber::getCurrent() !== null) {
\Fiber::suspend(["read" => $pipes[1]]);
}
}
fclose($pipes[1]);
fclose($pipes[2]);
proc_close($process);
$result = json_decode($output, true);
if (!is_array($result)) {
$this->messages .= "Mail worker returned no result\n";
return false;
}
$this->messages = $result["messages"] ?? "";
$this->transcript = $result["transcript"] ?? "";
$this->last_wire_message = $result["last_wire_message"] ?? "";
$this->last_response_body = $result["last_response_body"] ?? "";
return !empty($result["success"]);
}
/**
* Carries out one send inside the helper process started by
* sendImmediateViaWorker. It reads the send request from standard
* input, does the actual blocking send here (off the web server's
* loop, since this is a separate process), and writes the result back
* on standard output. The request and result travel as JSON; the
* message body is base64-encoded so binary attachment bytes survive
* the trip unchanged.
*
* @return void
*/
public static function runSendWorker()
{
$request = json_decode(stream_get_contents(STDIN), true);
if (!is_array($request)) {
fwrite(STDOUT, json_encode(["success" => false,
"messages" => "bad worker request\n"]));
return;
}
$client = new SmtpClient($request["sender_email"],
$request["server"], $request["port"], $request["login"],
$request["password"], $request["secure"],
$request["allow_self_signed"]);
/* The send can trigger PHP-level warnings (for example a refused
connection) that the global error handler prints as markup; ob
captures and drops that so the only thing left on standard
output is the JSON result the parent reads back. The send's own
error text is returned in the result's messages field. */
ob_start();
$success = $client->sendImmediate($request["subject"],
$request["from"], $request["to"],
base64_decode($request["message"]),
$request["attachments"], $request["extra_headers"]);
ob_end_clean();
fwrite(STDOUT, json_encode([
"success" => $success,
"messages" => $client->messages,
"transcript" => $client->transcript,
"last_wire_message" => $client->last_wire_message,
"last_response_body" => $client->last_response_body
]));
}
/**
* Adds a DKIM signature to a finished outbound message so the
* mail systems that receive it can confirm it really came from
* this server's domain and was not changed along the way, which
* is one of the things large providers such as Gmail and Yahoo
* check before accepting mail. Every outbound path signs through
* this one place so direct delivery and relay sending produce the
* same signature. Signing is skipped, and the message returned
* unchanged, when no signing selector is configured, no private
* key has been set up, or the From address has no domain, so an
* instance that has not set up DKIM still sends mail normally.
*
* @param string $message the finished message text to sign
* @return string the message with a DKIM-Signature header added,
* or the original text unchanged when DKIM is not set up
*/
public static function dkimSign($message)
{
$selector = C\nsdefined("MAIL_DKIM_SELECTOR") ?
C\MAIL_DKIM_SELECTOR : '';
return DkimKey::sign($message, $selector, time());
}
/**
* Hands an already-finished message to the mail server as-is and
* reports whether it was accepted. Unlike the send methods, this
* does not build or change the message — the caller already has
* the exact text (from MimeMessage or elsewhere) and those exact
* bytes go on the wire. This matters when delivering directly to
* another mail system, where the same message may be tried against
* several of the recipient's servers before one accepts it, and
* every copy should be identical (same unique id) rather than
* freshly rebuilt each attempt.
*
* Where to connect and how to log in come from this object. For
* delivering straight to another mail system, the caller sets up
* this object with no username or password so the login step is
* skipped, the way one mail server speaks to another.
*
* @param string $from the sender address named to the server
* @param array $recipients the recipient addresses; the one
* message is delivered to all of them
* @param string $bytes the finished message text, without the
* end-marker the server expects (this method adds it)
* @return bool true when every step through to closing the
* connection succeeded
*/
public function deliverBytes($from, $recipients, $bytes)
{
/* DKIM-sign the message before transmission so receivers can
verify it came from this server. Signing is a no-op when
no key exists or the From domain is missing, returning the
bytes unchanged, so delivery is never blocked by it. */
$bytes = self::dkimSign($bytes);
$this->messages = "";
$this->transcript = "";
$this->last_wire_message = $bytes;
$transcript_to = implode(", ", $recipients);
if (!$this->startSession()) {
$this->writeTranscript($from, $transcript_to, false);
return false;
}
$wire_from = self::bracketIpLiteralDomain($from);
$commands = ["MAIL FROM: <$wire_from>" => self::OKAY];
foreach ($recipients as $rcpt) {
$wire_rcpt = self::bracketIpLiteralDomain($rcpt);
$commands["RCPT TO: <$wire_rcpt>"] = self::OKAY;
}
$commands["DATA"] = self::START_INPUT;
$commands[$bytes . self::EOL . "."] = self::OKAY;
$success = true;
foreach ($commands as $command => $good_response) {
$response = $this->smtpCommand($command);
if ($response != $good_response) {
$this->messages .=
"$command failed!! $response $good_response\n";
$success = false;
break;
}
}
$this->endSession();
$this->writeTranscript($from, $transcript_to, $success);
return $success;
}
/**
* Looks up which mail servers accept mail for a domain, best
* choice first. It reads the domain's published list of mail
* servers; if the domain publishes none but does have a plain
* address record, the domain itself is used as its own mail
* server. Returns an empty list when neither is found, which the
* caller treats as "no way to reach this domain" and fails the
* recipient. It is a plain lookup that does not touch this
* object's own connection, so a caller typically takes the list
* and makes one sender per server to try in turn.
*
* @param string $domain the recipient's domain, such as
* "example.com"
* @return array the mail server names, best choice first, or an
* empty list when the domain cannot be reached
*/
public static function resolveMxHosts($domain)
{
$domain = trim((string) $domain);
if ($domain === '') {
return [];
}
$records = AsyncDnsResolver::query($domain,
AsyncDnsResolver::TYPE_MX);
if (!empty($records)) {
usort($records, function ($left, $right) {
return ((int) ($left['pri'] ?? 0)) -
((int) ($right['pri'] ?? 0));
});
$hosts = [];
foreach ($records as $record) {
$target = trim((string) ($record['target'] ?? ''));
if ($target !== '') {
$hosts[] = $target;
}
}
if (!empty($hosts)) {
return $hosts;
}
}
/* RFC 5321 section 5.1: if there are no MX resource records,
the domain name is treated as if it had an implicit mail
exchanger of preference zero pointing at itself, provided it
has an address record. */
$addresses = AsyncDnsResolver::query($domain,
AsyncDnsResolver::TYPE_A);
if (!empty($addresses) && filter_var($addresses[0]['ip'] ?? '',
FILTER_VALIDATE_IP)) {
return [$domain];
}
return [];
}
/**
* Adds a line to the mail log (mail.log). It does nothing when
* mail logging is switched off (the Mail Services checkbox in
* Server Settings controls this). When the log file grows past
* its size limit it is rotated: the current file is compressed and
* set aside, older compressed files shift down a slot, and the
* oldest beyond the kept count is dropped. Writing is locked so
* that two web requests logging at once do not jumble each other's
* lines. If writing fails (no log directory, a permission problem,
* a write error), the line still goes to PHP's own error log so an
* operator watching the web server log sees something.
*
* @param string $body the text to add; the caller includes its
* own timestamps and line breaks
*/
public static function appendLog($body)
{
if (!C\nsdefined('MAIL_LOG_ENABLED') ||
!C\p('MAIL_LOG_ENABLED')) {
return;
}
if (!C\nsdefined('LOG_DIR')) {
return;
}
$base = C\LOG_DIR . "/" .
substr(self::MAIL_LOG_NAME, 0, -4);
$logfile = $base . ".log";
clearstatcache(true, $logfile);
$max_size = C\nsdefined('MAX_LOG_FILE_SIZE') ?
(int) C\MAX_LOG_FILE_SIZE : 5000000;
$keep = C\nsdefined('NUMBER_OF_LOG_FILES') ?
(int) C\NUMBER_OF_LOG_FILES : 5;
if (file_exists($logfile) &&
filesize($logfile) > $max_size) {
$oldest = "$base.$keep.log.gz";
if (file_exists($oldest)) {
unlink($oldest);
}
for ($i = $keep; $i > 0; $i--) {
$previous = "$base." . ($i - 1) . ".log.gz";
if (file_exists($previous)) {
rename($previous, "$base.$i.log.gz");
}
}
if (is_readable($logfile)) {
$current = file_get_contents($logfile);
if ($current !== false) {
file_put_contents("$base.0.log.gz",
gzencode($current));
}
}
file_put_contents($logfile, "");
}
$written = file_put_contents($logfile, $body,
FILE_APPEND | LOCK_EX);
if ($written === false) {
error_log("mail log write failed at " . $logfile);
}
}
/**
* Saves the record of the most recent send to the mail log. It
* writes a heading line — the date, who the message was from and
* to, the server and port used, and whether the send succeeded —
* followed by the line-by-line conversation with the server, and
* any error notes. It goes through the shared log writer, so it
* does nothing when mail logging is off and the file is rotated
* when it grows too large.
*
* @param string $from the sender named to the server
* @param string $to the recipient named to the server
* @param bool $success whether the send is about to be reported
* as succeeded
*/
public function writeTranscript($from, $to, $success)
{
$status = $success ? "OK" : "FAILED";
$header = "=== " . date(DATE_RFC822) . " " .
"from=$from to=$to server=" . $this->server .
" port=" . $this->port . " status=$status ===\n";
$body = $header . $this->transcript;
if ($this->messages !== "") {
$body .= "--- errors ---\n" . $this->messages;
}
$body .= "\n";
self::appendLog($body);
}
/**
* Returns a readable explanation of why the most recent send
* failed, or an empty string when it succeeded. The text
* describes which step went wrong and how. Code checks the
* true/false result of a send first, and uses this only when it
* wants something to show a person — for example, after a failed
* Compose-and-send.
*
* @return string the failure explanation, or "" when there was
* no error
*/
public function getLastError()
{
return $this->messages ?? "";
}
/**
* Picks the domain for the right-hand side of an outgoing
* message's unique id. This is the same choice MimeMessage makes
* when it builds a message, exposed here as a named method so the
* choice can be checked on its own by a test. It uses the sender
* address's domain, falling back to the site's default sender
* address, then the site's web address, then a safe placeholder.
*
* @param string $from the sender address as given to a send
* @return string a bare domain (no brackets, scheme, or path)
* ready to drop into "<id@domain>"
*/
public static function messageIdDomain($from)
{
return MimeMessage::messageIdDomain($from);
}
/**
* Fixes up a sender or recipient address whose domain is a bare
* numeric IP address so the mail server will accept it. When the
* part after the @ is a plain IP like "root@127.0.0.1", most mail
* servers reject it as malformed; the accepted way to write it is
* with the IP in square brackets, "root@[127.0.0.1]" (and a small
* label is added for the newer IPv6 style). An address whose
* domain is already bracketed, or is an ordinary name rather than
* an IP, is returned unchanged, as is an address with no @. This
* only adjusts the address used in the conversation with the
* server; the From and To lines shown inside the message are left
* alone, since they may keep the plain form for display.
*
* @param string $address the address named to the server, without
* angle brackets; an empty string (including the empty
* "no sender" marker) is returned unchanged
* @return string the address with its IP domain bracketed when
* that applies; otherwise the input unchanged
*/
public static function bracketIpLiteralDomain($address)
{
if ($address === '') {
return $address;
}
$at_pos = strrpos($address, '@');
if ($at_pos === false) {
return $address;
}
$local = substr($address, 0, $at_pos);
$domain = substr($address, $at_pos + 1);
if ($domain === '' || $domain[0] === '[') {
return $address;
}
if (filter_var($domain, FILTER_VALIDATE_IP,
FILTER_FLAG_IPV4)) {
return $local . '@[' . $domain . ']';
}
if (filter_var($domain, FILTER_VALIDATE_IP,
FILTER_FLAG_IPV6)) {
return $local . '@[IPv6:' . $domain . ']';
}
return $address;
}
/**
* Fixes up the name this client announces itself by in its
* greeting when that name is a bare numeric IP address. The
* greeting is supposed to give either a real host name or an IP
* wrapped in square brackets, so a bare "127.0.0.1" is not valid
* and strict servers reject it; "[127.0.0.1]" (or the bracketed
* IPv6 form) is the accepted way to say "I have no host name, here
* is my address." A real host name, or one already bracketed, is
* returned unchanged. This is the greeting-name counterpart to the
* method that brackets the domain of an email address.
*
* @param string $host the greeting name, usually taken from the
* sender address's domain
* @return string the bracketed form when the name is a bare IP;
* otherwise the input unchanged
*/
public static function bracketIpLiteralHost($host)
{
if ($host === '' || $host[0] === '[') {
return $host;
}
if (filter_var($host, FILTER_VALIDATE_IP,
FILTER_FLAG_IPV4)) {
return '[' . $host . ']';
}
if (filter_var($host, FILTER_VALIDATE_IP,
FILTER_FLAG_IPV6)) {
return '[IPv6:' . $host . ']';
}
return $host;
}
/**
* Hands an email to the background mail process to send shortly,
* instead of sending it right now. The message is written into the
* mail queue folder, and the background process picks it up and
* delivers it. This keeps a slow send from holding up the page the
* person is on.
*
* @param string $subject the subject line
* @param string $from the sender address
* @param string $to the recipient address
* @param string $message the body text
* @param array $extra_headers optional further headers as a
* name => value map; stored in the queued record so the
* message is rebuilt with them when the batch is later sent
*/
public function sendQueue($subject, $from, $to, $message,
$extra_headers = [])
{
$mail_directory = C\WORK_DIRECTORY . self::MAIL_FOLDER;
if (!file_exists($mail_directory)) {
mkdir($mail_directory);
L\setWorldPermissions($mail_directory);
if (!file_exists($mail_directory)) {
L\crawlLog("Could not create mail directory!");
A\webExit();
}
}
$files = glob($mail_directory . "/*.txt");
$file_count = count($files);
$current_count = 0;
$current_time = time();
$diff = 0;
if ($file_count > 0) {
$file = end($files);
$file_name = str_replace($mail_directory . "/", "", $file);
$last_file_time = substr($file_name, 0, -4);
$diff = $current_time - $last_file_time;
}
$mail_details = serialize(array($subject, $from, $to, $message,
$extra_headers));
$this->messages = "Queuing: " . $mail_details;
if ($diff > C\MAIL_AGGREGATION_TIME || $file_count == 0)
{
L\crawlLog("...Creating a new file for next mailer batch!\n");
$file_time = time();
$fp = fopen($mail_directory . "/" . $file_time . ".txt", "a+");
if (flock($fp, LOCK_EX | LOCK_NB)) {
L\crawlLog("....Lock for mail file acquired!" .
" Sending emails!\n");
fwrite($fp, self::MESSAGE_SEPARATOR . $mail_details);
fwrite($fp, PHP_EOL);
flock($fp, LOCK_UN);
L\setWorldPermissions($mail_directory . "/" .
$file_time . ".txt");
} else {
L\crawlLog("Could not acquire the lock " .
" for $file_time.txt!\n");
}
} else {
$fp = fopen($mail_directory . "/" . $last_file_time . ".txt", "a+");
if (flock($fp, LOCK_EX | LOCK_NB)) {
L\crawlLog("....Lock acquired! Sending emails now!\n");
fwrite($fp, $mail_details);
fwrite($fp, PHP_EOL);
flock($fp, LOCK_UN);
L\setWorldPermissions($mail_directory . "/" .
$last_file_time . ".txt");
} else {
L\crawlLog("Could not acquire the lock! for $file!\n");
}
}
return;
}
}