From 74d5e196c7586252145bc9c9c1dea52c5a5f74f8 Mon Sep 17 00:00:00 2001 From: tonym Date: Sat, 6 Jun 2026 16:38:20 -0500 Subject: [PATCH] Add in-app Settings page for secrets; move Gmail creds out of source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - settings.py: JSON-on-volume store with env fallback; secrets never rendered. - /settings page (⚙️ in nav) to set Gmail address + app password + reminder prefs; password fields are write-only (blank keeps existing). - reminders.py reads Gmail creds from Settings instead of a hardcoded password (resolves #2 in code — note the old password is still in git history; rotate it). - mac_notification no longer crashes off-macOS; FLASK_DEBUG gates debug (#7). Co-Authored-By: Claude Opus 4.8 --- app.py | 26 +++++++++++++++++- reminders.py | 29 +++++++++++++++----- settings.py | 52 ++++++++++++++++++++++++++++++++++++ templates/base.html | 1 + templates/settings.html | 59 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 159 insertions(+), 8 deletions(-) create mode 100644 settings.py create mode 100644 templates/settings.html diff --git a/app.py b/app.py index 5ef2a79..6b7c8d3 100644 --- a/app.py +++ b/app.py @@ -6,6 +6,7 @@ import re from datetime import datetime, date import calendar as cal_module from storage import load_json, save_json +from settings import load_settings, save_settings, SECRET_KEYS from categories import CATEGORIES, CATEGORY_COLORS from bills import load_bills, save_bills, bills_for_month, calendar_grid, get_week_num from sinking import (load_funds, save_funds, FUND_COLORS, monthly_needed, @@ -2847,5 +2848,28 @@ def income_delete(entry_id): return redirect(url_for("income_view", year=year, month=month)) +@app.route("/settings", methods=["GET", "POST"]) +def settings_view(): + current = load_settings() + if request.method == "POST": + new = dict(current) + new["gmail_address"] = request.form.get("gmail_address", "").strip() + new["reminder_recipient"] = request.form.get("reminder_recipient", "").strip() + new["reminders_enabled"] = request.form.get("reminders_enabled") == "on" + # Secret fields: only overwrite when a new value is supplied; a blank + # field keeps the existing stored secret. + pw = request.form.get("gmail_app_password", "").strip() + if pw: + new["gmail_app_password"] = pw + save_settings(new) + return redirect(url_for("settings_view", saved=1)) + # Never send secret values to the template — only whether each one is set. + safe = {k: ("" if k in SECRET_KEYS else v) for k, v in current.items()} + secrets_status = {k: bool(current.get(k)) for k in SECRET_KEYS} + return render_template("settings.html", settings=safe, + secrets_status=secrets_status, + saved=request.args.get("saved")) + + if __name__ == "__main__": - app.run(debug=True, port=5000) + app.run(debug=os.environ.get("FLASK_DEBUG") == "1", port=5000) diff --git a/reminders.py b/reminders.py index 58fa358..8dbaf73 100644 --- a/reminders.py +++ b/reminders.py @@ -7,26 +7,41 @@ from email.mime.multipart import MIMEMultipart from datetime import date, timedelta import calendar as cal_module from bills import load_bills +from settings import get_setting -EMAIL = "bonna61@gmail.com" -APP_PASSWORD = "qowu zcff dbtk kqbs" + +def _gmail(): + """Resolve Gmail creds + recipient from Settings (env-var fallback).""" + email = get_setting("gmail_address", env="GMAIL_ADDRESS", default="") + pw = get_setting("gmail_app_password", env="GMAIL_APP_PASSWORD", default="") + recipient = get_setting("reminder_recipient", default="") or email + return email, pw, recipient def send_email(subject, body_html, body_text): + email, pw, recipient = _gmail() + if not (email and pw): + print("Gmail not configured in Settings — skipping email reminder.") + return msg = MIMEMultipart("alternative") msg["Subject"] = subject - msg["From"] = f"Moon Household Budget <{EMAIL}>" - msg["To"] = EMAIL + msg["From"] = f"Moon Household Budget <{email}>" + msg["To"] = recipient msg.attach(MIMEText(body_text, "plain")) msg.attach(MIMEText(body_html, "html")) with smtplib.SMTP_SSL("smtp.gmail.com", 465) as server: - server.login(EMAIL, APP_PASSWORD) - server.sendmail(EMAIL, EMAIL, msg.as_string()) + server.login(email, pw) + server.sendmail(email, recipient, msg.as_string()) def mac_notification(title, message): + # macOS-only; no-op (don't crash) when osascript isn't available, e.g. in + # the Linux container. script = f'display notification "{message}" with title "{title}" sound name "Default"' - subprocess.run(["osascript", "-e", script]) + try: + subprocess.run(["osascript", "-e", script], check=False) + except (FileNotFoundError, OSError): + pass def bills_due_in(days): diff --git a/settings.py b/settings.py new file mode 100644 index 0000000..c20c2d5 --- /dev/null +++ b/settings.py @@ -0,0 +1,52 @@ +"""App settings + secret storage (Gmail credentials, reminder prefs, etc.). + +Stored as JSON on the data volume (DATA_DIR/settings.json) via the atomic +storage layer — so it is NEVER committed to source control. Secrets resolve +with an environment-variable fallback, so a value can come from the settings +page OR from the container's .env. + +This is what lets us pull the hardcoded Gmail app password out of reminders.py +(issue #2): the password lives in settings.json on the volume instead. +""" +import os +from storage import load_json, save_json, DATA_DIR + +SETTINGS_FILE = os.path.join(DATA_DIR, "settings.json") + +# Keys holding secrets — their values are never rendered back to the browser. +SECRET_KEYS = {"gmail_app_password"} + +DEFAULTS = { + "gmail_address": "", + "gmail_app_password": "", + "reminder_recipient": "", # blank => send to gmail_address + "reminders_enabled": True, +} + + +def load_settings(): + data = load_json(SETTINGS_FILE, {}) or {} + merged = dict(DEFAULTS) + merged.update(data) + return merged + + +def save_settings(data): + save_json(SETTINGS_FILE, data) + + +def get_setting(key, env=None, default=""): + """Resolve a setting: stored value first, then env var, then default.""" + val = load_settings().get(key) + if val not in (None, ""): + return val + if env: + env_val = os.environ.get(env) + if env_val not in (None, ""): + return env_val + return default + + +def is_set(key): + """True if a (typically secret) value is present, without revealing it.""" + return bool(load_settings().get(key)) diff --git a/templates/base.html b/templates/base.html index b69fe80..fd6f9df 100644 --- a/templates/base.html +++ b/templates/base.html @@ -24,6 +24,7 @@ Net Worth Spending Report 💡 + ⚙️
diff --git a/templates/settings.html b/templates/settings.html new file mode 100644 index 0000000..e59b73a --- /dev/null +++ b/templates/settings.html @@ -0,0 +1,59 @@ +{% extends "base.html" %} +{% block content %} +
+

⚙️ Settings

+

+ Secrets are stored on the server (in the data volume) and are never shown + here once saved. Leave a password field blank to keep the current value. +

+ + {% if saved %} +
+ ✓ Settings saved. +
+ {% endif %} + +
+
+ 📧 Email reminders (Gmail) + + + + + + + + +
+ + +
+
+{% endblock %}