diff --git a/backend/bot/services/email_templates.py b/backend/bot/services/email_templates.py
index aa99519..48b3a06 100644
--- a/backend/bot/services/email_templates.py
+++ b/backend/bot/services/email_templates.py
@@ -89,6 +89,7 @@ def _t_text(i18n: JsonI18n, lang: str, key: str, **kwargs) -> str:
def _layout(
*,
settings: Settings,
+ language_code: str,
preheader: str,
heading: str,
intro_html: str,
@@ -98,6 +99,7 @@ def _layout(
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
brand_title = html.escape(_brand_title(settings))
logo_url = _public_logo_url(settings)
+ html_lang = html.escape((language_code or "en").replace("_", "-"), quote=True)
logo_block = ""
if logo_url:
logo_block = (
@@ -107,7 +109,7 @@ def _layout(
)
return f"""
-
+
@@ -307,6 +309,7 @@ def render_login_code(
rendered = _layout(
settings=settings,
+ language_code=lang,
preheader=preheader,
heading=heading,
intro_html=html.escape(intro),
@@ -359,6 +362,7 @@ def render_account_merged(
rendered = _layout(
settings=settings,
+ language_code=lang,
preheader=preheader,
heading=heading,
intro_html=html.escape(intro),
@@ -492,6 +496,7 @@ def render_payment_success(
rendered = _layout(
settings=settings,
+ language_code=lang,
preheader=preheader,
heading=heading,
intro_html=html.escape(intro),
@@ -542,6 +547,7 @@ def render_user_notification(
rendered = _layout(
settings=settings,
+ language_code=lang,
preheader=final_subject,
heading=final_heading,
intro_html=html.escape(final_intro),
@@ -615,6 +621,7 @@ def render_subscription_expiring(
rendered = _layout(
settings=settings,
+ language_code=lang,
preheader=preheader,
heading=heading,
intro_html=html.escape(intro),
@@ -702,6 +709,7 @@ def render_subscription_lifecycle_notification(
rendered = _layout(
settings=settings,
+ language_code=lang,
preheader=subject,
heading=subject,
intro_html=html.escape(intro),
@@ -738,28 +746,40 @@ def _support_email(
ticket_url: Optional[str],
cta_label: str,
) -> EmailContent:
+ i18n = _resolve_i18n(i18n)
lang = _normalize_lang(language, settings)
brand = _brand_title(settings)
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
safe_url = (ticket_url or "").strip()
- footer = _t_html(_resolve_i18n(i18n), lang, "email_footer_auto", brand=brand)
+ footer = _t_html(i18n, lang, "email_footer_auto", brand=brand)
+ localized_rows = [
+ (_t_text(i18n, lang, label) if str(label).startswith("email_") else str(label), value)
+ for label, value in rows
+ ]
preview_block = (
f'{html.escape(body_preview or "")}
'
)
- body_parts = [_info_rows_html(rows), preview_block]
+ body_parts = [_info_rows_html(localized_rows), preview_block]
if safe_url:
body_parts.append(_cta_button_html(label=cta_label, url=safe_url, accent=accent))
rendered = _layout(
settings=settings,
+ language_code=lang,
preheader=intro,
heading=heading,
intro_html=html.escape(intro),
body_html="".join(body_parts),
footer_html=footer,
)
- text_lines = [intro, "", *[f"{label}: {value}" for label, value in rows], "", body_preview]
+ text_lines = [
+ intro,
+ "",
+ *[f"{label}: {value}" for label, value in localized_rows],
+ "",
+ body_preview,
+ ]
if safe_url:
text_lines.extend(["", safe_url])
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
@@ -777,23 +797,25 @@ def render_support_new_ticket_admin(
snapshot_rows: Sequence[Tuple[str, str]],
ticket_url: Optional[str],
) -> EmailContent:
+ i18n = _resolve_i18n(i18n)
+ lang = _normalize_lang(language, settings)
rows = [
- ("Ticket", f"#{ticket_id}"),
- ("User", user_display),
- ("Subject", subject),
+ ("email_support_row_ticket", f"#{ticket_id}"),
+ ("email_support_row_user", user_display),
+ ("email_support_row_subject", subject),
*snapshot_rows,
]
return _support_email(
settings,
i18n,
- language,
- subject=f"New support ticket #{ticket_id}",
- heading=f"New support ticket #{ticket_id}",
- intro="A user opened a new support ticket.",
+ lang,
+ subject=_t_text(i18n, lang, "email_support_new_ticket_admin_subject", ticket_id=ticket_id),
+ heading=_t_text(i18n, lang, "email_support_new_ticket_admin_heading", ticket_id=ticket_id),
+ intro=_t_text(i18n, lang, "email_support_new_ticket_admin_intro"),
rows=rows,
body_preview=body_preview,
ticket_url=ticket_url,
- cta_label="Open ticket",
+ cta_label=_t_text(i18n, lang, "email_support_cta_open_ticket"),
)
@@ -809,23 +831,25 @@ def render_support_user_reply_admin(
snapshot_rows: Sequence[Tuple[str, str]],
ticket_url: Optional[str],
) -> EmailContent:
+ i18n = _resolve_i18n(i18n)
+ lang = _normalize_lang(language, settings)
rows = [
- ("Ticket", f"#{ticket_id}"),
- ("User", user_display),
- ("Subject", subject),
+ ("email_support_row_ticket", f"#{ticket_id}"),
+ ("email_support_row_user", user_display),
+ ("email_support_row_subject", subject),
*snapshot_rows,
]
return _support_email(
settings,
i18n,
- language,
- subject=f"New user reply in ticket #{ticket_id}",
- heading=f"User replied in ticket #{ticket_id}",
- intro="A user sent a new support message.",
+ lang,
+ subject=_t_text(i18n, lang, "email_support_user_reply_admin_subject", ticket_id=ticket_id),
+ heading=_t_text(i18n, lang, "email_support_user_reply_admin_heading", ticket_id=ticket_id),
+ intro=_t_text(i18n, lang, "email_support_user_reply_admin_intro"),
rows=rows,
body_preview=body_preview,
ticket_url=ticket_url,
- cta_label="Open ticket",
+ cta_label=_t_text(i18n, lang, "email_support_cta_open_ticket"),
)
@@ -839,17 +863,22 @@ def render_support_admin_reply_user(
body_preview: str,
ticket_url: Optional[str],
) -> EmailContent:
+ i18n = _resolve_i18n(i18n)
+ lang = _normalize_lang(language, settings)
return _support_email(
settings,
i18n,
- language,
- subject=f"New reply for ticket #{ticket_id}",
- heading=f"New reply for ticket #{ticket_id}",
- intro="Support has replied to your ticket.",
- rows=[("Ticket", f"#{ticket_id}"), ("Subject", subject)],
+ lang,
+ subject=_t_text(i18n, lang, "email_support_admin_reply_user_subject", ticket_id=ticket_id),
+ heading=_t_text(i18n, lang, "email_support_admin_reply_user_heading", ticket_id=ticket_id),
+ intro=_t_text(i18n, lang, "email_support_admin_reply_user_intro"),
+ rows=[
+ ("email_support_row_ticket", f"#{ticket_id}"),
+ ("email_support_row_subject", subject),
+ ],
body_preview=body_preview,
ticket_url=ticket_url,
- cta_label="Open in Mini App",
+ cta_label=_t_text(i18n, lang, "email_support_cta_open_mini_app"),
)
@@ -863,15 +892,24 @@ def render_support_ticket_closed_user(
body_preview: str = "",
ticket_url: Optional[str],
) -> EmailContent:
+ i18n = _resolve_i18n(i18n)
+ lang = _normalize_lang(language, settings)
return _support_email(
settings,
i18n,
- language,
- subject=f"Ticket #{ticket_id} was closed",
- heading=f"Ticket #{ticket_id} was closed",
- intro="Your support ticket has been closed.",
- rows=[("Ticket", f"#{ticket_id}"), ("Subject", subject)],
- body_preview=body_preview or "The ticket is closed.",
+ lang,
+ subject=_t_text(
+ i18n, lang, "email_support_ticket_closed_user_subject", ticket_id=ticket_id
+ ),
+ heading=_t_text(
+ i18n, lang, "email_support_ticket_closed_user_heading", ticket_id=ticket_id
+ ),
+ intro=_t_text(i18n, lang, "email_support_ticket_closed_user_intro"),
+ rows=[
+ ("email_support_row_ticket", f"#{ticket_id}"),
+ ("email_support_row_subject", subject),
+ ],
+ body_preview=body_preview or _t_text(i18n, lang, "email_support_ticket_closed_user_body"),
ticket_url=ticket_url,
- cta_label="Open in Mini App",
+ cta_label=_t_text(i18n, lang, "email_support_cta_open_mini_app"),
)
diff --git a/backend/bot/services/notification_service.py b/backend/bot/services/notification_service.py
index 9db3b8f..85db43e 100644
--- a/backend/bot/services/notification_service.py
+++ b/backend/bot/services/notification_service.py
@@ -273,10 +273,10 @@ class NotificationService:
return []
rows = []
for key, label in (
- ("tariff", "Tariff"),
- ("end_date", "End date"),
- ("remaining", "Remaining"),
- ("panel_status", "Panel status"),
+ ("tariff", "email_support_row_tariff"),
+ ("end_date", "email_support_row_end_date"),
+ ("remaining", "email_support_row_remaining"),
+ ("panel_status", "email_support_row_panel_status"),
):
value = snapshot.get(key)
if value:
diff --git a/locales/en.json b/locales/en.json
index a21cfb0..2524df2 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -664,6 +664,28 @@
"email_subscription_lifecycle_row_end_date": "Active until",
"email_subscription_lifecycle_cta": "Open dashboard",
"email_subscription_lifecycle_text_renew": "Dashboard: {url}",
+ "email_support_new_ticket_admin_subject": "New support ticket #{ticket_id}",
+ "email_support_new_ticket_admin_heading": "New support ticket #{ticket_id}",
+ "email_support_new_ticket_admin_intro": "A user opened a new support ticket.",
+ "email_support_user_reply_admin_subject": "New user reply in ticket #{ticket_id}",
+ "email_support_user_reply_admin_heading": "User replied in ticket #{ticket_id}",
+ "email_support_user_reply_admin_intro": "A user sent a new support message.",
+ "email_support_admin_reply_user_subject": "New reply for ticket #{ticket_id}",
+ "email_support_admin_reply_user_heading": "New reply for ticket #{ticket_id}",
+ "email_support_admin_reply_user_intro": "Support has replied to your ticket.",
+ "email_support_ticket_closed_user_subject": "Ticket #{ticket_id} was closed",
+ "email_support_ticket_closed_user_heading": "Ticket #{ticket_id} was closed",
+ "email_support_ticket_closed_user_intro": "Your support ticket has been closed.",
+ "email_support_ticket_closed_user_body": "The ticket is closed.",
+ "email_support_row_ticket": "Ticket",
+ "email_support_row_user": "User",
+ "email_support_row_subject": "Subject",
+ "email_support_row_tariff": "Tariff",
+ "email_support_row_end_date": "End date",
+ "email_support_row_remaining": "Remaining",
+ "email_support_row_panel_status": "Panel status",
+ "email_support_cta_open_ticket": "Open ticket",
+ "email_support_cta_open_mini_app": "Open in Mini App",
"wa_loading": "Loading...",
"wa_back": "Back",
"wa_next": "Next",
diff --git a/locales/ru.json b/locales/ru.json
index 431a693..674e4eb 100644
--- a/locales/ru.json
+++ b/locales/ru.json
@@ -664,6 +664,28 @@
"email_subscription_lifecycle_row_end_date": "Действует до",
"email_subscription_lifecycle_cta": "Открыть кабинет",
"email_subscription_lifecycle_text_renew": "Кабинет: {url}",
+ "email_support_new_ticket_admin_subject": "Новый тикет поддержки #{ticket_id}",
+ "email_support_new_ticket_admin_heading": "Новый тикет поддержки #{ticket_id}",
+ "email_support_new_ticket_admin_intro": "Пользователь создал новый тикет поддержки.",
+ "email_support_user_reply_admin_subject": "Новый ответ пользователя в тикете #{ticket_id}",
+ "email_support_user_reply_admin_heading": "Пользователь ответил в тикете #{ticket_id}",
+ "email_support_user_reply_admin_intro": "Пользователь отправил новое сообщение в поддержку.",
+ "email_support_admin_reply_user_subject": "Новый ответ по тикету #{ticket_id}",
+ "email_support_admin_reply_user_heading": "Новый ответ по тикету #{ticket_id}",
+ "email_support_admin_reply_user_intro": "Поддержка ответила на ваш тикет.",
+ "email_support_ticket_closed_user_subject": "Тикет #{ticket_id} закрыт",
+ "email_support_ticket_closed_user_heading": "Тикет #{ticket_id} закрыт",
+ "email_support_ticket_closed_user_intro": "Ваш тикет поддержки закрыт.",
+ "email_support_ticket_closed_user_body": "Тикет закрыт.",
+ "email_support_row_ticket": "Тикет",
+ "email_support_row_user": "Пользователь",
+ "email_support_row_subject": "Тема",
+ "email_support_row_tariff": "Тариф",
+ "email_support_row_end_date": "Дата окончания",
+ "email_support_row_remaining": "Осталось",
+ "email_support_row_panel_status": "Статус в панели",
+ "email_support_cta_open_ticket": "Открыть тикет",
+ "email_support_cta_open_mini_app": "Открыть в Mini App",
"wa_loading": "Загрузка...",
"wa_back": "Назад",
"wa_next": "Далее",
diff --git a/tests/test_email_localization.py b/tests/test_email_localization.py
new file mode 100644
index 0000000..8e5e066
--- /dev/null
+++ b/tests/test_email_localization.py
@@ -0,0 +1,259 @@
+import json
+import re
+from pathlib import Path
+from types import SimpleNamespace
+
+from bot.middlewares.i18n import JsonI18n
+from bot.services.email_templates import (
+ EmailContent,
+ render_account_merged,
+ render_login_code,
+ render_payment_success,
+ render_subscription_expiring,
+ render_subscription_lifecycle_notification,
+ render_support_admin_reply_user,
+ render_support_new_ticket_admin,
+ render_support_ticket_closed_user,
+ render_support_user_reply_admin,
+ render_user_notification,
+)
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+EMAIL_KEY_RE = re.compile(r"""["'](?Pemail_[a-z0-9_]+)["']""")
+EMAIL_KEY_ASSIGNMENT_HINTS = (
+ "subject_key",
+ "heading_key",
+ "intro_key",
+ "cta_label_key",
+)
+
+
+def _settings(default_language: str = "ru"):
+ return SimpleNamespace(
+ DEFAULT_LANGUAGE=default_language,
+ EMAIL_CODE_TTL_SECONDS=600,
+ WEBAPP_LOGO_URL="",
+ WEBAPP_LOGO_USE_EMOJI=False,
+ WEBAPP_PRIMARY_COLOR="#00fe7a",
+ WEBAPP_TITLE="Mini Shop",
+ )
+
+
+def _i18n(default_language: str = "ru"):
+ return JsonI18n(str(REPO_ROOT / "locales"), default=default_language)
+
+
+def _locale_keys(language: str) -> set[str]:
+ return set(json.loads((REPO_ROOT / "locales" / f"{language}.json").read_text("utf-8")))
+
+
+def _email_locale_keys_from(path: Path) -> set[str]:
+ text = path.read_text("utf-8")
+ keys: set[str] = set()
+ for match in EMAIL_KEY_RE.finditer(text):
+ line_start = text.rfind("\n", 0, match.start()) + 1
+ line_end = text.find("\n", match.end())
+ line = text[line_start : line_end if line_end != -1 else len(text)]
+ if "template_prefix" in line:
+ continue
+ if path.name == "email_templates.py" or any(
+ hint in line for hint in EMAIL_KEY_ASSIGNMENT_HINTS
+ ):
+ keys.add(match.group("key"))
+ return keys
+
+
+def _email_preview_locale_keys() -> set[str]:
+ path = REPO_ROOT / "docs-site" / "src" / "lib" / "emailPreviews.mjs"
+ return {match.group("key") for match in EMAIL_KEY_RE.finditer(path.read_text("utf-8"))}
+
+
+def _used_email_locale_keys() -> set[str]:
+ keys = _email_preview_locale_keys()
+ for path in (REPO_ROOT / "backend" / "bot").rglob("*.py"):
+ keys.update(_email_locale_keys_from(path))
+ return keys
+
+
+def _assert_content_is_localized(content: EmailContent, language: str):
+ assert content.subject
+ assert content.text
+ assert content.html
+ assert f' list[EmailContent]:
+ settings = _settings(default_language=language)
+ i18n = _i18n(default_language=language)
+ dashboard_url = "https://app.example.com/account"
+ ticket_url = "https://app.example.com/support/42"
+
+ contents: list[EmailContent] = [
+ render_login_code(
+ settings,
+ code="123456",
+ language_code=language,
+ magic_link="https://app.example.com/magic",
+ purpose="login",
+ i18n=i18n,
+ ),
+ render_login_code(
+ settings,
+ code="123456",
+ language_code=language,
+ purpose="set_password",
+ i18n=i18n,
+ ),
+ render_account_merged(
+ settings,
+ language_code=language,
+ primary_user_id=100200300,
+ removed_user_id=-42,
+ final_end_date_text="2026-06-21 10:00",
+ i18n=i18n,
+ ),
+ render_user_notification(
+ settings,
+ language_code=language,
+ subject=i18n.gettext(language, "email_payment_failed_subject"),
+ heading=i18n.gettext(language, "email_payment_failed_subject"),
+ intro=i18n.gettext(language, "email_user_notification_intro"),
+ cta_label=i18n.gettext(language, "email_user_notification_cta"),
+ message_text="Payment status changed.",
+ dashboard_url=dashboard_url,
+ i18n=i18n,
+ ),
+ ]
+
+ for sale_mode, months, traffic_gb in (
+ ("subscription", 1, None),
+ ("traffic", 0, 100),
+ ("premium_topup", 0, 25),
+ ("hwid_device", 2, None),
+ ("tariff_upgrade", 0, None),
+ ):
+ contents.append(
+ render_payment_success(
+ settings,
+ language_code=language,
+ sale_mode=sale_mode,
+ months=months,
+ traffic_gb=traffic_gb,
+ amount=390,
+ currency="RUB",
+ end_date_text="2026-06-21 10:00",
+ dashboard_url=dashboard_url,
+ provider_label="YooKassa",
+ i18n=i18n,
+ )
+ )
+
+ for days_left in (0, 1, 3):
+ contents.append(
+ render_subscription_expiring(
+ settings,
+ language_code=language,
+ days_left=days_left,
+ end_date_text="2026-06-21 10:00",
+ dashboard_url=dashboard_url,
+ i18n=i18n,
+ )
+ )
+
+ lifecycle_variants = (
+ {"notification_key": "before_2d_autorenew"},
+ {"notification_key": "expired"},
+ {"notification_key": "expired_24h_after"},
+ {"notification_key": "before_days", "days_left": 3},
+ {"notification_key": "before_hours", "hours_before": 6},
+ )
+ for kwargs in lifecycle_variants:
+ contents.append(
+ render_subscription_lifecycle_notification(
+ settings,
+ language_code=language,
+ message_text="Subscription lifecycle message.",
+ end_date_text="2026-06-21 10:00",
+ dashboard_url=dashboard_url,
+ i18n=i18n,
+ **kwargs,
+ )
+ )
+
+ contents.extend(
+ [
+ render_support_new_ticket_admin(
+ settings,
+ i18n,
+ language,
+ ticket_id=42,
+ user_display="user@example.com",
+ subject="Connection issue",
+ body_preview="Cannot connect.",
+ snapshot_rows=[
+ ("email_support_row_tariff", "Premium"),
+ ("email_support_row_remaining", "3 d. 4 h."),
+ ],
+ ticket_url="https://app.example.com/admin/support/42",
+ ),
+ render_support_user_reply_admin(
+ settings,
+ i18n,
+ language,
+ ticket_id=42,
+ user_display="user@example.com",
+ subject="Connection issue",
+ body_preview="Still cannot connect.",
+ snapshot_rows=[
+ ("email_support_row_tariff", "Premium"),
+ ("email_support_row_remaining", "3 d. 4 h."),
+ ],
+ ticket_url="https://app.example.com/admin/support/42",
+ ),
+ render_support_admin_reply_user(
+ settings,
+ i18n,
+ language,
+ ticket_id=42,
+ subject="Connection issue",
+ body_preview="Please try again.",
+ ticket_url=ticket_url,
+ ),
+ render_support_ticket_closed_user(
+ settings,
+ i18n,
+ language,
+ ticket_id=42,
+ subject="Connection issue",
+ ticket_url=ticket_url,
+ ),
+ ]
+ )
+ return contents
+
+
+def test_email_locale_keys_used_by_mailers_exist_in_base_locales():
+ used_keys = _used_email_locale_keys()
+
+ assert sorted(used_keys - _locale_keys("en")) == []
+ assert sorted(used_keys - _locale_keys("ru")) == []
+
+
+def test_all_email_template_variants_render_without_raw_locale_keys():
+ for language in ("en", "ru"):
+ for content in _all_rendered_email_variants(language):
+ _assert_content_is_localized(content, language)
+
+
+def test_support_email_templates_use_russian_copy_for_russian_recipients():
+ subjects = [content.subject for content in _all_rendered_email_variants("ru")[-4:]]
+
+ assert subjects == [
+ "Новый тикет поддержки #42",
+ "Новый ответ пользователя в тикете #42",
+ "Новый ответ по тикету #42",
+ "Тикет #42 закрыт",
+ ]
diff --git a/tests/test_support_notifications.py b/tests/test_support_notifications.py
index d73e4c1..2068380 100644
--- a/tests/test_support_notifications.py
+++ b/tests/test_support_notifications.py
@@ -1,9 +1,17 @@
import asyncio
+from pathlib import Path
from types import SimpleNamespace
+from bot.middlewares.i18n import JsonI18n
+from bot.services.email_templates import (
+ render_support_new_ticket_admin,
+ render_support_ticket_closed_user,
+)
from bot.services.notification_service import NotificationService
from config.settings import Settings
+REPO_ROOT = Path(__file__).resolve().parents[1]
+
def _settings(**overrides):
data = {
@@ -15,6 +23,49 @@ def _settings(**overrides):
return Settings(_env_file=None, **data)
+def _i18n():
+ return JsonI18n(str(REPO_ROOT / "locales"), default="ru")
+
+
+def test_support_ticket_closed_email_uses_user_language():
+ content = render_support_ticket_closed_user(
+ _settings(DEFAULT_LANGUAGE="en"),
+ _i18n(),
+ "ru",
+ ticket_id=7,
+ subject="Проблема с подключением",
+ ticket_url="https://app.example.com/support/7",
+ )
+
+ assert content.subject == "Тикет #7 закрыт"
+ assert '