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.
Admin-configured SMTP (server/port/username/password/from address/
STARTTLS, a singleton server_settings row set from /admin -- not env
vars, since it's operator infrastructure a household admin sets up
once through the UI) powers two features, both requiring the relevant
user to have an email set in their own Settings:
- "Forgot password?" on /login emails a one-hour single-use reset link
(password_reset_tokens table). The endpoint always returns the same
generic "check your email" response regardless of whether the address
matched an account, so it can't be used to enumerate registered users.
- A frame's Configuration tab can set a battery-alert threshold
(Frame.battery_alert_threshold_pct, -1 = disabled); POST /frame/battery
emails the owner the first time a report drops to or below it, then
stays quiet for the rest of that discharge cycle (battery_alert_sent,
reset alongside battery_history whenever the existing recharge-jump
detection fires) -- not once per wake.
New app/mail.py wraps stdlib smtplib (no new dependency); send_email()
never raises, so a broken mail server can't 500 a battery report or a
password-reset request. Schema migration v2 adds users.email and the
two frame columns via ALTER TABLE (safe against the live, already-
populated database) plus the two new tables via the existing
create_all-based migration runner.
Verified against a real (already-migrated, real user/frame data)
database: the v1->v2 migration, admin SMTP config + test-email button,
full forgot/reset-password roundtrip (including single-use token
invalidation and the no-enumeration response), and the battery alert
firing exactly once per crossing against a hand-rolled fake SMTP
server -- all via curl end-to-end, plus the standing legacy-device
curl suite to confirm the device protocol is untouched.