Add application logging, SMTP email notifications, and supporting changes
Phase 5.4 — Application Logging: - AppLog model + migration 0010 for unified audit/usage/error logging - Usage logging middleware in app.py (after_request for /api/* requests) - Error logging in 500 handler with traceback capture - Audit logging for auth events (register, login, login_failed, logout, password_change) and admin actions (backup, restore, user_delete, registration_toggle, smtp_config, smtp_test) - Admin log viewer (LogsView.vue) with stats, category/search/date filters, paginated table with expandable detail rows - Admin logs API endpoints in admin.py (GET /logs, GET /logs/stats) - Configurable retention via LOG_RETENTION_DAYS with hourly cleanup Phase 5.5 — SMTP Email Notifications: - aiosmtplib dependency for async email sending - Email service (services/email.py) with STARTTLS/implicit TLS support - Notification service (services/notifications.py) for security alerts and task due date reminders with per-user preferences - Admin SMTP config endpoints (GET/PUT /api/admin/smtp, POST test) - SMTP config in Config class with env var + Docker secret support - Settings UI: notification preferences for all users, SMTP config section for admin with test email Other changes: - stream_chat() now accepts optional options dict (for num_predict) - Increase assist MAX_BODY_CHARS from 3000 to 8000 - get_user_by_username() added to auth service - apiStreamPost buffer processing refactored for robustness - AppHeader: admin Logs nav link - Router: /admin/logs route Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
"""Notification service for security alerts and task reminders."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from sqlalchemy import func, select, text
|
||||
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.app_log import AppLog
|
||||
from fabledassistant.models.note import Note
|
||||
from fabledassistant.models.setting import Setting
|
||||
from fabledassistant.models.user import User
|
||||
from fabledassistant.services.email import is_smtp_configured, send_email
|
||||
from fabledassistant.services.logging import log_audit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_notification_task: asyncio.Task | None = None
|
||||
|
||||
SECURITY_EVENT_LABELS = {
|
||||
"login": "New Login",
|
||||
"login_failed": "Failed Login Attempt",
|
||||
"logout": "Logout",
|
||||
"password_change": "Password Changed",
|
||||
}
|
||||
|
||||
|
||||
async def _get_user_notification_pref(user_id: int, key: str) -> bool:
|
||||
"""Check if a user has a notification preference enabled (default True)."""
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Setting).where(Setting.user_id == user_id, Setting.key == key)
|
||||
)
|
||||
setting = result.scalar_one_or_none()
|
||||
# Default to enabled
|
||||
return setting.value != "false" if setting else True
|
||||
|
||||
|
||||
async def _get_user_email(user_id: int) -> str | None:
|
||||
"""Get a user's email address."""
|
||||
async with async_session() as session:
|
||||
user = await session.get(User, user_id)
|
||||
return user.email if user else None
|
||||
|
||||
|
||||
async def notify_security_event(
|
||||
user_id: int, event_type: str, details: dict | None = None
|
||||
) -> None:
|
||||
"""Send a security alert email to a user if they have alerts enabled and an email set."""
|
||||
try:
|
||||
if not await is_smtp_configured():
|
||||
return
|
||||
|
||||
if not await _get_user_notification_pref(user_id, "notify_security_alerts"):
|
||||
return
|
||||
|
||||
email = await _get_user_email(user_id)
|
||||
if not email:
|
||||
return
|
||||
|
||||
label = SECURITY_EVENT_LABELS.get(event_type, event_type)
|
||||
ip = details.get("ip_address", "Unknown") if details else "Unknown"
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||
|
||||
detail_rows = ""
|
||||
if details:
|
||||
for k, v in details.items():
|
||||
if k == "ip_address":
|
||||
continue
|
||||
detail_rows += f'<tr><td style="padding: 6px 12px; color: #6b7280; font-size: 14px;">{k}</td><td style="padding: 6px 12px; color: #374151; font-size: 14px;">{v}</td></tr>'
|
||||
|
||||
html = f"""
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 480px; margin: 0 auto; padding: 24px;">
|
||||
<div style="background: #6366f1; color: #fff; padding: 16px 24px; border-radius: 8px 8px 0 0; text-align: center;">
|
||||
<h1 style="margin: 0; font-size: 20px;">Security Alert</h1>
|
||||
</div>
|
||||
<div style="background: #f9fafb; padding: 24px; border: 1px solid #e5e7eb; border-top: none; border-radius: 0 0 8px 8px;">
|
||||
<p style="margin: 0 0 16px; color: #374151; font-size: 16px; font-weight: 600;">{label}</p>
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<tr><td style="padding: 6px 12px; color: #6b7280; font-size: 14px;">Time</td><td style="padding: 6px 12px; color: #374151; font-size: 14px;">{timestamp}</td></tr>
|
||||
<tr><td style="padding: 6px 12px; color: #6b7280; font-size: 14px;">IP Address</td><td style="padding: 6px 12px; color: #374151; font-size: 14px;">{ip}</td></tr>
|
||||
{detail_rows}
|
||||
</table>
|
||||
<p style="margin: 16px 0 0; color: #9ca3af; font-size: 12px;">If this wasn't you, change your password immediately.</p>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
await send_email(email, f"Fabled Assistant - {label}", html)
|
||||
except Exception:
|
||||
logger.exception("Failed to send security notification for user %d", user_id)
|
||||
|
||||
|
||||
async def check_due_tasks() -> None:
|
||||
"""Check for tasks due today and send reminder emails."""
|
||||
if not await is_smtp_configured():
|
||||
return
|
||||
|
||||
today = date.today()
|
||||
today_str = today.isoformat()
|
||||
|
||||
async with async_session() as session:
|
||||
# Find tasks due today or overdue, not done
|
||||
result = await session.execute(
|
||||
select(Note)
|
||||
.where(
|
||||
Note.status.isnot(None),
|
||||
Note.status != "done",
|
||||
Note.due_date <= today,
|
||||
)
|
||||
)
|
||||
tasks = result.scalars().all()
|
||||
|
||||
if not tasks:
|
||||
return
|
||||
|
||||
# Group by user
|
||||
tasks_by_user: dict[int, list[Note]] = {}
|
||||
for task in tasks:
|
||||
if task.user_id:
|
||||
tasks_by_user.setdefault(task.user_id, []).append(task)
|
||||
|
||||
for user_id, user_tasks in tasks_by_user.items():
|
||||
try:
|
||||
# Check notification pref
|
||||
if not await _get_user_notification_pref(user_id, "notify_task_reminders"):
|
||||
continue
|
||||
|
||||
email = await _get_user_email(user_id)
|
||||
if not email:
|
||||
continue
|
||||
|
||||
# Dedup: check if we already sent a reminder today
|
||||
dedup_result = await session.execute(
|
||||
select(func.count(AppLog.id)).where(
|
||||
AppLog.action == "task_reminder",
|
||||
AppLog.user_id == user_id,
|
||||
AppLog.created_at >= today_str,
|
||||
)
|
||||
)
|
||||
if (dedup_result.scalar() or 0) > 0:
|
||||
continue
|
||||
|
||||
# Build task list HTML
|
||||
task_rows = ""
|
||||
for task in user_tasks:
|
||||
overdue = task.due_date < today if task.due_date else False
|
||||
date_color = "#ef4444" if overdue else "#374151"
|
||||
date_label = f'<span style="color: {date_color};">{task.due_date.isoformat()}</span>' if task.due_date else ""
|
||||
overdue_badge = ' <span style="color: #ef4444; font-weight: 600; font-size: 12px;">(overdue)</span>' if overdue else ""
|
||||
task_rows += f"""
|
||||
<tr>
|
||||
<td style="padding: 8px 12px; border-bottom: 1px solid #e5e7eb; color: #374151; font-size: 14px;">{task.title}</td>
|
||||
<td style="padding: 8px 12px; border-bottom: 1px solid #e5e7eb; font-size: 14px;">{date_label}{overdue_badge}</td>
|
||||
<td style="padding: 8px 12px; border-bottom: 1px solid #e5e7eb; color: #6b7280; font-size: 14px;">{task.status}</td>
|
||||
</tr>
|
||||
"""
|
||||
|
||||
html = f"""
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 560px; margin: 0 auto; padding: 24px;">
|
||||
<div style="background: #6366f1; color: #fff; padding: 16px 24px; border-radius: 8px 8px 0 0; text-align: center;">
|
||||
<h1 style="margin: 0; font-size: 20px;">Task Reminders</h1>
|
||||
</div>
|
||||
<div style="background: #f9fafb; padding: 24px; border: 1px solid #e5e7eb; border-top: none; border-radius: 0 0 8px 8px;">
|
||||
<p style="margin: 0 0 16px; color: #374151;">You have {len(user_tasks)} task(s) due:</p>
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<tr style="background: #f3f4f6;">
|
||||
<th style="padding: 8px 12px; text-align: left; font-size: 13px; color: #6b7280;">Task</th>
|
||||
<th style="padding: 8px 12px; text-align: left; font-size: 13px; color: #6b7280;">Due</th>
|
||||
<th style="padding: 8px 12px; text-align: left; font-size: 13px; color: #6b7280;">Status</th>
|
||||
</tr>
|
||||
{task_rows}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
await send_email(email, f"Fabled Assistant - {len(user_tasks)} Task(s) Due", html)
|
||||
await log_audit(
|
||||
"task_reminder",
|
||||
user_id=user_id,
|
||||
details={"task_count": len(user_tasks), "task_ids": [t.id for t in user_tasks]},
|
||||
)
|
||||
logger.info("Sent task reminder to user %d (%d tasks)", user_id, len(user_tasks))
|
||||
except Exception:
|
||||
logger.exception("Failed to send task reminder for user %d", user_id)
|
||||
|
||||
|
||||
async def _notification_loop() -> None:
|
||||
while True:
|
||||
await asyncio.sleep(3600) # hourly
|
||||
try:
|
||||
await check_due_tasks()
|
||||
except Exception:
|
||||
logger.exception("Error in notification loop")
|
||||
|
||||
|
||||
def start_notification_loop() -> None:
|
||||
global _notification_task
|
||||
if _notification_task is None or _notification_task.done():
|
||||
_notification_task = asyncio.create_task(_notification_loop())
|
||||
Reference in New Issue
Block a user