e652dece9b
Updates user-visible branding across frontend (PWA manifest, page title, Settings, push fallback), backend (email templates and subjects, LLM system prompt, CalDAV displayname, SMTP from-name default), README, quickstart compose, and MCP server description. Also updates the CI image path and quickstart image reference to git.fabledsword.com/bvandeusen/fabledscribe in preparation for the Forgejo repo rename. Internal Python package, env vars, and database schema unchanged. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
448 lines
19 KiB
Python
448 lines
19 KiB
Python
"""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'<tr>'
|
|
f'<td style="padding:6px 0;color:#6b7280;font-size:13px;width:120px;">{k}</td>'
|
|
f'<td style="padding:6px 0;color:#111827;font-size:13px;">{v}</td>'
|
|
f'</tr>'
|
|
)
|
|
|
|
body = f"""
|
|
<p style="margin:0 0 16px;color:#111827;font-size:15px;font-weight:600;">{label}</p>
|
|
<table style="width:100%;border-collapse:collapse;background:#f9fafb;border:1px solid #e5e7eb;border-radius:6px;">
|
|
<tr>
|
|
<td style="padding:8px 12px;color:#6b7280;font-size:13px;width:120px;border-bottom:1px solid #e5e7eb;">Time</td>
|
|
<td style="padding:8px 12px;color:#111827;font-size:13px;border-bottom:1px solid #e5e7eb;">{timestamp}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding:8px 12px;color:#6b7280;font-size:13px;width:120px;{';border-bottom:1px solid #e5e7eb' if detail_rows else ''}">IP Address</td>
|
|
<td style="padding:8px 12px;color:#111827;font-size:13px;{';border-bottom:1px solid #e5e7eb' if detail_rows else ''}">{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>
|
|
"""
|
|
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"""
|
|
<p style="margin:0 0 16px;color:#374151;font-size:15px;">
|
|
We received a request to reset your password. Click the button below to choose a new password.
|
|
</p>
|
|
<div style="text-align:center;margin:24px 0;">
|
|
<a href="{reset_url}" style="display:inline-block;background:#7c3aed;color:#ffffff;padding:12px 32px;border-radius:6px;text-decoration:none;font-weight:600;font-size:15px;">
|
|
Reset Password
|
|
</a>
|
|
</div>
|
|
<p style="margin:0 0 6px;color:#6b7280;font-size:13px;">This link expires in 1 hour.</p>
|
|
<p style="margin:0 0 16px;color:#6b7280;font-size:13px;">If you didn't request this, you can safely ignore this email.</p>
|
|
<hr style="border:none;border-top:1px solid #e5e7eb;margin:16px 0;">
|
|
<p style="margin:0;color:#9ca3af;font-size:12px;">If the button doesn't work, copy and paste this link:</p>
|
|
<p style="margin:4px 0 0;color:#9ca3af;font-size:12px;word-break:break-all;">{reset_url}</p>
|
|
"""
|
|
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"""
|
|
<p style="margin:0 0 12px;color:#374151;font-size:15px;">
|
|
Your password was successfully changed at <strong>{timestamp}</strong>.
|
|
</p>
|
|
<p style="margin:0;color:#6b7280;font-size:13px;">If you didn't make this change, please contact your administrator immediately.</p>
|
|
"""
|
|
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"""
|
|
<p style="margin:0 0 16px;color:#374151;font-size:15px;">
|
|
<strong>{invited_by_username}</strong> has invited you to join Fabled Scribe.
|
|
Click the button below to create your account.
|
|
</p>
|
|
<div style="text-align:center;margin:24px 0;">
|
|
<a href="{invite_url}" style="display:inline-block;background:#7c3aed;color:#ffffff;padding:12px 32px;border-radius:6px;text-decoration:none;font-weight:600;font-size:15px;">
|
|
Accept Invitation
|
|
</a>
|
|
</div>
|
|
<p style="margin:0 0 6px;color:#6b7280;font-size:13px;">This invitation expires in 7 days.</p>
|
|
<p style="margin:0 0 16px;color:#6b7280;font-size:13px;">If you weren't expecting this, you can safely ignore this email.</p>
|
|
<hr style="border:none;border-top:1px solid #e5e7eb;margin:16px 0;">
|
|
<p style="margin:0;color:#9ca3af;font-size:12px;">If the button doesn't work, copy and paste this link:</p>
|
|
<p style="margin:4px 0 0;color:#9ca3af;font-size:12px;word-break:break-all;">{invite_url}</p>
|
|
"""
|
|
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'<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:11px;">(overdue)</span>' if overdue else ""
|
|
task_rows += (
|
|
f'<tr>'
|
|
f'<td style="padding:8px 12px;border-bottom:1px solid #e5e7eb;color:#111827;font-size:14px;">{task.title}</td>'
|
|
f'<td style="padding:8px 12px;border-bottom:1px solid #e5e7eb;font-size:14px;">{date_label}{overdue_badge}</td>'
|
|
f'<td style="padding:8px 12px;border-bottom:1px solid #e5e7eb;color:#6b7280;font-size:13px;">{task.status}</td>'
|
|
f'</tr>'
|
|
)
|
|
|
|
body = f"""
|
|
<p style="margin:0 0 16px;color:#374151;font-size:15px;">You have <strong>{len(user_tasks)}</strong> task(s) due:</p>
|
|
<table style="width:100%;border-collapse:collapse;border:1px solid #e5e7eb;border-radius:6px;overflow:hidden;">
|
|
<tr style="background:#f9fafb;">
|
|
<th style="padding:8px 12px;text-align:left;font-size:12px;color:#6b7280;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;border-bottom:1px solid #e5e7eb;">Task</th>
|
|
<th style="padding:8px 12px;text-align:left;font-size:12px;color:#6b7280;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;border-bottom:1px solid #e5e7eb;">Due</th>
|
|
<th style="padding:8px 12px;text-align:left;font-size:12px;color:#6b7280;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;border-bottom:1px solid #e5e7eb;">Status</th>
|
|
</tr>
|
|
{task_rows}
|
|
</table>
|
|
"""
|
|
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"<p>{body_text.replace(chr(10), '<br>')}</p>")
|
|
await send_email(user.email, subject, html)
|
|
except Exception:
|
|
logger.exception("Share email notification failed for user %d", user_id)
|
|
|
|
|
|
async def _group_member_ids(group_id: int) -> list[int]:
|
|
from fabledassistant.models.group import GroupMembership
|
|
async with async_session() as session:
|
|
rows = (await session.execute(
|
|
select(GroupMembership.user_id).where(GroupMembership.group_id == group_id)
|
|
)).scalars().all()
|
|
return list(rows)
|
|
|
|
|
|
async def notify_project_shared(
|
|
project_id: int,
|
|
permission: str,
|
|
invited_by_user_id: int,
|
|
target_user_id: int | None,
|
|
target_group_id: int | None,
|
|
) -> None:
|
|
from fabledassistant.models.project import Project
|
|
async with async_session() as session:
|
|
project = await session.get(Project, project_id)
|
|
inviter = await session.get(User, invited_by_user_id)
|
|
if not project or not inviter:
|
|
return
|
|
|
|
recipients: list[int] = []
|
|
if target_user_id:
|
|
recipients = [target_user_id]
|
|
elif target_group_id:
|
|
recipients = [uid for uid in await _group_member_ids(target_group_id) if uid != invited_by_user_id]
|
|
|
|
url = f"/projects/{project_id}"
|
|
msg = f"{inviter.username} shared \"{project.title}\" with you as {permission}"
|
|
|
|
for uid in recipients:
|
|
await create_in_app_notification(uid, "project_shared", {
|
|
"project_id": project_id,
|
|
"project_title": project.title,
|
|
"permission": permission,
|
|
"invited_by": inviter.username,
|
|
"url": url,
|
|
})
|
|
asyncio.create_task(_fire_push_notif(uid, "Project shared with you", msg, url))
|
|
asyncio.create_task(_fire_share_email(uid, f"[Fabled] {inviter.username} shared a project with you", msg))
|
|
|
|
|
|
async def notify_note_shared(
|
|
note_id: int,
|
|
permission: str,
|
|
invited_by_user_id: int,
|
|
target_user_id: int | None,
|
|
target_group_id: int | None,
|
|
) -> None:
|
|
from fabledassistant.models.note import Note
|
|
async with async_session() as session:
|
|
note = await session.get(Note, note_id)
|
|
inviter = await session.get(User, invited_by_user_id)
|
|
if not note or not inviter:
|
|
return
|
|
|
|
recipients: list[int] = []
|
|
if target_user_id:
|
|
recipients = [target_user_id]
|
|
elif target_group_id:
|
|
recipients = [uid for uid in await _group_member_ids(target_group_id) if uid != invited_by_user_id]
|
|
|
|
route = "tasks" if note.is_task else "notes"
|
|
url = f"/{route}/{note_id}"
|
|
msg = f"{inviter.username} shared \"{note.title}\" with you as {permission}"
|
|
|
|
for uid in recipients:
|
|
await create_in_app_notification(uid, "note_shared", {
|
|
"note_id": note_id,
|
|
"note_title": note.title,
|
|
"permission": permission,
|
|
"invited_by": inviter.username,
|
|
"url": url,
|
|
})
|
|
asyncio.create_task(_fire_push_notif(uid, "Note shared with you", msg, url))
|
|
asyncio.create_task(_fire_share_email(uid, f"[Fabled] {inviter.username} shared a note with you", msg))
|
|
|
|
|
|
async def notify_group_added(
|
|
group_id: int, role: str, invited_by_user_id: int, target_user_id: int
|
|
) -> None:
|
|
from fabledassistant.models.group import Group
|
|
async with async_session() as session:
|
|
group = await session.get(Group, group_id)
|
|
inviter = await session.get(User, invited_by_user_id)
|
|
if not group or not inviter:
|
|
return
|
|
|
|
url = f"/groups/{group_id}"
|
|
msg = f"{inviter.username} added you to group \"{group.name}\" as {role}"
|
|
|
|
await create_in_app_notification(target_user_id, "group_added", {
|
|
"group_id": group_id,
|
|
"group_name": group.name,
|
|
"role": role,
|
|
"invited_by": inviter.username,
|
|
"url": url,
|
|
})
|
|
asyncio.create_task(_fire_push_notif(target_user_id, "Added to a group", msg, url))
|
|
asyncio.create_task(_fire_share_email(target_user_id, "[Fabled] You've been added to a group", msg))
|
|
|
|
|
|
async def list_in_app_notifications(user_id: int, unread_only: bool = True) -> list[dict]:
|
|
from fabledassistant.models.notification import Notification
|
|
async with async_session() as session:
|
|
q = select(Notification).where(Notification.user_id == user_id)
|
|
if unread_only:
|
|
q = q.where(Notification.read_at.is_(None))
|
|
q = q.order_by(Notification.created_at.desc()).limit(50)
|
|
rows = (await session.execute(q)).scalars().all()
|
|
return [n.to_dict() for n in rows]
|
|
|
|
|
|
async def unread_notification_count(user_id: int) -> int:
|
|
from fabledassistant.models.notification import Notification
|
|
async with async_session() as session:
|
|
result = await session.execute(
|
|
select(func.count()).where(
|
|
Notification.user_id == user_id,
|
|
Notification.read_at.is_(None),
|
|
)
|
|
)
|
|
return result.scalar() or 0
|
|
|
|
|
|
async def mark_notification_read(user_id: int, notification_id: int) -> bool:
|
|
from fabledassistant.models.notification import Notification
|
|
from datetime import timezone as tz
|
|
async with async_session() as session:
|
|
n = (await session.execute(
|
|
select(Notification).where(
|
|
Notification.id == notification_id,
|
|
Notification.user_id == user_id,
|
|
)
|
|
)).scalar_one_or_none()
|
|
if not n:
|
|
return False
|
|
from datetime import datetime
|
|
n.read_at = datetime.now(tz.utc)
|
|
await session.commit()
|
|
return True
|
|
|
|
|
|
async def mark_all_notifications_read(user_id: int) -> int:
|
|
from fabledassistant.models.notification import Notification
|
|
from datetime import datetime, timezone as tz
|
|
from sqlalchemy import update as sa_update
|
|
async with async_session() as session:
|
|
result = await session.execute(
|
|
sa_update(Notification)
|
|
.where(Notification.user_id == user_id, Notification.read_at.is_(None))
|
|
.values(read_at=datetime.now(tz.utc))
|
|
.returning(Notification.id)
|
|
)
|
|
await session.commit()
|
|
return len(result.fetchall())
|