From c1c803b497f1a0eaadb5d8c11ef5a86bc5b2d3ed Mon Sep 17 00:00:00 2001 From: Thomas Faour Date: Wed, 22 Jul 2026 01:07:50 -0400 Subject: [PATCH] SMTP: implicit TLS (port 465) support + fix quarantined mail Two real fixes to app/mail.py, both found by testing against an actual mail server rather than just a fake stub: - Replaces the STARTTLS-only smtp_use_tls boolean with a three-way smtp_encryption ("none"/"starttls"/"ssl"). Implicit TLS (port 465, what Purelymail and most providers offer alongside 587/STARTTLS) is a different handshake entirely -- TLS from the first byte, not a plaintext connection that gets upgraded -- so it needs its own smtplib.SMTP_SSL code path, not just a skipped starttls() call. Schema migration v3 adds the column, backfills it from the old boolean, and drops the boolean (safe on a live, populated DB). - Outgoing mail was missing Date and Message-ID headers -- email.mime doesn't set either automatically, and a missing Message-ID in particular is enough for a strict content filter (confirmed via a real Postfix+Amavis mail server's logs: SPF/DKIM/DMARC all passed cleanly, but Amavis quarantined the message as "BAD-HEADER-0" purely for the missing id) to silently swallow an otherwise-legitimate email, even though smtplib reports success -- the send genuinely succeeds to the relay, it just never survives the recipient's own filtering. Both headers are now set, with the Message-ID's domain matching the From address. Verified: SMTP_SSL path against a hand-rolled implicit-TLS fake server (self-signed cert, client-side verification relaxed only in the test harness -- production code keeps ssl.create_default_context()'s real verification), the v2->v3 migration against live data, the full admin SMTP-save + test-email round trip over HTTP, and the standing legacy- device curl suite. --- server/app/mail.py | 37 ++++++++++++++++++++++++++------- server/app/migration.py | 14 +++++++++++++ server/app/models.py | 6 +++++- server/app/routers/pages.py | 4 ++-- server/app/templates/admin.html | 11 ++++++---- 5 files changed, 58 insertions(+), 14 deletions(-) diff --git a/server/app/mail.py b/server/app/mail.py index f32227e..4ee01ed 100644 --- a/server/app/mail.py +++ b/server/app/mail.py @@ -12,8 +12,10 @@ a warning on failure.""" from __future__ import annotations +import email.utils import logging import smtplib +import ssl from email.mime.text import MIMEText from .models import ServerSettings @@ -31,15 +33,36 @@ def send_email(settings: ServerSettings, to_address: str, subject: str, body: st msg["Subject"] = subject msg["From"] = settings.smtp_from_address or settings.smtp_username or "noreply@localhost" msg["To"] = to_address + # email.mime doesn't set either of these on its own -- and a missing + # Message-ID in particular is enough for a strict content filter + # (e.g. Amavis's header-sanity check) to quarantine an otherwise + # cleanly SPF/DKIM/DMARC-passing message outright. Domain in the + # generated id matches the From address so it's traceable back here. + msg["Date"] = email.utils.formatdate(localtime=True) + msg["Message-ID"] = email.utils.make_msgid(domain=msg["From"].rsplit("@", 1)[-1]) try: - with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=SMTP_TIMEOUT_S) as smtp: - if settings.smtp_use_tls: - smtp.starttls() - if settings.smtp_username: - smtp.login(settings.smtp_username, settings.smtp_password) - smtp.send_message(msg) + # "ssl" (implicit TLS, port 465 typically) needs a TLS socket from + # the very first byte -- SMTP_SSL, not SMTP+starttls(). Connecting + # a plaintext SMTP() to a TLS-only port fails outright (garbled + # banner/timeout), it doesn't degrade gracefully, so this has to + # be a real branch rather than "starttls() or not". + if settings.smtp_encryption == "ssl": + with smtplib.SMTP_SSL( + settings.smtp_host, settings.smtp_port, + timeout=SMTP_TIMEOUT_S, context=ssl.create_default_context(), + ) as smtp: + if settings.smtp_username: + smtp.login(settings.smtp_username, settings.smtp_password) + smtp.send_message(msg) + else: + with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=SMTP_TIMEOUT_S) as smtp: + if settings.smtp_encryption == "starttls": + smtp.starttls(context=ssl.create_default_context()) + if settings.smtp_username: + smtp.login(settings.smtp_username, settings.smtp_password) + smtp.send_message(msg) return True - except (OSError, smtplib.SMTPException) as e: + except (OSError, smtplib.SMTPException, ssl.SSLError) as e: logger.warning("Failed to send email to %s: %s", to_address, e) return False diff --git a/server/app/migration.py b/server/app/migration.py index 8989620..9ac6ada 100644 --- a/server/app/migration.py +++ b/server/app/migration.py @@ -39,9 +39,23 @@ def _migration_2(conn) -> None: Base.metadata.create_all(bind=conn) # creates the two new tables only; existing ones untouched +def _migration_3(conn) -> None: + """Replaces the STARTTLS-or-nothing smtp_use_tls boolean with a + three-way smtp_encryption ("none"/"starttls"/"ssl") -- implicit TLS + (port 465 typically) is a different handshake entirely, not just a + skipped starttls() call, so it needs its own connection path in + app/mail.py.""" + conn.execute(text("ALTER TABLE server_settings ADD COLUMN smtp_encryption TEXT NOT NULL DEFAULT 'starttls'")) + conn.execute(text( + "UPDATE server_settings SET smtp_encryption = CASE WHEN smtp_use_tls THEN 'starttls' ELSE 'none' END" + )) + conn.execute(text("ALTER TABLE server_settings DROP COLUMN smtp_use_tls")) + + MIGRATIONS = [ (1, _migration_1), (2, _migration_2), + (3, _migration_3), ] diff --git a/server/app/models.py b/server/app/models.py index f0bd5b2..70b2543 100644 --- a/server/app/models.py +++ b/server/app/models.py @@ -218,7 +218,11 @@ class ServerSettings(Base): smtp_username: Mapped[str] = mapped_column(String, default="") smtp_password: Mapped[str] = mapped_column(String, default="") smtp_from_address: Mapped[str] = mapped_column(String, default="") - smtp_use_tls: Mapped[bool] = mapped_column(Boolean, default=True) + # "none" (plaintext, port 25 typically), "starttls" (upgrades a + # plaintext connection, port 587 typically), or "ssl" (TLS from the + # first byte -- a different handshake entirely, not just starttls() + # skipped; port 465 typically). See app/mail.py. + smtp_encryption: Mapped[str] = mapped_column(String, default="starttls") class PasswordResetToken(Base): diff --git a/server/app/routers/pages.py b/server/app/routers/pages.py index 4aa6a72..cd20186 100644 --- a/server/app/routers/pages.py +++ b/server/app/routers/pages.py @@ -617,7 +617,7 @@ def admin_smtp_save( smtp_username: str = Form(""), smtp_password: str = Form(""), smtp_from_address: str = Form(""), - smtp_use_tls: bool = Form(False), + smtp_encryption: str = Form("starttls"), db: Session = Depends(get_db), ): """Saves the SMTP config used for password-reset emails and battery- @@ -632,7 +632,7 @@ def admin_smtp_save( if smtp_password.strip(): settings.smtp_password = smtp_password.strip() settings.smtp_from_address = smtp_from_address.strip() - settings.smtp_use_tls = smtp_use_tls + settings.smtp_encryption = smtp_encryption if smtp_encryption in ("none", "starttls", "ssl") else "starttls" db.commit() return _render_admin(request, db, admin, notice="SMTP settings saved.") diff --git a/server/app/templates/admin.html b/server/app/templates/admin.html index 5e75b6d..d5593c9 100644 --- a/server/app/templates/admin.html +++ b/server/app/templates/admin.html @@ -53,6 +53,13 @@ + @@ -63,10 +70,6 @@ -
- - -