<?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");
}
}