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
+30 -7
View File
@@ -12,8 +12,10 @@ a warning on failure."""
from __future__ import annotations from __future__ import annotations
import email.utils
import logging import logging
import smtplib import smtplib
import ssl
from email.mime.text import MIMEText from email.mime.text import MIMEText
from .models import ServerSettings from .models import ServerSettings
@@ -31,15 +33,36 @@ def send_email(settings: ServerSettings, to_address: str, subject: str, body: st
msg["Subject"] = subject msg["Subject"] = subject
msg["From"] = settings.smtp_from_address or settings.smtp_username or "noreply@localhost" msg["From"] = settings.smtp_from_address or settings.smtp_username or "noreply@localhost"
msg["To"] = to_address 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: try:
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=SMTP_TIMEOUT_S) as smtp: # "ssl" (implicit TLS, port 465 typically) needs a TLS socket from
if settings.smtp_use_tls: # the very first byte -- SMTP_SSL, not SMTP+starttls(). Connecting
smtp.starttls() # a plaintext SMTP() to a TLS-only port fails outright (garbled
if settings.smtp_username: # banner/timeout), it doesn't degrade gracefully, so this has to
smtp.login(settings.smtp_username, settings.smtp_password) # be a real branch rather than "starttls() or not".
smtp.send_message(msg) 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 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) logger.warning("Failed to send email to %s: %s", to_address, e)
return False 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 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 = [ MIGRATIONS = [
(1, _migration_1), (1, _migration_1),
(2, _migration_2), (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_username: Mapped[str] = mapped_column(String, default="")
smtp_password: Mapped[str] = mapped_column(String, default="") smtp_password: Mapped[str] = mapped_column(String, default="")
smtp_from_address: 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): class PasswordResetToken(Base):
+2 -2
View File
@@ -617,7 +617,7 @@ def admin_smtp_save(
smtp_username: str = Form(""), smtp_username: str = Form(""),
smtp_password: str = Form(""), smtp_password: str = Form(""),
smtp_from_address: str = Form(""), smtp_from_address: str = Form(""),
smtp_use_tls: bool = Form(False), smtp_encryption: str = Form("starttls"),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Saves the SMTP config used for password-reset emails and battery- """Saves the SMTP config used for password-reset emails and battery-
@@ -632,7 +632,7 @@ def admin_smtp_save(
if smtp_password.strip(): if smtp_password.strip():
settings.smtp_password = smtp_password.strip() settings.smtp_password = smtp_password.strip()
settings.smtp_from_address = smtp_from_address.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() db.commit()
return _render_admin(request, db, admin, notice="SMTP settings saved.") return _render_admin(request, db, admin, notice="SMTP settings saved.")
+7 -4
View File
@@ -53,6 +53,13 @@
<label>Port <label>Port
<input type="number" name="smtp_port" min="1" max="65535" value="{{ smtp.smtp_port }}"> <input type="number" name="smtp_port" min="1" max="65535" value="{{ smtp.smtp_port }}">
</label> </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 <label>Username
<input type="text" name="smtp_username" autocomplete="off" value="{{ smtp.smtp_username }}"> <input type="text" name="smtp_username" autocomplete="off" value="{{ smtp.smtp_username }}">
</label> </label>
@@ -63,10 +70,6 @@
<label>From address <label>From address
<input type="text" name="smtp_from_address" placeholder="[email protected]" value="{{ smtp.smtp_from_address }}"> <input type="text" name="smtp_from_address" placeholder="[email protected]" value="{{ smtp.smtp_from_address }}">
</label> </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> <button type="submit">Save SMTP settings</button>
</form> </form>
<form method="post" action="/admin/smtp/test" style="margin-top: 8px;"> <form method="post" action="/admin/smtp/test" style="margin-top: 8px;">