Files
bongo/contrib/testing/protocol-smoke.py
2026-07-22 16:40:52 +02:00

208 lines
7.1 KiB
Python
Executable File

#!/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
"""Exercise a local Bongo SMTP/IMAP/POP TLS delivery path."""
from __future__ import annotations
import argparse
from email.message import EmailMessage
import imaplib
import os
import poplib
import smtplib
import socket
import ssl
import sys
import time
import urllib.request
def environment(name: str, default: str = "") -> str:
return os.environ.get(name, default)
HOST = environment("BONGO_TEST_HOST", "127.0.0.1")
USER1 = environment("BONGO_TEST_USER1", "test1")
USER2 = environment("BONGO_TEST_USER2", "test2")
DOMAIN = environment("BONGO_TEST_DOMAIN", "bongo.test")
PASSWORD = environment("BONGO_TEST_PASSWORD")
PORTS = {
"pop3": int(environment("BONGO_TEST_POP3_PORT", "110")),
"imap": int(environment("BONGO_TEST_IMAP_PORT", "143")),
"smtps": int(environment("BONGO_TEST_SMTPS_PORT", "465")),
"submission": int(environment("BONGO_TEST_SUBMISSION_PORT", "587")),
"sieve": int(environment("BONGO_TEST_SIEVE_PORT", "689")),
"imaps": int(environment("BONGO_TEST_IMAPS_PORT", "993")),
"pop3s": int(environment("BONGO_TEST_POP3S_PORT", "995")),
}
TOKEN = environment("BONGO_TEST_TOKEN", f"bongo-smoke-{int(time.time())}")
TLS = ssl.create_default_context()
TLS.check_hostname = False
TLS.verify_mode = ssl.CERT_NONE
def wait_for_listeners(timeout: float = 30.0) -> str:
deadline = time.monotonic() + timeout
pending = set(PORTS.values())
while pending and time.monotonic() < deadline:
for port in tuple(pending):
try:
with socket.create_connection((HOST, port), timeout=0.25):
pending.remove(port)
except OSError:
pass
if pending:
time.sleep(0.25)
if pending:
raise RuntimeError(f"listeners did not become ready: {sorted(pending)}")
return "all protocol listeners accepted a connection"
def report(name, operation) -> bool:
try:
detail = operation()
print(f"PASS {name}: {detail}")
return True
except Exception as error: # integration test: report every failed leg
print(f"FAIL {name}: {type(error).__name__}: {error}")
return False
def web() -> str:
url = environment("BONGO_TEST_WEB_URL")
request = urllib.request.Request(url, method="GET")
with urllib.request.urlopen(request, timeout=10) as response:
return f"HTTP {response.status}, {response.geturl()}"
def smtp_submit() -> str:
message = EmailMessage()
message["From"] = f"{USER1}@{DOMAIN}"
message["To"] = f"{USER2}@{DOMAIN}"
message["Subject"] = TOKEN
message.set_content(f"Bongo local protocol smoke test {TOKEN}\n")
with smtplib.SMTP(HOST, PORTS["submission"], timeout=15) as client:
code, _ = client.ehlo()
if code != 250 or "starttls" not in client.esmtp_features:
raise RuntimeError("submission does not advertise STARTTLS")
client.starttls(context=TLS)
client.ehlo()
client.login(USER1, PASSWORD)
refused = client.send_message(message)
if refused:
raise RuntimeError(f"recipients refused: {refused}")
return TOKEN
def smtp_implicit() -> str:
with smtplib.SMTP_SSL(HOST, PORTS["smtps"], timeout=15,
context=TLS) as client:
code, _ = client.ehlo()
if code != 250:
raise RuntimeError(f"EHLO returned {code}")
client.login(USER1, PASSWORD)
return "TLS and authentication accepted"
def wait_for_imap() -> str:
deadline = time.monotonic() + 20
last = None
while time.monotonic() < deadline:
try:
with imaplib.IMAP4_SSL(HOST, PORTS["imaps"], ssl_context=TLS,
timeout=10) as client:
client.login(USER2, PASSWORD)
status, _ = client.select("INBOX", readonly=True)
if status != "OK":
raise RuntimeError("cannot select INBOX")
status, data = client.search(None, "SUBJECT", f'"{TOKEN}"')
if status == "OK" and data and data[0].split():
identifier = data[0].split()[-1]
status, _ = client.fetch(
identifier, "(BODY.PEEK[HEADER.FIELDS (SUBJECT)])")
if status != "OK":
raise RuntimeError("message fetch failed")
return f"message {identifier.decode()} found"
last = data
except Exception as error: # delivery can take a few seconds
last = error
time.sleep(1)
raise RuntimeError(f"message did not arrive: {last}")
def imap_starttls() -> str:
with imaplib.IMAP4(HOST, PORTS["imap"], timeout=15) as client:
capabilities = set(client.capabilities)
if "STARTTLS" not in capabilities and b"STARTTLS" not in capabilities:
raise RuntimeError(f"STARTTLS not advertised: {capabilities!r}")
client.starttls(ssl_context=TLS)
client.login(USER2, PASSWORD)
status, data = client.select("INBOX", readonly=True)
if status != "OK":
raise RuntimeError("cannot select INBOX")
return f"INBOX contains {data[0].decode()} message(s)"
def pop3_starttls() -> str:
client = poplib.POP3(HOST, PORTS["pop3"], timeout=15)
try:
capabilities = client.capa()
if "STLS" not in capabilities and b"STLS" not in capabilities:
raise RuntimeError(f"STLS not advertised: {capabilities!r}")
client.stls(context=TLS)
client.user(USER2)
client.pass_(PASSWORD)
count, size = client.stat()
return f"maildrop has {count} message(s), {size} bytes"
finally:
try:
client.quit()
except Exception:
client.close()
def pop3_implicit() -> str:
client = poplib.POP3_SSL(HOST, PORTS["pop3s"], timeout=15, context=TLS)
try:
client.user(USER2)
client.pass_(PASSWORD)
count, size = client.stat()
return f"maildrop has {count} message(s), {size} bytes"
finally:
try:
client.quit()
except Exception:
client.close()
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--wait-only", action="store_true")
arguments = parser.parse_args()
if arguments.wait_only:
return 0 if report("listener readiness", wait_for_listeners) else 1
if not PASSWORD:
parser.error("BONGO_TEST_PASSWORD must be set")
checks = [
("listener readiness", wait_for_listeners),
("SMTP submission STARTTLS", smtp_submit),
("SMTPS submission", smtp_implicit),
("IMAPS delivery", wait_for_imap),
("IMAP STARTTLS", imap_starttls),
("POP3 STLS", pop3_starttls),
("POP3S", pop3_implicit),
]
if environment("BONGO_TEST_WEB_URL"):
checks.append(("Web", web))
success = True
for name, operation in checks:
success = report(name, operation) and success
return 0 if success else 1
if __name__ == "__main__":
sys.exit(main())