/ 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;

/**
 * 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;
        }
    }
    /**
     * 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");
    }
    /**
     * dkimSign should prepend a DKIM-Signature header to a built
     * message and leave the original message intact below it, and
     * the signature it adds should verify against the matching
     * public key the way a receiving mail system would check it.
     * This is the signing that registration, group-notification,
     * and bulk mail now get on the relay path, the same as mail
     * delivered directly.
     */
    public function dkimSignAddsVerifiableSignatureTestCase()
    {
        $this->temp_dir = sys_get_temp_dir() . "/smtp_dkim_test_" .
            getmypid() . "_" . uniqid();
        DkimKey::setSecurityDir($this->temp_dir);
        DkimKey::ensureKeyPair();
        $built = MimeMessage::build("Alice <alice@example.com>",
            "bob@example.net", "Greetings", "Body text goes here.");
        $signed = SmtpClient::dkimSign($built);
        $this->assertTrue(strpos($signed, "DKIM-Signature: ") === 0,
            "dkimSign prepends a DKIM-Signature header");
        $this->assertTrue(strpos($signed, $built) !== false,
            "the built message is preserved below the signature");
        $public_pem = DkimKey::publicKeyPem(DkimKey::publicKeyBase64());
        $this->assertEqual(DkimKey::VERIFY_PASS,
            DkimKey::verifyWithPublicKey($signed, $public_pem),
            "the signature dkimSign added verifies for a receiver");
    }
    /**
     * dkimSign should return the message unchanged when no signing
     * key has been set up, so an instance that has not configured
     * DKIM still sends mail normally rather than having delivery
     * blocked.
     */
    public function dkimSignWithoutKeyIsNoOpTestCase()
    {
        $this->temp_dir = sys_get_temp_dir() . "/smtp_dkim_nokey_" .
            getmypid() . "_" . uniqid();
        DkimKey::setSecurityDir($this->temp_dir);
        $built = MimeMessage::build("Alice <alice@example.com>",
            "bob@example.net", "Greetings", "Body text goes here.");
        $signed = SmtpClient::dkimSign($built);
        $this->assertEqual($built, $signed,
            "with no signing key, dkimSign leaves the message alone");
    }
    /**
     * 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