mail notification

This commit is contained in:
Bastian Wagner
2026-08-15 20:47:02 +02:00
parent 7c9e19ba0b
commit 2aba1265af
16 changed files with 519 additions and 2 deletions

View File

@@ -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":

View File

@@ -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)

View File

@@ -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}"))

View File

@@ -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

View File

View File

@@ -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)

View File

@@ -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,

View File

@@ -9,3 +9,5 @@ class UserFormData:
garmin_email: str
garmin_password: str
enabled: bool
notify_email_enabled: bool = False
notification_email: str = ""

View File

@@ -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)

View File

@@ -36,6 +36,16 @@
Enabled
</label>
<label for="notify_email_enabled">
<input type="checkbox" id="notify_email_enabled" name="notify_email_enabled"
{% if user and user.notify_email_enabled %}checked{% endif %}>
Email me when this account needs attention
</label>
<label for="notification_email">Notification email</label>
<input type="email" id="notification_email" name="notification_email"
value="{{ user.notification_email if user and user.notification_email else '' }}">
<button type="submit">{% if user %}Save{% else %}Create{% endif %}</button>
</form>
</div>