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.
69 lines
2.9 KiB
Python
69 lines
2.9 KiB
Python
"""SMTP email sending -- password resets and battery-threshold alerts.
|
|
|
|
Config lives in the server_settings singleton row (admin-configured via
|
|
/admin, see routers/pages.py), not env vars -- it's operator
|
|
infrastructure a household admin sets up once through the UI, same
|
|
spirit as the rest of this project's "no separate config file" stance
|
|
post-redesign. Uses stdlib smtplib; no new dependency.
|
|
|
|
send_email() never raises -- a broken mail server shouldn't 500 a
|
|
password-reset request or a battery report; callers get a bool and log
|
|
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
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
SMTP_TIMEOUT_S = 10
|
|
|
|
|
|
def send_email(settings: ServerSettings, to_address: str, subject: str, body: str) -> bool:
|
|
if not settings.smtp_host or not to_address:
|
|
return False
|
|
|
|
msg = MIMEText(body)
|
|
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_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, ssl.SSLError) as e:
|
|
logger.warning("Failed to send email to %s: %s", to_address, e)
|
|
return False
|