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