Model
@@ -843,4 +1028,51 @@ async function handleRestoreFile(event: Event) {
.hidden-file-input {
display: none;
}
+
+/* Checkbox fields */
+.checkbox-field {
+ margin-bottom: 1rem;
+}
+.checkbox-field label {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ font-size: 0.95rem;
+ color: var(--color-text);
+ cursor: pointer;
+}
+.checkbox-field input[type="checkbox"] {
+ width: 16px;
+ height: 16px;
+ accent-color: var(--color-primary);
+}
+
+/* SMTP grid */
+.smtp-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 0 1rem;
+}
+@media (max-width: 480px) {
+ .smtp-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+/* Test email */
+.subsection-label {
+ margin: 0 0 0.5rem;
+ font-size: 0.9rem;
+ font-weight: 600;
+ color: var(--color-text-secondary);
+}
+.test-email-section {
+ border-top: 1px solid var(--color-border);
+ padding-top: 1rem;
+}
+.test-email-row {
+ display: flex;
+ gap: 0.5rem;
+ align-items: center;
+}
diff --git a/pyproject.toml b/pyproject.toml
index 6f58d0b..3513018 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -15,6 +15,7 @@ dependencies = [
"httpx>=0.27",
"hypercorn>=0.17",
"bcrypt>=4.0",
+ "aiosmtplib>=3.0",
]
[project.optional-dependencies]
diff --git a/src/fabledassistant/app.py b/src/fabledassistant/app.py
index 8e9943b..f56ec82 100644
--- a/src/fabledassistant/app.py
+++ b/src/fabledassistant/app.py
@@ -1,5 +1,6 @@
import logging
import time
+import traceback as tb_module
from pathlib import Path
from quart import Quart, g, jsonify, make_response, request, send_from_directory
@@ -59,6 +60,24 @@ def create_app() -> Quart:
response.status_code,
duration_ms,
)
+
+ # Log usage for API requests (skip logs endpoint to avoid recursion)
+ if request.path.startswith("/api/") and not request.path.startswith("/api/admin/logs"):
+ try:
+ from fabledassistant.services.logging import log_usage
+
+ user = getattr(g, "user", None)
+ await log_usage(
+ user_id=user.id if user else None,
+ username=user.username if user else None,
+ endpoint=request.path,
+ method=request.method,
+ status_code=response.status_code,
+ duration_ms=duration_ms,
+ )
+ except Exception:
+ logger.debug("Failed to log usage", exc_info=True)
+
return response
@app.before_serving
@@ -67,8 +86,12 @@ def create_app() -> Quart:
from fabledassistant.services.generation_buffer import start_cleanup_loop
from fabledassistant.services.llm import ensure_model
+ from fabledassistant.services.logging import start_log_retention_loop
+ from fabledassistant.services.notifications import start_notification_loop
start_cleanup_loop()
+ start_log_retention_loop()
+ start_notification_loop()
async def _pull_model():
try:
@@ -111,6 +134,23 @@ def create_app() -> Quart:
@app.errorhandler(500)
async def handle_500(error):
logger.exception("Internal server error on %s %s", request.method, request.path)
+
+ try:
+ from fabledassistant.services.logging import log_error
+
+ user = getattr(g, "user", None)
+ await log_error(
+ user_id=user.id if user else None,
+ username=user.username if user else None,
+ endpoint=request.path,
+ method=request.method,
+ error_type=type(error).__name__,
+ error_message=str(error),
+ traceback=tb_module.format_exc(),
+ )
+ except Exception:
+ logger.debug("Failed to log error", exc_info=True)
+
if request.path.startswith("/api/"):
return jsonify({"error": "Internal server error"}), 500
return "Internal Server Error", 500
diff --git a/src/fabledassistant/config.py b/src/fabledassistant/config.py
index d9c5e03..5483539 100644
--- a/src/fabledassistant/config.py
+++ b/src/fabledassistant/config.py
@@ -27,3 +27,13 @@ class Config:
SECRET_KEY: str = _read_secret("SECRET_KEY", "SECRET_KEY_FILE", "dev-secret-change-me")
SECURE_COOKIES: bool = os.environ.get("SECURE_COOKIES", "").lower() in ("1", "true", "yes")
LOG_LEVEL: str = os.environ.get("LOG_LEVEL", "INFO")
+ LOG_RETENTION_DAYS: int = int(os.environ.get("LOG_RETENTION_DAYS", "90"))
+
+ # SMTP defaults (overridden by DB settings when configured via admin UI)
+ SMTP_HOST: str = os.environ.get("SMTP_HOST", "")
+ SMTP_PORT: int = int(os.environ.get("SMTP_PORT", "587"))
+ SMTP_USERNAME: str = os.environ.get("SMTP_USERNAME", "")
+ SMTP_PASSWORD: str = _read_secret("SMTP_PASSWORD", "SMTP_PASSWORD_FILE", "")
+ SMTP_FROM_ADDRESS: str = os.environ.get("SMTP_FROM_ADDRESS", "")
+ SMTP_FROM_NAME: str = os.environ.get("SMTP_FROM_NAME", "Fabled Assistant")
+ SMTP_USE_TLS: bool = os.environ.get("SMTP_USE_TLS", "true").lower() in ("1", "true", "yes")
diff --git a/src/fabledassistant/models/__init__.py b/src/fabledassistant/models/__init__.py
index 0edd2b1..882c904 100644
--- a/src/fabledassistant/models/__init__.py
+++ b/src/fabledassistant/models/__init__.py
@@ -15,3 +15,4 @@ from fabledassistant.models.note import Note, TaskPriority, TaskStatus # noqa:
from fabledassistant.models.conversation import Conversation, Message # noqa: E402, F401
from fabledassistant.models.setting import Setting # noqa: E402, F401
from fabledassistant.models.user import User # noqa: E402, F401
+from fabledassistant.models.app_log import AppLog # noqa: E402, F401
diff --git a/src/fabledassistant/models/app_log.py b/src/fabledassistant/models/app_log.py
new file mode 100644
index 0000000..364ca5f
--- /dev/null
+++ b/src/fabledassistant/models/app_log.py
@@ -0,0 +1,48 @@
+from datetime import datetime, timezone
+
+from sqlalchemy import DateTime, Float, Index, Integer, Text
+from sqlalchemy.orm import Mapped, mapped_column
+
+from fabledassistant.models import Base
+
+
+class AppLog(Base):
+ __tablename__ = "app_logs"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ category: Mapped[str] = mapped_column(Text, nullable=False)
+ user_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
+ username: Mapped[str | None] = mapped_column(Text, nullable=True)
+ action: Mapped[str | None] = mapped_column(Text, nullable=True)
+ endpoint: Mapped[str | None] = mapped_column(Text, nullable=True)
+ method: Mapped[str | None] = mapped_column(Text, nullable=True)
+ status_code: Mapped[int | None] = mapped_column(Integer, nullable=True)
+ duration_ms: Mapped[float | None] = mapped_column(Float, nullable=True)
+ ip_address: Mapped[str | None] = mapped_column(Text, nullable=True)
+ details: Mapped[str | None] = mapped_column(Text, nullable=True)
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
+ )
+
+ __table_args__ = (
+ Index("ix_app_logs_category", "category"),
+ Index("ix_app_logs_user_id", "user_id"),
+ Index("ix_app_logs_created_at", "created_at"),
+ Index("ix_app_logs_category_created_at", "category", created_at.desc()),
+ )
+
+ def to_dict(self) -> dict:
+ return {
+ "id": self.id,
+ "category": self.category,
+ "user_id": self.user_id,
+ "username": self.username,
+ "action": self.action,
+ "endpoint": self.endpoint,
+ "method": self.method,
+ "status_code": self.status_code,
+ "duration_ms": self.duration_ms,
+ "ip_address": self.ip_address,
+ "details": self.details,
+ "created_at": self.created_at.isoformat() if self.created_at else None,
+ }
diff --git a/src/fabledassistant/routes/admin.py b/src/fabledassistant/routes/admin.py
index f4baec8..d5b1093 100644
--- a/src/fabledassistant/routes/admin.py
+++ b/src/fabledassistant/routes/admin.py
@@ -1,6 +1,6 @@
import json
-from quart import Blueprint, Response, jsonify, request
+from quart import Blueprint, Response, g, jsonify, request
from fabledassistant.auth import admin_required, login_required, get_current_user_id
from fabledassistant.services.auth import (
@@ -14,6 +14,9 @@ from fabledassistant.services.backup import (
export_user_backup,
restore_full_backup,
)
+from fabledassistant.services.email import SMTP_SETTING_KEYS, get_smtp_config, send_test_email
+from fabledassistant.services.logging import get_logs, get_log_stats, log_audit
+from fabledassistant.services.settings import set_settings_batch
admin_bp = Blueprint("admin", __name__, url_prefix="/api/admin")
@@ -26,13 +29,13 @@ async def backup():
if scope == "full":
# Full backup requires admin
- from quart import g
if g.user.role != "admin":
return jsonify({"error": "Admin access required for full backup"}), 403
data = await export_full_backup()
else:
data = await export_user_backup(uid)
+ await log_audit("backup", user_id=uid, username=g.user.username, ip_address=request.remote_addr, details={"scope": scope})
return Response(
json.dumps(data, indent=2, default=str),
content_type="application/json",
@@ -50,6 +53,8 @@ async def restore():
return jsonify({"error": "No backup data provided"}), 400
stats = await restore_full_backup(data)
+ uid = get_current_user_id()
+ await log_audit("restore", user_id=uid, username=g.user.username, ip_address=request.remote_addr, details={"stats": stats})
return jsonify({"status": "ok", "stats": stats})
@@ -70,6 +75,7 @@ async def remove_user(user_id: int):
deleted = await delete_user(user_id)
if not deleted:
return jsonify({"error": "User not found"}), 404
+ await log_audit("user_delete", user_id=current_uid, username=g.user.username, ip_address=request.remote_addr, details={"deleted_user_id": user_id})
return jsonify({"status": "ok"})
@@ -90,4 +96,82 @@ async def toggle_registration():
uid = get_current_user_id()
await set_registration_open(uid, bool(open_val))
+ await log_audit("registration_toggle", user_id=uid, username=g.user.username, ip_address=request.remote_addr, details={"open": bool(open_val)})
return jsonify({"status": "ok", "open": bool(open_val)})
+
+
+@admin_bp.route("/smtp", methods=["GET"])
+@admin_required
+async def get_smtp():
+ config = await get_smtp_config()
+ # Mask password
+ if config.get("smtp_password"):
+ config["smtp_password"] = "********"
+ return jsonify(config)
+
+
+@admin_bp.route("/smtp", methods=["PUT"])
+@admin_required
+async def update_smtp():
+ data = await request.get_json()
+ uid = get_current_user_id()
+
+ settings_to_save = {}
+ for key in SMTP_SETTING_KEYS:
+ if key in data:
+ # Skip password if it's the mask placeholder
+ if key == "smtp_password" and data[key] == "********":
+ continue
+ settings_to_save[key] = str(data[key])
+
+ if settings_to_save:
+ await set_settings_batch(uid, settings_to_save)
+ await log_audit("smtp_config", user_id=uid, username=g.user.username, ip_address=request.remote_addr)
+ return jsonify({"status": "ok"})
+
+
+@admin_bp.route("/smtp/test", methods=["POST"])
+@admin_required
+async def test_smtp():
+ data = await request.get_json()
+ recipient = (data.get("recipient") or "").strip()
+ if not recipient:
+ return jsonify({"error": "Recipient email is required"}), 400
+
+ uid = get_current_user_id()
+ try:
+ await send_test_email(recipient)
+ await log_audit("smtp_test", user_id=uid, username=g.user.username, ip_address=request.remote_addr, details={"recipient": recipient})
+ return jsonify({"status": "ok"})
+ except Exception as e:
+ return jsonify({"error": str(e)}), 500
+
+
+@admin_bp.route("/logs", methods=["GET"])
+@admin_required
+async def list_logs():
+ category = request.args.get("category")
+ user_id = request.args.get("user_id", type=int)
+ search = request.args.get("search")
+ date_from = request.args.get("date_from")
+ date_to = request.args.get("date_to")
+ limit = request.args.get("limit", 50, type=int)
+ offset = request.args.get("offset", 0, type=int)
+
+ logs, total = await get_logs(
+ category=category,
+ user_id=user_id,
+ search=search,
+ date_from=date_from,
+ date_to=date_to,
+ limit=min(limit, 200),
+ offset=offset,
+ )
+ return jsonify({"logs": logs, "total": total})
+
+
+@admin_bp.route("/logs/stats", methods=["GET"])
+@admin_required
+async def log_stats():
+ stats = await get_log_stats()
+ return jsonify(stats)
diff --git a/src/fabledassistant/routes/auth.py b/src/fabledassistant/routes/auth.py
index 17e51cf..183c713 100644
--- a/src/fabledassistant/routes/auth.py
+++ b/src/fabledassistant/routes/auth.py
@@ -1,4 +1,6 @@
-from quart import Blueprint, jsonify, request, session
+import asyncio
+
+from quart import Blueprint, g, jsonify, request, session
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.services.auth import (
@@ -6,9 +8,12 @@ from fabledassistant.services.auth import (
change_password,
create_user,
get_user_by_id,
+ get_user_by_username,
get_user_count,
is_registration_open,
)
+from fabledassistant.services.logging import log_audit
+from fabledassistant.services.notifications import notify_security_event
auth_bp = Blueprint("auth", __name__, url_prefix="/api/auth")
@@ -34,6 +39,7 @@ async def register():
return jsonify({"error": "Username already taken"}), 409
session["user_id"] = user.id
+ await log_audit("register", user_id=user.id, username=user.username, ip_address=request.remote_addr)
return jsonify(user.to_dict()), 201
@@ -48,14 +54,35 @@ async def login():
user = await authenticate(username, password)
if not user:
+ await log_audit("login_failed", username=username, ip_address=request.remote_addr)
+ # Try to notify the actual user about the failed attempt
+ target_user = await get_user_by_username(username)
+ if target_user:
+ asyncio.create_task(notify_security_event(
+ target_user.id, "login_failed",
+ {"ip_address": request.remote_addr, "username": username},
+ ))
return jsonify({"error": "Invalid username or password"}), 401
session["user_id"] = user.id
+ await log_audit("login", user_id=user.id, username=user.username, ip_address=request.remote_addr)
+ asyncio.create_task(notify_security_event(
+ user.id, "login",
+ {"ip_address": request.remote_addr},
+ ))
return jsonify(user.to_dict())
@auth_bp.route("/logout", methods=["POST"])
async def logout():
+ uid = session.get("user_id")
+ if uid:
+ user = await get_user_by_id(uid)
+ await log_audit("logout", user_id=uid, username=user.username if user else None, ip_address=request.remote_addr)
+ asyncio.create_task(notify_security_event(
+ uid, "logout",
+ {"ip_address": request.remote_addr},
+ ))
session.clear()
return jsonify({"status": "ok"})
@@ -86,6 +113,11 @@ async def update_password():
if not success:
return jsonify({"error": "Current password is incorrect"}), 403
+ await log_audit("password_change", user_id=uid, username=g.user.username, ip_address=request.remote_addr)
+ asyncio.create_task(notify_security_event(
+ uid, "password_change",
+ {"ip_address": request.remote_addr},
+ ))
return jsonify({"status": "ok"})
diff --git a/src/fabledassistant/services/assist.py b/src/fabledassistant/services/assist.py
index 20735b9..76781d1 100644
--- a/src/fabledassistant/services/assist.py
+++ b/src/fabledassistant/services/assist.py
@@ -1,4 +1,4 @@
-MAX_BODY_CHARS = 3000
+MAX_BODY_CHARS = 8000
def build_assist_messages(
diff --git a/src/fabledassistant/services/auth.py b/src/fabledassistant/services/auth.py
index 0616a98..c217125 100644
--- a/src/fabledassistant/services/auth.py
+++ b/src/fabledassistant/services/auth.py
@@ -87,6 +87,14 @@ async def get_user_by_id(user_id: int) -> User | None:
return await session.get(User, user_id)
+async def get_user_by_username(username: str) -> User | None:
+ async with async_session() as session:
+ result = await session.execute(
+ select(User).where(User.username == username)
+ )
+ return result.scalars().first()
+
+
async def change_password(user_id: int, current_password: str, new_password: str) -> bool:
"""Change a user's password. Returns True on success, False if current password is wrong."""
async with async_session() as session:
diff --git a/src/fabledassistant/services/email.py b/src/fabledassistant/services/email.py
new file mode 100644
index 0000000..4b42f8e
--- /dev/null
+++ b/src/fabledassistant/services/email.py
@@ -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 = """
+
+
+
Fabled Assistant
+
+
+
Your SMTP configuration is working correctly.
+
This is a test email sent from your Fabled Assistant instance.
+
+
+ """
+ await send_email(to, "Fabled Assistant - Test Email", html)
diff --git a/src/fabledassistant/services/llm.py b/src/fabledassistant/services/llm.py
index 9631cc8..70255a0 100644
--- a/src/fabledassistant/services/llm.py
+++ b/src/fabledassistant/services/llm.py
@@ -72,14 +72,19 @@ async def ensure_model(model: str) -> None:
async def stream_chat(
- messages: list[dict], model: str
+ messages: list[dict],
+ model: str,
+ options: dict | None = None,
) -> AsyncGenerator[str, None]:
"""Stream chat completion from Ollama, yielding content chunks."""
+ payload: dict = {"model": model, "messages": messages, "stream": True}
+ if options:
+ payload["options"] = options
async with httpx.AsyncClient(timeout=httpx.Timeout(300.0, connect=10.0)) as client:
async with client.stream(
"POST",
f"{Config.OLLAMA_URL}/api/chat",
- json={"model": model, "messages": messages, "stream": True},
+ json=payload,
) as resp:
resp.raise_for_status()
async for line in resp.aiter_lines():
diff --git a/src/fabledassistant/services/logging.py b/src/fabledassistant/services/logging.py
new file mode 100644
index 0000000..df8dac3
--- /dev/null
+++ b/src/fabledassistant/services/logging.py
@@ -0,0 +1,177 @@
+"""Application logging service for audit, usage, and error events."""
+
+import asyncio
+import json
+import logging
+import traceback as tb_module
+from datetime import datetime, timedelta, timezone
+
+from sqlalchemy import delete, func, select, text
+
+from fabledassistant.config import Config
+from fabledassistant.models import async_session
+from fabledassistant.models.app_log import AppLog
+
+logger = logging.getLogger(__name__)
+
+_retention_task: asyncio.Task | None = None
+
+
+async def log_audit(
+ action: str,
+ user_id: int | None = None,
+ username: str | None = None,
+ ip_address: str | None = None,
+ details: dict | None = None,
+) -> None:
+ async with async_session() as session:
+ log = AppLog(
+ category="audit",
+ user_id=user_id,
+ username=username,
+ action=action,
+ ip_address=ip_address,
+ details=json.dumps(details) if details else None,
+ )
+ session.add(log)
+ await session.commit()
+
+
+async def log_usage(
+ user_id: int | None = None,
+ username: str | None = None,
+ endpoint: str | None = None,
+ method: str | None = None,
+ status_code: int | None = None,
+ duration_ms: float | None = None,
+) -> None:
+ async with async_session() as session:
+ log = AppLog(
+ category="usage",
+ user_id=user_id,
+ username=username,
+ endpoint=endpoint,
+ method=method,
+ status_code=status_code,
+ duration_ms=duration_ms,
+ )
+ session.add(log)
+ await session.commit()
+
+
+async def log_error(
+ user_id: int | None = None,
+ username: str | None = None,
+ endpoint: str | None = None,
+ method: str | None = None,
+ error_type: str | None = None,
+ error_message: str | None = None,
+ traceback: str | None = None,
+) -> None:
+ details = {}
+ if error_type:
+ details["error_type"] = error_type
+ if error_message:
+ details["error_message"] = error_message
+ if traceback:
+ details["traceback"] = traceback
+
+ async with async_session() as session:
+ log = AppLog(
+ category="error",
+ user_id=user_id,
+ username=username,
+ endpoint=endpoint,
+ method=method,
+ details=json.dumps(details) if details else None,
+ )
+ session.add(log)
+ await session.commit()
+
+
+async def get_logs(
+ category: str | None = None,
+ user_id: int | None = None,
+ search: str | None = None,
+ date_from: str | None = None,
+ date_to: str | None = None,
+ limit: int = 50,
+ offset: int = 0,
+) -> tuple[list[dict], int]:
+ async with async_session() as session:
+ query = select(AppLog)
+ count_query = select(func.count(AppLog.id))
+
+ if category:
+ query = query.where(AppLog.category == category)
+ count_query = count_query.where(AppLog.category == category)
+ if user_id is not None:
+ query = query.where(AppLog.user_id == user_id)
+ count_query = count_query.where(AppLog.user_id == user_id)
+ if search:
+ pattern = f"%{search}%"
+ search_filter = (
+ AppLog.action.ilike(pattern)
+ | AppLog.endpoint.ilike(pattern)
+ | AppLog.username.ilike(pattern)
+ | AppLog.details.ilike(pattern)
+ )
+ query = query.where(search_filter)
+ count_query = count_query.where(search_filter)
+ if date_from:
+ query = query.where(AppLog.created_at >= date_from)
+ count_query = count_query.where(AppLog.created_at >= date_from)
+ if date_to:
+ query = query.where(AppLog.created_at <= date_to)
+ count_query = count_query.where(AppLog.created_at <= date_to)
+
+ total = (await session.execute(count_query)).scalar() or 0
+
+ query = query.order_by(AppLog.created_at.desc()).limit(limit).offset(offset)
+ result = await session.execute(query)
+ logs = [row.to_dict() for row in result.scalars().all()]
+
+ return logs, total
+
+
+async def get_log_stats() -> dict:
+ async with async_session() as session:
+ result = await session.execute(
+ text(
+ "SELECT category, COUNT(*) as count FROM app_logs GROUP BY category"
+ )
+ )
+ stats = {row.category: row.count for row in result}
+ return {
+ "audit": stats.get("audit", 0),
+ "usage": stats.get("usage", 0),
+ "error": stats.get("error", 0),
+ "total": sum(stats.values()),
+ }
+
+
+async def delete_old_logs(retention_days: int) -> int:
+ cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days)
+ async with async_session() as session:
+ result = await session.execute(
+ delete(AppLog).where(AppLog.created_at < cutoff)
+ )
+ await session.commit()
+ return result.rowcount
+
+
+async def _retention_loop() -> None:
+ while True:
+ await asyncio.sleep(3600) # hourly
+ try:
+ deleted = await delete_old_logs(Config.LOG_RETENTION_DAYS)
+ if deleted:
+ logger.info("Log retention: deleted %d old log entries", deleted)
+ except Exception:
+ logger.exception("Error in log retention cleanup")
+
+
+def start_log_retention_loop() -> None:
+ global _retention_task
+ if _retention_task is None or _retention_task.done():
+ _retention_task = asyncio.create_task(_retention_loop())
diff --git a/src/fabledassistant/services/notifications.py b/src/fabledassistant/services/notifications.py
new file mode 100644
index 0000000..92ad0dd
--- /dev/null
+++ b/src/fabledassistant/services/notifications.py
@@ -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'| {k} | {v} |
'
+
+ html = f"""
+
+
+
Security Alert
+
+
+
{label}
+
+ | Time | {timestamp} |
+ | IP Address | {ip} |
+ {detail_rows}
+
+
If this wasn't you, change your password immediately.
+
+
+ """
+ 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'{task.due_date.isoformat()}' if task.due_date else ""
+ overdue_badge = ' (overdue)' if overdue else ""
+ task_rows += f"""
+
+ | {task.title} |
+ {date_label}{overdue_badge} |
+ {task.status} |
+
+ """
+
+ html = f"""
+
+
+
Task Reminders
+
+
+
You have {len(user_tasks)} task(s) due:
+
+
+ | Task |
+ Due |
+ Status |
+
+ {task_rows}
+
+
+
+ """
+ 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())