"""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 _email_html, 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'
{label}
| Time | {timestamp} |
| IP Address | {ip} |
If this wasn't you, change your password immediately.
""" await send_email(email, f"Fabled Scribe - {label}", _email_html("Security Alert", body)) except Exception: logger.exception("Failed to send security notification for user %d", user_id) async def send_password_reset_email(email: str, reset_url: str) -> None: """Send a password reset email with a link to reset the user's password.""" body = f"""We received a request to reset your password. Click the button below to choose a new password.
This link expires in 1 hour.
If you didn't request this, you can safely ignore this email.
If the button doesn't work, copy and paste this link:
{reset_url}
""" await send_email(email, "Fabled Scribe - Password Reset", _email_html("Password Reset", body)) async def send_password_reset_success_email(email: str) -> None: """Send a notification that the password was successfully reset.""" timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") body = f"""Your password was successfully changed at {timestamp}.
If you didn't make this change, please contact your administrator immediately.
""" await send_email(email, "Fabled Scribe - Password Changed", _email_html("Password Changed", body)) async def send_invitation_email(email: str, invite_url: str, invited_by_username: str) -> None: """Send a branded invitation email with a registration link.""" body = f"""{invited_by_username} has invited you to join Fabled Scribe. Click the button below to create your account.
This invitation expires in 7 days.
If you weren't expecting this, you can safely ignore this email.
If the button doesn't work, copy and paste this link:
{invite_url}
""" await send_email(email, "Fabled Scribe - You're Invited!", _email_html("You're Invited!", body)) 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'You have {len(user_tasks)} task(s) due:
""" await send_email(email, f"Fabled Scribe - {len(user_tasks)} Task(s) Due", _email_html("Task Reminders", body)) 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()) # --------------------------------------------------------------------------- # In-app notifications (Notification model — shares, group events) # --------------------------------------------------------------------------- async def create_in_app_notification(user_id: int, notif_type: str, payload: dict): """Create an in-app Notification record.""" from fabledassistant.models.notification import Notification async with async_session() as session: n = Notification(user_id=user_id, type=notif_type, payload=payload) session.add(n) await session.commit() await session.refresh(n) return n async def _fire_push_notif(user_id: int, title: str, body: str, url: str) -> None: try: from fabledassistant.services.push import send_push_notification await send_push_notification(user_id, title, body, url=url) except Exception: logger.exception("Push notification failed for user %d", user_id) async def _fire_share_email(user_id: int, subject: str, body_text: str) -> None: try: if not await is_smtp_configured(): return async with async_session() as session: user = await session.get(User, user_id) if user and user.email: html = _email_html(subject, f"{body_text.replace(chr(10), '
')}