122 lines
4.0 KiB
PHP
122 lines
4.0 KiB
PHP
<?php
|
|
|
|
/**
|
|
* A one-shot ESMTP server, run as its own OS process (see FakeSmtpServer for
|
|
* why a subprocess and not an in-process fake is necessary here).
|
|
*
|
|
* Configuration arrives as one JSON object on STDIN: {"failAt": string|null,
|
|
* "failResponse": string, "advertiseAuth": bool}. failAt names the command
|
|
* after which the server answers with failResponse instead of success —
|
|
* 'mail', 'rcpt', or 'data'. null completes a normal, successful send.
|
|
* ('ehlo' is deliberately not a supported stage: EsmtpTransport retries a
|
|
* failed EHLO as plain HELO before giving up, so a clean single-rejection
|
|
* test needs a later stage.)
|
|
*
|
|
* Binds an ephemeral port on 127.0.0.1, prints it as the first line of
|
|
* STDOUT so the parent process can connect, accepts exactly one connection,
|
|
* and plays the minimal EHLO/MAIL FROM/RCPT TO/DATA dialogue. STARTTLS is
|
|
* never advertised in the EHLO response, so EsmtpTransport — auto_tls
|
|
* defaults true, but only upgrades when the server actually offers
|
|
* STARTTLS — never attempts it, and the whole exchange stays in plain text.
|
|
*
|
|
* AUTH is advertised only when advertiseAuth is true (see
|
|
* FakeSmtpServer::succeedingUnlessAuthAttempted()) — the base configuration
|
|
* never offers it, so EsmtpTransport::handleAuth() is never even reachable
|
|
* against it regardless of what credentials a client might have set.
|
|
*/
|
|
$config = json_decode((string) stream_get_contents(STDIN), true) ?: [];
|
|
$failAt = $config['failAt'] ?? null;
|
|
$failResponse = $config['failResponse'] ?? '550 Rejected';
|
|
$advertiseAuth = $config['advertiseAuth'] ?? false;
|
|
|
|
$server = stream_socket_server('tcp://127.0.0.1:0', $errno, $errstr);
|
|
|
|
if ($server === false) {
|
|
fwrite(STDERR, "bind failed: {$errstr}\n");
|
|
exit(1);
|
|
}
|
|
|
|
$name = stream_socket_get_name($server, false);
|
|
$port = (int) substr($name, strrpos($name, ':') + 1);
|
|
|
|
echo $port, "\n";
|
|
flush();
|
|
|
|
$conn = @stream_socket_accept($server, 10);
|
|
|
|
if ($conn === false) {
|
|
fwrite(STDERR, "accept timed out\n");
|
|
exit(1);
|
|
}
|
|
|
|
// Not readLine()/respond(): PHP function names are case-insensitive, and
|
|
// readLine collides with the readline extension's built-in readline(),
|
|
// fataling with "Cannot redeclare readline()" the moment this file loads.
|
|
function smtpReadLine($conn): string
|
|
{
|
|
return (string) fgets($conn, 8192);
|
|
}
|
|
|
|
function smtpRespond($conn, string $line): void
|
|
{
|
|
fwrite($conn, $line."\r\n");
|
|
}
|
|
|
|
smtpRespond($conn, '220 fake.smtp ESMTP');
|
|
|
|
smtpReadLine($conn); // EHLO
|
|
if ($advertiseAuth) {
|
|
// Multi-line EHLO: smtpRespond() only ever sends the final line, so the
|
|
// continuation is written directly.
|
|
fwrite($conn, "250-fake.smtp\r\n");
|
|
smtpRespond($conn, '250 AUTH LOGIN');
|
|
} else {
|
|
smtpRespond($conn, '250 fake.smtp');
|
|
}
|
|
|
|
// AUTH, if the client attempts it, arrives here in place of MAIL FROM —
|
|
// immediately after EHLO and before the send dialogue proper, exactly where
|
|
// EsmtpTransport::handleAuth() sends it.
|
|
$line = smtpReadLine($conn);
|
|
if ($advertiseAuth && str_starts_with(strtoupper(ltrim($line)), 'AUTH')) {
|
|
smtpRespond($conn, '535 5.7.8 Authentication attempted but this relay does not require it');
|
|
exit(0);
|
|
}
|
|
|
|
// $line is MAIL FROM's own line when AUTH was not attempted.
|
|
if ($failAt === 'mail') {
|
|
smtpRespond($conn, $failResponse);
|
|
exit(0);
|
|
}
|
|
smtpRespond($conn, '250 OK');
|
|
|
|
smtpReadLine($conn); // RCPT TO
|
|
if ($failAt === 'rcpt') {
|
|
smtpRespond($conn, $failResponse);
|
|
exit(0);
|
|
}
|
|
smtpRespond($conn, '250 OK');
|
|
|
|
smtpReadLine($conn); // DATA
|
|
if ($failAt === 'data') {
|
|
smtpRespond($conn, $failResponse);
|
|
exit(0);
|
|
}
|
|
smtpRespond($conn, '354 Go ahead');
|
|
|
|
// Message body: read until the bare "." terminator line.
|
|
while (($line = smtpReadLine($conn)) !== '' && rtrim($line, "\r\n") !== '.') {
|
|
// discard
|
|
}
|
|
smtpRespond($conn, '250 OK: queued as FAKE123');
|
|
|
|
// SmtpTransport::__destruct() calls stop(), which sends QUIT — but only
|
|
// after the send this helper exists for has already returned, so answering
|
|
// it is a courtesy, not a requirement.
|
|
stream_set_timeout($conn, 2);
|
|
smtpReadLine($conn);
|
|
smtpRespond($conn, '221 Bye');
|
|
|
|
fclose($conn);
|
|
fclose($server);
|