Invitation system (Phase 5.7): - invitation_tokens table (migration 0012) with SHA256-hashed tokens, 7-day expiry - Admin CRUD endpoints: POST/GET/DELETE /api/admin/invitations - Branded invitation email with registration link - /register-invite frontend view with token validation and account creation - Admin UI: invite form + pending invitations table with revoke - Configurable base URL setting for email links (replaces hardcoded Config.BASE_URL) Table of contents + markdown improvements (Phase 5.8): - TableOfContents component: sticky sidebar, heading parsing, smooth-scroll - Custom marked renderer adds id attributes to headings for anchor links - stripFirstLineTags() prevents leading #tags from rendering as headings - NoteViewerView/TaskViewerView flex layout with TOC sidebar (hidden ≤1200px) - Model catalog refresh: added llama3.2/3.3, gemma3, qwen3, phi4, deepseek-r1, qwen2.5-coder, dolphin3; added Reasoning category; removed discontinued models - Settings model section split into Installed/Available tabs Actionable dashboard (Phase 5.9): - due_before/due_after query params on /api/tasks (exclusive < / inclusive >=) - HomeView rewritten: overdue (red accent), due today, in progress, chats, notes - 5 parallel API calls via Promise.allSettled - Client-side done filtering and in-progress deduplication - Task sections hidden when empty; status toggle removes done tasks Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
124 lines
4.3 KiB
Python
124 lines
4.3 KiB
Python
"""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 get_base_url() -> str:
|
|
"""Get the application base URL from admin settings, falling back to Config.BASE_URL."""
|
|
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 == "base_url")
|
|
)
|
|
setting = result.scalars().first()
|
|
if setting and setting.value:
|
|
return setting.value.rstrip("/")
|
|
return Config.BASE_URL.rstrip("/")
|
|
|
|
|
|
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)
|