SeekquarrySeekquarry - Yioop Repo - Seekquarry

/ tests / SmtpClientTest.php⤓
<?php
/**
 * SeekQuarry/Yioop --
 * Open Source Pure PHP Search Engine, Crawler, and Indexer
 *
 * Copyright (C) 2009 - 2026  Chris Pollett chris@pollett.org
 *
 * LICENSE:
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 * END LICENSE
 *
 * @author Chris Pollett chris@pollett.org
 * @license https://www.gnu.org/licenses/ GPL3
 * @link https://www.seekquarry.com/
 * @copyright 2009 - 2026
 * @filesource
 */
namespace seekquarry\yioop\tests;

use seekquarry\yioop\configs as C;
use seekquarry\yioop\library as L;
use seekquarry\yioop\library\mail\DkimKey;
use seekquarry\yioop\library\mail\MimeMessage;
use seekquarry\yioop\library\mail\SmtpClient;
use seekquarry\yioop\library\UnitTest;
use seekquarry\yioop\models\Model;

/**
 * Mail client that writes down whether it opened a connection and
 * whether it carried the message to the recipients' own mail servers,
 * without doing either, so a case can see which path a send took.
 *
 * @author Chris Pollett chris@pollett.org
 */
class ProbeSmtpClientForCarrying extends SmtpClient
{
    /**
     * Whether a connection to a mail server was opened.
     * @var bool
     */
    public $opened = false;
    /**
     * Whether the message was carried to the recipients' own servers.
     * @var bool
     */
    public static $carried = false;
    /**
     * Says the connection could not be opened, and writes down that
     * opening one was tried at all.
     *
     * @param bool $use_tls whether STARTTLS would be asked for
     * @return bool always false
     */
    public function startSession($use_tls = true)
    {
        $this->opened = true;
        return false;
    }
    /**
     * Writes down that the message was carried, and says it arrived.
     *
     * @param string $from the address the message is sent from
     * @param array $recipients who it is going to
     * @param string $bytes the finished message
     * @return array the same shape a real carrying gives back
     */
    public static function deliverToRecipientServers($from, $recipients,
        $bytes)
    {
        self::$carried = true;
        return ['ok' => true, 'error' => ''];
    }
    /**
     * Keeps the mail log out of a test run.
     *
     * @param string $from the address the message is sent from
     * @param string $to who it was going to
     * @param bool $success whether the send worked
     * @return void
     */
    public function writeTranscript($from, $to, $success)
    {
    }
}
/**
 * Mail client whose two ways of sending both give back whatever they
 * are told to, so a case can see what the send above them hands on.
 *
 * @author Chris Pollett chris@pollett.org
 */
class ProbeSmtpClientForTaking extends SmtpClient
{
    /**
     * What both ways of sending give back.
     * @var bool
     */
    public $answer = true;
    /**
     * Stands in for writing the message to the folder the media
     * updater drains.
     *
     * @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 further headers
     * @return bool what this client was told to give back
     */
    public function sendQueue($subject, $from, $to, $message,
        $extra_headers = [])
    {
        return $this->answer;
    }
    /**
     * Stands in for handing the message to a mail server now.
     *
     * @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 $attachments files to attach
     * @param array $extra_headers further headers
     * @return bool what this client was told to give back
     */
    public function sendImmediate($subject, $from, $to, $message,
        $attachments = [], $extra_headers = [])
    {
        return $this->answer;
    }
}
/**
 * ProbeSmtpClientForDomains stands in for the name lookup and the
 * conversation with each domain's mail server, so a test can say which
 * domains take a message and which cannot be reached, and see which were
 * tried.
 */
class ProbeSmtpClientForDomains extends SmtpClient
{
    /**
     * $outcomes maps a domain to what its server says: the empty string
     * for a server that takes the message, otherwise its error
     * @var array
     */
    public static $outcomes = [];
    /**
     * $tried lists the domains a send was attempted for, in order
     * @var array
     */
    public static $tried = [];
    /**
     * $mx_hosts maps a domain to the mail servers resolveMxHosts gives for
     * it; a domain not named gets one made-up server
     * @var array
     */
    public static $mx_hosts = [];
    /**
     * resolveMxHosts gives the servers set in $mx_hosts for a domain, or
     * one made-up mail server, so no name server is asked.
     * @param string $domain the recipient's domain
     * @return array the server names for the domain
     */
    public static function resolveMxHosts($domain)
    {
        return self::$mx_hosts[$domain] ?? ["mx." . $domain];
    }
    /**
     * deliverToDomain records the domain and gives back the outcome set
     * for its first server, or else for the domain, instead of opening a
     * connection.
     * @param string $from the sender
     * @param string $domain the recipients' domain
     * @param array $recipients the addresses at that domain
     * @param array $mx_hosts the domain's mail servers
     * @param array $ports the ports that would be tried
     * @param string $bytes the message
     * @return string the outcome set for the domain
     */
    protected static function deliverToDomain($from, $domain, $recipients,
        $mx_hosts, $ports, $bytes)
    {
        self::$tried[] = $domain;
        return self::$outcomes[$mx_hosts[0]] ?? self::$outcomes[$domain] ??
            "";
    }
}
/**
 * Unit tests for the small, side-effect-free helpers in SmtpClient.
 * The send/sendImmediate/sendQueue paths talk to disk and SMTP and
 * are out of scope here; this file covers messageIdDomain, which
 * picks the right-hand side of an outbound Message-ID and is used
 * by every outbound email Yioop generates, and dkimSign, the one
 * place every outbound path signs through so a receiver can confirm
 * the mail's origin.
 *
 * @author Chris Pollett
 */
class SmtpClientTest extends UnitTest
{
    /**
     * Directory a DKIM test case points the signing key at, so the
     * generated key lives in a throwaway location and never touches
     * a real install. Left null by the cases that need no key.
     * @var string
     */
    public $temp_dir = null;
    /**
     * setUp points SmtpClient at MAIL_FOLDER, a folder in the tests
     * folder, for the ports it writes down. The DKIM cases set up their
     * own key folder.
     */
    public function setUp()
    {
        L\makePath(self::MAIL_FOLDER);
        SmtpClient::$mail_folder = self::MAIL_FOLDER;
    }
    /**
     * MAIL_FOLDER is the folder, under tests/test_files, that SmtpClient
     * writes down the ports that worked in during these cases.
     */
    const MAIL_FOLDER = C\TEST_DIR . "/test_files/smtp_client_mail";
    /**
     * tearDown deletes MAIL_FOLDER and points SmtpClient back at the
     * work directory's mail folder. It also removes the DKIM key and
     * folder a case may have made and restores the default key place,
     * so one case cannot leave a key behind that another would find.
     */
    public function tearDown()
    {
        (new Model())->db->unlinkRecursive(self::MAIL_FOLDER);
        SmtpClient::$mail_folder = C\WORK_DIRECTORY .
            SmtpClient::MAIL_FOLDER;
        if ($this->temp_dir !== null) {
            if (file_exists(DkimKey::privateKeyPath())) {
                unlink(DkimKey::privateKeyPath());
            }
            if (file_exists(DkimKey::publicKeyPath())) {
                unlink(DkimKey::publicKeyPath());
            }
            if (is_dir($this->temp_dir)) {
                rmdir($this->temp_dir);
            }
            DkimKey::setSecurityDir(null);
            $this->temp_dir = null;
        }
        /* The case that remembers a port writes a file beside the mail
           log. A test leaves nothing behind, so it goes here. */
        $ports = SmtpClient::deliveryPortsFile();
        if (file_exists($ports)) {
            @unlink($ports);
        }
    }
    /**
     * A site whose mail services are turned off has no mail server on
     * the machine to hand a message to, so its own client carries the
     * message to the recipients' own mail servers without opening a
     * connection to itself first. A site that does run one hands the
     * message over, and carries it only if that server does not answer.
     */
    public function noMailServerHereMeansNoLocalConnectionTestCase()
    {
        ProbeSmtpClientForCarrying::$carried = false;
        $alone = new ProbeSmtpClientForCarrying("bot@example.com",
            "localhost", SmtpClient::SUBMISSION_PORT, "bot", "",
            "starttls", true, true, false);
        $went = $alone->sendImmediate("a subject", "bot@example.com",
            "witness@example.org", "a message");
        $this->assertTrue($went, "the message goes");
        $this->assertFalse($alone->opened,
            "and no connection to this machine was opened");
        $this->assertTrue(ProbeSmtpClientForCarrying::$carried,
            "it was carried to the recipient's own mail server");
        ProbeSmtpClientForCarrying::$carried = false;
        $with_server = new ProbeSmtpClientForCarrying("bot@example.com",
            "localhost", SmtpClient::SUBMISSION_PORT, "bot", "",
            "starttls", true, true, true);
        $with_server->sendImmediate("a subject", "bot@example.com",
            "witness@example.org", "a message");
        $this->assertTrue($with_server->opened,
            "where a mail server runs here, it is handed the message");
        $this->assertTrue(ProbeSmtpClientForCarrying::$carried,
            "and the message is carried when that server does not answer");
    }
    /**
     * A send says whether the message was taken, whichever way it went.
     * The answer was thrown away between the sending and the caller, so
     * a screen that counts what went out reported that nothing had,
     * even where every message was on its way.
     */
    public function sendSaysWhetherTheMessageWasTakenTestCase()
    {
        $client = new ProbeSmtpClientForTaking("bot@example.com",
            "localhost", SmtpClient::SUBMISSION_PORT, "bot", "",
            "starttls", true, true, false);
        $client->answer = true;
        $this->assertTrue($client->send("a subject", "bot@example.com",
            "witness@example.org", "a message"),
            "a message that was taken says so");
        $client->answer = false;
        $this->assertFalse($client->send("a subject", "bot@example.com",
            "witness@example.org", "a message"),
            "and one that was not says that");
    }
    /**
     * The port a domain's mail server took last time is tried first.
     * A network that will not let mail out on the ordinary port made
     * every message wait ten seconds for a connection that never
     * answered before falling back to the one that works.
     */
    public function portThatWorkedIsTriedFirstTestCase()
    {
        $ports = [SmtpClient::SERVER_TO_SERVER_PORT,
            SmtpClient::SUBMISSION_PORT];
        $this->assertEqual($ports,
            SmtpClient::portsToTry($ports, 0),
            "with nothing remembered the order is left as it was");
        $this->assertEqual([SmtpClient::SUBMISSION_PORT,
            SmtpClient::SERVER_TO_SERVER_PORT],
            SmtpClient::portsToTry($ports, SmtpClient::SUBMISSION_PORT),
            "the port that worked comes first and the other still gets " .
            "a turn");
        $this->assertEqual($ports,
            SmtpClient::portsToTry($ports, 2525),
            "a remembered port that is not among them changes nothing");
        SmtpClient::rememberPort("example.org",
            SmtpClient::SUBMISSION_PORT);
        $this->assertEqual(SmtpClient::SUBMISSION_PORT,
            SmtpClient::rememberedPort("EXAMPLE.ORG"),
            "what was written down is read back whatever its capitals");
        $this->assertEqual(0, SmtpClient::rememberedPort("nowhere.test"),
            "a domain nothing was written down for gives nothing");
    }
    /**
     * A port of nothing makes an address nothing can be reached at, so
     * a site with no submission port written down falls back to the
     * standard one rather than trying to connect to "localhost:".
     */
    public function submissionPortFallsBackToTheStandardOneTestCase()
    {
        $this->assertEqual(SmtpClient::SUBMISSION_PORT,
            SmtpClient::submissionPortOrStandard(""),
            "nothing written down gives the standard port");
        $this->assertEqual(SmtpClient::SUBMISSION_PORT,
            SmtpClient::submissionPortOrStandard(0),
            "and so does a port of zero");
        $this->assertEqual(SmtpClient::SUBMISSION_PORT,
            SmtpClient::submissionPortOrStandard(
            SmtpClient::HIGHEST_PORT + 1),
            "and so does a number no port can be");
        $this->assertEqual(2525,
            SmtpClient::submissionPortOrStandard("2525"),
            "a port the site has written down is the one used");
    }
    /**
     * Carrying a message to the recipients' own mail servers means one
     * conversation with each domain's server rather than one for every
     * address, so the addresses have to be sorted by domain first. An
     * address with no domain in it has nowhere to be carried to, and
     * saying so is what keeps such a message from being sent nowhere.
     */
    public function recipientsByDomainTestCase()
    {
        $sorted = SmtpClient::recipientsByDomain(["alice@example.com",
            "bob@example.com", "carol@example.org"]);
        $this->assertEqual(["example.com", "example.org"],
            array_keys($sorted),
            "each domain is spoken to once however many are there");
        $this->assertEqual(["alice@example.com", "bob@example.com"],
            $sorted["example.com"],
            "everybody at one domain is carried in one conversation");
        $this->assertEqual(["example.org"], array_keys(
            SmtpClient::recipientsByDomain(["carol@example.ORG"])),
            "a domain written in capitals is the same domain");
        $this->assertFalse(SmtpClient::recipientsByDomain(["nobody"]),
            "an address naming no domain has nowhere to be carried to");
        $this->assertFalse(SmtpClient::recipientsByDomain(["nobody@"]),
            "an address whose domain is empty has nowhere either");
    }
    /**
     * messageIdDomain should return the right-hand side of the
     * sender address when one is present, ignoring whitespace and
     * angle brackets; fall back to MAIL_SENDER's domain when the
     * caller-supplied $from has no @; and fall back further to the
     * host portion of BASE_URL when neither has a usable domain.
     */
    public function messageIdDomainTestCase()
    {
        /* Plain email address */
        $this->assertEqual("example.com",
            SmtpClient::messageIdDomain("alice@example.com"),
            "Bare email's domain is extracted as-is");
        /* Mailbox-with-display-name form: angle brackets stripped */
        $this->assertEqual("example.com",
            SmtpClient::messageIdDomain("Alice <alice@example.com>"),
            "Angle-bracketed mailbox returns its host");
        /* Whitespace tolerated */
        $this->assertEqual("example.com",
            SmtpClient::messageIdDomain("alice@example.com  "),
            "Trailing whitespace does not leak into the domain");
        /* Multiple @ signs: take the rightmost (RFC-correct) */
        $this->assertEqual("example.com",
            SmtpClient::messageIdDomain("weird@local@example.com"),
            "Domain comes from the rightmost @, not the leftmost");
        /* Subdomains preserved */
        $this->assertEqual("mail.dept.example.org",
            SmtpClient::messageIdDomain("bob@mail.dept.example.org"),
            "Multi-label subdomain is returned in full");
        /*
            Empty $from with no @ falls through to MAIL_SENDER.
            The test setup defines MAIL_SENDER as a Yioop
            config constant during boot; whatever it is, the
            helper should return a non-empty string rather
            than crashing or returning something nonsensical.
         */
        $domain = SmtpClient::messageIdDomain("");
        $this->assertTrue(is_string($domain) && $domain !== "",
            "Empty \$from still resolves to a usable domain");
        $this->assertTrue(strpos($domain, "@") === false,
            "Resolved domain does not retain the @ from the fallback");
    }
    /**
     * bracketIpLiteralDomain should wrap bare IPv4 and IPv6
     * envelope-domain literals in the RFC 5321 sec 4.1.3
     * bracketed form (and prefix the IPv6 tag), pass
     * non-IP-literal domains through unchanged, leave
     * already-bracketed addresses alone, and tolerate the empty
     * string and addresses with no '@'.
     */
    public function bracketIpLiteralDomainTestCase()
    {
        $this->assertEqual("root@[127.0.0.1]",
            SmtpClient::bracketIpLiteralDomain("root@127.0.0.1"),
            "Bare IPv4 envelope domain gets bracketed");
        $this->assertEqual("noreply@[192.0.2.50]",
            SmtpClient::bracketIpLiteralDomain(
                "noreply@192.0.2.50"),
            "Non-loopback IPv4 bracketed too");
        $this->assertEqual("alice@[IPv6:::1]",
            SmtpClient::bracketIpLiteralDomain("alice@::1"),
            "Bare IPv6 envelope domain gets IPv6 tag and brackets");
        $this->assertEqual(
            "bob@[IPv6:2001:db8::8a2e:370:7334]",
            SmtpClient::bracketIpLiteralDomain(
                "bob@2001:db8::8a2e:370:7334"),
            "Full IPv6 address bracketed");
        $this->assertEqual("user@example.com",
            SmtpClient::bracketIpLiteralDomain(
                "user@example.com"),
            "FQDN domain left alone");
        $this->assertEqual("user@localhost",
            SmtpClient::bracketIpLiteralDomain("user@localhost"),
            "Single-label non-IP domain left alone");
        $this->assertEqual("user@[127.0.0.1]",
            SmtpClient::bracketIpLiteralDomain(
                "user@[127.0.0.1]"),
            "Already-bracketed IPv4 left alone (no double-bracket)");
        $this->assertEqual("user@[IPv6:::1]",
            SmtpClient::bracketIpLiteralDomain(
                "user@[IPv6:::1]"),
            "Already-bracketed IPv6 left alone");
        $this->assertEqual("",
            SmtpClient::bracketIpLiteralDomain(""),
            "Empty string passes through (SMTP null reverse-path)");
        $this->assertEqual("malformed-no-at-sign",
            SmtpClient::bracketIpLiteralDomain(
                "malformed-no-at-sign"),
            "Address with no @ passes through unchanged");
        $this->assertEqual("user@",
            SmtpClient::bracketIpLiteralDomain("user@"),
            "Address with empty domain passes through unchanged");
        $this->assertEqual("u@v@[127.0.0.1]",
            SmtpClient::bracketIpLiteralDomain("u@v@127.0.0.1"),
            "Multiple '@' chars: only the rightmost is the " .
            "delimiter; left-side '@' is part of the local-part");
    }
    /**
     * bracketIpLiteralHost should wrap a bare IPv4 or IPv6 EHLO/HELO
     * host in the RFC 5321 sec 4.1.3 address-literal form (with the
     * IPv6 tag), so the greeting argument is valid when the sender
     * domain is an IP and FQDN-strict receivers do not reject it. A
     * fully qualified domain name, a single non-IP label, an
     * already-bracketed host, and the empty string all pass through
     * unchanged.
     */
    public function bracketIpLiteralHostTestCase()
    {
        $this->assertEqual("[127.0.0.1]",
            SmtpClient::bracketIpLiteralHost("127.0.0.1"),
            "Bare IPv4 EHLO host gets bracketed");
        $this->assertEqual("[192.0.2.50]",
            SmtpClient::bracketIpLiteralHost("192.0.2.50"),
            "Non-loopback IPv4 host bracketed too");
        $this->assertEqual("[IPv6:::1]",
            SmtpClient::bracketIpLiteralHost("::1"),
            "Bare IPv6 EHLO host gets the IPv6 tag and brackets");
        $this->assertEqual("mail.pollett.org",
            SmtpClient::bracketIpLiteralHost("mail.pollett.org"),
            "FQDN host left alone");
        $this->assertEqual("localhost",
            SmtpClient::bracketIpLiteralHost("localhost"),
            "Single-label non-IP host left alone");
        $this->assertEqual("[127.0.0.1]",
            SmtpClient::bracketIpLiteralHost("[127.0.0.1]"),
            "Already-bracketed host left alone (no double-bracket)");
        $this->assertEqual("",
            SmtpClient::bracketIpLiteralHost(""),
            "Empty host passes through unchanged");
    }
    /**
     * A domain whose server cannot be reached does not stop the message
     * going to the next domain, and only its own address comes back as
     * undelivered, marked as worth trying again. A message to a town hall
     * whose server timed out once took its copy to a second address down
     * with it.
     */
    public function unreachedDomainHoldsBackNoOtherTestCase()
    {
        ProbeSmtpClientForDomains::$tried = [];
        ProbeSmtpClientForDomains::$outcomes = ["town.example" =>
            "Could not connect to smtp server mx.town.example:587 - " .
            "Operation timed out"];
        $result = ProbeSmtpClientForDomains::deliverToRecipientServers(
            "jannew@pollett.org", ["jennifer@town.example",
            "harry@east.example"], "Subject: hi\r\n\r\nbody");
        $this->assertEqual(["town.example", "east.example"],
            ProbeSmtpClientForDomains::$tried,
            "the second domain is tried after the first fails");
        $this->assertEqual(["jennifer@town.example"],
            array_keys($result['undelivered']),
            "only the unreached address is undelivered");
        $this->assertTrue(
            $result['undelivered']["jennifer@town.example"]['temporary'],
            "a server that could not be reached is worth trying again");
    }
    /**
     * isTemporaryFailure takes a server that could not be reached, or a
     * reply code starting with 4, as worth trying again, and a reply code
     * starting with 5 as final.
     */
    public function temporaryFailureKnownTestCase()
    {
        $this->assertTrue(SmtpClient::isTemporaryFailure(
            "Could not connect to smtp server mx.example:25 - timed out"),
            "an unreachable server is temporary");
        $this->assertTrue(SmtpClient::isTemporaryFailure(
            "RCPT TO: <a@b.example> failed!! 451 250"),
            "a 451 reply is temporary");
        $this->assertTrue(!SmtpClient::isTemporaryFailure(
            "RCPT TO: <a@b.example> failed!! 550 250"),
            "a 550 reply is final");
    }
    /**
     * replyToMicrosoftMailTriesItsServerTestCase checks that a domain with
     * no MX record, whose own address refuses the connection, is tried at
     * its Microsoft 365 server when the message replies to mail from
     * Microsoft 365, and is not tried there otherwise. A reply to a town
     * hall that uses Microsoft 365 but publishes no MX record bounced.
     */
    public function replyToMicrosoftMailTriesItsServerTestCase()
    {
        ProbeSmtpClientForDomains::$mx_hosts = ["town.example" =>
            ["town.example"]];
        ProbeSmtpClientForDomains::$outcomes = [
            "town.example" => "Could not connect to smtp server " .
            "town.example:25 - Connection refused",
            "town-example.mail.protection.outlook.com" => ""];
        ProbeSmtpClientForDomains::$tried = [];
        $reply = "In-Reply-To:\r\n <AB12@CH3PR01MB1234.namprd01.prod." .
            "outlook.com>\r\nSubject: Re: hi\r\n\r\nbody";
        $result = ProbeSmtpClientForDomains::deliverToRecipientServers(
            "jannew@pollett.org", ["clerk@town.example"], $reply);
        $this->assertTrue($result['ok'],
            "the reply reaches the Microsoft 365 server");
        $this->assertEqual(["town.example", "town.example"],
            ProbeSmtpClientForDomains::$tried,
            "the domain's own address is tried first");
        ProbeSmtpClientForDomains::$tried = [];
        $result = ProbeSmtpClientForDomains::deliverToRecipientServers(
            "jannew@pollett.org", ["clerk@town.example"],
            "Subject: hi\r\n\r\nbody");
        $this->assertEqual(["clerk@town.example"],
            array_keys($result['undelivered']),
            "a message that is no such reply is not sent there");
        ProbeSmtpClientForDomains::$mx_hosts = [];
        ProbeSmtpClientForDomains::$outcomes = [];
    }
    /**
     * hostedMailServerTestCase checks that hostedMailServer gives the
     * Microsoft 365 server named for the domain, or Google Workspace's one
     * server, from the message id ending in a reply's headers, and gives
     * nothing for a message that replies to neither.
     */
    public function hostedMailServerTestCase()
    {
        $this->assertEqual("town-example.mail.protection.outlook.com",
            SmtpClient::hostedMailServer("References: <a@b.prod." .
            "outlook.com>\r\n\r\nbody", "town.example"),
            "a reply to Microsoft 365 mail");
        $this->assertEqual("aspmx.l.google.com",
            SmtpClient::hostedMailServer("In-Reply-To: <CAx1@mail.gmail." .
            "com>\r\n\r\nbody", "town.example"),
            "a reply to Google mail");
        $this->assertEqual("", SmtpClient::hostedMailServer(
            "Subject: hi\r\n\r\nReferences: <a@b.prod.outlook.com>",
            "town.example"), "an id in the body is not read");
    }
    /**
     * failedHandshakeSendsNothingTestCase checks what happens when a
     * server offers STARTTLS and the TLS handshake then fails, as Gmail's
     * and iCloud's did for mail sent from pollett.org. Before, the client
     * went on in plaintext on a connection the server had already moved
     * to TLS, read no reply to MAIL FROM, and the message bounced with
     * "MAIL FROM failed!! 250". Now the session does not open, no second
     * connection is made, nothing goes in plaintext, the reason is
     * logged, and the failure counts as one to try again later.
     */
    public function failedHandshakeSendsNothingTestCase()
    {
        $client = new ScriptedSmtpClient("alice@example.com",
            "mx.example.test", SmtpClient::SERVER_TO_SERVER_PORT, "", "",
            "opportunistic");
        $client->scripts = [
            "220 mx.example.test ready\r\n250-mx.example.test\r\n" .
            "250 STARTTLS\r\n220 go ahead\r\n",
            "220 mx.example.test ready\r\n250 mx.example.test\r\n"];
        $this->assertFalse($client->startSession(),
            "the session does not open after a failed handshake");
        $this->assertEqual($client->opened, 1,
            "no second connection is opened");
        $error = $client->getLastError();
        $this->assertTrue(str_contains($error, "TLS handshake failed: "),
            "the reason is logged");
        $this->assertFalse(str_contains($error, "plaintext"),
            "nothing goes on in plaintext");
        $this->assertTrue(SmtpClient::isTemporaryFailure($error),
            "the message is tried again later rather than bounced");
    }
    /**
     * commandToClosedConnectionIsLoggedTestCase checks that smtpCommand
     * notes in the log a command written to a connection the server has
     * closed, rather than raising a notice. A failed TLS handshake leaves
     * the client writing to such a connection, and the notice showed up
     * in the suite's output when the suite ran under load.
     */
    public function commandToClosedConnectionIsLoggedTestCase()
    {
        [$client_end, $server_end] = stream_socket_pair(
            (strncasecmp(PHP_OS, "WIN", 3) === 0) ? STREAM_PF_INET :
            STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP);
        fclose($server_end);
        $client = new SmtpClient("alice@example.com", "mx.example.test",
            SmtpClient::SERVER_TO_SERVER_PORT, "", "", "opportunistic");
        $client->connection = $client_end;
        $client->smtpCommand("NOOP");
        fclose($client_end);
        $this->assertTrue(str_contains($client->transcript,
            "!!! the connection was closed"),
            "the command not sent is noted in the log");
    }
}

/**
 * ScriptedSmtpClient is an SmtpClient whose connections are one end of a
 * socket pair, the other end holding a server's replies written ahead,
 * so a session runs in this process with no server and no network.
 */
class ScriptedSmtpClient extends SmtpClient
{
    /**
     * $scripts holds the replies each connection, in turn, gives.
     * @var array
     */
    public $scripts = [];
    /**
     * $opened counts the connections the client asked for.
     * @var int
     */
    public $opened = 0;
    /**
     * openConnection gives the client one end of a socket pair, with the
     * next script written to the other end.
     *
     * @param string $address the address the client would connect to
     * @param int &$errno left as it is
     * @param string &$errstr left as it is
     * @param resource $context the encryption settings, not used
     * @return resource|bool the client's end, or false when no script
     *      is left
     */
    protected function openConnection($address, &$errno, &$errstr,
        $context)
    {
        $script = $this->scripts[$this->opened] ?? null;
        $this->opened++;
        if ($script === null) {
            return false;
        }
        [$client_end, $server_end] = stream_socket_pair(
            (strncasecmp(PHP_OS, "WIN", 3) === 0) ? STREAM_PF_INET :
            STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP);
        fwrite($server_end, $script);
        /* The server end is closed once its replies are written, so a
           TLS handshake the client starts reads the end of the
           connection and fails at once rather than waiting. */
        fclose($server_end);
        return $client_end;
    }
}