fix: localize support email templates
This commit is contained in:
@@ -89,6 +89,7 @@ def _t_text(i18n: JsonI18n, lang: str, key: str, **kwargs) -> str:
|
|||||||
def _layout(
|
def _layout(
|
||||||
*,
|
*,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
|
language_code: str,
|
||||||
preheader: str,
|
preheader: str,
|
||||||
heading: str,
|
heading: str,
|
||||||
intro_html: str,
|
intro_html: str,
|
||||||
@@ -98,6 +99,7 @@ def _layout(
|
|||||||
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
|
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
|
||||||
brand_title = html.escape(_brand_title(settings))
|
brand_title = html.escape(_brand_title(settings))
|
||||||
logo_url = _public_logo_url(settings)
|
logo_url = _public_logo_url(settings)
|
||||||
|
html_lang = html.escape((language_code or "en").replace("_", "-"), quote=True)
|
||||||
logo_block = ""
|
logo_block = ""
|
||||||
if logo_url:
|
if logo_url:
|
||||||
logo_block = (
|
logo_block = (
|
||||||
@@ -107,7 +109,7 @@ def _layout(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return f"""<!DOCTYPE html>
|
return f"""<!DOCTYPE html>
|
||||||
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
|
<html lang="{html_lang}" xmlns="http://www.w3.org/1999/xhtml">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
@@ -307,6 +309,7 @@ def render_login_code(
|
|||||||
|
|
||||||
rendered = _layout(
|
rendered = _layout(
|
||||||
settings=settings,
|
settings=settings,
|
||||||
|
language_code=lang,
|
||||||
preheader=preheader,
|
preheader=preheader,
|
||||||
heading=heading,
|
heading=heading,
|
||||||
intro_html=html.escape(intro),
|
intro_html=html.escape(intro),
|
||||||
@@ -359,6 +362,7 @@ def render_account_merged(
|
|||||||
|
|
||||||
rendered = _layout(
|
rendered = _layout(
|
||||||
settings=settings,
|
settings=settings,
|
||||||
|
language_code=lang,
|
||||||
preheader=preheader,
|
preheader=preheader,
|
||||||
heading=heading,
|
heading=heading,
|
||||||
intro_html=html.escape(intro),
|
intro_html=html.escape(intro),
|
||||||
@@ -492,6 +496,7 @@ def render_payment_success(
|
|||||||
|
|
||||||
rendered = _layout(
|
rendered = _layout(
|
||||||
settings=settings,
|
settings=settings,
|
||||||
|
language_code=lang,
|
||||||
preheader=preheader,
|
preheader=preheader,
|
||||||
heading=heading,
|
heading=heading,
|
||||||
intro_html=html.escape(intro),
|
intro_html=html.escape(intro),
|
||||||
@@ -542,6 +547,7 @@ def render_user_notification(
|
|||||||
|
|
||||||
rendered = _layout(
|
rendered = _layout(
|
||||||
settings=settings,
|
settings=settings,
|
||||||
|
language_code=lang,
|
||||||
preheader=final_subject,
|
preheader=final_subject,
|
||||||
heading=final_heading,
|
heading=final_heading,
|
||||||
intro_html=html.escape(final_intro),
|
intro_html=html.escape(final_intro),
|
||||||
@@ -615,6 +621,7 @@ def render_subscription_expiring(
|
|||||||
|
|
||||||
rendered = _layout(
|
rendered = _layout(
|
||||||
settings=settings,
|
settings=settings,
|
||||||
|
language_code=lang,
|
||||||
preheader=preheader,
|
preheader=preheader,
|
||||||
heading=heading,
|
heading=heading,
|
||||||
intro_html=html.escape(intro),
|
intro_html=html.escape(intro),
|
||||||
@@ -702,6 +709,7 @@ def render_subscription_lifecycle_notification(
|
|||||||
|
|
||||||
rendered = _layout(
|
rendered = _layout(
|
||||||
settings=settings,
|
settings=settings,
|
||||||
|
language_code=lang,
|
||||||
preheader=subject,
|
preheader=subject,
|
||||||
heading=subject,
|
heading=subject,
|
||||||
intro_html=html.escape(intro),
|
intro_html=html.escape(intro),
|
||||||
@@ -738,28 +746,40 @@ def _support_email(
|
|||||||
ticket_url: Optional[str],
|
ticket_url: Optional[str],
|
||||||
cta_label: str,
|
cta_label: str,
|
||||||
) -> EmailContent:
|
) -> EmailContent:
|
||||||
|
i18n = _resolve_i18n(i18n)
|
||||||
lang = _normalize_lang(language, settings)
|
lang = _normalize_lang(language, settings)
|
||||||
brand = _brand_title(settings)
|
brand = _brand_title(settings)
|
||||||
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
|
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
|
||||||
safe_url = (ticket_url or "").strip()
|
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 = (
|
preview_block = (
|
||||||
f'<div style="margin:0 0 16px 0;background:{_BG};border:1px solid {_BORDER};'
|
f'<div style="margin:0 0 16px 0;background:{_BG};border:1px solid {_BORDER};'
|
||||||
f"border-radius:14px;padding:14px 16px;font-size:14px;line-height:1.55;color:{_TEXT};"
|
f"border-radius:14px;padding:14px 16px;font-size:14px;line-height:1.55;color:{_TEXT};"
|
||||||
f'white-space:pre-wrap;">{html.escape(body_preview or "")}</div>'
|
f'white-space:pre-wrap;">{html.escape(body_preview or "")}</div>'
|
||||||
)
|
)
|
||||||
body_parts = [_info_rows_html(rows), preview_block]
|
body_parts = [_info_rows_html(localized_rows), preview_block]
|
||||||
if safe_url:
|
if safe_url:
|
||||||
body_parts.append(_cta_button_html(label=cta_label, url=safe_url, accent=accent))
|
body_parts.append(_cta_button_html(label=cta_label, url=safe_url, accent=accent))
|
||||||
rendered = _layout(
|
rendered = _layout(
|
||||||
settings=settings,
|
settings=settings,
|
||||||
|
language_code=lang,
|
||||||
preheader=intro,
|
preheader=intro,
|
||||||
heading=heading,
|
heading=heading,
|
||||||
intro_html=html.escape(intro),
|
intro_html=html.escape(intro),
|
||||||
body_html="".join(body_parts),
|
body_html="".join(body_parts),
|
||||||
footer_html=footer,
|
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:
|
if safe_url:
|
||||||
text_lines.extend(["", safe_url])
|
text_lines.extend(["", safe_url])
|
||||||
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
|
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]],
|
snapshot_rows: Sequence[Tuple[str, str]],
|
||||||
ticket_url: Optional[str],
|
ticket_url: Optional[str],
|
||||||
) -> EmailContent:
|
) -> EmailContent:
|
||||||
|
i18n = _resolve_i18n(i18n)
|
||||||
|
lang = _normalize_lang(language, settings)
|
||||||
rows = [
|
rows = [
|
||||||
("Ticket", f"#{ticket_id}"),
|
("email_support_row_ticket", f"#{ticket_id}"),
|
||||||
("User", user_display),
|
("email_support_row_user", user_display),
|
||||||
("Subject", subject),
|
("email_support_row_subject", subject),
|
||||||
*snapshot_rows,
|
*snapshot_rows,
|
||||||
]
|
]
|
||||||
return _support_email(
|
return _support_email(
|
||||||
settings,
|
settings,
|
||||||
i18n,
|
i18n,
|
||||||
language,
|
lang,
|
||||||
subject=f"New support ticket #{ticket_id}",
|
subject=_t_text(i18n, lang, "email_support_new_ticket_admin_subject", ticket_id=ticket_id),
|
||||||
heading=f"New support ticket #{ticket_id}",
|
heading=_t_text(i18n, lang, "email_support_new_ticket_admin_heading", ticket_id=ticket_id),
|
||||||
intro="A user opened a new support ticket.",
|
intro=_t_text(i18n, lang, "email_support_new_ticket_admin_intro"),
|
||||||
rows=rows,
|
rows=rows,
|
||||||
body_preview=body_preview,
|
body_preview=body_preview,
|
||||||
ticket_url=ticket_url,
|
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]],
|
snapshot_rows: Sequence[Tuple[str, str]],
|
||||||
ticket_url: Optional[str],
|
ticket_url: Optional[str],
|
||||||
) -> EmailContent:
|
) -> EmailContent:
|
||||||
|
i18n = _resolve_i18n(i18n)
|
||||||
|
lang = _normalize_lang(language, settings)
|
||||||
rows = [
|
rows = [
|
||||||
("Ticket", f"#{ticket_id}"),
|
("email_support_row_ticket", f"#{ticket_id}"),
|
||||||
("User", user_display),
|
("email_support_row_user", user_display),
|
||||||
("Subject", subject),
|
("email_support_row_subject", subject),
|
||||||
*snapshot_rows,
|
*snapshot_rows,
|
||||||
]
|
]
|
||||||
return _support_email(
|
return _support_email(
|
||||||
settings,
|
settings,
|
||||||
i18n,
|
i18n,
|
||||||
language,
|
lang,
|
||||||
subject=f"New user reply in ticket #{ticket_id}",
|
subject=_t_text(i18n, lang, "email_support_user_reply_admin_subject", ticket_id=ticket_id),
|
||||||
heading=f"User replied in ticket #{ticket_id}",
|
heading=_t_text(i18n, lang, "email_support_user_reply_admin_heading", ticket_id=ticket_id),
|
||||||
intro="A user sent a new support message.",
|
intro=_t_text(i18n, lang, "email_support_user_reply_admin_intro"),
|
||||||
rows=rows,
|
rows=rows,
|
||||||
body_preview=body_preview,
|
body_preview=body_preview,
|
||||||
ticket_url=ticket_url,
|
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,
|
body_preview: str,
|
||||||
ticket_url: Optional[str],
|
ticket_url: Optional[str],
|
||||||
) -> EmailContent:
|
) -> EmailContent:
|
||||||
|
i18n = _resolve_i18n(i18n)
|
||||||
|
lang = _normalize_lang(language, settings)
|
||||||
return _support_email(
|
return _support_email(
|
||||||
settings,
|
settings,
|
||||||
i18n,
|
i18n,
|
||||||
language,
|
lang,
|
||||||
subject=f"New reply for ticket #{ticket_id}",
|
subject=_t_text(i18n, lang, "email_support_admin_reply_user_subject", ticket_id=ticket_id),
|
||||||
heading=f"New reply for ticket #{ticket_id}",
|
heading=_t_text(i18n, lang, "email_support_admin_reply_user_heading", ticket_id=ticket_id),
|
||||||
intro="Support has replied to your ticket.",
|
intro=_t_text(i18n, lang, "email_support_admin_reply_user_intro"),
|
||||||
rows=[("Ticket", f"#{ticket_id}"), ("Subject", subject)],
|
rows=[
|
||||||
|
("email_support_row_ticket", f"#{ticket_id}"),
|
||||||
|
("email_support_row_subject", subject),
|
||||||
|
],
|
||||||
body_preview=body_preview,
|
body_preview=body_preview,
|
||||||
ticket_url=ticket_url,
|
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 = "",
|
body_preview: str = "",
|
||||||
ticket_url: Optional[str],
|
ticket_url: Optional[str],
|
||||||
) -> EmailContent:
|
) -> EmailContent:
|
||||||
|
i18n = _resolve_i18n(i18n)
|
||||||
|
lang = _normalize_lang(language, settings)
|
||||||
return _support_email(
|
return _support_email(
|
||||||
settings,
|
settings,
|
||||||
i18n,
|
i18n,
|
||||||
language,
|
lang,
|
||||||
subject=f"Ticket #{ticket_id} was closed",
|
subject=_t_text(
|
||||||
heading=f"Ticket #{ticket_id} was closed",
|
i18n, lang, "email_support_ticket_closed_user_subject", ticket_id=ticket_id
|
||||||
intro="Your support ticket has been closed.",
|
),
|
||||||
rows=[("Ticket", f"#{ticket_id}"), ("Subject", subject)],
|
heading=_t_text(
|
||||||
body_preview=body_preview or "The ticket is closed.",
|
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,
|
ticket_url=ticket_url,
|
||||||
cta_label="Open in Mini App",
|
cta_label=_t_text(i18n, lang, "email_support_cta_open_mini_app"),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -273,10 +273,10 @@ class NotificationService:
|
|||||||
return []
|
return []
|
||||||
rows = []
|
rows = []
|
||||||
for key, label in (
|
for key, label in (
|
||||||
("tariff", "Tariff"),
|
("tariff", "email_support_row_tariff"),
|
||||||
("end_date", "End date"),
|
("end_date", "email_support_row_end_date"),
|
||||||
("remaining", "Remaining"),
|
("remaining", "email_support_row_remaining"),
|
||||||
("panel_status", "Panel status"),
|
("panel_status", "email_support_row_panel_status"),
|
||||||
):
|
):
|
||||||
value = snapshot.get(key)
|
value = snapshot.get(key)
|
||||||
if value:
|
if value:
|
||||||
|
|||||||
@@ -664,6 +664,28 @@
|
|||||||
"email_subscription_lifecycle_row_end_date": "Active until",
|
"email_subscription_lifecycle_row_end_date": "Active until",
|
||||||
"email_subscription_lifecycle_cta": "Open dashboard",
|
"email_subscription_lifecycle_cta": "Open dashboard",
|
||||||
"email_subscription_lifecycle_text_renew": "Dashboard: {url}",
|
"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_loading": "Loading...",
|
||||||
"wa_back": "Back",
|
"wa_back": "Back",
|
||||||
"wa_next": "Next",
|
"wa_next": "Next",
|
||||||
|
|||||||
@@ -664,6 +664,28 @@
|
|||||||
"email_subscription_lifecycle_row_end_date": "Действует до",
|
"email_subscription_lifecycle_row_end_date": "Действует до",
|
||||||
"email_subscription_lifecycle_cta": "Открыть кабинет",
|
"email_subscription_lifecycle_cta": "Открыть кабинет",
|
||||||
"email_subscription_lifecycle_text_renew": "Кабинет: {url}",
|
"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_loading": "Загрузка...",
|
||||||
"wa_back": "Назад",
|
"wa_back": "Назад",
|
||||||
"wa_next": "Далее",
|
"wa_next": "Далее",
|
||||||
|
|||||||
@@ -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"""["'](?P<key>email_[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'<html lang="{language}"' in content.html
|
||||||
|
assert "email_" not in content.subject
|
||||||
|
assert "email_" not in content.text
|
||||||
|
assert "email_" not in content.html
|
||||||
|
|
||||||
|
|
||||||
|
def _all_rendered_email_variants(language: str) -> 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 закрыт",
|
||||||
|
]
|
||||||
@@ -1,9 +1,17 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
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 bot.services.notification_service import NotificationService
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
def _settings(**overrides):
|
def _settings(**overrides):
|
||||||
data = {
|
data = {
|
||||||
@@ -15,6 +23,49 @@ def _settings(**overrides):
|
|||||||
return Settings(_env_file=None, **data)
|
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 '<html lang="ru"' in content.html
|
||||||
|
assert "Ticket #7 was closed" not in content.text
|
||||||
|
assert "Тема: Проблема с подключением" in content.text
|
||||||
|
assert "Открыть в Mini App" in content.html
|
||||||
|
|
||||||
|
|
||||||
|
def test_support_admin_email_localizes_snapshot_rows_for_recipient():
|
||||||
|
content = render_support_new_ticket_admin(
|
||||||
|
_settings(DEFAULT_LANGUAGE="en"),
|
||||||
|
_i18n(),
|
||||||
|
"ru",
|
||||||
|
ticket_id=11,
|
||||||
|
user_display="user@example.com",
|
||||||
|
subject="Не работает сайт",
|
||||||
|
body_preview="Не открывается личный кабинет",
|
||||||
|
snapshot_rows=[
|
||||||
|
("email_support_row_tariff", "Стандарт"),
|
||||||
|
("email_support_row_remaining", "3 д. 2 ч."),
|
||||||
|
],
|
||||||
|
ticket_url="https://app.example.com/admin/support/11",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert content.subject == "Новый тикет поддержки #11"
|
||||||
|
assert "Тариф: Стандарт" in content.text
|
||||||
|
assert "Осталось: 3 д. 2 ч." in content.text
|
||||||
|
assert "Tariff: Standard" not in content.text
|
||||||
|
|
||||||
|
|
||||||
def test_support_ticket_url_uses_subscription_mini_app_url():
|
def test_support_ticket_url_uses_subscription_mini_app_url():
|
||||||
service = NotificationService(
|
service = NotificationService(
|
||||||
bot=SimpleNamespace(),
|
bot=SimpleNamespace(),
|
||||||
|
|||||||
Reference in New Issue
Block a user