diff --git a/contrib/testing/README.md b/contrib/testing/README.md index ba529ff..04d3a75 100644 --- a/contrib/testing/README.md +++ b/contrib/testing/README.md @@ -30,6 +30,12 @@ one-off files in `/tmp`: the Queue tests. Both a predeclared size and a `MAIL FROM` without `SIZE` must receive `452 4.3.1`. Its trap restores the original SMTP document, spool mount, service, Queue, and Store state. +- `smtp-pipelining-check.py` sends complete command groups without waiting + between commands. It requires ordered replies for ordinary commands, + rejected and accepted recipients in one transaction, `DATA`, an unknown + command, and commands already buffered after the message terminator. The + accepted message must reach Store exactly once and all marked fixtures are + removed afterward. - `smtp-submission-auth-check.py` exercises port 587 STARTTLS and port 465 implicit TLS directly, including the shared GSASL PLAIN and LOGIN mechanisms, pre-TLS AUTH rejection, invalid credentials, and repeated diff --git a/contrib/testing/smtp-pipelining-check.py b/contrib/testing/smtp-pipelining-check.py new file mode 100755 index 0000000..0734f01 --- /dev/null +++ b/contrib/testing/smtp-pipelining-check.py @@ -0,0 +1,322 @@ +#!/usr/bin/env python3 +# This program is free software, licensed under the terms of the GNU GPL. +# See the Bongo COPYING file for full details. +# Copyright (c) 2026 Bongo Project contributors + +"""Verify SMTP PIPELINING response order and transaction recovery.""" + +from __future__ import annotations + +import os +import re +import socket +import subprocess +import time + +from bongo.store.StoreClient import StoreClient + + +HOST = os.environ.get("BONGO_TEST_HOST", "127.0.0.1") +PORT = int(os.environ.get("BONGO_TEST_SMTP_PORT", "25")) +STORE_PORT = int(os.environ.get("BONGO_TEST_STORE_PORT", "689")) +QUEUE_TOOL = os.environ.get( + "BONGO_TEST_QUEUE_TOOL", "/usr/bin/bongo-queuetool" +) +USER = os.environ.get("BONGO_TEST_USER", "test1") +DOMAIN = os.environ.get("BONGO_TEST_DOMAIN", "bongo.test") +PASSWORD = os.environ.get("BONGO_TEST_PASSWORD", "") +TIMEOUT = float(os.environ.get("BONGO_TEST_TIMEOUT", "30")) +ALLOW_LIVE = os.environ.get("BONGO_ALLOW_LIVE_SMTP_TEST") == "1" +TOKEN = f"smtp12-{os.getpid()}-{int(time.time())}" +TOKEN_PREFIX = b"smtp12-" +RESPONSE = re.compile(rb"^([0-9]{3})([- ])(.*)\r\n$") +QUEUE_ID = re.compile(r"^[0-9A-Fa-f]{3}-[0-9A-Fa-f]+$") + + +class SMTP12Error(RuntimeError): + """Raised when a pipelined SMTP session loses ordering or state.""" + + +def run_queue( + *arguments: str, binary: bool = False, check: bool = True +) -> subprocess.CompletedProcess: + completed = subprocess.run( + [ + "sudo", + "-n", + "-u", + "bongo", + QUEUE_TOOL, + *arguments, + ], + capture_output=True, + text=not binary, + check=False, + ) + if check and completed.returncode: + stdout = completed.stdout + stderr = completed.stderr + if binary: + stdout = stdout.decode("utf-8", "replace") + stderr = stderr.decode("utf-8", "replace") + raise SMTP12Error( + f"{QUEUE_TOOL} {' '.join(arguments)} failed: " + f"{stderr.strip() or stdout.strip()}" + ) + return completed + + +class SMTPConnection: + def __init__(self) -> None: + self.socket = socket.create_connection((HOST, PORT), timeout=TIMEOUT) + self.socket.settimeout(TIMEOUT) + self.reader = self.socket.makefile("rb") + + def close(self) -> None: + try: + self.reader.close() + finally: + self.socket.close() + + def response(self) -> tuple[int, list[bytes]]: + lines: list[bytes] = [] + response_code: int | None = None + while True: + line = self.reader.readline(8194) + if not line: + raise SMTP12Error("connection closed while awaiting response") + if len(line) > 8192: + raise SMTP12Error("SMTP response line exceeded 8192 bytes") + match = RESPONSE.match(line) + if match is None: + raise SMTP12Error(f"malformed SMTP response: {line!r}") + code = int(match.group(1)) + if response_code is None: + response_code = code + elif code != response_code: + raise SMTP12Error( + f"multiline response changed status: {line!r}" + ) + lines.append(match.group(3)) + if match.group(2) == b" ": + return code, lines + + def require(self, expected: int, context: str) -> list[bytes]: + code, lines = self.response() + if code != expected: + raise SMTP12Error( + f"{context}: expected SMTP {expected}, got {code}: {lines!r}" + ) + return lines + + def command(self, command: str, expected: int) -> list[bytes]: + self.socket.sendall(command.encode("ascii") + b"\r\n") + return self.require(expected, command) + + def pipeline( + self, + commands: list[str], + expected: list[int], + context: str, + ) -> list[list[bytes]]: + if len(commands) != len(expected): + raise SMTP12Error("internal pipeline expectation mismatch") + wire = "".join(f"{command}\r\n" for command in commands) + self.socket.sendall(wire.encode("ascii")) + return [ + self.require(code, f"{context} response {index + 1}") + for index, code in enumerate(expected) + ] + + +def queue_token_ids() -> list[str]: + matches: list[str] = [] + for line in run_queue("list").stdout.splitlines(): + fields = line.split() + if not fields or QUEUE_ID.fullmatch(fields[0]) is None: + continue + message = run_queue( + "message", fields[0], binary=True, check=False + ) + if message.returncode == 0 and TOKEN_PREFIX in message.stdout: + matches.append(fields[0]) + return matches + + +def open_store() -> StoreClient: + return StoreClient( + USER, + USER, + authPassword=PASSWORD, + host=HOST, + port=STORE_PORT, + ) + + +def store_token_documents(store: StoreClient) -> list[str]: + identifiers = [entry.uid for entry in store.List("/mail/INBOX")] + matches: list[str] = [] + for identifier in identifiers: + try: + data = store.Read(identifier) + except Exception: + continue + if TOKEN_PREFIX in data: + matches.append(identifier) + return matches + + +def cleanup(store: StoreClient) -> None: + deadline = time.monotonic() + 20 + quiet_since: float | None = None + while time.monotonic() < deadline: + found = False + for document in store_token_documents(store): + found = True + store.Delete(document) + for queue_id in queue_token_ids(): + found = True + run_queue("delete", queue_id, check=False) + if found: + quiet_since = None + elif quiet_since is None: + quiet_since = time.monotonic() + elif time.monotonic() - quiet_since >= 1: + return + time.sleep(0.25) + + +def wait_for_delivery(store: StoreClient) -> str: + deadline = time.monotonic() + TIMEOUT + while time.monotonic() < deadline: + documents = store_token_documents(store) + if len(documents) > 1: + raise SMTP12Error( + f"pipelined message delivered more than once: {documents!r}" + ) + if documents: + return documents[0] + time.sleep(0.25) + raise SMTP12Error("pipelined message did not reach the Store") + + +def message() -> bytes: + return ( + f"From: SMTP-12 \r\n" + f"To: {USER}@{DOMAIN}\r\n" + f"Subject: SMTP-12 pipelining {TOKEN}\r\n" + f"Message-ID: <{TOKEN}@{DOMAIN}>\r\n" + f"X-Bongo-Test: {TOKEN}\r\n" + "\r\n" + f"Pipelined delivery fixture {TOKEN}.\r\n" + ).encode("ascii") + + +def main() -> int: + if not ALLOW_LIVE: + raise SMTP12Error( + "set BONGO_ALLOW_LIVE_SMTP_TEST=1 for the live delivery test" + ) + if not PASSWORD: + raise SMTP12Error("BONGO_TEST_PASSWORD is required") + + store = open_store() + try: + cleanup(store) + client = SMTPConnection() + try: + client.require(220, "greeting") + capabilities = client.command( + "EHLO smtp12-client.example", 250 + ) + if b"PIPELINING" not in capabilities: + raise SMTP12Error( + f"EHLO did not advertise PIPELINING: {capabilities!r}" + ) + + client.pipeline( + [ + "MAIL FROM:", + f"RCPT TO:<{USER}@{DOMAIN}>", + "RSET", + "NOOP", + ], + [250, 250, 250, 250], + "ordinary", + ) + + client.pipeline( + [ + "MAIL FROM:", + "RCPT TO:", + "DATA", + "RSET", + "NOOP", + ], + [250, 554, 503, 250, 250], + "rejected recipient", + ) + + client.pipeline( + [ + "MAIL FROM:", + "BOGUS", + f"RCPT TO:<{USER}@{DOMAIN}>", + "RSET", + ], + [250, 500, 250, 250], + "unknown command", + ) + + client.pipeline( + [ + "MAIL FROM:", + "RCPT TO:", + f"RCPT TO:<{USER}@{DOMAIN}>", + "DATA", + ], + [250, 554, 250, 354], + "mixed recipients and DATA", + ) + client.socket.sendall( + message() + + b".\r\n" + + b"NOOP\r\nRSET\r\nQUIT\r\n" + ) + client.require(250, "message completion") + client.require(250, "post-DATA pipelined NOOP") + client.require(250, "post-DATA pipelined RSET") + client.require(221, "post-DATA pipelined QUIT") + client.socket.settimeout(2) + if client.reader.read(1) != b"": + raise SMTP12Error("server did not close after pipelined QUIT") + finally: + client.close() + + document = wait_for_delivery(store) + store.Delete(document) + cleanup(store) + if store_token_documents(store) or queue_token_ids(): + raise SMTP12Error("test content remains after cleanup") + finally: + try: + cleanup(store) + finally: + store.Quit() + + print( + "SMTP-12 PASS advertised=yes ordinary-order=250/250/250/250 " + "rejection-order=250/554/503/250/250 " + "unknown-command=500/recovered mixed-recipients=554/250 " + "data-sync=354 post-data=250/250/250/221 " + "duplicate=no queue-clean=yes store-clean=yes" + ) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (OSError, SMTP12Error, ValueError) as error: + print(f"SMTP-12 FAIL: {type(error).__name__}: {error}") + raise SystemExit(1) diff --git a/docs/release-testing-0.7.md b/docs/release-testing-0.7.md index 50dcd94..178cfcf 100644 --- a/docs/release-testing-0.7.md +++ b/docs/release-testing-0.7.md @@ -139,7 +139,7 @@ inputs and outputs is absent from the ZIP. | SMTP-09 | Trusted port-26/Mailman-style return receives DKIM through relayhost hostname, port, TLS, optional authentication, failure, and recovery | [PASS](test-evidence/0.7-r1.md#smtp-09) | | | | SMTP-10 | Open-relay tests reject unauthenticated/untrusted third-party relay | [PASS](test-evidence/0.7-r1.md#smtp-10) | | | | SMTP-11 | `SIZE` exact limit, over-limit, absent size, and disk-space responses | [PASS](test-evidence/0.7-r1.md#smtp-11) | | | -| SMTP-12 | `PIPELINING` ordering and error recovery | | | | +| SMTP-12 | `PIPELINING` ordering and error recovery | [PASS](test-evidence/0.7-r1.md#smtp-12) | | | | SMTP-13 | `8BITMIME` remains byte-correct | | | | | SMTP-14 | DSN `RET`, `ENVID`, `NOTIFY`, and `ORCPT` cases | | | | | SMTP-15 | `CHUNKING`/bounded `BDAT`, including zero/last and malformed chunks | | | | diff --git a/docs/test-evidence/0.7-r1.md b/docs/test-evidence/0.7-r1.md index 56a686c..820de34 100644 --- a/docs/test-evidence/0.7-r1.md +++ b/docs/test-evidence/0.7-r1.md @@ -2563,6 +2563,46 @@ found no mount over `/var/lib/bongo/spool`, an empty Queue, active zero. The exact-limit Store object and the interrupted first-run fixture were also removed. +## SMTP-12 + +Result: **PASS** + +The reusable `smtp-pipelining-check.py` connected without authentication to +the installed port-25 listener and required EHLO to advertise `PIPELINING`. +It sent each command group in one socket write and only then read replies in +the original command order. + +The ordinary group returned four ordered `250` replies for `MAIL`, local +`RCPT`, `RSET`, and `NOOP`. A second group returned +`250/554/503/250/250` for `MAIL`, a denied remote recipient, `DATA` without +an accepted recipient, `RSET`, and `NOOP`. An unknown command between a +valid `MAIL` and local `RCPT` returned `500` without corrupting the active +transaction. + +The delivery group deliberately mixed a rejected remote recipient and an +accepted local recipient before `DATA`, producing `250/554/250/354`. Only +after receiving the required `354` synchronization response did the client +send the message. The complete DATA body, terminator, `NOOP`, `RSET`, and +`QUIT` were then sent in one write. Bongo returned +`250/250/250/221` in order, closed after `QUIT`, and delivered the marked +message exactly once. + +```sh +export BONGO_ALLOW_LIVE_SMTP_TEST=1 +export BONGO_TEST_PASSWORD='the-disposable-test-password' +./contrib/testing/smtp-pipelining-check.py +``` + +The installed `d782d593` debug build reported on 2026-07-27: + +```text +SMTP-12 PASS advertised=yes ordinary-order=250/250/250/250 rejection-order=250/554/503/250/250 unknown-command=500/recovered mixed-recipients=554/250 data-sync=354 post-data=250/250/250/221 duplicate=no queue-clean=yes store-clean=yes +``` + +No configuration or service state changed. The fixture removed the delivered +Store object and any uniquely marked interrupted-run entries; the final Queue +and Store checks were empty. + ## SMTP-20 Result: **PASS**