import html
import logging
from email.message import EmailMessage
import aiosmtplib
from sqlalchemy.ext.asyncio import AsyncSession
from app.crypto import decrypt
from app.models import CustomTheme, SmtpSettings, User
from app.services.smtp_settings_service import get_smtp_settings
logger = logging.getLogger(__name__)
class SmtpNotConfiguredError(Exception):
pass
# #68: the 6 tokens actually used by the email template below, out of the
# full ~12-token palette themes.css defines per preset -- an email has no
# equivalent of --ds-surface-2/--ds-void-2/--ds-highlight/--ds-danger, it's
# one card on one background with one accent. Kept in sync by hand with
# frontend/src/styles/tokens.css (the default) and themes.css (the other
# three presets) -- there's no way to share the source of truth across the
# Python/CSS boundary, so if either changes the other needs updating too.
DEFAULT_PALETTE = {
"void": "#07080f",
"surface": "#101030",
"border": "#242478",
"text": "#fce4fc",
"muted": "#c0ccd8",
"accent": "#60d8fc",
}
_PRESET_PALETTES: dict[str, dict[str, str]] = {
"dark": DEFAULT_PALETTE,
"light": {
"void": "#f5f3fb",
"surface": "#ffffff",
"border": "#d8d2ee",
"text": "#1a1030",
"muted": "#675f80",
"accent": "#0891b2",
},
"midnight": {
"void": "#000000",
"surface": "#0a0a14",
"border": "#262640",
"text": "#ffffff",
"muted": "#a8b0c0",
"accent": "#00f0ff",
},
"sunset": {
"void": "#120a07",
"surface": "#241408",
"border": "#4a2c14",
"text": "#fce8d8",
"muted": "#c8b0a0",
"accent": "#fca050",
},
}
async def _resolve_palette(db: AsyncSession, theme_user: User | None) -> dict[str, str]:
"""#68: an email addressed to an existing user is styled with *their*
selected theme (mirroring the app itself), not a fixed look -- but
there's no such thing as "their theme" for someone who doesn't have an
account yet (site invites), so theme_user is None there and this falls
back to the default DarkSingularity palette, same as a logged-out page.
"""
if theme_user is None or theme_user.theme is None:
return DEFAULT_PALETTE
if theme_user.theme == "custom":
if theme_user.active_custom_theme_id is not None:
# A fresh PK fetch, not `theme_user.active_custom_theme` --
# that relationship is essentially never eager-loaded by
# whatever query got this User row in the first place, and
# touching it lazily here would raise MissingGreenlet in
# async SQLAlchemy.
custom = await db.get(CustomTheme, theme_user.active_custom_theme_id)
if custom is not None:
colors = custom.colors
return {key: colors[key] for key in DEFAULT_PALETTE}
return DEFAULT_PALETTE
return _PRESET_PALETTES.get(theme_user.theme, DEFAULT_PALETTE)
def _render_text(paragraphs: list[str], cta_label: str | None, cta_url: str | None) -> str:
body = "\n\n".join(paragraphs)
if cta_label and cta_url:
body += f"\n\n{cta_label}: {cta_url}"
return body
def _render_html(
palette: dict[str, str],
subject: str,
paragraphs: list[str],
cta_label: str | None,
cta_url: str | None,
) -> str:
# Table-based layout with everything inlined -- not the app's own CSS
# custom properties (email clients strip