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
+133 -53
View File
@@ -1,8 +1,9 @@
"""
Alert Service — budget threshold alerts triggered after expense transactions.
Checks all budgeted categories for the current month and returns alert dicts
for any that just crossed 80% or 100%. Also sends an email if SMTP is configured.
Checks budgeted categories and flashes/emails when spending crosses 80% or 100%.
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()):
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 flask import current_app
log = logging.getLogger(__name__)
from app.extensions import db
log = logging.getLogger('app.alert_service')
def _smtp_configured():
@@ -31,12 +34,12 @@ def _smtp_configured():
def _send_email(subject, body_html):
cfg = current_app.config
host = cfg['SMTP_HOST']
port = int(cfg.get('SMTP_PORT', 587))
user = cfg['SMTP_USER']
password = cfg['SMTP_PASSWORD']
to_addr = cfg['ALERT_EMAIL']
cfg = current_app.config
host = cfg['SMTP_HOST']
port = int(cfg.get('SMTP_PORT', 587))
user = cfg['SMTP_USER']
pw = cfg['SMTP_PASSWORD']
to_addr = cfg['ALERT_EMAIL']
msg = MIMEMultipart('alternative')
msg['Subject'] = subject
@@ -45,31 +48,47 @@ def _send_email(subject, body_html):
msg.attach(MIMEText(body_html, 'html'))
try:
context = ssl.create_default_context()
ctx = ssl.create_default_context()
with smtplib.SMTP(host, port) as srv:
srv.ehlo()
srv.starttls(context=context)
srv.login(user, password)
srv.starttls(context=ctx)
srv.login(user, pw)
srv.sendmail(user, to_addr, msg.as_string())
log.info('[alert] email sent: %s%s', subject, to_addr)
return True
except Exception as exc:
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):
"""
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}
level: 'over' (≥100%) | 'warning' (≥80%)
Only returns categories that have a budget set.
"""
from app.services.budget_service import get_budget_summary
if month_str is None:
today = date.today()
month_str = today.strftime('%Y-%m')
month_str = date.today().strftime('%Y-%m')
summary = get_budget_summary(month_str)
alerts = []
@@ -81,56 +100,117 @@ def get_budget_alerts(month_str=None):
alerts.append({**item, 'level': 'over'})
elif pct >= 80:
alerts.append({**item, 'level': 'warning'})
return alerts
def check_and_flash_budget_alerts(flash_fn):
"""
Check current month's budgets and flash any threshold alerts.
Optionally sends an email summary if SMTP is configured.
Check current month's budgets. For each category that newly crossed 80% or
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.
flash_fn: Flask's flash() function (passed in to avoid circular imports)
flash_fn: Flask's flash() (passed in to avoid circular imports at module level)
"""
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:
alerts = get_budget_alerts()
budgets = Budget.query.filter_by(month=month_str).all()
except Exception as exc:
log.error('[alert] check failed: %s', exc, exc_info=True)
log.error('[alert] failed to query budgets: %s', exc)
return
if not alerts:
return
dirty_budgets = []
email_lines = []
for a in alerts:
cat_name = a['category'].name if a['category'] else 'Unknown'
pct = a['pct']
spent = a['spent']
limit = a['limit']
symbol = current_app.config.get('APP_CURRENCY_SYMBOL', '$')
for b in budgets:
try:
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
cat_name = cat.name if cat else 'Unknown'
if a['level'] == 'over':
msg = (f'Budget exceeded: <strong>{cat_name}</strong> — '
f'{symbol}{spent:,.2f} spent of {symbol}{limit:,.2f} limit ({pct:.0f}%)')
flash_fn(msg, 'danger')
else:
msg = (f'Budget warning: <strong>{cat_name}</strong> — '
f'{symbol}{spent:,.2f} of {symbol}{limit:,.2f} ({pct:.0f}% used)')
flash_fn(msg, 'warning')
# 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> — '
f'{symbol}{spent:,.2f} of {symbol}{limit:,.2f} ({pct:.0f}%)')
flash_fn(msg, 'danger')
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)
email_lines.append(
f'<li><b>{cat_name}</b>: {symbol}{spent:,.2f} / {symbol}{limit:,.2f} '
f'({pct:.0f}%) — <b>{"EXCEEDED" if a["level"]=="over" else "Warning"}</b></li>'
)
# 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> — '
f'{symbol}{spent:,.2f} of {symbol}{limit:,.2f} ({pct:.0f}% used)')
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)
if _smtp_configured() and email_lines:
today = date.today().strftime('%B %Y')
body = (
f'<h3>PFM Budget Alert — {today}</h3>'
f'<p>The following budget categories need your attention:</p>'
f'<ul>{"".join(email_lines)}</ul>'
f'<p><a href="{current_app.config.get("APP_URL","")}/budgets">View Budgets →</a></p>'
)
_send_email(f'PFM Budget Alert — {today}', body)
except Exception as exc:
log.error('[alert] error checking budget %s: %s', b.id, exc)
if dirty_budgets:
try:
db.session.commit()
except Exception as exc:
log.error('[alert] failed to save alert flags: %s', exc)
db.session.rollback()
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)