diff --git a/docs-site/scripts/generate-email-previews.py b/docs-site/scripts/generate-email-previews.py
new file mode 100644
index 0000000..e3a4253
--- /dev/null
+++ b/docs-site/scripts/generate-email-previews.py
@@ -0,0 +1,406 @@
+import json
+import sys
+from pathlib import Path
+from types import SimpleNamespace
+
+REPO_ROOT = Path(__file__).resolve().parents[2]
+BACKEND_ROOT = REPO_ROOT / "backend"
+sys.path.insert(0, str(BACKEND_ROOT))
+if hasattr(sys.stdout, "reconfigure"):
+ sys.stdout.reconfigure(encoding="utf-8")
+
+from bot.middlewares.i18n import JsonI18n # noqa: E402
+from bot.services.email_templates import ( # noqa: E402
+ 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,
+)
+
+LANGUAGE = "ru"
+
+
+def settings():
+ return SimpleNamespace(
+ DEFAULT_LANGUAGE=LANGUAGE,
+ EMAIL_CODE_TTL_SECONDS=600,
+ WEBAPP_LOGO_URL="",
+ WEBAPP_LOGO_USE_EMOJI=False,
+ WEBAPP_PRIMARY_COLOR="#00fe7a",
+ WEBAPP_TITLE="remnawave-minishop",
+ )
+
+
+I18N = JsonI18n(str(REPO_ROOT / "locales"), default=LANGUAGE)
+SETTINGS = settings()
+SAMPLE = {
+ "amount": 390,
+ "code": "483921",
+ "currency": "RUB",
+ "dashboard_url": "https://mini.example.com/app",
+ "end_date": "21.06.2026, 18:00",
+ "magic_url": "https://mini.example.com/app/auth/magic/preview",
+ "premium_traffic": 25,
+ "regular_traffic": 100,
+ "ticket_url": "https://mini.example.com/app/support/42",
+}
+
+
+def t(key: str, **kwargs) -> str:
+ return I18N.gettext(LANGUAGE, key, **kwargs)
+
+
+def preview(item_id: str, category: str, title: str, content):
+ return {
+ "id": item_id,
+ "category": category,
+ "title": title,
+ "subject": content.subject,
+ "html": content.html,
+ }
+
+
+def payment_preview(
+ item_id: str,
+ title: str,
+ sale_mode: str,
+ *,
+ months: int = 0,
+ traffic_gb: float | None = None,
+):
+ return preview(
+ item_id,
+ "Платежи",
+ title,
+ render_payment_success(
+ SETTINGS,
+ language_code=LANGUAGE,
+ sale_mode=sale_mode,
+ months=months,
+ traffic_gb=traffic_gb,
+ amount=SAMPLE["amount"],
+ currency=SAMPLE["currency"],
+ end_date_text=SAMPLE["end_date"],
+ dashboard_url=SAMPLE["dashboard_url"],
+ provider_label="YooKassa",
+ i18n=I18N,
+ ),
+ )
+
+
+def user_notification_preview(
+ item_id: str,
+ title: str,
+ subject_key: str,
+ message_text: str,
+ *,
+ cta_label_key: str = "email_user_notification_cta",
+):
+ subject = t(subject_key)
+ return preview(
+ item_id,
+ "Уведомления",
+ title,
+ render_user_notification(
+ SETTINGS,
+ language_code=LANGUAGE,
+ subject=subject,
+ heading=subject,
+ intro=t("email_user_notification_intro"),
+ message_text=message_text,
+ dashboard_url=SAMPLE["dashboard_url"],
+ cta_label=t(cta_label_key),
+ i18n=I18N,
+ ),
+ )
+
+
+def expiring_preview(item_id: str, title: str, days_left: int):
+ return preview(
+ item_id,
+ "Подписка",
+ title,
+ render_subscription_expiring(
+ SETTINGS,
+ language_code=LANGUAGE,
+ days_left=days_left,
+ end_date_text=SAMPLE["end_date"],
+ dashboard_url=SAMPLE["dashboard_url"],
+ i18n=I18N,
+ ),
+ )
+
+
+def lifecycle_preview(
+ item_id: str,
+ title: str,
+ notification_key: str,
+ message_text: str,
+ *,
+ mirrored_from_telegram: bool = False,
+ days_left: int | None = None,
+ hours_before: int | None = None,
+):
+ return preview(
+ item_id,
+ "Подписка",
+ title,
+ render_subscription_lifecycle_notification(
+ SETTINGS,
+ language_code=LANGUAGE,
+ notification_key=notification_key,
+ message_text=message_text,
+ end_date_text=SAMPLE["end_date"],
+ dashboard_url=SAMPLE["dashboard_url"],
+ mirrored_from_telegram=mirrored_from_telegram,
+ days_left=days_left,
+ hours_before=hours_before,
+ i18n=I18N,
+ ),
+ )
+
+
+def support_snapshot_rows():
+ return [
+ ("email_support_row_tariff", "Premium"),
+ ("email_support_row_remaining", "3 д. 4 ч."),
+ ]
+
+
+EMAIL_PREVIEWS = [
+ preview(
+ "login-code",
+ "Доступ",
+ "Код для входа",
+ render_login_code(
+ SETTINGS,
+ code=SAMPLE["code"],
+ language_code=LANGUAGE,
+ magic_link=SAMPLE["magic_url"],
+ purpose="login",
+ i18n=I18N,
+ ),
+ ),
+ preview(
+ "set-password-code",
+ "Доступ",
+ "Код для создания пароля",
+ render_login_code(
+ SETTINGS,
+ code=SAMPLE["code"],
+ language_code=LANGUAGE,
+ purpose="set_password",
+ i18n=I18N,
+ ),
+ ),
+ preview(
+ "account-merged",
+ "Аккаунт",
+ "Аккаунты объединены",
+ render_account_merged(
+ SETTINGS,
+ language_code=LANGUAGE,
+ primary_user_id=100200300,
+ removed_user_id=-42,
+ final_end_date_text=SAMPLE["end_date"],
+ i18n=I18N,
+ ),
+ ),
+ payment_preview(
+ "payment-subscription",
+ "Оплата подписки",
+ "subscription",
+ months=1,
+ ),
+ payment_preview(
+ "payment-traffic",
+ "Покупка трафика",
+ "traffic",
+ traffic_gb=SAMPLE["regular_traffic"],
+ ),
+ payment_preview(
+ "payment-premium-traffic",
+ "Покупка premium-трафика",
+ "premium_topup",
+ traffic_gb=SAMPLE["premium_traffic"],
+ ),
+ payment_preview("payment-hwid", "Покупка HWID-устройств", "hwid_device", months=2),
+ payment_preview("payment-tariff-upgrade", "Платное повышение тарифа", "tariff_upgrade"),
+ user_notification_preview(
+ "payment-failed",
+ "Неуспешная оплата",
+ "email_payment_failed_subject",
+ "Платеж не был завершен. Можно попробовать еще раз из личного кабинета.",
+ ),
+ user_notification_preview(
+ "payment-method-bound",
+ "Способ оплаты привязан",
+ "email_payment_method_bound_subject",
+ "Автопродление подключено, следующий платеж пройдет автоматически.",
+ ),
+ user_notification_preview(
+ "referral-bonus",
+ "Реферальный бонус",
+ "email_referral_bonus_subject",
+ "Друг активировал подписку, и бонусные дни уже добавлены к вашему аккаунту.",
+ ),
+ user_notification_preview(
+ "trial-traffic-depleted",
+ "Трафик пробного периода закончился",
+ "email_trial_traffic_depleted_subject",
+ "Пробный трафик израсходован. Оформите подписку, чтобы продолжить пользоваться сервисом.",
+ ),
+ user_notification_preview(
+ "regular-traffic-almost",
+ "Обычный трафик почти закончился",
+ "email_traffic_warning_regular_almost_subject",
+ "Использовано больше 85% трафика тарифа. Можно докупить пакет заранее.",
+ cta_label_key="email_traffic_warning_regular_cta",
+ ),
+ user_notification_preview(
+ "regular-traffic-depleted",
+ "Обычный трафик закончился",
+ "email_traffic_warning_regular_depleted_subject",
+ "Трафик тарифа израсходован. Докупите пакет, чтобы восстановить доступ.",
+ cta_label_key="email_traffic_warning_regular_cta",
+ ),
+ user_notification_preview(
+ "premium-traffic-almost",
+ "Premium-трафик почти закончился",
+ "email_traffic_warning_premium_almost_subject",
+ "Premium-трафика осталось мало. Можно докупить пакет до полного расхода.",
+ cta_label_key="email_traffic_warning_premium_cta",
+ ),
+ user_notification_preview(
+ "premium-traffic-depleted",
+ "Premium-трафик закончился",
+ "email_traffic_warning_premium_depleted_subject",
+ (
+ "Premium-трафик израсходован. Докупите пакет, "
+ "чтобы продолжить использовать premium-маршруты."
+ ),
+ cta_label_key="email_traffic_warning_premium_cta",
+ ),
+ expiring_preview(
+ "subscription-expiring-today",
+ "Подписка заканчивается сегодня",
+ 0,
+ ),
+ expiring_preview(
+ "subscription-expiring-tomorrow",
+ "Подписка заканчивается завтра",
+ 1,
+ ),
+ expiring_preview(
+ "subscription-expiring-days",
+ "Подписка скоро закончится",
+ 3,
+ ),
+ lifecycle_preview(
+ "lifecycle-before-days",
+ "Lifecycle: осталось несколько дней",
+ "before_days",
+ "Подписка скоро закончится. Продлите ее заранее, чтобы доступ не прерывался.",
+ days_left=3,
+ ),
+ lifecycle_preview(
+ "lifecycle-before-hours",
+ "Lifecycle: осталось несколько часов",
+ "before_hours",
+ "До окончания подписки осталось несколько часов.",
+ hours_before=6,
+ ),
+ lifecycle_preview(
+ "lifecycle-expired",
+ "Lifecycle: подписка закончилась",
+ "expired",
+ "Подписка закончилась. Продлите доступ в личном кабинете.",
+ ),
+ lifecycle_preview(
+ "lifecycle-expired-after",
+ "Lifecycle: подписка закончилась вчера",
+ "expired_24h_after",
+ "Вчера подписка была отключена. Вы можете восстановить доступ продлением.",
+ ),
+ lifecycle_preview(
+ "lifecycle-autorenew",
+ "Lifecycle: автопродление завтра",
+ "before_2d_autorenew",
+ "Завтра будет выполнено автопродление подписки.",
+ ),
+ lifecycle_preview(
+ "lifecycle-mirrored",
+ "Lifecycle: копия Telegram-уведомления",
+ "before_days",
+ "Это письмо дублирует важное уведомление, отправленное в Telegram.",
+ mirrored_from_telegram=True,
+ days_left=2,
+ ),
+ preview(
+ "support-new-ticket-admin",
+ "Поддержка",
+ "Новый тикет для администратора",
+ render_support_new_ticket_admin(
+ SETTINGS,
+ I18N,
+ LANGUAGE,
+ ticket_id=42,
+ user_display="alex@example.com",
+ subject="Не работает подключение",
+ body_preview="Пользователь не может подключиться после продления.",
+ snapshot_rows=support_snapshot_rows(),
+ ticket_url="https://mini.example.com/app/admin/support/42",
+ ),
+ ),
+ preview(
+ "support-user-reply-admin",
+ "Поддержка",
+ "Ответ пользователя для администратора",
+ render_support_user_reply_admin(
+ SETTINGS,
+ I18N,
+ LANGUAGE,
+ ticket_id=42,
+ user_display="alex@example.com",
+ subject="Не работает подключение",
+ body_preview="Проблема повторилась на телефоне и ноутбуке.",
+ snapshot_rows=support_snapshot_rows(),
+ ticket_url="https://mini.example.com/app/admin/support/42",
+ ),
+ ),
+ preview(
+ "support-admin-reply-user",
+ "Поддержка",
+ "Ответ поддержки пользователю",
+ render_support_admin_reply_user(
+ SETTINGS,
+ I18N,
+ LANGUAGE,
+ ticket_id=42,
+ subject="Не работает подключение",
+ body_preview="Мы обновили конфигурацию. Попробуйте подключиться еще раз.",
+ ticket_url=SAMPLE["ticket_url"],
+ ),
+ ),
+ preview(
+ "support-ticket-closed-user",
+ "Поддержка",
+ "Тикет закрыт",
+ render_support_ticket_closed_user(
+ SETTINGS,
+ I18N,
+ LANGUAGE,
+ ticket_id=42,
+ subject="Не работает подключение",
+ ticket_url=SAMPLE["ticket_url"],
+ ),
+ ),
+]
+
+print(json.dumps(EMAIL_PREVIEWS, ensure_ascii=False))
diff --git a/docs-site/src/lib/emailPreviews.mjs b/docs-site/src/lib/emailPreviews.mjs
index e18b388..3a0d6b8 100644
--- a/docs-site/src/lib/emailPreviews.mjs
+++ b/docs-site/src/lib/emailPreviews.mjs
@@ -1,463 +1,55 @@
-import { existsSync, readFileSync } from "node:fs";
+import { spawnSync } from "node:child_process";
+import { existsSync } from "node:fs";
import { resolve } from "node:path";
-const localePathCandidates = [
- resolve(process.cwd(), "..", "locales", "ru.json"),
- resolve(process.cwd(), "locales", "ru.json"),
+const repoRootCandidates = [
+ resolve(process.cwd(), ".."),
+ resolve(process.cwd()),
];
-const localePath =
- localePathCandidates.find((candidate) => existsSync(candidate)) ||
- localePathCandidates[0];
-const messages = JSON.parse(readFileSync(localePath, "utf8"));
+const repoRoot =
+ repoRootCandidates.find(
+ (candidate) =>
+ existsSync(resolve(candidate, "backend")) &&
+ existsSync(resolve(candidate, "docs-site")),
+ ) || repoRootCandidates[0];
+const generatorPath = resolve(
+ repoRoot,
+ "docs-site",
+ "scripts",
+ "generate-email-previews.py",
+);
-const sample = {
- amount: "390 RUB",
- brand: "remnawave-minishop",
- code: "483921",
- dashboardUrl: "https://mini.example.com/app",
- endDate: "21.06.2026, 18:00",
- magicUrl: "https://mini.example.com/app/auth/magic/preview",
- minutes: 10,
- premiumTraffic: "25",
- regularTraffic: "100",
- ticketUrl: "https://mini.example.com/app/support/42",
-};
+const pythonCommands = [
+ process.env.PYTHON,
+ process.platform === "win32" ? "python" : "python3",
+ "python",
+].filter(Boolean);
-const interpolate = (template, values = {}) =>
- String(template || "").replace(/\{([a-zA-Z0-9_]+)\}/g, (match, name) =>
- Object.prototype.hasOwnProperty.call(values, name) ? values[name] : match,
+let lastError = "";
+let generated = null;
+for (const command of pythonCommands) {
+ const result = spawnSync(command, [generatorPath], {
+ cwd: repoRoot,
+ encoding: "utf8",
+ env: {
+ ...process.env,
+ PYTHONIOENCODING: "utf-8",
+ },
+ });
+ if (result.status === 0 && result.stdout) {
+ generated = result.stdout;
+ break;
+ }
+ lastError = [result.error?.message, result.stderr, result.stdout]
+ .filter(Boolean)
+ .join("\n")
+ .trim();
+}
+
+if (!generated) {
+ throw new Error(
+ `Failed to generate email previews from backend templates.\n${lastError}`,
);
+}
-const t = (key, values = {}) => interpolate(messages[key] || key, values);
-
-const escapeHtml = (value) =>
- String(value || "")
- .replace(/&/g, "&")
- .replace(//g, ">")
- .replace(/"/g, """);
-
-const textWithBreaks = (value) => escapeHtml(value).replace(/\n/g, "
");
-
-const stripTags = (value) =>
- String(value || "")
- .replace(/
/gi, "\n")
- .replace(/<[^>]*>/g, "");
-
-const renderRows = (rows = []) => {
- if (!rows.length) return "";
- return `
| ${escapeHtml(label)} | -${escapeHtml(value)} | -
${escapeHtml(subject)}
-${textWithBreaks(intro)}
` : ""} - ${ - code - ? `${textWithBreaks(note)}
` : ""} -Все транзакционные письма собраны на одной странице и подписаны по - сценарию отправки. Тексты берутся из русской локализации приложения. + сценарию отправки. HTML-превью генерируются теми же шаблонами, + которые отправляются пользователям.