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,109 @@
|
||||
"""Email service for sending notifications via SMTP."""
|
||||
|
||||
import logging
|
||||
from email.message import EmailMessage
|
||||
|
||||
import aiosmtplib
|
||||
from sqlalchemy import select
|
||||
|
||||
from fabledassistant.config import Config
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.setting import Setting
|
||||
from fabledassistant.models.user import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SMTP_SETTING_KEYS = [
|
||||
"smtp_host",
|
||||
"smtp_port",
|
||||
"smtp_username",
|
||||
"smtp_password",
|
||||
"smtp_from_address",
|
||||
"smtp_from_name",
|
||||
"smtp_use_tls",
|
||||
]
|
||||
|
||||
|
||||
async def get_smtp_config() -> dict[str, str]:
|
||||
"""Get SMTP config from admin's settings, falling back to env vars."""
|
||||
config: dict[str, str] = {
|
||||
"smtp_host": Config.SMTP_HOST,
|
||||
"smtp_port": str(Config.SMTP_PORT),
|
||||
"smtp_username": Config.SMTP_USERNAME,
|
||||
"smtp_password": Config.SMTP_PASSWORD,
|
||||
"smtp_from_address": Config.SMTP_FROM_ADDRESS,
|
||||
"smtp_from_name": Config.SMTP_FROM_NAME,
|
||||
"smtp_use_tls": "true" if Config.SMTP_USE_TLS else "false",
|
||||
}
|
||||
|
||||
# Override with DB settings from admin user
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Setting)
|
||||
.join(User, Setting.user_id == User.id)
|
||||
.where(User.role == "admin", Setting.key.in_(SMTP_SETTING_KEYS))
|
||||
)
|
||||
for setting in result.scalars().all():
|
||||
config[setting.key] = setting.value
|
||||
|
||||
return config
|
||||
|
||||
|
||||
async def is_smtp_configured() -> bool:
|
||||
"""Check if SMTP is configured (has a host set)."""
|
||||
config = await get_smtp_config()
|
||||
return bool(config.get("smtp_host"))
|
||||
|
||||
|
||||
async def send_email(to: str, subject: str, html_body: str) -> None:
|
||||
"""Send an email via SMTP."""
|
||||
config = await get_smtp_config()
|
||||
host = config.get("smtp_host")
|
||||
if not host:
|
||||
logger.debug("SMTP not configured, skipping email to %s", to)
|
||||
return
|
||||
|
||||
port = int(config.get("smtp_port", "587"))
|
||||
username = config.get("smtp_username", "")
|
||||
password = config.get("smtp_password", "")
|
||||
from_address = config.get("smtp_from_address", "")
|
||||
from_name = config.get("smtp_from_name", "Fabled Assistant")
|
||||
use_tls = config.get("smtp_use_tls", "true") == "true"
|
||||
|
||||
msg = EmailMessage()
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = f"{from_name} <{from_address}>" if from_name else from_address
|
||||
msg["To"] = to
|
||||
msg.set_content(subject) # plain text fallback
|
||||
msg.add_alternative(html_body, subtype="html")
|
||||
|
||||
try:
|
||||
await aiosmtplib.send(
|
||||
msg,
|
||||
hostname=host,
|
||||
port=port,
|
||||
username=username or None,
|
||||
password=password or None,
|
||||
start_tls=use_tls and port != 465,
|
||||
use_tls=port == 465,
|
||||
)
|
||||
logger.info("Email sent to %s: %s", to, subject)
|
||||
except Exception:
|
||||
logger.exception("Failed to send email to %s: %s", to, subject)
|
||||
raise
|
||||
|
||||
|
||||
async def send_test_email(to: str) -> None:
|
||||
"""Send a branded test email."""
|
||||
html = """
|
||||
<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;">Fabled Assistant</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 12px; color: #374151;">Your SMTP configuration is working correctly.</p>
|
||||
<p style="margin: 0; color: #6b7280; font-size: 14px;">This is a test email sent from your Fabled Assistant instance.</p>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
await send_email(to, "Fabled Assistant - Test Email", html)
|
||||
Reference in New Issue
Block a user