diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 64a1019..3bba977 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -376,7 +376,7 @@ async def test_smtp_settings_endpoint( ): require_site_admin(current_user) try: - await send_test_email(db, current_user.email) + await send_test_email(db, current_user.email, theme_user=current_user) except SmtpNotConfiguredError: raise HTTPException(status_code=400, detail="SMTP is not configured yet") except Exception as exc: diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py index 0d7b5f8..5af419c 100644 --- a/backend/app/services/email_service.py +++ b/backend/app/services/email_service.py @@ -1,3 +1,4 @@ +import html import logging from email.message import EmailMessage @@ -5,7 +6,7 @@ import aiosmtplib from sqlalchemy.ext.asyncio import AsyncSession from app.crypto import decrypt -from app.models import SmtpSettings +from app.models import CustomTheme, SmtpSettings, User from app.services.smtp_settings_service import get_smtp_settings logger = logging.getLogger(__name__) @@ -15,14 +16,156 @@ class SmtpNotConfiguredError(Exception): pass -async def _deliver(cfg: SmtpSettings, to_address: str, subject: str, body: str) -> None: +# #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