Files
computer-playbook/roles/web-app-stalwart/files/test/probe_mail.py
kevinveenbirkenbachandClaude Opus 5.5 65a884beba test(web-app-stalwart): require the relay denial code in the CLI probe
The unauthenticated half of the relay probe passed on any 5xx answer.
A server that refused the recipient for another reason would have
counted as "relay denied". It now requires 550, the code Stalwart gives
for a denied relay.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 19:27:27 +02:00

134 lines
4.5 KiB
Python
Executable File

#!/usr/bin/env python3
"""Send one message, or look one up, without depending on curl's optional protocols.
`curl` is built per distro and its SMTP and IMAP protocol handlers are optional:
on CentOS the system curl reports `Protocol "smtp" not supported`, so a harness
that shells out to it passes on Debian and fails on CentOS for reasons that have
nothing to do with the mail server. Python's stdlib carries both protocols on
every distro the matrix runs, and the migration script it exercises already
speaks IMAP through imaplib.
probe_mail.py send <host> <from> <rcpt> <eml-file>
probe_mail.py find <host> <user> <password> <subject>
probe_mail.py relay <host> <sender> <password>
`relay` exits 0 when an unauthenticated session is refused an external recipient
and the same recipient is accepted once `<sender>` has authenticated, 1 otherwise.
`find` exits 0 when the subject is present in INBOX or Junk, 1 when it is not.
Both mailboxes are searched because a test domain publishes no mail-auth DNS, so
a correctly delivered message is legitimately filed under Junk. Certificate
verification is off for the same reason the migration script turns it off: the
loopback hop reaches Stalwart's bootstrap certificate, which matches no hostname.
`imaplib` is forced to UTF-8 for the same reason too — it encodes command
arguments as ASCII, so a password holding any non-ASCII character (the platform
generates passwords containing '€') raises UnicodeEncodeError before LOGIN is
ever sent.
"""
from __future__ import annotations
import contextlib
import imaplib
import smtplib
import ssl
import sys
from pathlib import Path
SMTP_PORT = 25
SMTPS_PORT = 465
IMAPS_PORT = 993
TIMEOUT = 60
MAILBOXES = ("INBOX", "Junk Mail")
EXTERNAL_RECIPIENT = "relay-probe@example.org"
RELAY_DENIED = 550
def unverified_context() -> ssl.SSLContext:
context = ssl.create_default_context()
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
return context
def send(host: str, sender: str, recipient: str, eml_path: str) -> int:
message = Path(eml_path).read_bytes()
with smtplib.SMTP(host, SMTP_PORT, timeout=TIMEOUT) as smtp:
smtp.sendmail(sender, [recipient], message)
return 0
def relay(host: str, sender: str, password: str) -> int:
with smtplib.SMTP(host, SMTP_PORT, timeout=TIMEOUT) as smtp:
smtp.ehlo()
smtp.mail(sender)
anonymous, _ = smtp.rcpt(EXTERNAL_RECIPIENT)
if anonymous != RELAY_DENIED:
print(
f"relay not denied: an unauthenticated session got {anonymous} "
f"for {EXTERNAL_RECIPIENT}, expected {RELAY_DENIED}",
file=sys.stderr,
)
return 1
with smtplib.SMTP_SSL(
host, SMTPS_PORT, timeout=TIMEOUT, context=unverified_context()
) as smtp:
try:
smtp.login(sender, password)
except smtplib.SMTPAuthenticationError as error:
print(
f"submission refused: {sender} cannot authenticate ({error.smtp_code})",
file=sys.stderr,
)
return 1
smtp.mail(sender)
authenticated, _ = smtp.rcpt(EXTERNAL_RECIPIENT)
if authenticated != 250:
print(
f"submission refused: {sender} authenticated but got {authenticated} "
f"for {EXTERNAL_RECIPIENT}",
file=sys.stderr,
)
return 1
return 0
def find(host: str, user: str, password: str, subject: str) -> int:
imap = imaplib.IMAP4_SSL(
host, IMAPS_PORT, ssl_context=unverified_context(), timeout=TIMEOUT
)
imap._encoding = "utf-8"
try:
imap.login(user, password)
for mailbox in MAILBOXES:
typ, _ = imap.select(f'"{mailbox}"', readonly=True)
if typ != "OK":
continue
typ, data = imap.search(None, "SUBJECT", f'"{subject}"')
if typ == "OK" and data and data[0].split():
return 0
finally:
with contextlib.suppress(Exception):
imap.logout()
return 1
def main(argv: list[str]) -> int:
if len(argv) < 2:
print(__doc__, file=sys.stderr)
return 2
action, args = argv[1], argv[2:]
if action == "send" and len(args) == 4:
return send(*args)
if action == "find" and len(args) == 4:
return find(*args)
if action == "relay" and len(args) == 3:
return relay(*args)
print(__doc__, file=sys.stderr)
return 2
if __name__ == "__main__":
sys.exit(main(sys.argv))