SMTP: implicit TLS (port 465) support + fix quarantined mail
Build and push server image / build-and-push (push) Successful in 38s

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.
This commit is contained in:
2026-07-22 01:07:50 -04:00
parent 8e10ca540e
commit c1c803b497
5 changed files with 58 additions and 14 deletions
+26 -3
View File
@@ -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:
# "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_use_tls:
smtp.starttls()
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
+14
View File
@@ -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),
]
+5 -1
View File
@@ -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):
+2 -2
View File
@@ -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.")
+7 -4
View File
@@ -53,6 +53,13 @@
<label>Port
<input type="number" name="smtp_port" min="1" max="65535" value="{{ smtp.smtp_port }}">
</label>
<label>Encryption
<select name="smtp_encryption">
<option value="starttls" {% if smtp.smtp_encryption == "starttls" %}selected{% endif %}>STARTTLS (usually port 587)</option>
<option value="ssl" {% if smtp.smtp_encryption == "ssl" %}selected{% endif %}>SSL/TLS (usually port 465)</option>
<option value="none" {% if smtp.smtp_encryption == "none" %}selected{% endif %}>None (usually port 25)</option>
</select>
</label>
<label>Username
<input type="text" name="smtp_username" autocomplete="off" value="{{ smtp.smtp_username }}">
</label>
@@ -63,10 +70,6 @@
<label>From address
<input type="text" name="smtp_from_address" placeholder="[email protected]" value="{{ smtp.smtp_from_address }}">
</label>
<div class="checkbox-row">
<input type="checkbox" id="smtp_use_tls" name="smtp_use_tls" value="true" {% if smtp.smtp_use_tls %}checked{% endif %}>
<label for="smtp_use_tls">Use STARTTLS</label>
</div>
<button type="submit">Save SMTP settings</button>
</form>
<form method="post" action="/admin/smtp/test" style="margin-top: 8px;">