EMAIL-WP-0005-T02: add GreenMail test harness
Adds tests/harness/docker-compose.yml running GreenMail 2.1.12 (digest-pinned) with SMTP 3025, IMAP 3143 and the API bound to 127.0.0.1 only, plus a config/harness-imap.yml scanner profile and harness README. Auth is disabled and no users are declared, so a mailbox is created on first login and per-test users need no provisioning. GreenMail standalone offers no STARTTLS, only plaintext or implicit TLS, while SMTPProvider hardcoded starttls() -- so no send could reach it. SMTPProvider now takes a security mode via EMAIL_CONNECT_SMTP_SECURITY, defaulting to starttls. plaintext is refused for any non-loopback host, and hostnames are never resolved to decide that, so a misconfigured deployment fails at startup rather than sending credentials in the clear. Trusting GreenMail's self-signed cert was rejected as the wider risk; see DECISIONS.md. Verified end to end against the live harness: SMTPProvider.send -> GreenMail -> ImapMailboxSource, and the documented scan-mailbox CLI. Suite: 52 passed with the harness down. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
86f22c2e65
commit
89fd13ac2d
8 changed files with 329 additions and 4 deletions
|
|
@ -4,6 +4,7 @@ from __future__ import annotations
|
|||
|
||||
import hmac
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import smtplib
|
||||
|
|
@ -148,7 +149,25 @@ class SQLiteDeliveryStore:
|
|||
|
||||
|
||||
class SMTPProvider:
|
||||
def __init__(self, host: str, port: int, username: str, password: str, sender: str) -> None:
|
||||
#: STARTTLS is the only transport allowed against a remote provider.
|
||||
#: `plaintext` exists for loopback test servers that cannot offer TLS and is
|
||||
#: refused for any other host, so a misconfiguration cannot put credentials
|
||||
#: on the wire in the clear.
|
||||
SECURITY_MODES = ("starttls", "plaintext")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
port: int,
|
||||
username: str,
|
||||
password: str,
|
||||
sender: str,
|
||||
security: str = "starttls",
|
||||
) -> None:
|
||||
if security not in self.SECURITY_MODES:
|
||||
raise ValueError(f"Unsupported SMTP security mode: {security}")
|
||||
if security == "plaintext" and not _is_loopback_host(host):
|
||||
raise ValueError("Plaintext SMTP is only permitted for loopback hosts.")
|
||||
self.host, self.port, self.username, self.password, self.sender = (
|
||||
host,
|
||||
port,
|
||||
|
|
@ -156,6 +175,7 @@ class SMTPProvider:
|
|||
password,
|
||||
sender,
|
||||
)
|
||||
self.security = security
|
||||
|
||||
def send(self, recipient: str, subject: str, text: str) -> str:
|
||||
message = EmailMessage()
|
||||
|
|
@ -163,7 +183,8 @@ class SMTPProvider:
|
|||
message.set_content(text)
|
||||
try:
|
||||
with smtplib.SMTP(self.host, self.port, timeout=10) as smtp:
|
||||
smtp.starttls()
|
||||
if self.security == "starttls":
|
||||
smtp.starttls()
|
||||
smtp.login(self.username, self.password)
|
||||
smtp.send_message(message)
|
||||
except TimeoutError as exc:
|
||||
|
|
@ -352,6 +373,18 @@ class TransactionalApplication:
|
|||
return [body]
|
||||
|
||||
|
||||
def _is_loopback_host(host: str) -> bool:
|
||||
candidate = (host or "").strip().strip("[]")
|
||||
if candidate.lower() == "localhost":
|
||||
return True
|
||||
try:
|
||||
return ipaddress.ip_address(candidate).is_loopback
|
||||
except ValueError:
|
||||
# Never resolve names here: a hostname that happens to point at 127.0.0.1
|
||||
# today is not a durable guarantee that credentials stay on the host.
|
||||
return False
|
||||
|
||||
|
||||
def _valid_address(value: str) -> bool:
|
||||
_, address = parseaddr(value)
|
||||
return address == value and "@" in address and "\n" not in address and "\r" not in address
|
||||
|
|
@ -364,6 +397,7 @@ def main() -> None:
|
|||
os.environ["EMAIL_CONNECT_SMTP_USERNAME"],
|
||||
os.environ["EMAIL_CONNECT_SMTP_PASSWORD"],
|
||||
os.environ["EMAIL_CONNECT_SENDER"],
|
||||
security=os.environ.get("EMAIL_CONNECT_SMTP_SECURITY", "starttls"),
|
||||
)
|
||||
app = TransactionalApplication(
|
||||
SQLiteDeliveryStore(os.environ.get("EMAIL_CONNECT_DATABASE_PATH", "/data/email-connect.db")),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue