From 77b124d61e4ae9c9f569d88403b58d4719b3ec97 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Thu, 4 Jun 2026 23:22:53 +0300 Subject: [PATCH] fix: apply webapp theme accent in emails and deeplinks --- .../app/web/templates/open_app_gateway.html | 25 ++++--- backend/bot/app/web/webapp/assets.py | 66 +++++++++++++++++-- backend/bot/services/email_templates.py | 34 ++++++++-- backend/config/webapp_themes_config.py | 32 +++++++++ tests/test_email_localization.py | 39 ++++++++++- tests/test_webapp_route_contract.py | 34 ++++++++++ tests/test_webapp_themes_config.py | 39 +++++++++++ 7 files changed, 247 insertions(+), 22 deletions(-) diff --git a/backend/bot/app/web/templates/open_app_gateway.html b/backend/bot/app/web/templates/open_app_gateway.html index 287e051..04fd848 100644 --- a/backend/bot/app/web/templates/open_app_gateway.html +++ b/backend/bot/app/web/templates/open_app_gateway.html @@ -8,11 +8,18 @@ " + ) + + def _favicon_head_markup(favicon_url: str) -> str: href = str(favicon_url or "").strip() if not href: diff --git a/backend/bot/services/email_templates.py b/backend/bot/services/email_templates.py index 57da1eb..69bf3de 100644 --- a/backend/bot/services/email_templates.py +++ b/backend/bot/services/email_templates.py @@ -16,6 +16,8 @@ from pathlib import Path from typing import TYPE_CHECKING, Optional, Sequence, Tuple from urllib.parse import urlsplit +from config.webapp_themes_config import effective_webapp_theme_accent + if TYPE_CHECKING: from bot.middlewares.i18n import JsonI18n from config.settings import Settings @@ -74,6 +76,17 @@ def _safe_color(value: Optional[str]) -> str: return _DEFAULT_ACCENT +def _theme_accent(settings: Settings) -> str: + primary = _safe_color(getattr(settings, "WEBAPP_PRIMARY_COLOR", None)) + try: + catalog = getattr(settings, "webapp_themes_catalog", None) + if catalog is None: + return primary + return _safe_color(effective_webapp_theme_accent(catalog, primary)) + except Exception: + return primary + + def _public_logo_url(settings: Settings) -> Optional[str]: """Email recipients can't reach the in-app /webapp-logo proxy, so only a stored public https URL can be used directly. Anything else is dropped.""" @@ -174,8 +187,9 @@ def _layout( intro_html: str, body_html: str, footer_html: str, + accent: Optional[str] = None, ) -> _EmailLayout: - accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR) + accent = _safe_color(accent) if accent else _theme_accent(settings) brand_title = html.escape(_brand_title(settings)) logo_url, inline_images = _email_logo(settings) html_lang = html.escape((language_code or "en").replace("_", "-"), quote=True) @@ -344,7 +358,7 @@ def render_login_code( i18n = _resolve_i18n(i18n) lang = _normalize_lang(language_code, settings) minutes = _format_minutes(settings.EMAIL_CODE_TTL_SECONDS) - accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR) + accent = _theme_accent(settings) brand = _brand_title(settings) template_prefix = "email_set_password_code" if purpose == "set_password" else "email_login_code" safe_magic_link = (magic_link or "").strip() if template_prefix == "email_login_code" else "" @@ -404,6 +418,7 @@ def render_login_code( intro_html=html.escape(intro), body_html=body_html, footer_html=footer, + accent=accent, ) return _email_content(subject=subject, text="\n".join(text_lines), layout=rendered) @@ -477,7 +492,7 @@ def render_payment_success( ) -> EmailContent: i18n = _resolve_i18n(i18n) lang = _normalize_lang(language_code, settings) - accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR) + accent = _theme_accent(settings) brand = _brand_title(settings) sale_base = (sale_mode or "").split("@", 1)[0].split("|", 1)[0] is_traffic = sale_base in { @@ -591,6 +606,7 @@ def render_payment_success( intro_html=html.escape(intro), body_html="".join(body_parts), footer_html=footer, + accent=accent, ) return _email_content(subject=subject, text="\n".join(text_lines), layout=rendered) @@ -609,7 +625,7 @@ def render_user_notification( ) -> EmailContent: i18n = _resolve_i18n(i18n) lang = _normalize_lang(language_code, settings) - accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR) + accent = _theme_accent(settings) brand = _brand_title(settings) safe_dashboard_url = (dashboard_url or "").strip() final_subject = (subject or "").strip() or _t_text( @@ -642,6 +658,7 @@ def render_user_notification( intro_html=html.escape(final_intro), body_html="".join(body_parts), footer_html=footer, + accent=accent, ) text_lines = [final_subject, "", _telegram_html_to_text(message_text)] if safe_dashboard_url: @@ -667,7 +684,7 @@ def render_subscription_expiring( ) -> EmailContent: i18n = _resolve_i18n(i18n) lang = _normalize_lang(language_code, settings) - accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR) + accent = _theme_accent(settings) brand = _brand_title(settings) safe_dashboard_url = (dashboard_url or "").strip() days = max(0, int(days_left)) @@ -716,6 +733,7 @@ def render_subscription_expiring( intro_html=html.escape(intro), body_html="".join(body_parts), footer_html=footer, + accent=accent, ) return _email_content(subject=subject, text="\n".join(text_lines), layout=rendered) @@ -764,7 +782,7 @@ def render_subscription_lifecycle_notification( ) -> EmailContent: i18n = _resolve_i18n(i18n) lang = _normalize_lang(language_code, settings) - accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR) + accent = _theme_accent(settings) brand = _brand_title(settings) safe_dashboard_url = (dashboard_url or "").strip() end_date = end_date_text or "—" @@ -804,6 +822,7 @@ def render_subscription_lifecycle_notification( intro_html=html.escape(intro), body_html="".join(body_parts), footer_html=footer, + accent=accent, ) text_lines = [subject, "", message_text] @@ -838,7 +857,7 @@ def _support_email( i18n = _resolve_i18n(i18n) lang = _normalize_lang(language, settings) brand = _brand_title(settings) - accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR) + accent = _theme_accent(settings) safe_url = (ticket_url or "").strip() footer = _t_html(i18n, lang, "email_footer_auto", brand=brand) localized_rows = [ @@ -861,6 +880,7 @@ def _support_email( intro_html=html.escape(intro), body_html="".join(body_parts), footer_html=footer, + accent=accent, ) text_lines = [ intro, diff --git a/backend/config/webapp_themes_config.py b/backend/config/webapp_themes_config.py index bae3ab6..4cbeefe 100644 --- a/backend/config/webapp_themes_config.py +++ b/backend/config/webapp_themes_config.py @@ -619,6 +619,38 @@ def merge_primary_accent_into_theme_tokens( return base +def effective_webapp_theme_accent( + config: WebappThemesConfig, + primary_accent: str, + *, + theme_key: Optional[str] = None, +) -> str: + """Return the accent color users see for the selected/default Web App theme.""" + try: + fallback = ThemeTokens(accent=primary_accent or "#00fe7a").accent or "#00fe7a" + except ValueError: + fallback = "#00fe7a" + theme: Optional[WebappTheme] = None + if theme_key: + theme = config.theme_by_key(theme_key) + if theme is not None and not theme.enabled: + theme = None + if theme is None: + theme = config.theme_by_key(config.default_theme) + if theme is None: + enabled = config.enabled_themes() + theme = enabled[0] if enabled else None + if theme is None: + return fallback + + tokens = ( + merge_primary_accent_into_theme_tokens(theme, fallback) + if theme.use_primary_accent + else theme.tokens + ) + return tokens.accent or fallback + + def public_theme_payload(theme: WebappTheme, primary_accent: str) -> Dict[str, object]: tokens = ( merge_primary_accent_into_theme_tokens(theme, primary_accent) diff --git a/tests/test_email_localization.py b/tests/test_email_localization.py index 8f872c1..8ecf27e 100644 --- a/tests/test_email_localization.py +++ b/tests/test_email_localization.py @@ -20,6 +20,7 @@ from bot.services.email_templates import ( render_support_user_reply_admin, render_user_notification, ) +from config.webapp_themes_config import WebappThemesConfig REPO_ROOT = Path(__file__).resolve().parents[1] EMAIL_KEY_RE = re.compile(r"""["'](?Pemail_[a-z0-9_]+)["']""") @@ -31,13 +32,19 @@ EMAIL_KEY_ASSIGNMENT_HINTS = ( ) -def _settings(default_language: str = "ru"): +def _settings( + default_language: str = "ru", + *, + webapp_themes_catalog: WebappThemesConfig | None = None, + primary_color: str = "#00fe7a", +): return SimpleNamespace( DEFAULT_LANGUAGE=default_language, EMAIL_CODE_TTL_SECONDS=600, WEBAPP_LOGO_URL="", - WEBAPP_PRIMARY_COLOR="#00fe7a", + WEBAPP_PRIMARY_COLOR=primary_color, WEBAPP_TITLE="Mini Shop", + webapp_themes_catalog=webapp_themes_catalog, ) @@ -256,6 +263,34 @@ def test_all_email_template_variants_render_without_raw_locale_keys(): _assert_content_is_localized(content, language) +def test_email_templates_use_default_webapp_theme_accent(): + catalog = WebappThemesConfig( + default_theme="custom", + themes=[ + { + "key": "custom", + "enabled": True, + "default": True, + "tokens": {"color_scheme": "dark", "accent": "#123abc"}, + } + ], + ) + settings = _settings("en", webapp_themes_catalog=catalog, primary_color="#00fe7a") + + content = render_login_code( + settings, + code="123456", + language_code="en", + magic_link="https://app.example.com/magic", + purpose="login", + i18n=_i18n("en"), + ) + + assert "color:#123abc" in content.html + assert 'bgcolor="#123abc"' in content.html + assert "#00fe7a" not in content.html + + def test_uploaded_webapp_logo_is_embedded_inline(tmp_path, monkeypatch): uploads_dir = tmp_path / "uploads" uploads_dir.mkdir() diff --git a/tests/test_webapp_route_contract.py b/tests/test_webapp_route_contract.py index 6e202d1..6836a84 100644 --- a/tests/test_webapp_route_contract.py +++ b/tests/test_webapp_route_contract.py @@ -11,6 +11,7 @@ from aiohttp.test_utils import make_mocked_request from bot.app.web import admin_api, subscription_webapp from bot.app.web.admin_api_impl import auth as admin_auth_routes from bot.app.web.webapp_auth import create_webapp_session_token +from config.webapp_themes_config import WebappThemesConfig REPO_ROOT = Path(__file__).resolve().parents[1] @@ -332,6 +333,39 @@ class WebAppRouteContractTests(unittest.TestCase): (REPO_ROOT / "backend/bot/app/web/templates/open_app_gateway.html").is_file() ) + def test_app_deeplink_gateway_uses_webapp_theme_accent(self): + catalog = WebappThemesConfig( + default_theme="custom", + themes=[ + { + "key": "custom", + "enabled": True, + "default": True, + "tokens": {"color_scheme": "dark", "accent": "#123abc"}, + } + ], + ) + request = _Request( + app={ + "settings": SimpleNamespace( + WEBAPP_ENABLED=True, + WEBAPP_TITLE="/minishop", + DEFAULT_LANGUAGE="en", + WEBAPP_PRIMARY_COLOR="#00fe7a", + webapp_themes_catalog=catalog, + ) + } + ) + request["csp_nonce"] = "nonce-value" + + response = asyncio.run(subscription_webapp.app_deeplink_route(request)) + + self.assertEqual(response.status, 200) + self.assertIn('id="webapp-initial-theme"', response.text) + self.assertIn("--accent:#123abc", response.text) + self.assertIn("background: var(--accent)", response.text) + self.assertNotIn("background: #14b86f;", response.text) + def test_app_launch_i18n_keys_are_available_to_webapp_bootstrap(self): required_keys = { "wa_app_launch_title", diff --git a/tests/test_webapp_themes_config.py b/tests/test_webapp_themes_config.py index d697947..9d26dcf 100644 --- a/tests/test_webapp_themes_config.py +++ b/tests/test_webapp_themes_config.py @@ -8,6 +8,7 @@ from config.webapp_themes_config import ( apply_webapp_theme_env_overrides, builtin_webapp_themes_config, default_webapp_theme_descriptors, + effective_webapp_theme_accent, ensure_webapp_core_themes, load_webapp_theme_dir, public_themes_catalog_payload, @@ -281,6 +282,44 @@ class WebappThemesConfigTests(unittest.TestCase): self.assertTrue(win95["use_in_admin"]) self.assertNotIn("accent", win95["tokens"]) + def test_effective_accent_uses_default_theme_token(self): + cfg = WebappThemesConfig( + default_theme="custom", + themes=[ + { + "key": "custom", + "enabled": True, + "default": True, + "tokens": {"color_scheme": "dark", "accent": "#123abc"}, + } + ], + ) + + self.assertEqual(effective_webapp_theme_accent(cfg, "#00fe7a"), "#123abc") + + def test_effective_accent_can_use_preview_theme_token(self): + cfg = WebappThemesConfig( + default_theme="dark", + themes=[ + { + "key": "dark", + "enabled": True, + "default": True, + "tokens": {"color_scheme": "dark", "accent": "#123abc"}, + }, + { + "key": "neon", + "enabled": True, + "tokens": {"color_scheme": "dark", "accent": "#ff33aa"}, + }, + ], + ) + + self.assertEqual( + effective_webapp_theme_accent(cfg, "#00fe7a", theme_key="neon"), + "#ff33aa", + ) + def test_theme_accent_is_normalized_to_hex(self): cfg = WebappThemesConfig( default_theme="custom",