diff --git a/contrib/testing/README.md b/contrib/testing/README.md index 04d3a75..afe67fc 100644 --- a/contrib/testing/README.md +++ b/contrib/testing/README.md @@ -36,6 +36,11 @@ one-off files in `/tmp`: 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-8bitmime-check.py` requires `8BITMIME` after EHLO, rejects its use + after HELO and an invalid BODY value, then submits an ISO-8859-1 message + containing deliberately non-UTF-8 high octets and a dot-stuffed line. The + bytes following Bongo's added transport headers must match the submitted + message exactly in Store. - `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-8bitmime-check.py b/contrib/testing/smtp-8bitmime-check.py new file mode 100755 index 0000000..6cceb69 --- /dev/null +++ b/contrib/testing/smtp-8bitmime-check.py @@ -0,0 +1,166 @@ +#!/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 byte-exact inbound SMTP 8BITMIME delivery.""" + +from __future__ import annotations + +import importlib.util +import os +from pathlib import Path +import sys +import time + + +TOKEN = f"smtp13-{os.getpid()}-{int(time.time())}" +TOKEN_PREFIX = b"smtp13-" +TIMEOUT = float(os.environ.get("BONGO_TEST_TIMEOUT", "30")) +ALLOW_LIVE = os.environ.get("BONGO_ALLOW_LIVE_SMTP_TEST") == "1" + + +class SMTP13Error(RuntimeError): + """Raised when an 8BITMIME transaction changes message bytes.""" + + +def load_helpers(): + path = Path(__file__).with_name("smtp-pipelining-check.py") + specification = importlib.util.spec_from_file_location( + "bongo_smtp13_helpers", path + ) + if specification is None or specification.loader is None: + raise SMTP13Error(f"cannot load SMTP test helpers from {path}") + module = importlib.util.module_from_spec(specification) + sys.modules[specification.name] = module + specification.loader.exec_module(module) + module.TOKEN_PREFIX = TOKEN_PREFIX + return module + + +HELPERS = load_helpers() + + +def desired_message() -> bytes: + headers = ( + f"From: SMTP-13 \r\n" + f"To: {HELPERS.USER}@{HELPERS.DOMAIN}\r\n" + f"Subject: SMTP-13 byte preservation {TOKEN}\r\n" + f"Message-ID: <{TOKEN}@{HELPERS.DOMAIN}>\r\n" + f"X-Bongo-Test: {TOKEN}\r\n" + "MIME-Version: 1.0\r\n" + "Content-Type: text/plain; charset=iso-8859-1\r\n" + "Content-Transfer-Encoding: 8bit\r\n" + "\r\n" + ).encode("ascii") + body = ( + b"Gr\xfc\xdfe aus dem 8-Bit-Test.\r\n" + b".Diese Zeile beginnt mit einem Punkt.\r\n" + b"Alle hohen Oktette bleiben erhalten: \x80\xa0\xc4\xe4\xfe\xff\r\n" + ) + return headers + body + + +def dot_stuff(message: bytes) -> bytes: + if message.startswith(b"."): + message = b"." + message + return message.replace(b"\r\n.", b"\r\n..") + + +def wait_for_message(store) -> tuple[str, bytes]: + deadline = time.monotonic() + TIMEOUT + while time.monotonic() < deadline: + identifiers = HELPERS.store_token_documents(store) + if len(identifiers) > 1: + raise SMTP13Error( + f"8BITMIME message delivered more than once: {identifiers!r}" + ) + if identifiers: + return identifiers[0], store.Read(identifiers[0]) + time.sleep(0.25) + raise SMTP13Error("8BITMIME message did not reach the Store") + + +def main() -> int: + if not ALLOW_LIVE: + raise SMTP13Error( + "set BONGO_ALLOW_LIVE_SMTP_TEST=1 for the live delivery test" + ) + if not HELPERS.PASSWORD: + raise SMTP13Error("BONGO_TEST_PASSWORD is required") + + expected = desired_message() + store = HELPERS.open_store() + try: + HELPERS.cleanup(store) + client = HELPERS.SMTPConnection() + try: + client.require(220, "greeting") + client.command("HELO smtp13-client.example", 250) + client.command( + "MAIL FROM: BODY=8BITMIME", 555 + ) + + capabilities = client.command( + "EHLO smtp13-client.example", 250 + ) + if b"8BITMIME" not in capabilities: + raise SMTP13Error( + f"EHLO did not advertise 8BITMIME: {capabilities!r}" + ) + client.command( + "MAIL FROM: BODY=8BIT", 555 + ) + client.command( + "MAIL FROM: BODY=8BITMIME", 250 + ) + client.command( + f"RCPT TO:<{HELPERS.USER}@{HELPERS.DOMAIN}>", 250 + ) + client.command("DATA", 354) + client.socket.sendall(dot_stuff(expected) + b".\r\n") + client.require(250, "8BITMIME DATA completion") + client.command("QUIT", 221) + finally: + client.close() + + identifier, stored = wait_for_message(store) + if not stored.endswith(expected): + common = 0 + maximum = min(len(stored), len(expected)) + while common < maximum and stored[-maximum + common] == expected[common]: + common += 1 + raise SMTP13Error( + "stored message does not end in the exact submitted bytes " + f"(stored={len(stored)}, submitted={len(expected)}, " + f"matching-prefix={common})" + ) + if b"\x80\xa0\xc4\xe4\xfe\xff\r\n" not in stored: + raise SMTP13Error("stored message omitted high-bit octets") + if b"\r\n.Diese Zeile" not in stored: + raise SMTP13Error("SMTP dot transparency changed the leading dot") + + store.Delete(identifier) + HELPERS.cleanup(store) + if HELPERS.store_token_documents(store) or HELPERS.queue_token_ids(): + raise SMTP13Error("test content remains after cleanup") + finally: + try: + HELPERS.cleanup(store) + finally: + store.Quit() + + print( + "SMTP-13 PASS advertised=yes helo-parameter=555 " + "invalid-body=555 body-8bitmime=250 high-octets=exact " + "dot-transparency=exact duplicate=no queue-clean=yes store-clean=yes" + ) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (OSError, SMTP13Error, ValueError) as error: + print(f"SMTP-13 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 178cfcf..e994f01 100644 --- a/docs/release-testing-0.7.md +++ b/docs/release-testing-0.7.md @@ -140,7 +140,7 @@ inputs and outputs is absent from the ZIP. | 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 | [PASS](test-evidence/0.7-r1.md#smtp-12) | | | -| SMTP-13 | `8BITMIME` remains byte-correct | | | | +| SMTP-13 | `8BITMIME` remains byte-correct | [PASS](test-evidence/0.7-r1.md#smtp-13) | | | | SMTP-14 | DSN `RET`, `ENVID`, `NOTIFY`, and `ORCPT` cases | | | | | SMTP-15 | `CHUNKING`/bounded `BDAT`, including zero/last and malformed chunks | | | | | SMTP-16 | `BINARYMIME` remains unadvertised and rejected | | | | diff --git a/docs/test-evidence/0.7-r1.md b/docs/test-evidence/0.7-r1.md index 820de34..c3674a6 100644 --- a/docs/test-evidence/0.7-r1.md +++ b/docs/test-evidence/0.7-r1.md @@ -2603,6 +2603,42 @@ 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-13 + +Result: **PASS** + +The reusable `smtp-8bitmime-check.py` first used classic HELO and verified +that the ESMTP-only `BODY=8BITMIME` parameter received `555`. After EHLO it +required the advertised `8BITMIME` capability, rejected the invalid +`BODY=8BIT` value with `555`, and accepted `BODY=8BITMIME`. + +The test message used ASCII headers declaring `charset=iso-8859-1` and +`Content-Transfer-Encoding: 8bit`. Its body deliberately contained the raw +octet sequence `80 a0 c4 e4 fe ff`, including bytes which are not a valid +standalone UTF-8 sequence. Another body line began with a dot and was +dot-stuffed only on the SMTP wire. + +After local Queue-to-Store delivery, the fixture allowed Bongo's expected +transport headers before the submitted message but required every submitted +byte from `From:` through the final CRLF to be an exact suffix of the stored +object. The high-octet sequence was unchanged and dot transparency produced +exactly one leading dot. + +```sh +export BONGO_ALLOW_LIVE_SMTP_TEST=1 +export BONGO_TEST_PASSWORD='the-disposable-test-password' +./contrib/testing/smtp-8bitmime-check.py +``` + +The installed `d782d593` debug build reported on 2026-07-27: + +```text +SMTP-13 PASS advertised=yes helo-parameter=555 invalid-body=555 body-8bitmime=250 high-octets=exact dot-transparency=exact duplicate=no queue-clean=yes store-clean=yes +``` + +No configuration or service state changed. The delivered Store object and +all uniquely marked interrupted-run Queue or Store fixtures were removed. + ## SMTP-20 Result: **PASS**