/ 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\library\mail\DkimKey;
use seekquarry\yioop\library\mail\MimeMessage;
use seekquarry\yioop\library\mail\SmtpClient;
use seekquarry\yioop\library\UnitTest;

/**
 * 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.
     *
     * @return bool always false
     */
    public function startSession()
    {
        $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;
    }
}
/**
 * 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;
    /**
     * No setUp needed; the helpers under test are pure statics and
     * the DKIM cases set up their own throwaway key directory.
     */
    public function setUp()
    {
    }
    /**
     * Removes the throwaway DKIM key and directory a case may have
     * created, and restores the default key location, so one case
     * cannot leave a key behind that another case would pick up.
     */
    public function tearDown()
    {
        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");
    }
    /**
     * When a send runs inside a fiber, sendImmediate hands the whole
     * exchange to a worker process and suspends on the worker's pipe
     * rather than blocking the loop; once the worker answers, its result
     * (here a refused connection, so a failed send with the connect
     * message) is carried back onto the client. A port nothing listens on
     * is used so the worker's connect is refused at once instead of
     * waiting out the connect timeout. This case spawns a helper process,
     * so it takes longer than the in-memory cases.
     */
    public function cooperativeSendOffloadTestCase()
    {
        $probe_errno = 0;
        $probe_error = "";
        $probe = stream_socket_server("tcp://127.0.0.1:0",
            $probe_errno, $probe_error);
        $probe_name = stream_socket_get_name($probe, false);
        $closed_port = (int) substr($probe_name,
            strrpos($probe_name, ":") + 1);
        fclose($probe);
        $client = new SmtpClient("alice@example.com", "127.0.0.1",
            $closed_port, "", "");
        $suspended = false;
        $fiber = new \Fiber(function () use ($client) {
            return $client->sendImmediate("Subject", "alice@example.com",
                "bob@example.com", "Body text");
        });
        $waited = $fiber->start();
        while ($fiber->isSuspended()) {
            if (is_array($waited) && isset($waited["read"])) {
                $suspended = true;
            }
            $waited = $fiber->resume();
        }
        $this->assertTrue($suspended,
            "the send suspends on the worker pipe instead of blocking");
        $this->assertTrue($fiber->getReturn() === false,
            "a refused connection comes back as a failed send");
        $this->assertTrue(
            stripos($client->getLastError(), "connect") !== false,
            "the worker's failure message is carried back to the client");
    }
}
X