fix: apply webapp theme accent in emails and deeplinks

This commit is contained in:
3252a8
2026-06-04 23:22:53 +03:00
parent 090c88603e
commit 77b124d61e
7 changed files with 247 additions and 22 deletions
@@ -8,11 +8,18 @@
<style nonce="__NONCE__">
:root {
color-scheme: dark light;
--accent: #14b86f;
--accent-contrast: #03120b;
--bg: #0b1017;
--panel-3: #344052;
--border: #2d3847;
--text: #f7fafc;
--muted: #aeb8c5;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", sans-serif;
background: #0b1017;
color: #f7fafc;
background: var(--bg);
color: var(--text);
}
body {
@@ -39,7 +46,7 @@
p {
margin: 0;
color: #aeb8c5;
color: var(--muted);
font-size: 15px;
line-height: 1.55;
}
@@ -57,8 +64,8 @@
justify-content: center;
border: 1px solid transparent;
border-radius: 8px;
background: #14b86f;
color: #03120b;
background: var(--accent);
color: var(--accent-contrast);
padding: 0 18px;
box-sizing: border-box;
font: inherit;
@@ -68,15 +75,15 @@
}
.button.secondary {
border-color: #2d3847;
border-color: var(--border);
background: transparent;
color: #f7fafc;
color: var(--text);
}
.button[aria-disabled="true"] {
pointer-events: none;
background: #344052;
color: #aeb8c5;
background: var(--panel-3);
color: var(--muted);
}
[hidden] {
+62 -4
View File
@@ -5,6 +5,7 @@ import gzip
from config.webapp_themes_config import (
default_webapp_theme_asset_file,
default_webapp_theme_css_files,
effective_webapp_theme_accent,
ensure_default_webapp_theme_descriptor_files,
public_theme_payload,
public_themes_catalog_payload,
@@ -1188,6 +1189,11 @@ async def app_deeplink_route(request: web.Request) -> web.Response:
nonce = html.escape(str(request.get("csp_nonce", "")), quote=True)
query = getattr(request, "query", {}) or {}
themes_catalog = getattr(settings, "webapp_themes_catalog", None)
primary_color = getattr(settings, "WEBAPP_PRIMARY_COLOR", None) or "#00fe7a"
initial_theme = (
_initial_theme_for_request(request, themes_catalog) if themes_catalog is not None else None
)
lang = _normalize_language(query.get("lang") or getattr(settings, "DEFAULT_LANGUAGE", "ru"))
messages = _app_deeplink_i18n_payload(request, lang)
page_title = _webapp_page_title(settings, messages["title"])
@@ -1204,6 +1210,14 @@ async def app_deeplink_route(request: web.Request) -> web.Response:
.replace("__NONCE__", nonce)
.replace("__MESSAGES_JSON__", messages_json)
)
initial_theme_markup = _app_deeplink_theme_head_markup(
request,
initial_theme,
themes_catalog,
primary_color,
)
if initial_theme_markup:
html_text = html_text.replace("</head>", f"{initial_theme_markup}\n</head>", 1)
html_text = _apply_webapp_head_metadata(html_text, page_title, favicon_url)
response = web.Response(text=html_text, content_type="text/html", charset="utf-8")
response.headers["Cache-Control"] = "no-store"
@@ -1578,7 +1592,8 @@ def _theme_css_href_for_html(theme: Any) -> str:
def _initial_theme_for_request(request: web.Request, catalog: Any) -> Any:
preview_key = str(request.query.get("theme_preview") or "").strip()
query = getattr(request, "query", {}) or {}
preview_key = str(query.get("theme_preview") or "").strip()
if preview_key:
preview_theme = catalog.theme_by_key(preview_key)
if preview_theme is not None and preview_theme.enabled:
@@ -1590,13 +1605,16 @@ def _initial_theme_for_request(request: web.Request, catalog: Any) -> Any:
return catalog.enabled_themes()[0] if catalog.enabled_themes() else None
def _initial_theme_head_markup(request: web.Request, theme: Any, primary_color: str) -> str:
def _initial_theme_tokens(theme: Any, primary_color: str) -> Dict[str, Any]:
if theme is None:
return ""
return {}
payload = public_theme_payload(theme, primary_color)
tokens = payload.get("tokens") if isinstance(payload, dict) else {}
tokens = tokens if isinstance(tokens, dict) else {}
return tokens if isinstance(tokens, dict) else {}
def _initial_theme_declarations(tokens: Dict[str, Any]) -> List[str]:
declarations = []
for token_key, css_name in _INITIAL_THEME_TOKEN_CSS_MAP.items():
if token_key in _INITIAL_THEME_LOGO_SCALE_TOKENS:
@@ -1610,6 +1628,15 @@ def _initial_theme_head_markup(request: web.Request, theme: Any, primary_color:
value = str(tokens.get(token_key) or "").strip()
if value:
declarations.append(f"{css_name}:{value}")
return declarations
def _initial_theme_head_markup(request: web.Request, theme: Any, primary_color: str) -> str:
if theme is None:
return ""
tokens = _initial_theme_tokens(theme, primary_color)
declarations = _initial_theme_declarations(tokens)
scheme = "light" if tokens.get("color_scheme") == "light" else "dark"
bg = str(tokens.get("bg") or "").strip()
@@ -1633,6 +1660,37 @@ def _initial_theme_head_markup(request: web.Request, theme: Any, primary_color:
return stylesheet + "\n" + style_tag
def _app_deeplink_theme_head_markup(
request: web.Request,
theme: Any,
catalog: Any,
primary_color: str,
) -> str:
tokens = _initial_theme_tokens(theme, primary_color)
declarations = _initial_theme_declarations(tokens)
try:
accent = effective_webapp_theme_accent(
catalog,
primary_color,
theme_key=str(getattr(theme, "key", "") or "") or None,
)
except Exception:
accent = str(primary_color or "#00fe7a").strip() or "#00fe7a"
if accent and not any(item.startswith("--accent:") for item in declarations):
declarations.insert(0, f"--accent:{accent}")
if not declarations:
return ""
scheme = "light" if tokens.get("color_scheme") == "light" else "dark"
nonce = html.escape(str(request.get("csp_nonce", "")), quote=True)
return (
f'<style id="webapp-initial-theme" nonce="{nonce}">'
f"html{{color-scheme:{scheme};}}"
f":root{{{';'.join(declarations)}}}"
"</style>"
)
def _favicon_head_markup(favicon_url: str) -> str:
href = str(favicon_url or "").strip()
if not href:
+27 -7
View File
@@ -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,
+32
View File
@@ -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)