Add SMTP email: password reset + per-frame battery-threshold alerts
Build and push server image / build-and-push (push) Successful in 40s
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:
@@ -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)."""
|
||||
|
||||
Reference in New Issue
Block a user