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",