Files
FabledScribe/src/fabledassistant/services/email.py
T
bvandeusen 0984dae2e7 Improve favicon contrast and redesign email templates with logo
favicon.svg:
- Light mode: replace near-black fill (#2d3748) with indigo brand color
  (#6366f1 fill, #4f46e5 stroke, #a5b4fc page lines) — distinctive and
  high-contrast without the dark/black appearance
- Dark mode unchanged

email.py:
- Add _EMAIL_LOGO_SVG: inline SVG with white palette for rendering on
  the indigo header (white book, lavender lines, gold sparkle)
- Add _email_html(title, body): shared template wrapper — gray outer
  background, white card with border-radius, indigo header with logo +
  app name, content area, footer

notifications.py:
- Import and use _email_html for all six email functions: security alert,
  password reset, password reset success, invitation, task reminder,
  test email
- Clean up all inline HTML to match the new card layout and spacing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 20:33:10 -05:00

176 lines
6.8 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__)
# Inline SVG logo for email (white palette, renders on indigo header)
_EMAIL_LOGO_SVG = (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32"'
' style="display:inline-block;vertical-align:middle;margin-right:8px;">'
'<path d="M4 7 C4 7 8 5 16 6 C24 5 28 7 28 7 L28 26 C28 26 24 24 16 25 C8 24 4 26 4 26 Z"'
' fill="#ffffff" stroke="#c4b5fd" stroke-width="0.5"/>'
'<line x1="16" y1="6" x2="16" y2="25" stroke="#c4b5fd" stroke-width="0.8"/>'
'<line x1="7" y1="11" x2="14" y2="10.5" stroke="#ddd6fe" stroke-width="0.6" stroke-linecap="round"/>'
'<line x1="7" y1="14" x2="14" y2="13.5" stroke="#ddd6fe" stroke-width="0.6" stroke-linecap="round"/>'
'<line x1="7" y1="17" x2="14" y2="16.5" stroke="#ddd6fe" stroke-width="0.6" stroke-linecap="round"/>'
'<line x1="7" y1="20" x2="12" y2="19.5" stroke="#ddd6fe" stroke-width="0.6" stroke-linecap="round"/>'
'<line x1="18" y1="10.5" x2="25" y2="11" stroke="#ddd6fe" stroke-width="0.6" stroke-linecap="round"/>'
'<line x1="18" y1="13.5" x2="25" y2="14" stroke="#ddd6fe" stroke-width="0.6" stroke-linecap="round"/>'
'<line x1="18" y1="16.5" x2="25" y2="17" stroke="#ddd6fe" stroke-width="0.6" stroke-linecap="round"/>'
'<g transform="translate(24, 5)">'
'<path d="M0 -4 L1 -1 L4 0 L1 1 L0 4 L-1 1 L-4 0 L-1 -1 Z" fill="#f6ad55"/>'
'<path d="M0 -2.5 L0.6 -0.6 L2.5 0 L0.6 0.6 L0 2.5 L-0.6 0.6 L-2.5 0 L-0.6 -0.6 Z" fill="#fbd38d"/>'
'</g>'
'<circle cx="20" cy="3" r="0.7" fill="#f6ad55" opacity="0.7"/>'
'<circle cx="27" cy="8" r="0.5" fill="#fbd38d" opacity="0.6"/>'
'</svg>'
)
def _email_html(title: str, body: str) -> str:
"""Wrap email body content in the standard Fabled Assistant template."""
return f"""<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="margin:0;padding:0;background:#f3f4f6;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
<div style="padding:32px 16px;">
<div style="max-width:520px;margin:0 auto;">
<div style="background:#ffffff;border:1px solid #e5e7eb;border-radius:8px;overflow:hidden;">
<!-- Header -->
<div style="background:#6366f1;padding:20px 24px;text-align:center;">
<div style="margin-bottom:6px;">
{_EMAIL_LOGO_SVG}<span style="display:inline-block;vertical-align:middle;color:#ffffff;font-size:17px;font-weight:600;letter-spacing:0.01em;">Fabled Assistant</span>
</div>
<p style="margin:0;color:#c7d2fe;font-size:13px;">{title}</p>
</div>
<!-- Body -->
<div style="padding:28px 24px;">
{body}
</div>
<!-- Footer -->
<div style="border-top:1px solid #e5e7eb;padding:14px 24px;text-align:center;background:#f9fafb;">
<p style="margin:0;color:#9ca3af;font-size:12px;">This email was sent by your Fabled Assistant instance.</p>
</div>
</div>
</div>
</div>
</body>
</html>"""
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."""
body = """
<p style="margin:0 0 12px;color:#111827;font-size:15px;font-weight:600;">SMTP is configured correctly</p>
<p style="margin:0;color:#6b7280;font-size:14px;">Your Fabled Assistant instance can send email notifications.</p>
"""
await send_email(to, "Fabled Assistant - Test Email", _email_html("Test Email", body))