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 '