diff --git a/.env.example b/.env.example
index 52673f5..74b0d57 100644
--- a/.env.example
+++ b/.env.example
@@ -7,3 +7,12 @@ DATA_DIR=/data
DATABASE_URL=sqlite:////data/app.db
# Set to true only when TLS terminates in front of this service.
SESSION_HTTPS_ONLY=false
+# Optional: SMTP settings for "email me when action is required" user
+# notifications. Leave SMTP_HOST unset to disable sending (notifications are
+# silently skipped rather than failing a sync run).
+SMTP_HOST=
+SMTP_PORT=587
+SMTP_USERNAME=
+SMTP_PASSWORD=
+SMTP_FROM_ADDRESS=
+SMTP_USE_TLS=true
diff --git a/app/config.py b/app/config.py
index bb1646a..7ae8349 100644
--- a/app/config.py
+++ b/app/config.py
@@ -15,6 +15,12 @@ class Settings(BaseSettings):
database_url: str | None = Field(default=None, alias="DATABASE_URL")
sync_interval_minutes: PositiveInt = Field(default=5, alias="SYNC_INTERVAL_MINUTES")
session_https_only: bool = Field(default=False, alias="SESSION_HTTPS_ONLY")
+ smtp_host: str | None = Field(default=None, alias="SMTP_HOST")
+ smtp_port: int = Field(default=587, alias="SMTP_PORT")
+ smtp_username: str | None = Field(default=None, alias="SMTP_USERNAME")
+ smtp_password: str | None = Field(default=None, alias="SMTP_PASSWORD")
+ smtp_from_address: str | None = Field(default=None, alias="SMTP_FROM_ADDRESS")
+ smtp_use_tls: bool = Field(default=True, alias="SMTP_USE_TLS")
@model_validator(mode="after")
def derive_paths(self) -> "Settings":
diff --git a/app/db/models.py b/app/db/models.py
index 256ef8a..4be39bd 100644
--- a/app/db/models.py
+++ b/app/db/models.py
@@ -53,6 +53,8 @@ class SyncUser(Base):
mywhoosh_password_enc: Mapped[str] = mapped_column(Text, nullable=False)
garmin_email_enc: Mapped[str] = mapped_column(Text, nullable=False)
garmin_password_enc: Mapped[str] = mapped_column(Text, nullable=False)
+ notify_email_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
+ notification_email: Mapped[str | None] = mapped_column(String(255))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
diff --git a/app/db/session.py b/app/db/session.py
index 22f07db..a275848 100644
--- a/app/db/session.py
+++ b/app/db/session.py
@@ -1,9 +1,21 @@
-from sqlalchemy import create_engine
+from sqlalchemy import create_engine, inspect, text
from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session, sessionmaker
from app.db.models import Base
+# Columns added to existing tables after their initial release. create_all()
+# only creates missing tables, never adds columns to tables that already
+# exist, so a column added to a model here must also be listed below or an
+# already-deployed database will never receive it and the app will crash
+# reading/writing that column.
+_ADDITIVE_COLUMNS: dict[str, list[tuple[str, str]]] = {
+ "sync_users": [
+ ("notify_email_enabled", "BOOLEAN NOT NULL DEFAULT 0"),
+ ("notification_email", "VARCHAR(255)"),
+ ],
+}
+
def create_db_engine(database_url: str) -> Engine:
connect_args = {"check_same_thread": False} if database_url.startswith("sqlite") else {}
@@ -16,3 +28,24 @@ def create_session_factory(engine: Engine) -> sessionmaker[Session]:
def initialize_schema(engine: Engine) -> None:
Base.metadata.create_all(engine)
+ _apply_additive_migrations(engine)
+
+
+def _apply_additive_migrations(engine: Engine) -> None:
+ if engine.dialect.name != "sqlite":
+ # ALTER TABLE ... ADD COLUMN syntax/type names below are only
+ # verified against sqlite, the only backend this app is deployed
+ # against; a fresh create_all() on another backend already has every
+ # current column, so skipping here only matters for a pre-existing
+ # non-sqlite database, which does not exist in practice.
+ return
+ inspector = inspect(engine)
+ existing_tables = set(inspector.get_table_names())
+ with engine.begin() as conn:
+ for table, columns in _ADDITIVE_COLUMNS.items():
+ if table not in existing_tables:
+ continue
+ existing_columns = {col["name"] for col in inspector.get_columns(table)}
+ for name, ddl_type in columns:
+ if name not in existing_columns:
+ conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {name} {ddl_type}"))
diff --git a/app/main.py b/app/main.py
index f9dc5cc..2f7fbfb 100644
--- a/app/main.py
+++ b/app/main.py
@@ -10,6 +10,7 @@ from app.db.session import create_db_engine, create_session_factory, initialize_
from app.fit.rewriter import convert_fit_device
from app.garmin.uploader import GarminUploader
from app.mywhoosh.client import MyWhooshClient
+from app.notifications.emailer import EmailNotifier
from app.security.credentials import CredentialCipher
from app.sync.manager import SyncManager
from app.sync.scheduler import SyncScheduler
@@ -33,6 +34,15 @@ def create_app(settings: Settings | None = None) -> FastAPI:
def garmin_factory(email, password, tokenstore):
return GarminUploader(email=email, password=password, tokenstore=tokenstore)
+ notifier = EmailNotifier(
+ host=resolved.smtp_host,
+ port=resolved.smtp_port,
+ username=resolved.smtp_username,
+ password=resolved.smtp_password,
+ from_address=resolved.smtp_from_address,
+ use_tls=resolved.smtp_use_tls,
+ )
+
sync_manager = SyncManager(
session_factory=app.state.session_factory,
credential_cipher=cipher,
@@ -40,6 +50,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
mywhoosh_factory=mywhoosh_factory,
garmin_factory=garmin_factory,
fit_converter=convert_fit_device,
+ notifier=notifier,
)
app.state.sync_manager = sync_manager
diff --git a/app/notifications/__init__.py b/app/notifications/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/notifications/emailer.py b/app/notifications/emailer.py
new file mode 100644
index 0000000..10055c2
--- /dev/null
+++ b/app/notifications/emailer.py
@@ -0,0 +1,42 @@
+from __future__ import annotations
+
+import smtplib
+from email.message import EmailMessage
+
+
+class EmailNotifier:
+ def __init__(
+ self,
+ *,
+ host: str | None,
+ port: int,
+ username: str | None,
+ password: str | None,
+ from_address: str | None,
+ use_tls: bool,
+ ) -> None:
+ self.host = host
+ self.port = port
+ self.username = username
+ self.password = password
+ self.from_address = from_address
+ self.use_tls = use_tls
+
+ @property
+ def configured(self) -> bool:
+ return bool(self.host and self.from_address)
+
+ def send(self, *, to_address: str, subject: str, body: str) -> None:
+ if not self.configured:
+ return
+ message = EmailMessage()
+ message["Subject"] = subject
+ message["From"] = self.from_address
+ message["To"] = to_address
+ message.set_content(body)
+ with smtplib.SMTP(self.host, self.port, timeout=10) as smtp:
+ if self.use_tls:
+ smtp.starttls()
+ if self.username and self.password:
+ smtp.login(self.username, self.password)
+ smtp.send_message(message)
diff --git a/app/sync/manager.py b/app/sync/manager.py
index 6429687..264c7ed 100644
--- a/app/sync/manager.py
+++ b/app/sync/manager.py
@@ -64,6 +64,7 @@ class SyncManager:
mywhoosh_factory: Callable[[MyWhooshTokenStore], Any],
garmin_factory: Callable[[str, str, Path], Any],
fit_converter: Callable[[Path, Path], Any],
+ notifier: Any = None,
) -> None:
self.session_factory = session_factory
self.credential_cipher = credential_cipher
@@ -71,9 +72,24 @@ class SyncManager:
self.mywhoosh_factory = mywhoosh_factory
self.garmin_factory = garmin_factory
self.fit_converter = fit_converter
+ self.notifier = notifier
self._locks: dict[int, asyncio.Lock] = {}
self._locks_guard = asyncio.Lock()
+ def _notify_action_required(self, user: Any, message: str | None) -> None:
+ if self.notifier is None or not user.notify_email_enabled or not user.notification_email:
+ return
+ try:
+ self.notifier.send(
+ to_address=user.notification_email,
+ subject=f"MyWhoosh-Garmin Sync: action required for {user.name}",
+ body=message or "Your sync requires attention. Check the dashboard for details.",
+ )
+ except Exception:
+ logger.warning(
+ "sync_user: failed to send action-required notification for user %s", user.id, exc_info=True
+ )
+
async def _lock_for(self, user_id: int) -> asyncio.Lock:
async with self._locks_guard:
return self._locks.setdefault(user_id, asyncio.Lock())
@@ -102,6 +118,12 @@ class SyncManager:
if user is None:
raise ValueError(f"user {user_id} not found")
+ # Captured before this run mutates action_reason, so the
+ # end-of-run notification below only fires on a transition into
+ # (or between) action-required states -- never repeatedly for a
+ # cause that's already been reported and still unresolved.
+ previous_action_reason = user.action_reason
+
sync_run_repo = SyncRunRepository(session)
run = sync_run_repo.start(user_id)
@@ -366,6 +388,8 @@ class SyncManager:
summary_error=summary_error,
)
session.commit()
+ if user.action_reason is not None and user.action_reason != previous_action_reason:
+ self._notify_action_required(user, summary_error)
return SyncOutcome(
user_id=user.id,
status=status.value,
diff --git a/app/web/forms.py b/app/web/forms.py
index 6010dc4..c50b2e9 100644
--- a/app/web/forms.py
+++ b/app/web/forms.py
@@ -9,3 +9,5 @@ class UserFormData:
garmin_email: str
garmin_password: str
enabled: bool
+ notify_email_enabled: bool = False
+ notification_email: str = ""
diff --git a/app/web/routes.py b/app/web/routes.py
index e217e43..1f7bd30 100644
--- a/app/web/routes.py
+++ b/app/web/routes.py
@@ -96,6 +96,8 @@ def create_user(
garmin_email: str = Form(""),
garmin_password: str = Form(""),
enabled: str | None = Form(None),
+ notify_email_enabled: str | None = Form(None),
+ notification_email: str = Form(""),
):
require_admin(request)
validate_csrf(request, csrf_token)
@@ -110,7 +112,11 @@ def create_user(
garmin_email=garmin_email,
garmin_password=garmin_password,
enabled=enabled is not None,
+ notify_email_enabled=notify_email_enabled is not None,
+ notification_email=notification_email,
)
+ if form.notify_email_enabled:
+ _require_non_empty(form.notification_email, "notification_email")
cipher = _cipher(request)
with request.app.state.session_factory() as session:
repository = UserRepository(session)
@@ -121,6 +127,8 @@ def create_user(
mywhoosh_password_enc=cipher.encrypt(form.mywhoosh_password),
garmin_email_enc=cipher.encrypt(form.garmin_email.strip()),
garmin_password_enc=cipher.encrypt(form.garmin_password),
+ notify_email_enabled=form.notify_email_enabled,
+ notification_email=form.notification_email.strip() or None,
)
user_id = user.id
return RedirectResponse(f"/users/{user_id}", status_code=303)
@@ -175,6 +183,8 @@ def update_user(
garmin_email: str = Form(""),
garmin_password: str = Form(""),
enabled: str | None = Form(None),
+ notify_email_enabled: str | None = Form(None),
+ notification_email: str = Form(""),
):
require_admin(request)
validate_csrf(request, csrf_token)
@@ -187,7 +197,11 @@ def update_user(
garmin_email=garmin_email,
garmin_password=garmin_password,
enabled=enabled is not None,
+ notify_email_enabled=notify_email_enabled is not None,
+ notification_email=notification_email,
)
+ if form.notify_email_enabled:
+ _require_non_empty(form.notification_email, "notification_email")
cipher = _cipher(request)
with request.app.state.session_factory() as session:
repository = UserRepository(session)
@@ -197,6 +211,8 @@ def update_user(
"enabled": form.enabled,
"mywhoosh_email_enc": cipher.encrypt(form.mywhoosh_email.strip()),
"garmin_email_enc": cipher.encrypt(form.garmin_email.strip()),
+ "notify_email_enabled": form.notify_email_enabled,
+ "notification_email": form.notification_email.strip() or None,
}
if form.mywhoosh_password:
values["mywhoosh_password_enc"] = cipher.encrypt(form.mywhoosh_password)
diff --git a/app/web/templates/users/form.html b/app/web/templates/users/form.html
index f64fc66..fca91fb 100644
--- a/app/web/templates/users/form.html
+++ b/app/web/templates/users/form.html
@@ -36,6 +36,16 @@
Enabled
+
+
+
+
+
diff --git a/tests/db/test_session.py b/tests/db/test_session.py
new file mode 100644
index 0000000..2d0f656
--- /dev/null
+++ b/tests/db/test_session.py
@@ -0,0 +1,109 @@
+from sqlalchemy import create_engine, inspect, text
+from sqlalchemy.pool import StaticPool
+
+from app.db.models import Base
+from app.db.session import initialize_schema
+
+
+def test_initialize_schema_creates_fresh_database() -> None:
+ engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
+ try:
+ initialize_schema(engine)
+ columns = {col["name"] for col in inspect(engine).get_columns("sync_users")}
+ assert "notify_email_enabled" in columns
+ assert "notification_email" in columns
+ finally:
+ engine.dispose()
+
+
+def test_initialize_schema_adds_missing_columns_without_dropping_existing_rows() -> None:
+ """Regression test: a database created before notify_email_enabled/
+ notification_email existed must gain those columns in place, keeping
+ every already-stored user row intact -- create_all() alone would not add
+ columns to a table that already exists."""
+ engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
+ try:
+ # Simulate the pre-existing production schema by creating every
+ # table via the current models, then dropping the two new columns
+ # back off sync_users the only way sqlite allows: rebuild the table.
+ Base.metadata.create_all(engine)
+ with engine.begin() as conn:
+ conn.execute(text("ALTER TABLE sync_users RENAME TO sync_users_old"))
+ conn.execute(
+ text(
+ """
+ CREATE TABLE sync_users (
+ id INTEGER PRIMARY KEY,
+ name VARCHAR(120) NOT NULL,
+ enabled BOOLEAN NOT NULL,
+ health_state VARCHAR NOT NULL,
+ mywhoosh_state VARCHAR(32) NOT NULL,
+ garmin_state VARCHAR(32) NOT NULL,
+ action_reason TEXT,
+ mywhoosh_email_enc TEXT NOT NULL,
+ mywhoosh_password_enc TEXT NOT NULL,
+ garmin_email_enc TEXT NOT NULL,
+ garmin_password_enc TEXT NOT NULL,
+ created_at DATETIME,
+ updated_at DATETIME
+ )
+ """
+ )
+ )
+ conn.execute(
+ text(
+ """
+ INSERT INTO sync_users (
+ id, name, enabled, health_state, mywhoosh_state, garmin_state,
+ action_reason, mywhoosh_email_enc, mywhoosh_password_enc,
+ garmin_email_enc, garmin_password_enc, created_at, updated_at
+ )
+ SELECT id, name, enabled, health_state, mywhoosh_state, garmin_state,
+ action_reason, mywhoosh_email_enc, mywhoosh_password_enc,
+ garmin_email_enc, garmin_password_enc, created_at, updated_at
+ FROM sync_users_old
+ """
+ )
+ )
+ conn.execute(text("DROP TABLE sync_users_old"))
+ conn.execute(
+ text(
+ """
+ INSERT INTO sync_users (
+ id, name, enabled, health_state, mywhoosh_state, garmin_state,
+ mywhoosh_email_enc, mywhoosh_password_enc, garmin_email_enc, garmin_password_enc
+ ) VALUES (
+ 1, 'Existing User', 1, 'healthy', 'connected', 'connected',
+ 'enc-mw-email', 'enc-mw-pass', 'enc-garmin-email', 'enc-garmin-pass'
+ )
+ """
+ )
+ )
+
+ columns_before = {col["name"] for col in inspect(engine).get_columns("sync_users")}
+ assert "notify_email_enabled" not in columns_before
+
+ initialize_schema(engine)
+
+ columns_after = {col["name"] for col in inspect(engine).get_columns("sync_users")}
+ assert "notify_email_enabled" in columns_after
+ assert "notification_email" in columns_after
+
+ with engine.connect() as conn:
+ row = conn.execute(text("SELECT name, notify_email_enabled, notification_email FROM sync_users")).one()
+ assert row.name == "Existing User"
+ assert row.notify_email_enabled == 0
+ assert row.notification_email is None
+ finally:
+ engine.dispose()
+
+
+def test_initialize_schema_is_idempotent() -> None:
+ engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
+ try:
+ initialize_schema(engine)
+ initialize_schema(engine)
+ columns = {col["name"] for col in inspect(engine).get_columns("sync_users")}
+ assert "notify_email_enabled" in columns
+ finally:
+ engine.dispose()
diff --git a/tests/notifications/test_emailer.py b/tests/notifications/test_emailer.py
new file mode 100644
index 0000000..a023e6d
--- /dev/null
+++ b/tests/notifications/test_emailer.py
@@ -0,0 +1,69 @@
+from unittest.mock import MagicMock, patch
+
+from app.notifications.emailer import EmailNotifier
+
+
+def _notifier(**overrides) -> EmailNotifier:
+ defaults = dict(
+ host="smtp.example.com",
+ port=587,
+ username="user@example.com",
+ password="secret",
+ from_address="sync@example.com",
+ use_tls=True,
+ )
+ defaults.update(overrides)
+ return EmailNotifier(**defaults)
+
+
+def test_unconfigured_notifier_is_not_configured() -> None:
+ notifier = _notifier(host=None)
+ assert notifier.configured is False
+
+
+def test_configured_notifier_is_configured() -> None:
+ assert _notifier().configured is True
+
+
+def test_send_skips_silently_when_not_configured() -> None:
+ notifier = _notifier(host=None)
+ with patch("app.notifications.emailer.smtplib.SMTP") as smtp_cls:
+ notifier.send(to_address="user@example.com", subject="s", body="b")
+ smtp_cls.assert_not_called()
+
+
+def test_send_uses_starttls_and_login_when_configured() -> None:
+ notifier = _notifier()
+ smtp_instance = MagicMock()
+ smtp_instance.__enter__.return_value = smtp_instance
+ with patch("app.notifications.emailer.smtplib.SMTP", return_value=smtp_instance) as smtp_cls:
+ notifier.send(to_address="rider@example.com", subject="Action required", body="Check the dashboard")
+
+ smtp_cls.assert_called_once_with("smtp.example.com", 587, timeout=10)
+ smtp_instance.starttls.assert_called_once()
+ smtp_instance.login.assert_called_once_with("user@example.com", "secret")
+ assert smtp_instance.send_message.call_count == 1
+ sent_message = smtp_instance.send_message.call_args[0][0]
+ assert sent_message["To"] == "rider@example.com"
+ assert sent_message["From"] == "sync@example.com"
+ assert sent_message["Subject"] == "Action required"
+
+
+def test_send_skips_login_without_credentials() -> None:
+ notifier = _notifier(username=None, password=None)
+ smtp_instance = MagicMock()
+ smtp_instance.__enter__.return_value = smtp_instance
+ with patch("app.notifications.emailer.smtplib.SMTP", return_value=smtp_instance):
+ notifier.send(to_address="rider@example.com", subject="s", body="b")
+
+ smtp_instance.login.assert_not_called()
+
+
+def test_send_skips_starttls_when_disabled() -> None:
+ notifier = _notifier(use_tls=False)
+ smtp_instance = MagicMock()
+ smtp_instance.__enter__.return_value = smtp_instance
+ with patch("app.notifications.emailer.smtplib.SMTP", return_value=smtp_instance):
+ notifier.send(to_address="rider@example.com", subject="s", body="b")
+
+ smtp_instance.starttls.assert_not_called()
diff --git a/tests/sync/fakes.py b/tests/sync/fakes.py
index 234bbe7..f893920 100644
--- a/tests/sync/fakes.py
+++ b/tests/sync/fakes.py
@@ -28,3 +28,11 @@ class FakeGarminUploader:
if self.error is not None:
raise self.error
return self.result
+
+
+class FakeNotifier:
+ def __init__(self) -> None:
+ self.sent: list[dict] = []
+
+ def send(self, *, to_address: str, subject: str, body: str) -> None:
+ self.sent.append({"to_address": to_address, "subject": subject, "body": body})
diff --git a/tests/sync/test_manager.py b/tests/sync/test_manager.py
index f367d57..8508e7d 100644
--- a/tests/sync/test_manager.py
+++ b/tests/sync/test_manager.py
@@ -10,7 +10,7 @@ from app.mywhoosh.client import MyWhooshDeviceConflictError
from app.mywhoosh.models import MyWhooshActivity
from app.sync.manager import SyncManager
from tests.sync.conftest import FakeFitConverter, _create_user
-from tests.sync.fakes import FakeGarminUploader, FakeMyWhooshClient
+from tests.sync.fakes import FakeGarminUploader, FakeMyWhooshClient, FakeNotifier
@pytest.mark.asyncio
@@ -229,6 +229,82 @@ async def test_device_conflict_sets_distinct_action_reason(seeded_user: SyncUser
assert reloaded.health_state == HealthState.ACTION_REQUIRED
+@pytest.mark.asyncio
+async def test_notifies_on_new_action_required_when_opted_in(session_factory, cipher, settings) -> None:
+ with session_factory() as session:
+ user = _create_user(session, cipher)
+ user.notify_email_enabled = True
+ user.notification_email = "alerts@example.com"
+ session.commit()
+ user_id = user.id
+
+ notifier = FakeNotifier()
+ manager = SyncManager(
+ session_factory=session_factory,
+ credential_cipher=cipher,
+ settings=settings,
+ mywhoosh_factory=lambda token_store: DeviceConflictMyWhooshClient(),
+ garmin_factory=lambda email, password, tokenstore: FakeGarminUploader(),
+ fit_converter=FakeFitConverter(),
+ notifier=notifier,
+ )
+
+ await manager.sync_user(user_id)
+
+ assert len(notifier.sent) == 1
+ assert notifier.sent[0]["to_address"] == "alerts@example.com"
+ assert "another device" in notifier.sent[0]["body"]
+
+
+@pytest.mark.asyncio
+async def test_does_not_notify_when_not_opted_in(session_factory, cipher, settings) -> None:
+ user_id = None
+ with session_factory() as session:
+ user = _create_user(session, cipher)
+ user_id = user.id
+
+ notifier = FakeNotifier()
+ manager = SyncManager(
+ session_factory=session_factory,
+ credential_cipher=cipher,
+ settings=settings,
+ mywhoosh_factory=lambda token_store: DeviceConflictMyWhooshClient(),
+ garmin_factory=lambda email, password, tokenstore: FakeGarminUploader(),
+ fit_converter=FakeFitConverter(),
+ notifier=notifier,
+ )
+
+ await manager.sync_user(user_id)
+
+ assert notifier.sent == []
+
+
+@pytest.mark.asyncio
+async def test_does_not_renotify_for_unresolved_unchanged_reason(session_factory, cipher, settings) -> None:
+ with session_factory() as session:
+ user = _create_user(session, cipher)
+ user.notify_email_enabled = True
+ user.notification_email = "alerts@example.com"
+ session.commit()
+ user_id = user.id
+
+ notifier = FakeNotifier()
+ manager = SyncManager(
+ session_factory=session_factory,
+ credential_cipher=cipher,
+ settings=settings,
+ mywhoosh_factory=lambda token_store: DeviceConflictMyWhooshClient(),
+ garmin_factory=lambda email, password, tokenstore: FakeGarminUploader(),
+ fit_converter=FakeFitConverter(),
+ notifier=notifier,
+ )
+
+ await manager.sync_user(user_id)
+ await manager.sync_user(user_id)
+
+ assert len(notifier.sent) == 1
+
+
@pytest.mark.asyncio
async def test_mfa_code_reaches_real_garmin_uploader_via_sync_manager(
session_factory, cipher, settings, seeded_user: SyncUser
diff --git a/tests/web/test_users.py b/tests/web/test_users.py
index 66df812..af4a4b6 100644
--- a/tests/web/test_users.py
+++ b/tests/web/test_users.py
@@ -366,6 +366,106 @@ def test_update_user_rejects_empty_mywhoosh_email(client: TestClient) -> None:
assert response.status_code == 400
+def test_create_user_without_notifications_requires_no_email(client: TestClient) -> None:
+ login(client)
+ page = client.get("/users/new")
+ csrf = extract_csrf(page.text)
+ response = client.post(
+ "/users",
+ data={"csrf_token": csrf, **_create_payload()},
+ follow_redirects=False,
+ )
+ assert response.status_code == 303
+
+
+def test_create_user_rejects_notify_enabled_without_email(client: TestClient) -> None:
+ login(client)
+ page = client.get("/users/new")
+ csrf = extract_csrf(page.text)
+ response = client.post(
+ "/users",
+ data={"csrf_token": csrf, **_create_payload(notify_email_enabled="on")},
+ )
+ assert response.status_code == 400
+
+
+def test_create_user_persists_notification_preferences(client: TestClient) -> None:
+ login(client)
+ page = client.get("/users/new")
+ csrf = extract_csrf(page.text)
+ response = client.post(
+ "/users",
+ data={
+ "csrf_token": csrf,
+ **_create_payload(notify_email_enabled="on", notification_email="alerts@example.com"),
+ },
+ follow_redirects=False,
+ )
+ assert response.status_code == 303
+ user_id = int(response.headers["location"].rsplit("/", 1)[-1])
+
+ with client.app.state.session_factory() as session:
+ user = UserRepository(session).get(user_id)
+ assert user is not None
+ assert user.notify_email_enabled is True
+ assert user.notification_email == "alerts@example.com"
+
+
+def test_update_user_can_enable_notifications_without_reentering_passwords(client: TestClient) -> None:
+ login(client)
+ user_id = create_user_via_http(client)
+
+ edit_page = client.get(f"/users/{user_id}/edit")
+ csrf = extract_csrf(edit_page.text)
+ response = client.post(
+ f"/users/{user_id}",
+ data={
+ "csrf_token": csrf,
+ "name": "Max",
+ "mywhoosh_email": "max@example.com",
+ "mywhoosh_password": "",
+ "garmin_email": "max-garmin@example.com",
+ "garmin_password": "",
+ "enabled": "on",
+ "notify_email_enabled": "on",
+ "notification_email": "alerts@example.com",
+ },
+ follow_redirects=True,
+ )
+ assert response.status_code == 200
+
+ with client.app.state.session_factory() as session:
+ user = UserRepository(session).get(user_id)
+ assert user is not None
+ assert user.notify_email_enabled is True
+ assert user.notification_email == "alerts@example.com"
+ cipher = CredentialCipher(client.app.state.settings.credential_encryption_key)
+ assert cipher.decrypt(user.mywhoosh_password_enc) == "mw-secret"
+ assert cipher.decrypt(user.garmin_password_enc) == "garmin-secret"
+
+
+def test_update_user_rejects_notify_enabled_without_email(client: TestClient) -> None:
+ login(client)
+ user_id = create_user_via_http(client)
+ edit_page = client.get(f"/users/{user_id}/edit")
+ csrf = extract_csrf(edit_page.text)
+ response = client.post(
+ f"/users/{user_id}",
+ data={
+ "csrf_token": csrf,
+ "name": "Max",
+ "mywhoosh_email": "max@example.com",
+ "mywhoosh_password": "",
+ "garmin_email": "max-garmin@example.com",
+ "garmin_password": "",
+ "enabled": "on",
+ "notify_email_enabled": "on",
+ "notification_email": "",
+ },
+ )
+ assert response.status_code == 400
+
+
def test_update_user_rejects_empty_garmin_email(client: TestClient) -> None:
login(client)
user_id = create_user_via_http(client)