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
+33
View File
@@ -41,6 +41,39 @@
</tbody>
</table>
<h2 class="card-title" style="margin-top: 24px;">Email (SMTP)</h2>
<p class="sub">Used for "forgot password" links and battery-low
alerts (set per frame in its Configuration tab). Each user needs
an email set in their own Settings for either to reach them.</p>
<form method="post" action="/admin/smtp">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>SMTP server
<input type="text" name="smtp_host" placeholder="smtp.example.com" value="{{ smtp.smtp_host }}">
</label>
<label>Port
<input type="number" name="smtp_port" min="1" max="65535" value="{{ smtp.smtp_port }}">
</label>
<label>Username
<input type="text" name="smtp_username" autocomplete="off" value="{{ smtp.smtp_username }}">
</label>
<label>Password
<input type="password" name="smtp_password" autocomplete="off"
placeholder="{% if smtp.smtp_password %}(unchanged -- enter a new one to replace){% else %}smtp password{% endif %}">
</label>
<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;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="secondary">Send test email to myself</button>
</form>
<h2 class="card-title" style="margin-top: 24px;">Enroll a user</h2>
<form method="post" action="/admin/users">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
+28
View File
@@ -0,0 +1,28 @@
{% extends "base.html" %}
{% block page_class %}page-narrow{% endblock %}
{% block subtitle %}
<p class="sub">Reset your password</p>
{% endblock %}
{% block content %}
<section class="card">
<h2 class="card-title">Forgot password</h2>
{% if sent %}
<div class="status ok">If that email is on an account, a reset link is on its way.</div>
<p class="sub" style="margin-top: 14px;">Nothing arriving? The server's
SMTP settings may not be configured yet -- ask your admin.</p>
{% else %}
{% if error %}<div class="status err">{{ error }}</div>{% endif %}
<p class="sub">Enter the email on your account and we'll send a reset link.</p>
<form method="post" action="/forgot-password">
<label>Email
<input type="email" name="email" required autofocus autocomplete="email">
</label>
<button type="submit">Send reset link</button>
</form>
{% endif %}
<p class="sub" style="margin-top: 14px;"><a href="/login">Back to log in</a></p>
</section>
{% endblock %}
+13
View File
@@ -99,6 +99,19 @@
<button type="button" class="secondary" id="firmware-check-now">Check now</button>
<button type="button" id="firmware-update-btn" style="display: none;">Update frame</button>
</section>
<section class="card">
<h2 class="card-title">Battery alerts</h2>
<label>Email me when battery drops below (%)
<input type="number" id="battery_alert_threshold_pct" min="0" max="100"
value="{% if frame.battery_alert_threshold_pct >= 0 %}{{ frame.battery_alert_threshold_pct }}{% endif %}"
placeholder="disabled">
</label>
<p class="sub" style="margin-top: 8px;">Sent once per discharge cycle
to the frame owner's email (set in Settings) -- clear the field to
disable. Needs SMTP configured by an admin.</p>
<button type="button" class="secondary" id="battery-alert-save">Save</button>
</section>
</div>
</div>
+1
View File
@@ -20,5 +20,6 @@
</label>
<button type="submit">Log in</button>
</form>
<p class="sub" style="margin-top: 14px;"><a href="/forgot-password">Forgot your password?</a></p>
</section>
{% endblock %}
+26
View File
@@ -0,0 +1,26 @@
{% extends "base.html" %}
{% block page_class %}page-narrow{% endblock %}
{% block subtitle %}
<p class="sub">Reset your password</p>
{% endblock %}
{% block content %}
<section class="card">
<h2 class="card-title">Set a new password</h2>
{% if error %}<div class="status err">{{ error }}</div>{% endif %}
{% if valid %}
<form method="post" action="/reset-password/{{ token }}">
<label>New password
<input type="password" name="password" minlength="8" required autofocus autocomplete="new-password">
</label>
<button type="submit">Set password</button>
</form>
{% else %}
<p class="sub">This reset link is invalid or has expired -- links are
only good for an hour.</p>
<p class="sub" style="margin-top: 14px;"><a href="/forgot-password">Request a new one</a></p>
{% endif %}
</section>
{% endblock %}
+6
View File
@@ -14,6 +14,12 @@
<label>Display name
<input type="text" name="display_name" maxlength="64" value="{{ user.display_name }}">
</label>
<label>Email
<input type="email" name="email" value="{{ user.email }}" placeholder="[email protected]">
</label>
<p class="sub" style="margin-top: 8px;">Used for password-reset links
and, for frames you own, battery-low alerts (set a threshold in a
frame's Configuration tab).</p>
<label>Immich URL
<input type="text" name="immich_url" placeholder="http://your-immich-host:2283"
value="{{ user.immich_url }}">