*/ private array $pipes; public readonly int $port; private function __construct($process, array $pipes, int $port) { $this->process = $process; $this->pipes = $pipes; $this->port = $port; } /** Accepts one connection and completes a full, successful send. */ public static function succeeding(): self { return self::spawn(['failAt' => null]); } /** * Accepts one connection, advertises AUTH LOGIN in its EHLO response, * and completes a full successful send — UNLESS the client attempts to * authenticate, which is rejected outright. * * The point is proving a client deliberately did NOT attempt AUTH, not * merely that it could succeed without it: succeeding() never advertises * AUTH at all, so Symfony's EsmtpTransport::handleAuth() is never even * reachable against it — a mailbox that wrongly called setUsername() * with a real value would pass every test that uses succeeding() too. * Advertising the capability here is what makes the two cases tell * apart: "chose not to authenticate" against a server that offered it, * versus "had no opportunity to" against one that never did. */ public static function succeedingUnlessAuthAttempted(): self { return self::spawn(['failAt' => null, 'advertiseAuth' => true]); } /** * Accepts one connection and rejects it with a real SMTP response at the * given stage. 'rcpt' is the simplest choice for a caller that just wants * *a* rejection: MAIL FROM has already succeeded by then, so the failure * is unambiguously the server's own — 'ehlo' is deliberately unsupported * because EsmtpTransport retries a failed EHLO as plain HELO rather than * giving up, which this one-shot server does not simulate. */ public static function rejectingAt(string $stage, string $response): self { return self::spawn(['failAt' => $stage, 'failResponse' => $response]); } private static function spawn(array $config): self { $process = proc_open( [PHP_BINARY, __DIR__.'/fake_smtp_server_process.php'], [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, ); if (! is_resource($process)) { throw new RuntimeException('Could not start the fake SMTP server subprocess.'); } fwrite($pipes[0], json_encode($config)); fclose($pipes[0]); // A bound port comes back almost instantly; this timeout only // matters if the subprocess never starts at all, so the test suite // fails fast instead of hanging. stream_set_timeout($pipes[1], 5); $port = (int) trim((string) fgets($pipes[1])); if ($port <= 0) { $stderr = stream_get_contents($pipes[2]); proc_terminate($process); proc_close($process); throw new RuntimeException("Fake SMTP server failed to bind a port. stderr: {$stderr}"); } return new self($process, $pipes, $port); } public function stop(): void { foreach ($this->pipes as $pipe) { if (is_resource($pipe)) { fclose($pipe); } } proc_terminate($this->process); proc_close($this->process); } }