Files
Moon-Household-Budget/reminders.py
tonym 74d5e196c7 Add in-app Settings page for secrets; move Gmail creds out of source
- 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 <noreply@anthropic.com>
2026-06-06 16:38:20 -05:00

141 lines
5.5 KiB
Python

import smtplib
import subprocess
import json
import os
from email.mime.text import MIMEText
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
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"] = 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, 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"'
try:
subprocess.run(["osascript", "-e", script], check=False)
except (FileNotFoundError, OSError):
pass
def bills_due_in(days):
today = date.today()
target = today + timedelta(days=days)
all_bills = load_bills()
due = []
for b in all_bills:
if not b.get("active", True):
continue
if b.get("autopay", False):
continue
if not b.get("remind", True):
continue
due_day = b.get("due_day")
if not due_day:
continue
_, num_days = cal_module.monthrange(target.year, target.month)
due_day_clamped = min(due_day, num_days)
bill_date = date(target.year, target.month, due_day_clamped)
if bill_date == target:
due.append({**b, "due_date": bill_date})
return due
def check_and_send_reminders():
sent = []
for days_ahead, label in [(3, "in 3 days"), (0, "TODAY")]:
due = bills_due_in(days_ahead)
if not due:
continue
total = sum(b.get("amount", 0) for b in due if b.get("amount"))
# Mac notifications — one per bill
for b in due:
amt = f"${b['amount']:.2f}" if b.get("amount") else "amount varies"
mac_notification(
f"Bill Due {label.title()}",
f"{b['name']}{amt}"
)
# Email — one digest
subject = f"🌙 Moon Budget: {len(due)} bill{'s' if len(due) != 1 else ''} due {label}"
rows = ""
for b in due:
amt = f"${b['amount']:.2f}" if b.get("amount") else ""
cat = b.get("category", "")
rows += f"""
<tr>
<td style="padding:8px 12px;border-bottom:1px solid #EDE8E2;">{b['name']}</td>
<td style="padding:8px 12px;border-bottom:1px solid #EDE8E2;font-weight:600;">{amt}</td>
<td style="padding:8px 12px;border-bottom:1px solid #EDE8E2;color:#7A6E68;">{cat}</td>
</tr>"""
total_row = f"${total:.2f}" if total else ""
body_html = f"""
<div style="font-family:'Helvetica Neue',Arial,sans-serif;max-width:520px;margin:0 auto;background:#FAF7F2;padding:24px;border-radius:12px;">
<h2 style="font-size:22px;color:#3A3330;margin-bottom:4px;">🌙 Moon Household Budget</h2>
<p style="color:#7A6E68;margin-bottom:20px;font-size:14px;">Bill reminder — due <strong>{label}</strong></p>
<table style="width:100%;border-collapse:collapse;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 2px 8px rgba(80,60,50,0.08);">
<thead>
<tr style="background:#F5F1EC;">
<th style="text-align:left;padding:8px 12px;font-size:11px;text-transform:uppercase;letter-spacing:0.07em;color:#B0A89E;">Bill</th>
<th style="text-align:left;padding:8px 12px;font-size:11px;text-transform:uppercase;letter-spacing:0.07em;color:#B0A89E;">Amount</th>
<th style="text-align:left;padding:8px 12px;font-size:11px;text-transform:uppercase;letter-spacing:0.07em;color:#B0A89E;">Category</th>
</tr>
</thead>
<tbody>{rows}</tbody>
{f'<tfoot><tr><td style="padding:8px 12px;font-weight:700;" colspan="2">Total: {total_row}</td><td></td></tr></tfoot>' if total_row else ''}
</table>
<p style="color:#B0A89E;font-size:12px;margin-top:20px;text-align:center;">Moon Household Budget · sent to bonna61@gmail.com</p>
</div>"""
body_text = f"Moon Budget — Bills due {label}:\n\n"
for b in due:
amt = f"${b['amount']:.2f}" if b.get("amount") else "varies"
body_text += f"{b['name']}{amt}\n"
if total:
body_text += f"\nTotal: ${total:.2f}\n"
send_email(subject, body_html, body_text)
sent.append(f"{len(due)} bills due {label}")
return sent
if __name__ == "__main__":
results = check_and_send_reminders()
if results:
print("Sent reminders:", ", ".join(results))
else:
print("No reminders to send today.")