SMTP: implicit TLS (port 465) support + fix quarantined mail
Build and push server image / build-and-push (push) Successful in 38s
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:
+30
-7
@@ -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
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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.")
|
||||
|
||||
|
||||
@@ -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;">
|
||||
|
||||
Reference in New Issue
Block a user