diff --git a/.claude/skills/deploy-massprice/SKILL.md b/.claude/skills/deploy-massprice/SKILL.md index f72e637..926ca1b 100644 --- a/.claude/skills/deploy-massprice/SKILL.md +++ b/.claude/skills/deploy-massprice/SKILL.md @@ -38,7 +38,9 @@ cd /mnt/user/appdata/massprice && docker compose restart 1. Create `/mnt/user/appdata/massprice` on Unraid and a real `.env` there (copy `.env.example`, fill in `LOGIN_PASSWORD`, `SECRET_KEY`, - `HOST_PORT`, `TZ`). + `HOST_PORT`, `TZ` — and optionally `GMAIL_USER`/`GMAIL_APP_PASSWORD`/ + `ALERT_EMAIL` for the session-expiry email alert, same Gmail-app- + password pattern as `lunch-notifier`). 2. First `docker compose up -d --build` — confirm the container starts and `/login` responds on `HOST_PORT`. 3. Log into the webui, go to the **Session** page, and follow its @@ -61,12 +63,14 @@ cd /mnt/user/appdata/massprice && docker compose restart ## Persistent state, never touched by deploys -`.env` holds `LOGIN_PASSWORD` and `SECRET_KEY`. `data/` (bind-mounted to +`.env` holds `LOGIN_PASSWORD`, `SECRET_KEY`, and (optionally) +`GMAIL_USER`/`GMAIL_APP_PASSWORD`/`ALERT_EMAIL`. `data/` (bind-mounted to `/data`) holds `schedules.json` (the schedules set from the webui), `auth_state.json` (the saved MassPrice session imported via the Session page — a live bearer credential for the user's MassPrice account while valid, see `CLAUDE.md`; losing this just costs one re-import, not data), -`run_history.json`, and `rules_cache.json`. Both are excluded from the +`run_history.json`, `rules_cache.json`, and `notify_state.json` (the +session-expiry alert's send-once dedupe flag). Both are excluded from the deploy rsync (which runs `--delete`) and from git. ## If scheduled runs stop working diff --git a/.env.example b/.env.example index f99f17c..09778cd 100644 --- a/.env.example +++ b/.env.example @@ -16,4 +16,16 @@ HOST_PORT=5057 # ── Timezone ───────────────────────────────────────────────────────── # Controls what "6:00 AM" means for your schedules -TZ=America/New_York +TZ=America/Phoenix + +# ── Session-expiry email alert (optional) ─────────────────────────── +# Sent once when the saved MassPrice session expires/is missing — not +# on every failed run, just the first one after it goes bad. Same +# Gmail-app-password pattern as lunch-notifier: GMAIL_APP_PASSWORD is a +# Gmail *app password* (Google Account → Security → App Passwords), not +# your real Gmail password — required since Gmail blocks plain SMTP auth +# with the account password. Leave blank to disable the alert entirely. +GMAIL_USER= +GMAIL_APP_PASSWORD= +# Defaults to GMAIL_USER if left blank +ALERT_EMAIL= diff --git a/CLAUDE.md b/CLAUDE.md index 24b639b..55061dc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,10 +23,10 @@ tool is confirmed working, so there's exactly one thing triggering runs. (`LOGIN_PASSWORD` env var, `hmac.compare_digest`) — this protects the *admin UI itself*, unrelated to the MassPrice/TCGplayer login below. Routes: `/` (Dashboard — rule cards with schedule editor), `/activity` - (run history), and a small JSON API + (run history), `/session` (login-session import), and a small JSON API (`/api/rules/refresh`, `/api/rules//schedule`, - `/api/rules//run`) that the Dashboard's JS calls without full page - reloads. + `/api/rules//run`, `/api/session/import`) that the frontend JS + calls without full page reloads. - **`scheduler.py`** — an in-process APScheduler `BackgroundScheduler` holding one `CronTrigger` job per `(rule, schedule entry)` pair, rebuilt from `schedules.json` on startup and whenever the webui saves @@ -61,7 +61,8 @@ tool is confirmed working, so there's exactly one thing triggering runs. - **`storage.py`** — tiny JSON read/write helpers (temp-file + `os.replace`, so a crash mid-write can't corrupt state) for everything under `/data`: `schedules.json`, `auth_state.json`, - `run_history.json` (bounded to the last 200 entries), `rules_cache.json`. + `run_history.json` (bounded to the last 200 entries), `rules_cache.json`, + `notify_state.json` (the session-expiry alert's send-once dedupe flag). - **`templates/` / `static/`** — a small, plain dark dashboard. This is a deliberately different, simpler visual identity than Arr Summary's teletext system — that design language is specific to that app, not a @@ -119,6 +120,27 @@ The Dashboard shows a red banner (`_session_warning()` in `app.py`) when the most recent run/refresh failed specifically because of a missing/ expired session, so this doesn't just look like "no rules found." +## Session-expiry email alert + +**`notify.py`** — a thin Gmail-SMTP-with-app-password sender, the same +pattern `lunch-notifier` already uses on this box (reused deliberately +rather than adding a second notification channel). `GMAIL_USER`/ +`GMAIL_APP_PASSWORD` (a Gmail **app password**, not the real account +password — Gmail blocks plain SMTP auth otherwise) and optional +`ALERT_EMAIL` (defaults to `GMAIL_USER`). `send_alert()` is a best-effort +no-op — returns `False` and just logs — if those env vars aren't set or +the send fails, since a broken mail config shouldn't break automation. + +Fires exactly **once** per expiry, not on every failed scheduled run +while it's down: `automation._notify_session_expired_once()` is called +from `_ensure_authenticated()` right before it raises, checks/sets a +`session_expired_notified` flag in `notify_state.json`, and only sends +if that flag isn't already set. The flag resets to `False` the moment +`import_cookies()` successfully saves a fresh session, so the next +expiry gets its own alert. The flag is only set `True` after a +confirmed-sent email — if mail is misconfigured, every failing run keeps +retrying the send rather than silently giving up after the first one. + ## Local development ```bash diff --git a/app.py b/app.py index ad393c3..e6afb15 100644 --- a/app.py +++ b/app.py @@ -8,7 +8,7 @@ import automation import scheduler import storage -APP_VERSION = "0.3.0" +APP_VERSION = "0.4.0" APP_VERSION_DATE = "2026-09-01" app = Flask(__name__) diff --git a/automation.py b/automation.py index 7f7ddec..19aaa91 100644 --- a/automation.py +++ b/automation.py @@ -36,6 +36,7 @@ from datetime import datetime, timezone from playwright.sync_api import sync_playwright +import notify import storage logger = logging.getLogger("massprice.automation") @@ -79,9 +80,33 @@ def _is_logged_out(page): return "/Rule" not in page.url +def _notify_session_expired_once(): + """Send the email alert only on the first failure after the session + goes bad, not on every scheduled run that fails while it's down — + the flag resets when a fresh session is imported successfully.""" + state = storage.load("notify_state.json", {}) + if state.get("session_expired_notified"): + return + sent = notify.send_alert( + "MassPrice Scheduler: login session expired", + "The saved MassPrice session has expired or is missing. " + "Scheduled rule runs will keep failing until you log back in " + "via the Session page in the MassPrice Scheduler webui and " + "import a fresh cookie export.", + ) + # Only mark as notified if the email actually sent — an unset/broken + # mail config shouldn't silently swallow the next attempt forever. + if sent: + storage.save( + "notify_state.json", + {"session_expired_notified": True, "notified_at": datetime.now(timezone.utc).isoformat()}, + ) + + def _ensure_authenticated(page): page.goto(f"{BASE_URL}/Rule/List") if _is_logged_out(page): + _notify_session_expired_once() raise AutomationError( "MassPrice session expired or missing — log in via the " "Session page and paste in a fresh cookie export." @@ -264,5 +289,6 @@ def import_cookies(raw_text): "itself, after finishing login, not from the login page." ) _save_state(context) + storage.save("notify_state.json", {"session_expired_notified": False}) finally: browser.close() diff --git a/docker-compose.yml b/docker-compose.yml index 9b60f50..c046887 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,5 +15,8 @@ services: environment: - LOGIN_PASSWORD=${LOGIN_PASSWORD} - SECRET_KEY=${SECRET_KEY} - - TZ=${TZ:-America/New_York} + - TZ=${TZ:-America/Phoenix} + - GMAIL_USER=${GMAIL_USER:-} + - GMAIL_APP_PASSWORD=${GMAIL_APP_PASSWORD:-} + - ALERT_EMAIL=${ALERT_EMAIL:-} restart: unless-stopped diff --git a/notify.py b/notify.py new file mode 100644 index 0000000..f8a15d4 --- /dev/null +++ b/notify.py @@ -0,0 +1,34 @@ +"""Email alerts via Gmail SMTP + app password — the same pattern already +used by lunch-notifier on this box, reused here rather than adding a new +notification service. Silently a no-op if GMAIL_USER/GMAIL_APP_PASSWORD +aren't set, so the alert feature is opt-in. +""" +import logging +import os +import smtplib +from email.mime.text import MIMEText + +logger = logging.getLogger("massprice.notify") + +GMAIL_USER = os.environ.get("GMAIL_USER") +GMAIL_APP_PASSWORD = os.environ.get("GMAIL_APP_PASSWORD") +ALERT_EMAIL = os.environ.get("ALERT_EMAIL") or GMAIL_USER + + +def send_alert(subject, body): + """Best-effort: logs and returns False on any failure rather than + raising, since a broken alert channel shouldn't break automation.""" + if not GMAIL_USER or not GMAIL_APP_PASSWORD or not ALERT_EMAIL: + return False + msg = MIMEText(body) + msg["Subject"] = subject + msg["From"] = GMAIL_USER + msg["To"] = ALERT_EMAIL + try: + with smtplib.SMTP_SSL("smtp.gmail.com", 465) as server: + server.login(GMAIL_USER, GMAIL_APP_PASSWORD) + server.sendmail(GMAIL_USER, [ALERT_EMAIL], msg.as_string()) + return True + except Exception: + logger.exception("Failed to send alert email") + return False