341 lines
12 KiB
Python
Executable File
341 lines
12 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
|
|
import base64
|
|
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", "4190")),
|
|
"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 sieve_quote(value: str) -> str:
|
|
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
|
|
|
|
|
class ManageSieveClient:
|
|
def __init__(self) -> None:
|
|
self.socket = socket.create_connection(
|
|
(HOST, PORTS["sieve"]), timeout=15)
|
|
self.stream = self.socket.makefile("rb")
|
|
self.read_response("greeting")
|
|
|
|
def close(self) -> None:
|
|
try:
|
|
self.stream.close()
|
|
finally:
|
|
self.socket.close()
|
|
|
|
def send(self, data: bytes) -> None:
|
|
self.socket.sendall(data)
|
|
|
|
def line(self) -> bytes:
|
|
line = self.stream.readline(65537)
|
|
if not line or len(line) > 65536 or not line.endswith(b"\n"):
|
|
raise RuntimeError("ManageSieve connection ended mid-response")
|
|
return line
|
|
|
|
def read_response(self, operation: str) -> list[str]:
|
|
lines: list[str] = []
|
|
while True:
|
|
try:
|
|
decoded = self.line().decode(
|
|
"utf-8", "strict").rstrip("\r\n")
|
|
except TimeoutError as error:
|
|
raise RuntimeError(
|
|
f"ManageSieve {operation} timed out") from error
|
|
lines.append(decoded)
|
|
upper = decoded.upper()
|
|
if upper == "OK" or upper.startswith("OK ") or \
|
|
upper == "NO" or upper.startswith("NO ") or \
|
|
upper == "BYE" or upper.startswith("BYE "):
|
|
if not upper.startswith("OK"):
|
|
raise RuntimeError(
|
|
f"ManageSieve {operation} failed: {decoded}")
|
|
return lines
|
|
|
|
def command(self, operation: str, command: str) -> list[str]:
|
|
self.send(command.encode("utf-8") + b"\r\n")
|
|
return self.read_response(operation)
|
|
|
|
def starttls(self) -> None:
|
|
self.command("STARTTLS", "STARTTLS")
|
|
self.stream.close()
|
|
self.socket = TLS.wrap_socket(self.socket, server_hostname=HOST)
|
|
self.stream = self.socket.makefile("rb")
|
|
|
|
def literal_command(
|
|
self, operation: str, command: str, payload: bytes) -> list[str]:
|
|
self.send(
|
|
f"{command} {{{len(payload)}+}}\r\n".encode("utf-8") + payload)
|
|
return self.read_response(operation)
|
|
|
|
def get_script(self, name: str) -> bytes:
|
|
self.send(f"GETSCRIPT {sieve_quote(name)}\r\n".encode("utf-8"))
|
|
marker = self.line().decode("ascii", "strict").strip()
|
|
if not marker.startswith("{") or not marker.endswith("}"):
|
|
raise RuntimeError(f"invalid GETSCRIPT literal marker: {marker}")
|
|
length = int(marker[1:-1])
|
|
payload = self.stream.read(length)
|
|
if len(payload) != length or self.stream.read(2) != b"\r\n":
|
|
raise RuntimeError("truncated GETSCRIPT literal")
|
|
self.read_response("GETSCRIPT")
|
|
return payload
|
|
|
|
|
|
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 managesieve() -> str:
|
|
script_name = f"{TOKEN[:48]} filter"
|
|
renamed = f"{TOKEN[:48]} renamed"
|
|
script = (
|
|
'require ["fileinto"];\r\n'
|
|
'if header :contains "Subject" "Bongo" { keep; }\r\n'
|
|
).encode("utf-8")
|
|
auth = base64.b64encode(
|
|
b"\0" + USER1.encode("utf-8") + b"\0" + PASSWORD.encode("utf-8")
|
|
).decode("ascii")
|
|
client = ManageSieveClient()
|
|
try:
|
|
client.starttls()
|
|
capabilities = client.command("CAPABILITY", "CAPABILITY")
|
|
if not any('"SASL"' in line and "PLAIN" in line
|
|
for line in capabilities):
|
|
raise RuntimeError("SASL PLAIN not advertised after STARTTLS")
|
|
client.command(
|
|
"AUTHENTICATE",
|
|
f'AUTHENTICATE "PLAIN" "{auth}"')
|
|
client.literal_command(
|
|
"CHECKSCRIPT", "CHECKSCRIPT", script)
|
|
client.literal_command(
|
|
"PUTSCRIPT", f"PUTSCRIPT {sieve_quote(script_name)}", script)
|
|
listing = client.command("LISTSCRIPTS", "LISTSCRIPTS")
|
|
if not any(sieve_quote(script_name) in line for line in listing):
|
|
raise RuntimeError("uploaded script missing from LISTSCRIPTS")
|
|
client.command(
|
|
"SETACTIVE", f"SETACTIVE {sieve_quote(script_name)}")
|
|
if client.get_script(script_name) != script:
|
|
raise RuntimeError("GETSCRIPT returned different script bytes")
|
|
client.command(
|
|
"RENAMESCRIPT",
|
|
f"RENAMESCRIPT {sieve_quote(script_name)} {sieve_quote(renamed)}")
|
|
listing = client.command("LISTSCRIPTS", "LISTSCRIPTS")
|
|
if not any(sieve_quote(renamed) in line and "ACTIVE" in line
|
|
for line in listing):
|
|
raise RuntimeError("renamed active script missing")
|
|
client.command("SETACTIVE", 'SETACTIVE ""')
|
|
client.command(
|
|
"DELETESCRIPT", f"DELETESCRIPT {sieve_quote(renamed)}")
|
|
client.command("LOGOUT", "LOGOUT")
|
|
finally:
|
|
client.close()
|
|
return "upload, activate, retrieve, rename and delete passed"
|
|
|
|
|
|
def managesieve_repeated() -> str:
|
|
first = managesieve()
|
|
second = managesieve()
|
|
return f"two consecutive sessions passed ({first}; {second})"
|
|
|
|
|
|
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")
|
|
parser.add_argument("--sieve-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")
|
|
if arguments.sieve_only:
|
|
return 0 if report(
|
|
"ManageSieve NMAP Store", managesieve_repeated) else 1
|
|
checks = [
|
|
("listener readiness", wait_for_listeners),
|
|
("ManageSieve NMAP Store", managesieve_repeated),
|
|
("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())
|