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