208 lines
7.1 KiB
Python
Executable File
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())
|