# Mindbaboon Goal tracker with iteration-based email reminders. You set a goal, pick a cadence (week / 2 weeks / month), and the app emails you to check in. Responding to the email feeds a small history log so you can see the cycle of attempts over time. Stack: Flask + SQLite + APScheduler, packaged as Docker. UI gated behind Google OAuth (email allowlist). Includes a REST API and an MCP server so you can manage goals from an LLM client (e.g. Claude Code) or any HTTP tool — the API uses an independent `X-API-Key` so MCP/cron jobs keep working without browser sessions. ## Architecture | Piece | What it is | |---|---| | `mindbaboon.py` | Flask app (UI routes: `/`, `/add`, `/edit`, `/settings`) | | `iteration.py` | Blueprint handling the iteration response flow (`/iteration/`) | | `api.py` | REST API blueprint (`/api/...`), API-key auth | | `scheduler.py` | APScheduler + email sender, persistent via SQLAlchemyJobStore | | `database.py` | SQLite helpers | | `init_mindbaboon_db.py` | Idempotent schema init | | `mcp_server/` | Standalone MCP server wrapping the REST API | Timers survive container restarts: APScheduler stores jobs in the `apscheduler_jobs` table in the same SQLite DB as goals, so `next_run_time` is recovered on boot. Missed fires within `misfire_grace_time` (60s) run on startup; older misses are dropped (`coalesce=True` collapses repeats). A separate idempotency guard in `send_goal_reminder` refuses to send a reminder if one was already sent for the same goal within the last hour — defense against restart races causing duplicate emails. ## Run Production deployment runs on a Proxmox host (LXC or VM with Docker). Local Mac/dev box is only used to build and ship — never to "host" the service. The pieces: 1. Copy `.env.example` to `.env` and fill in SMTP creds and an API key: ```bash cp .env.example .env python -c "import secrets; print(secrets.token_hex(32))" # paste into MINDBABOON_API_KEY ``` 2. On the Proxmox-hosted target, in the project directory. Either pull the prebuilt image from GHCR (recommended) or build from source: ```bash docker compose -p mindbaboon pull && docker compose -p mindbaboon up -d # prebuilt (ghcr.io/petronijus/mindbaboon) # or, to build locally instead: docker compose -p mindbaboon up -d --build ``` The `-p mindbaboon` is load-bearing — compose uses it to name the data volume (`mindbaboon_mindbaboon_data`). Changing it strands your existing DB. 3. Open `http://:5000` to confirm the UI loads. ### Critical: never run two instances against the same DB The scheduler uses a SQLite-backed job store with no inter-process locking. If you start the app twice against the same database (two containers, container + bare-metal process, two compose stacks), each process will independently fire every scheduled job and you'll get duplicate emails — both startup mail and reminder mail. Symptoms and diagnostics: ```bash # 1) Confirm there's exactly one container docker ps --filter "name=mindbaboon" # 2) Check the live process count from inside the container docker exec ps aux | grep python # 3) Grep the logs — should see exactly one "STARTUP" line per real boot docker logs 2>&1 | grep STARTUP # 4) Hit /api/health and note the "pid" + "hostname" fields. Repeated # calls should always return the same pid until you restart. If pid # flips between two values, that's two processes load-balancing. curl http://:5000/api/health | jq '.pid, .hostname' ``` The app also self-defends: startup emails sent within 5 minutes of an earlier one are skipped (logged as `WARNING - Skipping startup email`), and reminder emails for the same goal within an hour are skipped (`WARNING - Skipping reminder for goal X`). If you see those warnings, you have two instances and should track down the second one. ## Authentication UI is gated behind **Google OAuth 2.0** (authorization code flow with PKCE). On first visit to any UI route, an unauthenticated user is redirected to `/login`, signs in with Google, and the backend verifies: 1. ID token signature against Google JWKS (via Authlib) 2. `email_verified=true` claim 3. Email is in `ALLOWED_EMAILS` (lowercase exact match) A 30-day Flask session cookie (`__Host-` prefixed, Secure+HttpOnly+ SameSite=Lax) is set on success. Logout is POST-only with CSRF token. **API endpoints (`/api/...`) are NOT gated by OAuth** — they keep the `X-API-Key` header check from `api.py`. This lets MCP servers, cron jobs, and curl scripts keep working independently of browser sessions. ### Setup (Google OAuth) 1. https://console.cloud.google.com/ → APIs & Services → Credentials 2. Create OAuth 2.0 Client ID, type **Web application** 3. Authorized redirect URI: `https:///oauth2/callback` 4. Drop `GOOGLE_OAUTH_CLIENT_ID` + `GOOGLE_OAUTH_CLIENT_SECRET` into `.env` 5. Set `ALLOWED_EMAILS=you@example.com,...` and `FLASK_SECRET_KEY=...` ### Defense in depth (recommended) For belt-and-suspenders security, add a **Cloudflare Access** policy in the Zero Trust dashboard on the same hostname. Cloudflare gates the edge with your email allowlist before any request hits the docker host. The app's own OAuth still works for direct LAN/Tailscale access. Security headers (HSTS, CSP, X-Frame-Options, etc.) are set by Flask-Talisman. CSRF tokens via Flask-WTF on every POST form. Rate limiting on `/oauth2/callback` via Flask-Limiter. ## REST API Base URL `/api`. Every endpoint except `/api/health` requires the `X-API-Key` header. | Method | Path | | |---|---|---| | GET | `/api/health` | Scheduler state, job count, each job's next_run_time | | GET | `/api/goals?include_completed=false` | List goals | | POST | `/api/goals` | Create goal (+ schedule if `iteration` set) | | GET | `/api/goals/` | Single goal | | PATCH | `/api/goals/` | Update allowed fields | | DELETE | `/api/goals/` | Delete goal and its history | | POST | `/api/goals//complete` | Record iteration completion | | POST | `/api/goals//snooze` | Pause reminders | | POST | `/api/goals//resume` | Resume and reschedule | | GET | `/api/goals//history` | Goal + iteration history | Valid `iteration` values: `"week"`, `"2 weeks"`, `"month"`. The `timedelta` kwargs behind these keys live in `config.py`. Note that `"month"` is implemented as `weeks=4` (28 days), not a calendar month — over a year this drifts by ~5 days. If you need true calendar-month cadence, swap the `IntervalTrigger` for a `CronTrigger`. Quick check after a restart: ```bash curl http://:5000/api/health # scheduler_running: true, jobs[] with future next_run_time, pid + hostname ``` ## Dependencies Python dependencies are declared twice, on purpose: | File | Role | |---|---| | `requirements.txt`, `mcp_server/requirements.txt` | Intent — floors and upper bounds a human edits | | `requirements.lock`, `mcp_server/requirements.lock` | Exact, hash-pinned resolve that actually gets installed | The Docker image installs with `pip install --require-hashes -r requirements.lock`, so a build resolves to the same packages that were last tested instead of whatever PyPI serves that day (an unbounded `mcp>=1.0.0` floor once let a fresh build pull mcp 2.0, which had removed the module the MCP server imported). To change a dependency, edit the requirements file, then regenerate and commit both lockfiles: ```bash scripts/deps-lock.sh # needs uv; resolves for Python 3.11 with --generate-hashes ``` Installing outside Docker works the same way: ```bash pip install --require-hashes -r requirements.lock pip install --require-hashes -r mcp_server/requirements.lock # MCP server only ``` ## MCP server `mcp_server/` exposes the REST API as MCP tools so an LLM can create goals, mark iterations done, snooze, and query state. Setup is in [mcp_server/README.md](mcp_server/README.md). ## Env vars | Var | Purpose | |---|---| | `EMAIL_SMTP_SERVER`, `EMAIL_SMTP_PORT`, `EMAIL_USERNAME`, `EMAIL_PASSWORD` | SMTP for outbound mail — defaults to the Resend relay, where the username is the literal `resend` and the password is the API key | | `EMAIL_FROM` | Sender address. Required on a relay like Resend (the login isn't an address) and must sit on a verified domain; falls back to `EMAIL_USERNAME` | | `DEFAULT_TO_ADDRESS` | Who receives reminders | | `SERVER_HOST` | Hostname used in email reminder links | | `MINDBABOON_API_KEY` | Required to call `/api/...` (except `/health`) | | `FLASK_SECRET_KEY` | 32-byte hex, signs session cookies. Hard-fail if missing | | `GOOGLE_OAUTH_CLIENT_ID` / `GOOGLE_OAUTH_CLIENT_SECRET` | Google OAuth Web client | | `ALLOWED_EMAILS` | Comma-separated lowercase emails permitted to sign in | | `TZ` | Timezone for scheduler (default `Europe/Prague`) | Never commit `.env`. It's in `.gitignore`. `docker-compose.yml` loads it via `env_file:`. ## Data Single SQLite DB at `/app/data/mindbaboon.db` inside the container, backed by the `mindbaboon_mindbaboon_data` named volume when running with `-p mindbaboon`. Tables: - `goals` — the goals themselves. `is_silenced=1` means the next scheduled tick will skip sending email (set when a reminder fires, cleared when the user responds via `/iteration/` or `/api/.../resume`). - `goal_history` — one row per completed iteration, with reflection text - `iteration_history` — status transitions (Scheduled / yes / no) - `apscheduler_jobs` — serialized jobs + `next_run_time` (binary blob, written by APScheduler) - `settings` — key/value for things like `default_email` and the global iteration slot (`iteration_weekday` / `iteration_hour` / `iteration_minute`) FK `ON DELETE CASCADE` on `goal_history` and `iteration_history` is wired up — `get_db_connection()` enables `PRAGMA foreign_keys = ON`, so deleting a goal automatically removes its history rows. ## Releases The image is **built and pushed to GHCR locally** (not in CI): ```bash echo "$GHCR_PAT" | docker login ghcr.io -u petronijus --password-stdin # PAT: write:packages scripts/build-push.sh # builds + pushes ghcr.io/petronijus/mindbaboon:{VERSION,latest} ``` Tagging `vX.Y.Z` (via the `repo-release` flow) then only validates the Docker build in CI and cuts a GitHub release with notes — it does **not** publish the image. The version is the `VERSION` file (also in the footer and `/api/health`). Deploy pulls the prebuilt image (`docker compose -p mindbaboon pull && up -d`). See [CHANGELOG.md](CHANGELOG.md). ## License [MIT](LICENSE) ## Secrets `.env` is gitignored and kept SOPS-encrypted in the private overlay (`private/config/env.sops`) so it syncs across machines without plaintext in git. With the overlay cloned into `./private` and the 1Password CLI signed in, run `./scripts/secrets-decrypt.sh` to materialize `.env` (the age private key is fetched from 1Password), or `./scripts/secrets-edit.sh` to change it. Prereqs: `brew install sops age`. > **Do not edit `.env` by hand to make a change stick.** It is a generated > artifact — the next `secrets-decrypt.sh` overwrites it from `env.sops`. To > change any value (e.g. `SERVER_HOST`), run `secrets-edit.sh`, commit the > updated `private/config/env.sops`, then on the host re-run > `secrets-decrypt.sh` and `docker compose -p mindbaboon up -d` to apply it.