Add SMTP email: password reset + per-frame battery-threshold alerts
Build and push server image / build-and-push (push) Successful in 40s

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.
This commit is contained in:
2026-07-22 00:51:54 -04:00
parent a45444ab4b
commit 8e10ca540e
15 changed files with 456 additions and 8 deletions
+44
View File
@@ -44,6 +44,10 @@ class User(Base):
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
immich_url: Mapped[str] = mapped_column(String, default="")
immich_api_key: Mapped[str] = mapped_column(String, default="")
# Password-reset emails and battery-threshold alerts (frames.owner's
# email -- see routers/device.py's frame_battery) go here; blank = no
# email configured, both features silently no-op for this user.
email: Mapped[str] = mapped_column(String, default="")
created_at: Mapped[float] = mapped_column(Float, default=time.time)
__table_args__ = (
@@ -139,6 +143,13 @@ class Frame(Base):
device_firmware_version: Mapped[str] = mapped_column(String, default="")
device_board_variant: Mapped[str] = mapped_column(String, default="")
# Battery-low email alert (see routers/device.py's frame_battery).
# -1 = disabled. Sent to the owner's email once per discharge cycle
# (battery_alert_sent resets alongside battery_history whenever a
# recharge is detected, same trigger as stats_recharge_cycles).
battery_alert_threshold_pct: Mapped[int] = mapped_column(Integer, default=-1)
battery_alert_sent: Mapped[bool] = mapped_column(Boolean, default=False)
# -- firmware / OTA (per frame; image lives at /data/firmware/<id>.bin) --
firmware_available_version: Mapped[str] = mapped_column(String, default="")
firmware_update_repo_url: Mapped[str] = mapped_column(String, default="")
@@ -191,6 +202,39 @@ class PendingClaim(Base):
expires_at: Mapped[float] = mapped_column(Float)
class ServerSettings(Base):
"""Singleton row (id always 1) holding operator-level SMTP config, set
from /admin -- not env vars, since this is infrastructure a household
admin configures once through the UI rather than at container
deploy time. Used for password-reset emails and battery-threshold
alerts (see app/mail.py). smtp_host empty = email sending disabled;
every send site checks that and no-ops rather than erroring."""
__tablename__ = "server_settings"
id: Mapped[int] = mapped_column(primary_key=True)
smtp_host: Mapped[str] = mapped_column(String, default="")
smtp_port: Mapped[int] = mapped_column(Integer, default=587)
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)
class PasswordResetToken(Base):
"""A single-use, time-limited "forgot password" link. token is the
URL-safe secret itself (not hashed, like PendingClaim/manage_token --
it's a short-lived bearer credential emailed once, not a long-lived
session secret)."""
__tablename__ = "password_reset_tokens"
token: Mapped[str] = mapped_column(String, primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
created_at: Mapped[float] = mapped_column(Float, default=time.time)
expires_at: Mapped[float] = mapped_column(Float)
class BatteryLog(Base):
"""Every battery report ever, per frame -- the permanent record behind
the battery history chart (was a 20k-entry JSON array in config.json)."""