06/05 Optimize app: add email notification

This commit is contained in:
2026-06-05 14:54:11 -04:00
parent 04acefd9f8
commit 1a4e68b422
8 changed files with 304 additions and 55 deletions
+2
View File
@@ -11,6 +11,8 @@ class Budget(db.Model):
limit_amount = db.Column(db.Numeric(15, 2), nullable=False) limit_amount = db.Column(db.Numeric(15, 2), nullable=False)
rollover_enabled = db.Column(db.Boolean, default=False) rollover_enabled = db.Column(db.Boolean, default=False)
rollover_amount = db.Column(db.Numeric(15, 2), default=0.00) rollover_amount = db.Column(db.Numeric(15, 2), default=0.00)
alert_sent_80 = db.Column(db.Boolean, default=False, nullable=False)
alert_sent_100 = db.Column(db.Boolean, default=False, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=datetime.utcnow)
category = db.relationship('Category') category = db.relationship('Category')
+1
View File
@@ -18,6 +18,7 @@ class User(UserMixin, db.Model):
groq_model = db.Column(db.String(64), default='llama-3.3-70b-versatile') groq_model = db.Column(db.String(64), default='llama-3.3-70b-versatile')
totp_secret = db.Column(db.String(64), nullable=True) totp_secret = db.Column(db.String(64), nullable=True)
totp_enabled = db.Column(db.Boolean, default=False, nullable=False) totp_enabled = db.Column(db.Boolean, default=False, nullable=False)
budget_alerts_enabled = db.Column(db.Boolean, default=False, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=datetime.utcnow)
last_login = db.Column(db.DateTime, nullable=True) last_login = db.Column(db.DateTime, nullable=True)
+20 -1
View File
@@ -68,6 +68,7 @@ class ProfileForm(FlaskForm):
]) ])
currency = SelectField('Currency', choices=CURRENCIES) currency = SelectField('Currency', choices=CURRENCIES)
groq_model = SelectField('AI Model', choices=GROQ_MODELS) groq_model = SelectField('AI Model', choices=GROQ_MODELS)
budget_alerts_enabled = BooleanField('Email me when a budget category reaches 80% or 100%')
submit = SubmitField('Save Profile') submit = SubmitField('Save Profile')
@@ -156,10 +157,28 @@ def profile():
current_user.currency = form.currency.data current_user.currency = form.currency.data
current_user.currency_symbol = CURRENCY_SYMBOLS.get(form.currency.data, '$') current_user.currency_symbol = CURRENCY_SYMBOLS.get(form.currency.data, '$')
current_user.groq_model = form.groq_model.data current_user.groq_model = form.groq_model.data
current_user.budget_alerts_enabled = form.budget_alerts_enabled.data
db.session.commit() db.session.commit()
flash('Profile updated.', 'success') flash('Profile updated.', 'success')
return redirect(url_for('settings.profile')) return redirect(url_for('settings.profile'))
return render_template('settings/profile.html', form=form) smtp_ok = all([
current_app.config.get('SMTP_HOST'),
current_app.config.get('SMTP_USER'),
current_app.config.get('SMTP_PASSWORD'),
current_app.config.get('ALERT_EMAIL'),
])
return render_template('settings/profile.html', form=form,
smtp_ok=smtp_ok,
alert_email=current_app.config.get('ALERT_EMAIL', ''))
@settings_bp.route('/test-email', methods=['POST'])
@login_required
def test_email():
from app.services.alert_service import send_test_email
ok, msg = send_test_email()
flash(msg, 'success' if ok else 'danger')
return redirect(url_for('settings.profile'))
@settings_bp.route('/password', methods=['GET', 'POST']) @settings_bp.route('/password', methods=['GET', 'POST'])
+123 -43
View File
@@ -1,8 +1,9 @@
""" """
Alert Service — budget threshold alerts triggered after expense transactions. Alert Service — budget threshold alerts triggered after expense transactions.
Checks all budgeted categories for the current month and returns alert dicts Checks budgeted categories and flashes/emails when spending crosses 80% or 100%.
for any that just crossed 80% or 100%. Also sends an email if SMTP is configured. Dedup: alert_sent_80 / alert_sent_100 flags on each Budget row prevent repeat
emails within the same month. Flags default to False on new Budget rows.
Usage (in routes after db.session.commit()): Usage (in routes after db.session.commit()):
from app.services.alert_service import check_and_flash_budget_alerts from app.services.alert_service import check_and_flash_budget_alerts
@@ -17,7 +18,9 @@ from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from flask import current_app from flask import current_app
log = logging.getLogger(__name__) from app.extensions import db
log = logging.getLogger('app.alert_service')
def _smtp_configured(): def _smtp_configured():
@@ -35,7 +38,7 @@ def _send_email(subject, body_html):
host = cfg['SMTP_HOST'] host = cfg['SMTP_HOST']
port = int(cfg.get('SMTP_PORT', 587)) port = int(cfg.get('SMTP_PORT', 587))
user = cfg['SMTP_USER'] user = cfg['SMTP_USER']
password = cfg['SMTP_PASSWORD'] pw = cfg['SMTP_PASSWORD']
to_addr = cfg['ALERT_EMAIL'] to_addr = cfg['ALERT_EMAIL']
msg = MIMEMultipart('alternative') msg = MIMEMultipart('alternative')
@@ -45,31 +48,47 @@ def _send_email(subject, body_html):
msg.attach(MIMEText(body_html, 'html')) msg.attach(MIMEText(body_html, 'html'))
try: try:
context = ssl.create_default_context() ctx = ssl.create_default_context()
with smtplib.SMTP(host, port) as srv: with smtplib.SMTP(host, port) as srv:
srv.ehlo() srv.ehlo()
srv.starttls(context=context) srv.starttls(context=ctx)
srv.login(user, password) srv.login(user, pw)
srv.sendmail(user, to_addr, msg.as_string()) srv.sendmail(user, to_addr, msg.as_string())
log.info('[alert] email sent: %s%s', subject, to_addr) log.info('[alert] email sent: %s%s', subject, to_addr)
return True
except Exception as exc: except Exception as exc:
log.error('[alert] email failed: %s', exc, exc_info=True) log.error('[alert] email failed: %s', exc, exc_info=True)
return False
def send_test_email():
"""Send a test email to verify SMTP config. Returns (ok: bool, message: str)."""
if not _smtp_configured():
return False, ('SMTP not configured. '
'Set SMTP_HOST, SMTP_USER, SMTP_PASSWORD, and ALERT_EMAIL in .env.')
to_addr = current_app.config['ALERT_EMAIL']
body = (
'<p>This is a test email from your <strong>Personal Finance Management</strong> app.</p>'
'<p>Budget alerts are configured correctly.</p>'
)
ok = _send_email('PFM — Test Email', body)
if ok:
return True, f'Test email sent to {to_addr}.'
return False, 'SMTP error — check server logs for details.'
def get_budget_alerts(month_str=None): def get_budget_alerts(month_str=None):
""" """
Return list of alert dicts for budget categories at or over threshold. Return list of alert dicts for budget categories at or over threshold.
Does NOT check dedup flags — use for display purposes only.
Each dict: {category, spent, limit, pct, level} Each dict: {category, spent, limit, pct, level}
level: 'over' (≥100%) | 'warning' (≥80%) level: 'over' (≥100%) | 'warning' (≥80%)
Only returns categories that have a budget set.
""" """
from app.services.budget_service import get_budget_summary from app.services.budget_service import get_budget_summary
if month_str is None: if month_str is None:
today = date.today() month_str = date.today().strftime('%Y-%m')
month_str = today.strftime('%Y-%m')
summary = get_budget_summary(month_str) summary = get_budget_summary(month_str)
alerts = [] alerts = []
@@ -81,56 +100,117 @@ def get_budget_alerts(month_str=None):
alerts.append({**item, 'level': 'over'}) alerts.append({**item, 'level': 'over'})
elif pct >= 80: elif pct >= 80:
alerts.append({**item, 'level': 'warning'}) alerts.append({**item, 'level': 'warning'})
return alerts return alerts
def check_and_flash_budget_alerts(flash_fn): def check_and_flash_budget_alerts(flash_fn):
""" """
Check current month's budgets and flash any threshold alerts. Check current month's budgets. For each category that newly crossed 80% or
Optionally sends an email summary if SMTP is configured. 100%, flash a message and (if SMTP configured and user has alerts enabled)
send one email. Dedup flags on the Budget row prevent repeat sends.
Call this after committing an expense transaction. Call this after committing an expense transaction.
flash_fn: Flask's flash() (passed in to avoid circular imports at module level)
flash_fn: Flask's flash() function (passed in to avoid circular imports)
""" """
from app.models.budget import Budget
from app.models.user import User
from app.services.budget_service import get_month_spending
user = User.query.first()
alerts_enabled = user.budget_alerts_enabled if user else False
symbol = (user.currency_symbol or '$') if user else '$'
month_str = date.today().strftime('%Y-%m')
try: try:
alerts = get_budget_alerts() budgets = Budget.query.filter_by(month=month_str).all()
except Exception as exc: except Exception as exc:
log.error('[alert] check failed: %s', exc, exc_info=True) log.error('[alert] failed to query budgets: %s', exc)
return return
if not alerts: dirty_budgets = []
return
email_lines = [] for b in budgets:
for a in alerts: try:
cat_name = a['category'].name if a['category'] else 'Unknown' limit = float(b.limit_amount) + float(b.rollover_amount or 0)
pct = a['pct'] if limit <= 0:
spent = a['spent'] continue
limit = a['limit'] spent = get_month_spending(b.category_id, month_str)
symbol = current_app.config.get('APP_CURRENCY_SYMBOL', '$') pct = spent / limit * 100
cat = b.category
cat_name = cat.name if cat else 'Unknown'
if a['level'] == 'over': # 100% threshold — only fire if not already sent this month
if pct >= 100 and not b.alert_sent_100:
msg = (f'Budget exceeded: <strong>{cat_name}</strong> — ' msg = (f'Budget exceeded: <strong>{cat_name}</strong> — '
f'{symbol}{spent:,.2f} spent of {symbol}{limit:,.2f} limit ({pct:.0f}%)') f'{symbol}{spent:,.2f} of {symbol}{limit:,.2f} ({pct:.0f}%)')
flash_fn(msg, 'danger') flash_fn(msg, 'danger')
else: if alerts_enabled and _smtp_configured():
_send_single_alert_email(cat_name, spent, limit, pct, symbol, threshold=100)
b.alert_sent_100 = True
dirty_budgets.append(b)
# 80% threshold — only fire if not already sent (and 100% not yet hit)
elif pct >= 80 and not b.alert_sent_80:
msg = (f'Budget warning: <strong>{cat_name}</strong> — ' msg = (f'Budget warning: <strong>{cat_name}</strong> — '
f'{symbol}{spent:,.2f} of {symbol}{limit:,.2f} ({pct:.0f}% used)') f'{symbol}{spent:,.2f} of {symbol}{limit:,.2f} ({pct:.0f}% used)')
flash_fn(msg, 'warning') flash_fn(msg, 'warning')
if alerts_enabled and _smtp_configured():
_send_single_alert_email(cat_name, spent, limit, pct, symbol, threshold=80)
b.alert_sent_80 = True
dirty_budgets.append(b)
email_lines.append( except Exception as exc:
f'<li><b>{cat_name}</b>: {symbol}{spent:,.2f} / {symbol}{limit:,.2f} ' log.error('[alert] error checking budget %s: %s', b.id, exc)
f'({pct:.0f}%) — <b>{"EXCEEDED" if a["level"]=="over" else "Warning"}</b></li>'
)
if _smtp_configured() and email_lines: if dirty_budgets:
today = date.today().strftime('%B %Y') try:
body = ( db.session.commit()
f'<h3>PFM Budget Alert — {today}</h3>' except Exception as exc:
f'<p>The following budget categories need your attention:</p>' log.error('[alert] failed to save alert flags: %s', exc)
f'<ul>{"".join(email_lines)}</ul>' db.session.rollback()
f'<p><a href="{current_app.config.get("APP_URL","")}/budgets">View Budgets →</a></p>'
)
_send_email(f'PFM Budget Alert — {today}', body) def _send_single_alert_email(cat_name, spent, limit, pct, symbol, threshold):
label = '100%+' if threshold >= 100 else '80%'
remaining = max(limit - spent, 0)
color = '#ef4444' if threshold >= 100 else '#f59e0b'
emoji = '🚨' if threshold >= 100 else '⚠️'
app_url = current_app.config.get('APP_URL', '')
body = f"""
<html><body style="font-family:sans-serif;color:#0f172a;max-width:520px;margin:0 auto;">
<div style="background:#0f172a;padding:20px 24px;border-radius:8px 8px 0 0;">
<h2 style="color:#f1f5f9;margin:0;font-size:18px;">{emoji} Budget Alert — {label}</h2>
</div>
<div style="border:1px solid #e2e8f0;border-top:none;padding:24px;border-radius:0 0 8px 8px;">
<p style="font-size:15px;margin-top:0;">
Your <strong>{cat_name}</strong> budget has reached
<strong style="color:{color};">{pct:.0f}%</strong>.
</p>
<table style="width:100%;border-collapse:collapse;font-size:13px;margin-bottom:16px;">
<tr style="background:#f8fafc;">
<td style="padding:8px 12px;border:1px solid #e2e8f0;">Spent</td>
<td style="padding:8px 12px;border:1px solid #e2e8f0;font-weight:bold;color:#ef4444;">{symbol}{spent:,.2f}</td>
</tr>
<tr>
<td style="padding:8px 12px;border:1px solid #e2e8f0;">Budget limit</td>
<td style="padding:8px 12px;border:1px solid #e2e8f0;">{symbol}{limit:,.2f}</td>
</tr>
<tr style="background:#f8fafc;">
<td style="padding:8px 12px;border:1px solid #e2e8f0;">Remaining</td>
<td style="padding:8px 12px;border:1px solid #e2e8f0;color:{'#ef4444' if remaining==0 else '#10b981'};">{symbol}{remaining:,.2f}</td>
</tr>
</table>
<a href="{app_url}/budgets"
style="display:inline-block;background:#3b82f6;color:#fff;padding:10px 20px;border-radius:6px;text-decoration:none;font-size:13px;">
View Budgets →
</a>
<p style="font-size:11px;color:#94a3b8;margin-top:20px;margin-bottom:0;">
Disable alerts in Settings → Profile.
</p>
</div>
</body></html>"""
subject = f'Budget Alert: {cat_name} at {label} ({pct:.0f}% used)'
_send_email(subject, body)
+1 -1
View File
@@ -156,7 +156,7 @@
<tr> <tr>
<td> <td>
<span style="display:inline-block;width:8px;height:8px;border-radius:2px;background:{{ row.color }};margin-right:6px;"></span> <span style="display:inline-block;width:8px;height:8px;border-radius:2px;background:{{ row.color }};margin-right:6px;"></span>
{{ row.icon }} {{ row.name }} <i class="bi {{ row.icon }}" style="color:{{ row.color }};"></i> {{ row.name }}
</td> </td>
<td class="text-end mono">{{ row.this_month | currency }}</td> <td class="text-end mono">{{ row.this_month | currency }}</td>
<td class="text-end mono text-muted">{{ row.last_month | currency }}</td> <td class="text-end mono text-muted">{{ row.last_month | currency }}</td>
+30
View File
@@ -43,6 +43,36 @@
<small class="text-muted" style="font-size:11px;">AI model used for chat and daily insights.</small> <small class="text-muted" style="font-size:11px;">AI model used for chat and daily insights.</small>
</div> </div>
<hr style="border-color:var(--border);">
<div class="mb-3">
<div class="form-label fw-medium" style="font-size:13px;">Budget Alerts</div>
<div class="form-check">
{{ form.budget_alerts_enabled(class="form-check-input") }}
{{ form.budget_alerts_enabled.label(class="form-check-label", style="font-size:13px;") }}
</div>
{% if smtp_ok %}
<small class="text-muted" style="font-size:11px;">
<i class="bi bi-check-circle-fill text-success me-1"></i>SMTP configured — alerts will go to <strong>{{ alert_email }}</strong>
</small>
{% else %}
<small class="text-danger" style="font-size:11px;">
<i class="bi bi-exclamation-circle me-1"></i>SMTP not configured — set SMTP_HOST, SMTP_USER, SMTP_PASSWORD, ALERT_EMAIL in .env to enable emails.
</small>
{% endif %}
</div>
{% if smtp_ok %}
<div class="mb-4">
<form method="POST" action="{{ url_for('settings.test_email') }}" style="display:inline;">
{{ form.hidden_tag() }}
<button type="submit" class="btn btn-sm btn-outline-secondary" style="font-size:12px;">
<i class="bi bi-envelope me-1"></i>Send Test Email
</button>
</form>
</div>
{% endif %}
{{ form.submit(class="btn btn-primary") }} {{ form.submit(class="btn btn-primary") }}
<a href="{{ url_for('settings.password') }}" class="btn btn-outline-secondary ms-2">Change Password</a> <a href="{{ url_for('settings.password') }}" class="btn btn-outline-secondary ms-2">Change Password</a>
</form> </form>
+53
View File
@@ -0,0 +1,53 @@
"""
Migration: add budget alert columns.
Run once after deploying the budget alerts feature:
python scripts/add_budget_alert_columns.py
Adds to `budgets`:
alert_sent_80 BOOLEAN NOT NULL DEFAULT FALSE
alert_sent_100 BOOLEAN NOT NULL DEFAULT FALSE
Adds to `users`:
budget_alerts_enabled BOOLEAN NOT NULL DEFAULT FALSE
"""
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app import create_app
from app.extensions import db
app = create_app()
with app.app_context():
with db.engine.connect() as conn:
# budgets table
for col, default in [('alert_sent_80', '0'), ('alert_sent_100', '0')]:
try:
conn.execute(db.text(
f"ALTER TABLE budgets ADD COLUMN {col} TINYINT(1) NOT NULL DEFAULT {default}"
))
print(f"Added budgets.{col}")
except Exception as e:
if 'Duplicate column' in str(e) or '1060' in str(e):
print(f"budgets.{col} already exists — skipping")
else:
raise
# users table
try:
conn.execute(db.text(
"ALTER TABLE users ADD COLUMN budget_alerts_enabled TINYINT(1) NOT NULL DEFAULT 0"
))
print("Added users.budget_alerts_enabled")
except Exception as e:
if 'Duplicate column' in str(e) or '1060' in str(e):
print("users.budget_alerts_enabled already exists — skipping")
else:
raise
conn.commit()
print("Done.")
+64
View File
@@ -0,0 +1,64 @@
"""
Daily cron safety-net: check all budgets for the current month and send any
unsent 80% / 100% alert emails.
Run daily (e.g. 9 AM) so alerts fire even if the triggering transaction was
imported via a provider sync rather than entered manually through the UI.
python scripts/check_budget_alerts.py
"""
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from datetime import date
from app import create_app
from app.extensions import db
app = create_app()
with app.app_context():
from app.models.user import User
from app.models.budget import Budget
from app.services.alert_service import (
_smtp_configured, _send_single_alert_email
)
from app.services.budget_service import get_month_spending
user = User.query.first()
if not user or not user.budget_alerts_enabled:
print("Budget alerts disabled — nothing to do.")
sys.exit(0)
if not _smtp_configured():
print("SMTP not configured — nothing to do.")
sys.exit(0)
month_str = date.today().strftime('%Y-%m')
symbol = user.currency_symbol or '$'
budgets = Budget.query.filter_by(month=month_str).all()
sent = 0
for b in budgets:
limit = float(b.limit_amount) + float(b.rollover_amount or 0)
if limit <= 0:
continue
spent = get_month_spending(b.category_id, month_str)
pct = spent / limit * 100
cat = b.category
name = cat.name if cat else 'Unknown'
if pct >= 100 and not b.alert_sent_100:
_send_single_alert_email(name, spent, limit, pct, symbol, threshold=100)
b.alert_sent_100 = True
sent += 1
elif pct >= 80 and not b.alert_sent_80:
_send_single_alert_email(name, spent, limit, pct, symbol, threshold=80)
b.alert_sent_80 = True
sent += 1
if sent:
db.session.commit()
print(f"Budget alert check complete — {sent} email(s) sent.")