diff --git a/backend/bot/middlewares/i18n.py b/backend/bot/middlewares/i18n.py
index fe34100..56f598b 100644
--- a/backend/bot/middlewares/i18n.py
+++ b/backend/bot/middlewares/i18n.py
@@ -307,6 +307,7 @@ class JsonI18n:
self.locales_data: Dict[str, Dict[str, str]] = {}
self._overrides_path: Optional[Path] = None
self._overrides_file_mtime_ns: Optional[int] = None
+ self._overrides_file_content: Optional[str] = None
self._overrides_file_next_check = 0.0
self._overrides_file_check_interval_seconds = 1.0
self._load_locales()
@@ -419,6 +420,7 @@ class JsonI18n:
if self._overrides_file_mtime_ns is None:
return False
self._overrides_file_mtime_ns = None
+ self._overrides_file_content = None
logging.info(
"Locale overrides file removed; keeping current in-memory overrides until "
"the DB fallback is reloaded"
@@ -432,19 +434,8 @@ class JsonI18n:
)
return False
- if not force and stat.st_mtime_ns == self._overrides_file_mtime_ns:
- return False
-
try:
- payload = json.loads(self._overrides_path.read_text(encoding="utf-8"))
- except json.JSONDecodeError as exc:
- logging.warning(
- "Failed to parse locale overrides file %s: %s",
- self._overrides_path,
- exc,
- )
- self._overrides_file_mtime_ns = stat.st_mtime_ns
- return False
+ content = self._overrides_path.read_text(encoding="utf-8")
except OSError as exc:
logging.warning(
"Failed to read locale overrides file %s: %s",
@@ -453,7 +444,27 @@ class JsonI18n:
)
return False
+ if (
+ not force
+ and stat.st_mtime_ns == self._overrides_file_mtime_ns
+ and content == self._overrides_file_content
+ ):
+ return False
+
+ try:
+ payload = json.loads(content)
+ except json.JSONDecodeError as exc:
+ logging.warning(
+ "Failed to parse locale overrides file %s: %s",
+ self._overrides_path,
+ exc,
+ )
+ self._overrides_file_mtime_ns = stat.st_mtime_ns
+ self._overrides_file_content = content
+ return False
+
self._overrides_file_mtime_ns = stat.st_mtime_ns
+ self._overrides_file_content = content
self.set_locale_overrides(payload)
logging.info("Locale overrides reloaded from %s", self._overrides_path)
return True
diff --git a/backend/bot/payment_providers/shared/webhooks.py b/backend/bot/payment_providers/shared/webhooks.py
index 6cf9eeb..474df6b 100644
--- a/backend/bot/payment_providers/shared/webhooks.py
+++ b/backend/bot/payment_providers/shared/webhooks.py
@@ -6,6 +6,7 @@ from typing import Any, Optional
from aiogram import Bot
from sqlalchemy.ext.asyncio import AsyncSession
+from bot.services.user_email_notifications import send_user_notification_email
from db.dal import payment_dal, user_dal
from db.models import Payment
@@ -55,11 +56,21 @@ async def notify_user_payment_failed(
db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE
)
translator = make_translator(i18n, language)
+ message_text = translator(message_key)
try:
- await bot.send_message(payment.user_id, translator(message_key))
+ await bot.send_message(payment.user_id, message_text)
except Exception:
logging.exception(
"Webhook helper: failed to notify user %s about %s.",
payment.user_id,
message_key,
)
+ if db_user:
+ await send_user_notification_email(
+ settings=settings,
+ i18n=i18n,
+ user=db_user,
+ subject_key="email_payment_failed_subject",
+ message_text=message_text,
+ dashboard_url=(getattr(settings, "SUBSCRIPTION_MINI_APP_URL", "") or None),
+ )
diff --git a/backend/bot/payment_providers/yookassa.py b/backend/bot/payment_providers/yookassa.py
index 26c1194..575cfab 100644
--- a/backend/bot/payment_providers/yookassa.py
+++ b/backend/bot/payment_providers/yookassa.py
@@ -35,6 +35,7 @@ from bot.services.lknpd_service import LknpdService
from bot.services.panel_api_service import PanelApiService
from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
+from bot.services.user_email_notifications import send_user_notification_email
from bot.utils.config_link import prepare_config_links
from bot.utils.install_links import ensure_user_install_guide_links
from bot.utils.request_security import ip_in_allowlist, request_client_ip
@@ -953,7 +954,20 @@ async def process_cancelled_payment(
user_lang = db_user.language_code
_ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs)
- await bot.send_message(user_id, _("payment_failed"))
+ message_text = _("payment_failed")
+ try:
+ await bot.send_message(user_id, message_text)
+ except Exception:
+ logging.exception("Failed to notify YooKassa user %s about cancelled payment.", user_id)
+ if db_user:
+ await send_user_notification_email(
+ settings=settings,
+ i18n=i18n,
+ user=db_user,
+ subject_key="email_payment_failed_subject",
+ message_text=message_text,
+ dashboard_url=(settings.SUBSCRIPTION_MINI_APP_URL or None),
+ )
except Exception as e_process_cancel:
logging.error(
@@ -1206,13 +1220,32 @@ async def yookassa_webhook_route(request: web.Request):
get_back_to_payment_methods_keyboard,
)
- await bot.send_message(
- chat_id=user_id,
- text=_("payment_method_bound_success"),
- reply_markup=get_back_to_payment_methods_keyboard(
- i18n_lang, i18n_instance
- ),
- )
+ message_text = _("payment_method_bound_success")
+ try:
+ await bot.send_message(
+ chat_id=user_id,
+ text=message_text,
+ reply_markup=get_back_to_payment_methods_keyboard(
+ i18n_lang, i18n_instance
+ ),
+ )
+ except Exception:
+ logging.exception(
+ "Failed to notify user %s "
+ "about payment method binding.",
+ user_id,
+ )
+ if db_user:
+ await send_user_notification_email(
+ settings=settings,
+ i18n=i18n_instance,
+ user=db_user,
+ subject_key="email_payment_method_bound_subject",
+ message_text=message_text,
+ dashboard_url=(
+ settings.SUBSCRIPTION_MINI_APP_URL or None
+ ),
+ )
except Exception:
pass
# Attempt to cancel the authorization to avoid charge hold
diff --git a/backend/bot/services/email_templates.py b/backend/bot/services/email_templates.py
index 1c33817..aa99519 100644
--- a/backend/bot/services/email_templates.py
+++ b/backend/bot/services/email_templates.py
@@ -202,6 +202,41 @@ def _format_traffic(traffic_gb: Optional[float]) -> str:
return str(int(value)) if value.is_integer() else f"{value:g}"
+_ALLOWED_INLINE_TAGS = {
+ "b": "strong",
+ "strong": "strong",
+ "i": "em",
+ "em": "em",
+ "u": "u",
+ "s": "s",
+ "code": "code",
+}
+_INLINE_TAG_RE = re.compile(r"?(?:b|strong|i|em|u|s|code)>", re.IGNORECASE)
+_ANY_TAG_RE = re.compile(r"<[^>]+>")
+
+
+def _telegram_html_to_email_html(value: str) -> str:
+ """Escape arbitrary text while preserving the tiny Telegram HTML subset we use."""
+ source = str(value or "")
+ chunks: list[str] = []
+ cursor = 0
+ for match in _INLINE_TAG_RE.finditer(source):
+ chunks.append(html.escape(source[cursor : match.start()]))
+ raw_tag = match.group(0)
+ closing = raw_tag.startswith("")
+ tag_name = raw_tag.strip(">").lower()
+ mapped = _ALLOWED_INLINE_TAGS.get(tag_name)
+ if mapped:
+ chunks.append(f"{mapped}>" if closing else f"<{mapped}>")
+ cursor = match.end()
+ chunks.append(html.escape(source[cursor:]))
+ return "".join(chunks).replace("\n", "
")
+
+
+def _telegram_html_to_text(value: str) -> str:
+ return html.unescape(_ANY_TAG_RE.sub("", str(value or "")))
+
+
def _format_minutes(seconds: int) -> int:
return max(1, int(seconds) // 60)
@@ -351,12 +386,15 @@ def render_payment_success(
lang = _normalize_lang(language_code, settings)
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
brand = _brand_title(settings)
- is_traffic = (sale_mode or "").split("@", 1)[0].split("|", 1)[0] in {
+ sale_base = (sale_mode or "").split("@", 1)[0].split("|", 1)[0]
+ is_traffic = sale_base in {
"traffic",
"traffic_package",
"topup",
"premium_topup",
}
+ is_hwid = sale_base in {"hwid_device", "hwid_devices", "hwid_devices_renewal"}
+ is_tariff_upgrade = sale_base == "tariff_upgrade"
amount_text = _format_amount(amount, currency)
safe_dashboard_url = (dashboard_url or "").strip()
end_date = end_date_text or "—"
@@ -370,7 +408,12 @@ def render_payment_success(
cta_label = _t_text(i18n, lang, "email_payment_success_cta")
if is_traffic:
- intro = _t_text(i18n, lang, "email_payment_success_intro_traffic", traffic_gb=traffic_label)
+ intro_key = (
+ "email_payment_success_intro_premium_topup"
+ if sale_base == "premium_topup"
+ else "email_payment_success_intro_traffic"
+ )
+ intro = _t_text(i18n, lang, intro_key, traffic_gb=traffic_label)
period_label = _t_text(i18n, lang, "email_payment_success_row_traffic")
period_value = _t_text(
i18n, lang, "email_payment_success_traffic_value", traffic_gb=traffic_label
@@ -383,6 +426,30 @@ def render_payment_success(
traffic_gb=traffic_label,
end_date=end_date,
)
+ elif is_hwid:
+ devices_count = max(0, int(months or 0))
+ intro = _t_text(i18n, lang, "email_payment_success_intro_hwid", count=devices_count)
+ period_label = _t_text(i18n, lang, "email_payment_success_row_hwid")
+ period_value = _t_text(i18n, lang, "email_payment_success_hwid_value", count=devices_count)
+ text = _t_text(
+ i18n,
+ lang,
+ "email_payment_success_text_hwid",
+ amount=amount_text,
+ count=devices_count,
+ end_date=end_date,
+ )
+ elif is_tariff_upgrade:
+ intro = _t_text(i18n, lang, "email_payment_success_intro_tariff_upgrade")
+ period_label = _t_text(i18n, lang, "email_payment_success_row_operation")
+ period_value = _t_text(i18n, lang, "email_payment_success_tariff_upgrade_value")
+ text = _t_text(
+ i18n,
+ lang,
+ "email_payment_success_text_tariff_upgrade",
+ amount=amount_text,
+ end_date=end_date,
+ )
else:
months_int = int(months or 0)
intro = _t_text(i18n, lang, "email_payment_success_intro_subscription", months=months_int)
@@ -434,6 +501,66 @@ def render_payment_success(
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
+def render_user_notification(
+ settings: Settings,
+ *,
+ language_code: Optional[str],
+ subject: str,
+ message_text: str,
+ dashboard_url: Optional[str] = None,
+ cta_label: Optional[str] = None,
+ heading: Optional[str] = None,
+ intro: Optional[str] = None,
+ i18n: Optional[JsonI18n] = None,
+) -> EmailContent:
+ i18n = _resolve_i18n(i18n)
+ lang = _normalize_lang(language_code, settings)
+ accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
+ brand = _brand_title(settings)
+ safe_dashboard_url = (dashboard_url or "").strip()
+ final_subject = (subject or "").strip() or _t_text(
+ i18n, lang, "email_user_notification_subject"
+ )
+ final_heading = (heading or "").strip() or final_subject
+ final_intro = (intro or "").strip() or _t_text(i18n, lang, "email_user_notification_intro")
+ final_cta_label = (cta_label or "").strip() or _t_text(
+ i18n,
+ lang,
+ "email_user_notification_cta",
+ )
+ footer = _t_html(i18n, lang, "email_footer_auto", brand=brand)
+ message_html = (
+ f'
{_telegram_html_to_email_html(message_text)}
'
+ )
+ body_parts = [message_html]
+ if safe_dashboard_url:
+ body_parts.append(
+ _cta_button_html(label=final_cta_label, url=safe_dashboard_url, accent=accent)
+ )
+
+ rendered = _layout(
+ settings=settings,
+ preheader=final_subject,
+ heading=final_heading,
+ intro_html=html.escape(final_intro),
+ body_html="".join(body_parts),
+ footer_html=footer,
+ )
+ text_lines = [final_subject, "", _telegram_html_to_text(message_text)]
+ if safe_dashboard_url:
+ text_lines.extend(
+ [
+ "",
+ _t_text(
+ i18n, lang, "email_user_notification_text_dashboard", url=safe_dashboard_url
+ ),
+ ]
+ )
+ return EmailContent(subject=final_subject, text="\n".join(text_lines), html=rendered)
+
+
def render_subscription_expiring(
settings: Settings,
*,
diff --git a/backend/bot/services/referral_service.py b/backend/bot/services/referral_service.py
index c533d14..53efd36 100644
--- a/backend/bot/services/referral_service.py
+++ b/backend/bot/services/referral_service.py
@@ -7,6 +7,7 @@ from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from bot.middlewares.i18n import JsonI18n
+from bot.services.user_email_notifications import send_user_notification_email
from config.settings import Settings
from db.dal import payment_dal, subscription_dal, user_dal
@@ -141,18 +142,36 @@ class ReferralService:
inviter_user_model.language_code or default_lang_for_placeholder
)
_i = lambda k, **kw: self.i18n.gettext(inviter_lang, k, **kw)
- await self.bot.send_message(
- inviter_user_id,
- _i(
- "referral_bonus_inviter_notification_extended",
- days=inviter_bonus_days,
- referee_name=referee_name_for_msg,
- new_end_date=new_end_date_inviter.strftime("%Y-%m-%d"),
+ message_text = _i(
+ "referral_bonus_inviter_notification_extended",
+ days=inviter_bonus_days,
+ referee_name=referee_name_for_msg,
+ new_end_date=new_end_date_inviter.strftime("%Y-%m-%d"),
+ )
+ try:
+ await self.bot.send_message(inviter_user_id, message_text)
+ except Exception as e_notify_inviter:
+ logging.error(
+ f"Failed to send bonus notification to inviter {inviter_user_id}: {e_notify_inviter}" # noqa: E501
+ )
+ await send_user_notification_email(
+ settings=self.settings,
+ i18n=self.i18n,
+ user=inviter_user_model,
+ subject_key="email_referral_bonus_subject",
+ message_text=message_text,
+ dashboard_url=(
+ getattr(
+ self.settings,
+ "SUBSCRIPTION_MINI_APP_URL",
+ "",
+ )
+ or None
),
)
except Exception as e_notify_inviter:
logging.error(
- f"Failed to send bonus notification to inviter {inviter_user_id}: {e_notify_inviter}" # noqa: E501
+ f"Failed to prepare bonus notification for inviter {inviter_user_id}: {e_notify_inviter}" # noqa: E501
)
else:
logging.info(
@@ -209,13 +228,34 @@ class ReferralService:
_i = lambda k, **kw: self.i18n.gettext(
inviter_lang, k, **kw
)
- await self.bot.send_message(
- inviter_user_id,
- _i(
- "referral_bonus_inviter_notification_new_sub",
- days=inviter_bonus_days,
- referee_name=referee_name_for_msg,
- new_end_date=bonus_end_date.strftime("%Y-%m-%d"),
+ message_text = _i(
+ "referral_bonus_inviter_notification_new_sub",
+ days=inviter_bonus_days,
+ referee_name=referee_name_for_msg,
+ new_end_date=bonus_end_date.strftime("%Y-%m-%d"),
+ )
+ try:
+ await self.bot.send_message(
+ inviter_user_id,
+ message_text,
+ )
+ except Exception as e_notify_inviter:
+ logging.error(
+ f"Failed to send bonus notification to inviter {inviter_user_id}: {e_notify_inviter}" # noqa: E501
+ )
+ await send_user_notification_email(
+ settings=self.settings,
+ i18n=self.i18n,
+ user=inviter_user_model,
+ subject_key="email_referral_bonus_subject",
+ message_text=message_text,
+ dashboard_url=(
+ getattr(
+ self.settings,
+ "SUBSCRIPTION_MINI_APP_URL",
+ "",
+ )
+ or None
),
)
else:
diff --git a/backend/bot/services/subscription_notification_worker.py b/backend/bot/services/subscription_notification_worker.py
index 67d5f07..2ed511b 100644
--- a/backend/bot/services/subscription_notification_worker.py
+++ b/backend/bot/services/subscription_notification_worker.py
@@ -19,6 +19,7 @@ from bot.services.subscription_lifecycle_notifications import (
SubscriptionNotificationStage,
)
from bot.services.subscription_service import SubscriptionService
+from bot.services.user_email_notifications import send_user_notification_email
from config.settings import Settings
from db.advisory_locks import acquire_subscription_background_sync_lock
from db.dal import subscription_dal
@@ -206,11 +207,22 @@ class SubscriptionNotificationWorker:
.order_by(Subscription.end_date.asc())
)
for sub in result.scalars().all():
- if await subscription_dal.has_subscription_notification(
+ legacy_sent = await subscription_dal.has_subscription_notification(
session,
sub.subscription_id,
"trial_traffic_depleted",
- ):
+ )
+ telegram_done = legacy_sent or await subscription_dal.has_subscription_notification(
+ session,
+ sub.subscription_id,
+ "trial_traffic_depleted:telegram",
+ )
+ email_done = await subscription_dal.has_subscription_notification(
+ session,
+ sub.subscription_id,
+ "trial_traffic_depleted:email",
+ )
+ if telegram_done and email_done:
continue
used = int(getattr(sub, "traffic_used_bytes", 0) or 0)
@@ -232,14 +244,29 @@ class SubscriptionNotificationWorker:
if limit <= 0 or used < limit:
continue
- if not await self._send_trial_traffic_depleted(sub, used=used, limit=limit):
- continue
- await subscription_dal.record_subscription_notification(
- session,
- sub.subscription_id,
- "trial_traffic_depleted",
- sent_at=now,
+ delivery = await self._send_trial_traffic_depleted(
+ sub,
+ used=used,
+ limit=limit,
+ send_telegram=not telegram_done,
+ send_email=not email_done,
)
+ if not delivery["telegram"] and not delivery["email"]:
+ continue
+ if delivery["telegram"]:
+ await subscription_dal.record_subscription_notification(
+ session,
+ sub.subscription_id,
+ "trial_traffic_depleted:telegram",
+ sent_at=now,
+ )
+ if delivery["email"]:
+ await subscription_dal.record_subscription_notification(
+ session,
+ sub.subscription_id,
+ "trial_traffic_depleted:email",
+ sent_at=now,
+ )
async def _panel_user(self, sub: Subscription) -> Optional[dict]:
panel_uuid = str(getattr(sub, "panel_user_uuid", "") or "").strip()
@@ -261,30 +288,46 @@ class SubscriptionNotificationWorker:
*,
used: int,
limit: int,
- ) -> bool:
+ send_telegram: bool = True,
+ send_email: bool = True,
+ ) -> dict[str, bool]:
user_id = int(getattr(sub, "user_id", 0) or 0)
- if user_id <= 0:
- return False
user = getattr(sub, "user", None)
lang = getattr(user, "language_code", None) or self.settings.DEFAULT_LANGUAGE
translate = lambda k, **kw: self.i18n.gettext(lang, k, **kw)
remaining = max(0, limit - used)
- try:
- await self.bot.send_message(
- user_id,
- translate(
- "trial_traffic_depleted_notification",
- used=hd.quote(self._fmt_bytes(used)),
- remaining=hd.quote(self._fmt_bytes(remaining)),
- limit_total=hd.quote(self._fmt_bytes(limit)),
- ),
- reply_markup=get_subscribe_only_markup(lang, self.i18n),
- parse_mode="HTML",
+ message_text = translate(
+ "trial_traffic_depleted_notification",
+ used=hd.quote(self._fmt_bytes(used)),
+ remaining=hd.quote(self._fmt_bytes(remaining)),
+ limit_total=hd.quote(self._fmt_bytes(limit)),
+ )
+ telegram_sent = False
+ email_sent = False
+ if send_telegram and user_id > 0:
+ try:
+ await self.bot.send_message(
+ user_id,
+ message_text,
+ reply_markup=get_subscribe_only_markup(lang, self.i18n),
+ parse_mode="HTML",
+ )
+ telegram_sent = True
+ except Exception:
+ logging.exception(
+ "Failed to send trial traffic depleted warning to user %s",
+ user_id,
+ )
+ if send_email and user:
+ email_sent = await send_user_notification_email(
+ settings=self.settings,
+ i18n=self.i18n,
+ user=user,
+ subject_key="email_trial_traffic_depleted_subject",
+ message_text=message_text,
+ dashboard_url=(getattr(self.settings, "SUBSCRIPTION_MINI_APP_URL", "") or None),
)
- return True
- except Exception:
- logging.exception("Failed to send trial traffic depleted warning to user %s", user_id)
- return False
+ return {"telegram": telegram_sent, "email": email_sent}
def _max_before_window(self) -> timedelta:
days_before = max(0, int(getattr(self.settings, "SUBSCRIPTION_NOTIFY_DAYS_BEFORE", 0) or 0))
diff --git a/backend/bot/services/subscription_service_impl/devices.py b/backend/bot/services/subscription_service_impl/devices.py
index df30706..b0d0beb 100644
--- a/backend/bot/services/subscription_service_impl/devices.py
+++ b/backend/bot/services/subscription_service_impl/devices.py
@@ -357,6 +357,15 @@ class HwidDeviceMixin:
valid_from=valid_from,
valid_until=valid_until,
)
+ await self._send_payment_success_email(
+ db_user=db_user,
+ sale_mode="hwid_devices_renewal" if renewal else "hwid_devices",
+ months=purchased_devices,
+ traffic_gb=None,
+ payment_amount=payment_amount,
+ end_date=valid_until,
+ provider=provider,
+ )
return {
"subscription_id": updated_sub.subscription_id,
"end_date": updated_sub.end_date,
diff --git a/backend/bot/services/subscription_service_impl/lifecycle.py b/backend/bot/services/subscription_service_impl/lifecycle.py
index 49da0a7..021c74d 100644
--- a/backend/bot/services/subscription_service_impl/lifecycle.py
+++ b/backend/bot/services/subscription_service_impl/lifecycle.py
@@ -444,6 +444,17 @@ class SubscriptionLifecycleMixin:
)
result["end_date"] = sub.end_date
result["is_active"] = sub.is_active
+ db_user = await user_dal.get_user_by_id(session, user_id)
+ if db_user:
+ await self._send_payment_success_email(
+ db_user=db_user,
+ sale_mode="tariff_upgrade",
+ months=0,
+ traffic_gb=None,
+ payment_amount=payment_amount,
+ end_date=sub.end_date,
+ provider=provider,
+ )
return result
tariff = self._resolve_tariff(tariff_key, "period") if self._tariffs_config() else None
diff --git a/backend/bot/services/subscription_service_impl/payments.py b/backend/bot/services/subscription_service_impl/payments.py
index 32d1683..1916afc 100644
--- a/backend/bot/services/subscription_service_impl/payments.py
+++ b/backend/bot/services/subscription_service_impl/payments.py
@@ -93,7 +93,7 @@ class PaymentContextMixin:
"""Best-effort branded email confirming the payment. No-op if SMTP or
the user's email aren't set. Failures are logged and swallowed so the
payment flow is never blocked by mail delivery."""
- if not self.settings.email_auth_configured:
+ if not getattr(self.settings, "email_auth_configured", False):
return
recipient = (db_user.email or "").strip() if db_user else ""
if not recipient:
diff --git a/backend/bot/services/subscription_service_impl/traffic.py b/backend/bot/services/subscription_service_impl/traffic.py
index 6da8ae1..f6a46dc 100644
--- a/backend/bot/services/subscription_service_impl/traffic.py
+++ b/backend/bot/services/subscription_service_impl/traffic.py
@@ -269,6 +269,15 @@ class TrafficMixin:
purchased_bytes=purchase_bytes,
kind="topup",
)
+ await self._send_payment_success_email(
+ db_user=db_user,
+ sale_mode="topup",
+ months=0,
+ traffic_gb=float(traffic_gb),
+ payment_amount=payment_amount,
+ end_date=getattr(updated_sub, "end_date", None),
+ provider=provider,
+ )
return {
"subscription_id": sub.subscription_id,
"traffic_limit_bytes": new_limit,
@@ -376,6 +385,15 @@ class TrafficMixin:
purchased_bytes=purchase_bytes,
kind="premium_topup",
)
+ await self._send_payment_success_email(
+ db_user=db_user,
+ sale_mode="premium_topup",
+ months=0,
+ traffic_gb=float(traffic_gb),
+ payment_amount=payment_amount,
+ end_date=getattr(sub, "end_date", None),
+ provider=provider,
+ )
return {
"subscription_id": sub.subscription_id,
"premium_limit_bytes": premium_limit,
diff --git a/backend/bot/services/tariff_worker.py b/backend/bot/services/tariff_worker.py
index 5764fb0..49c2dad 100644
--- a/backend/bot/services/tariff_worker.py
+++ b/backend/bot/services/tariff_worker.py
@@ -15,6 +15,7 @@ from bot.infra.redis import redis_lock
from bot.middlewares.i18n import JsonI18n
from bot.services.panel_api_service import PanelApiService
from bot.services.subscription_service import SubscriptionService
+from bot.services.user_email_notifications import send_user_notification_email
from bot.utils.date_utils import month_start
from bot.utils.mini_app_url import subscription_mini_app_topup_url
from config.settings import Settings
@@ -102,6 +103,36 @@ class TariffTrafficWorker:
button = InlineKeyboardButton(text=_(fallback_key), callback_data="tariff_topup:list")
return InlineKeyboardMarkup(inline_keyboard=[[button]])
+ async def _send_traffic_warning_email(
+ self,
+ session: AsyncSession,
+ *,
+ user_id: int,
+ subject_key: str,
+ message_text: str,
+ kind: str,
+ ) -> None:
+ try:
+ user = await user_dal.get_user_by_id(session, user_id)
+ except Exception:
+ logging.exception("TariffTrafficWorker: failed to load user %s for email", user_id)
+ return
+ if not user:
+ return
+ await send_user_notification_email(
+ settings=self.settings,
+ i18n=self.i18n,
+ user=user,
+ subject_key=subject_key,
+ message_text=message_text,
+ dashboard_url=subscription_mini_app_topup_url(self.settings, kind),
+ cta_label_key=(
+ "email_traffic_warning_premium_cta"
+ if kind == "premium"
+ else "email_traffic_warning_regular_cta"
+ ),
+ )
+
async def run(self) -> None:
if not self.settings.tariffs_config:
return
@@ -525,6 +556,8 @@ class TariffTrafficWorker:
return
ratio = used_val / limit_val
levels = list(getattr(self.settings, "tariff_traffic_warning_levels", [85, 90, 95]))
+ if 100 not in levels:
+ levels.append(100)
for level in levels:
threshold = level / 100
if ratio < threshold:
@@ -545,30 +578,32 @@ class TariffTrafficWorker:
level=level,
traffic_limit_bytes=limit_val if tariff.billing_model == "traffic" else None,
)
+ user_lang = await self._user_lang(session, sub.user_id)
+ _ = (
+ (lambda k, **kw: self.i18n.gettext(user_lang, k, **kw))
+ if self.i18n
+ else (lambda k, **kw: k)
+ )
+ left_pct = max(0, 100 - level)
+ tariff_name = hd.quote(str(tariff.name(user_lang)))
+ usage = self._usage_placeholders(used_val, limit_val)
+ if level < 100:
+ text = _(
+ "traffic_warning_regular_almost",
+ tariff_name=tariff_name,
+ left_pct=left_pct,
+ **usage,
+ )
+ subject_key = "email_traffic_warning_regular_almost_subject"
+ else:
+ text = _(
+ "traffic_warning_regular_depleted",
+ tariff_name=tariff_name,
+ **usage,
+ )
+ subject_key = "email_traffic_warning_regular_depleted_subject"
if self.bot:
try:
- user_lang = await self._user_lang(session, sub.user_id)
- _ = (
- (lambda k, **kw: self.i18n.gettext(user_lang, k, **kw))
- if self.i18n
- else (lambda k, **kw: k)
- )
- left_pct = max(0, 100 - level)
- tariff_name = hd.quote(str(tariff.name(user_lang)))
- usage = self._usage_placeholders(used_val, limit_val)
- if level < 100:
- text = _(
- "traffic_warning_regular_almost",
- tariff_name=tariff_name,
- left_pct=left_pct,
- **usage,
- )
- else:
- text = _(
- "traffic_warning_regular_depleted",
- tariff_name=tariff_name,
- **usage,
- )
markup = self._traffic_topup_markup(user_lang, "regular")
await self.bot.send_message(
sub.user_id,
@@ -578,6 +613,13 @@ class TariffTrafficWorker:
)
except Exception:
logging.exception("Failed to send traffic warning to user %s", sub.user_id)
+ await self._send_traffic_warning_email(
+ session,
+ user_id=sub.user_id,
+ subject_key=subject_key,
+ message_text=text,
+ kind="regular",
+ )
if ratio >= 1.0 and not sub.is_throttled:
logging.info(
"Tariff traffic limit reached for user %s subscription %s. "
@@ -1001,31 +1043,31 @@ class TariffTrafficWorker:
level=PREMIUM_WARNING_DEPLETED_LEVEL,
traffic_limit_bytes=None,
)
+ user_lang = await self._user_lang(session, sub.user_id)
+ _ = (
+ (lambda k, **kw: self.i18n.gettext(user_lang, k, **kw))
+ if self.i18n
+ else (lambda k, **kw: k)
+ )
+ access = await self.subscription_service.premium_access_for_tariff(tariff)
+ labels = access.get("node_labels") or access.get("squad_labels") or []
+ if labels:
+ visible = [hd.quote(str(x)) for x in labels[:8]]
+ servers = "\n".join(f"• {label}" for label in visible)
+ if len(labels) > len(visible):
+ more = len(labels) - len(visible)
+ servers += "\n" + _("traffic_warning_premium_servers_more", count=more)
+ else:
+ servers = _("traffic_warning_premium_generic_servers")
+ usage = self._usage_placeholders(used_val, limit_val)
+ text = _(
+ "traffic_warning_premium_depleted",
+ tariff_name=hd.quote(str(tariff.name(user_lang))),
+ servers=servers,
+ **usage,
+ )
if self.bot:
try:
- user_lang = await self._user_lang(session, sub.user_id)
- _ = (
- (lambda k, **kw: self.i18n.gettext(user_lang, k, **kw))
- if self.i18n
- else (lambda k, **kw: k)
- )
- access = await self.subscription_service.premium_access_for_tariff(tariff)
- labels = access.get("node_labels") or access.get("squad_labels") or []
- if labels:
- visible = [hd.quote(str(x)) for x in labels[:8]]
- servers = "\n".join(f"• {label}" for label in visible)
- if len(labels) > len(visible):
- more = len(labels) - len(visible)
- servers += "\n" + _("traffic_warning_premium_servers_more", count=more)
- else:
- servers = _("traffic_warning_premium_generic_servers")
- usage = self._usage_placeholders(used_val, limit_val)
- text = _(
- "traffic_warning_premium_depleted",
- tariff_name=hd.quote(str(tariff.name(user_lang))),
- servers=servers,
- **usage,
- )
markup = self._traffic_topup_markup(user_lang, "premium")
await self.bot.send_message(
sub.user_id,
@@ -1037,6 +1079,13 @@ class TariffTrafficWorker:
logging.exception(
"Failed to send premium traffic depleted warning to user %s", sub.user_id
)
+ await self._send_traffic_warning_email(
+ session,
+ user_id=sub.user_id,
+ subject_key="email_traffic_warning_premium_depleted_subject",
+ message_text=text,
+ kind="premium",
+ )
return
for level in levels:
@@ -1060,43 +1109,51 @@ class TariffTrafficWorker:
level=storage_level,
traffic_limit_bytes=None,
)
- if not self.bot:
- continue
- try:
- user_lang = await self._user_lang(session, sub.user_id)
- _ = (
- (lambda k, **kw: self.i18n.gettext(user_lang, k, **kw))
- if self.i18n
- else (lambda k, **kw: k)
- )
- access = await self.subscription_service.premium_access_for_tariff(tariff)
- labels = access.get("node_labels") or access.get("squad_labels") or []
- if labels:
- visible = [hd.quote(str(x)) for x in labels[:8]]
- servers = "\n".join(f"• {label}" for label in visible)
- if len(labels) > len(visible):
- more = len(labels) - len(visible)
- servers += "\n" + _("traffic_warning_premium_servers_more", count=more)
- else:
- servers = _("traffic_warning_premium_generic_servers")
- left_pct = max(0, 100 - int(level))
- usage = self._usage_placeholders(used_val, limit_val)
- text = _(
- "traffic_warning_premium_almost",
- tariff_name=hd.quote(str(tariff.name(user_lang))),
- left_pct=left_pct,
- servers=servers,
- **usage,
- )
- markup = self._traffic_topup_markup(user_lang, "premium")
- await self.bot.send_message(
- sub.user_id,
- text,
- reply_markup=markup,
- parse_mode="HTML",
- )
- except Exception:
- logging.exception("Failed to send premium traffic warning to user %s", sub.user_id)
+ user_lang = await self._user_lang(session, sub.user_id)
+ _ = (
+ (lambda k, **kw: self.i18n.gettext(user_lang, k, **kw))
+ if self.i18n
+ else (lambda k, **kw: k)
+ )
+ access = await self.subscription_service.premium_access_for_tariff(tariff)
+ labels = access.get("node_labels") or access.get("squad_labels") or []
+ if labels:
+ visible = [hd.quote(str(x)) for x in labels[:8]]
+ servers = "\n".join(f"• {label}" for label in visible)
+ if len(labels) > len(visible):
+ more = len(labels) - len(visible)
+ servers += "\n" + _("traffic_warning_premium_servers_more", count=more)
+ else:
+ servers = _("traffic_warning_premium_generic_servers")
+ left_pct = max(0, 100 - int(level))
+ usage = self._usage_placeholders(used_val, limit_val)
+ text = _(
+ "traffic_warning_premium_almost",
+ tariff_name=hd.quote(str(tariff.name(user_lang))),
+ left_pct=left_pct,
+ servers=servers,
+ **usage,
+ )
+ if self.bot:
+ try:
+ markup = self._traffic_topup_markup(user_lang, "premium")
+ await self.bot.send_message(
+ sub.user_id,
+ text,
+ reply_markup=markup,
+ parse_mode="HTML",
+ )
+ except Exception:
+ logging.exception(
+ "Failed to send premium traffic warning to user %s", sub.user_id
+ )
+ await self._send_traffic_warning_email(
+ session,
+ user_id=sub.user_id,
+ subject_key="email_traffic_warning_premium_almost_subject",
+ message_text=text,
+ kind="premium",
+ )
async def _premium_node_uuids_for_tariff(self, tariff) -> list[str]:
cache_key = tuple(sorted(tariff.premium_squad_uuids or []))
diff --git a/backend/bot/services/user_email_notifications.py b/backend/bot/services/user_email_notifications.py
new file mode 100644
index 0000000..6dc86be
--- /dev/null
+++ b/backend/bot/services/user_email_notifications.py
@@ -0,0 +1,84 @@
+import logging
+from typing import Any, Optional
+
+from bot.middlewares.i18n import JsonI18n
+from bot.services.email_auth_service import EmailAuthService
+from bot.services.email_templates import render_user_notification
+from config.settings import Settings
+
+
+def _translate(
+ i18n: Optional[JsonI18n],
+ language: str,
+ key: Optional[str],
+ fallback: str = "",
+ **kwargs: Any,
+) -> str:
+ if not key:
+ return fallback
+ if not i18n:
+ return fallback or key
+ text = i18n.gettext(language, key, **kwargs)
+ return fallback if text == key and fallback else text
+
+
+async def send_user_notification_email(
+ *,
+ settings: Settings,
+ i18n: Optional[JsonI18n],
+ user: Any,
+ subject_key: str,
+ message_text: str,
+ dashboard_url: Optional[str] = None,
+ cta_label_key: str = "email_user_notification_cta",
+ subject_kwargs: Optional[dict[str, Any]] = None,
+ heading_key: Optional[str] = None,
+ intro_key: Optional[str] = None,
+) -> bool:
+ if not getattr(settings, "email_auth_configured", False):
+ return False
+ recipient = str(getattr(user, "email", "") or "").strip()
+ if not recipient:
+ return False
+
+ language = (
+ str(getattr(user, "language_code", "") or "").strip()
+ or getattr(settings, "DEFAULT_LANGUAGE", "ru")
+ or "ru"
+ )
+ kwargs = subject_kwargs or {}
+ subject = _translate(i18n, language, subject_key, subject_key, **kwargs)
+ heading = _translate(i18n, language, heading_key, subject, **kwargs)
+ intro = _translate(
+ i18n,
+ language,
+ intro_key or "email_user_notification_intro",
+ "Notification from your account.",
+ )
+ cta_label = _translate(
+ i18n,
+ language,
+ cta_label_key or "email_user_notification_cta",
+ "Open dashboard",
+ )
+
+ try:
+ content = render_user_notification(
+ settings,
+ language_code=language,
+ subject=subject,
+ heading=heading,
+ intro=intro,
+ message_text=message_text,
+ dashboard_url=dashboard_url,
+ cta_label=cta_label,
+ i18n=i18n,
+ )
+ await EmailAuthService(settings, i18n).send_rendered_email(
+ email=recipient,
+ content=content,
+ )
+ return True
+ except Exception:
+ logging.exception("Failed to send user notification email to %s.", recipient)
+ return False
diff --git a/docs/features/notifications.md b/docs/features/notifications.md
index 66dc5bd..1090eac 100644
--- a/docs/features/notifications.md
+++ b/docs/features/notifications.md
@@ -2,6 +2,8 @@
Minishop отправляет уведомления в Telegram и на email. Telegram-канал означает личные сообщения пользователю, сообщения администраторам из `ADMIN_IDS` или сообщения в `LOG_CHAT_ID` - зависит от события. Email работает только при настроенном SMTP и наличии email у получателя.
+Для пользовательских строк отметка `Telegram = ✓` означает, что событие умеет отправляться в Telegram пользователю с привязанным Telegram-аккаунтом. Email-only пользователь такое Telegram-сообщение не получит.
+
Для уведомлений жизненного цикла подписки есть отдельный флаг `SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED`. Если он включен, пользовательские уведомления об окончании подписки отправляются в Telegram при наличии привязанного Telegram-аккаунта и на email при наличии привязанной почты.
## Сводная таблица
@@ -10,22 +12,33 @@ Minishop отправляет уведомления в Telegram и на email.
| --- | --- | :---: | :---: | --- |
| Код входа по email и magic link | Пользователь | - | ✓ | Нужны `SMTP_*` и `EMAIL_*` настройки. Magic link также требует корректный `SUBSCRIPTION_MINI_APP_URL`. |
| Код установки или смены пароля | Пользователь | - | ✓ | Нужны `SMTP_*` и `EMAIL_*` настройки. |
-| Успешная оплата подписки | Пользователь | ✓ | ✓ | Email отправляется, если SMTP настроен и у пользователя есть email. |
-| Успешная покупка пакета трафика | Пользователь | ✓ | ✓ | Email отправляется, если SMTP настроен и у пользователя есть email. |
-| Успешная покупка premium-трафика | Пользователь | ✓ | ✓ | Email отправляется, если SMTP настроен и у пользователя есть email. |
-| Успешная покупка HWID-устройств | Пользователь | ✓ | - | Отправляется после оплаты `hwid_devices` или `hwid_devices_renewal`. |
-| Ошибка оплаты по webhook провайдера | Пользователь | ✓ | - | Отправляется, когда платежный провайдер сообщает о неуспешном платеже. |
-| Напоминание за 3 дня до окончания подписки | Пользователь | ✓ | ✓ | Управляется `SUBSCRIPTION_NOTIFY_DAYS_BEFORE` и `SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED`. |
+| Успешная оплата подписки | Пользователь | ✓ | ✓ | Для `subscription`; email отправляется, если SMTP настроен и у пользователя есть email. |
+| Успешная покупка отдельного пакета трафика | Пользователь | ✓ | ✓ | Для `traffic` / `traffic_package`; email отправляется, если SMTP настроен и у пользователя есть email. |
+| Успешная докупка обычного трафика к тарифу | Пользователь | ✓ | ✓ | Для `topup`; email отправляется, если SMTP настроен и у пользователя есть email. |
+| Успешная покупка premium-трафика | Пользователь | ✓ | ✓ | Для `premium_topup`; email отправляется, если SMTP настроен и у пользователя есть email. |
+| Успешная покупка HWID-устройств | Пользователь | ✓ | ✓ | Отправляется после оплаты `hwid_devices` или `hwid_devices_renewal`; email отправляется, если SMTP настроен и у пользователя есть email. |
+| Платное повышение тарифа | Пользователь | ✓ | ✓ | Для `tariff_upgrade`; email отправляется, если SMTP настроен и у пользователя есть email. |
+| Способ оплаты YooKassa привязан | Пользователь | ✓ | ✓ | Отправляется после успешного сохранения платежного метода через webhook YooKassa; email отправляется, если SMTP настроен и у пользователя есть email. |
+| Ошибка оплаты по webhook провайдера | Пользователь | ✓ | ✓ | Отправляется, когда платежный провайдер сообщает о неуспешном платеже; email отправляется, если SMTP настроен и у пользователя есть email. |
+| Реферальный бонус приглашателю: активная подписка продлена | Пользователь-пригласивший | ✓ | ✓ | Отправляется после оплаты приглашенным пользователем, если бонус продлил активную подписку приглашателя. |
+| Реферальный бонус приглашателю: создана бонусная подписка | Пользователь-пригласивший | ✓ | ✓ | Отправляется после оплаты приглашенным пользователем, если у приглашателя не было активной подписки для продления. |
+| Напоминание за 3 дня до окончания подписки | Пользователь | ✓ | ✓ | Работает, если `SUBSCRIPTION_NOTIFY_DAYS_BEFORE >= 3`; email требует `SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED=True`. |
| Напоминание за 2 дня до окончания подписки | Пользователь | ✓ | ✓ | Работает, если `SUBSCRIPTION_NOTIFY_DAYS_BEFORE >= 2`; email требует `SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED=True`. |
-| Предупреждение о списании автопродления за 48 часов | Пользователь | ✓ | ✓ | Отправляется вместо обычного 48-часового напоминания для YooKassa-подписок с включенным автопродлением. |
-| Напоминание за 1 день до окончания подписки | Пользователь | ✓ | ✓ | Работает, если `SUBSCRIPTION_NOTIFY_DAYS_BEFORE >= 1`. Для YooKassa auto-renew 24-часовой webhook сначала пытается списать продление и может не отправить напоминание. |
-| Напоминание за несколько часов до окончания подписки | Пользователь | ✓ | ✓ | Отправляется локальным worker, если `SUBSCRIPTION_NOTIFY_HOURS_BEFORE` задан от 1 до 23. |
+| Предупреждение о списании автопродления за 48 часов | Пользователь | ✓ | ✓ | Отправляется вместо обычного 48-часового напоминания для YooKassa-подписок с включенным автопродлением; email требует `SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED=True`. |
+| Напоминание за 1 день до окончания подписки | Пользователь | ✓ | ✓ | Работает, если `SUBSCRIPTION_NOTIFY_DAYS_BEFORE >= 1`; email требует `SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED=True`. Для YooKassa auto-renew 24-часовой webhook сначала пытается списать продление и может не отправить напоминание. |
+| Напоминание за несколько часов до окончания подписки | Пользователь | ✓ | ✓ | Отправляется локальным worker, если `SUBSCRIPTION_NOTIFY_HOURS_BEFORE` задан от 1 до 23; email требует `SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED=True`. |
| Уведомление в день окончания подписки | Пользователь | ✓ | ✓ | Управляется `SUBSCRIPTION_NOTIFY_ON_EXPIRE`; email требует `SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED=True`. |
| Уведомление через сутки после окончания подписки | Пользователь | ✓ | ✓ | Управляется `SUBSCRIPTION_NOTIFY_AFTER_EXPIRE`; email требует `SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED=True`. |
-| Исчерпан трафик пробного периода | Пользователь | ✓ | - | Отправляется локальным worker для trial-подписок. |
+| Исчерпан трафик пробного периода | Пользователь | ✓ | ✓ | Отправляется локальным worker для trial-подписок; email отправляется, если SMTP настроен и у пользователя есть email. |
+| Обычный трафик тарифа почти исчерпан | Пользователь | ✓ | ✓ | Отправляется `TariffTrafficWorker` по уровням из `TARIFF_TRAFFIC_WARNING_LEVELS`; email отправляется, если SMTP настроен и у пользователя есть email. |
+| Обычный трафик тарифа исчерпан | Пользователь | ✓ | ✓ | Отправляется `TariffTrafficWorker`, когда регулярный лимит трафика достигнут; email отправляется, если SMTP настроен и у пользователя есть email. |
+| Premium-трафик тарифа почти исчерпан | Пользователь | ✓ | ✓ | Отправляется `TariffTrafficWorker` по уровням из `TARIFF_TRAFFIC_WARNING_LEVELS` для premium-квоты; email отправляется, если SMTP настроен и у пользователя есть email. |
+| Premium-трафик тарифа исчерпан | Пользователь | ✓ | ✓ | Отправляется `TariffTrafficWorker`, когда premium-квота достигнута; email отправляется, если SMTP настроен и у пользователя есть email. |
| Ответ администратора в тикете поддержки | Пользователь | ✓ | ✓ | Telegram отправляется пользователям с Telegram-аккаунтом, email - пользователям с привязанным email. |
| Закрытие тикета поддержки | Пользователь | ✓ | ✓ | Telegram отправляется пользователям с Telegram-аккаунтом, email - пользователям с привязанным email. |
-| Слияние email- и Telegram-аккаунтов | Пользователь | - | ✓ | Email отправляется на итоговый email аккаунта после безопасного merge. |
+| Слияние email- и Telegram-аккаунтов | Пользователь | - | ✓ | Email отправляется на итоговый email при merge из Web App-формы привязки; Telegram OAuth-link merge логируется для админов без пользовательского email-письма. |
+| Рассылка администратора пользователям | Пользователи с Telegram | ✓ | - | Отправляется через очередь из Telegram-админки или Web Admin выбранной аудитории: `all`, `active`, `inactive`. |
+| Прямое сообщение администратора пользователю | Пользователь с Telegram | ✓ | - | Отправляется из карточки пользователя в Telegram-админке или Web Admin; email-канала нет. |
| Новый тикет поддержки | Администраторы | ✓ | ✓ | Telegram/log уведомления зависят от `LOG_SUPPORT`; email включается через `SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_ENABLED`. |
| Ответ пользователя в тикете поддержки | Администраторы | ✓ | ✓ | Telegram/log уведомления зависят от `LOG_SUPPORT`; email включается через `SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_ENABLED`. |
| Новая регистрация через Telegram | Администраторы / лог-чат | ✓ | - | Управляется `LOG_NEW_USERS`. |
@@ -46,11 +59,11 @@ Minishop отправляет уведомления в Telegram и на email.
Если пользователь привязал email к Telegram-аккаунту, уведомления об окончании подписки дублируются в оба канала: Telegram и email. Если привязан только Telegram - уйдет только Telegram. Если есть только email - уйдет только email.
-Email-доставка требует одновременно:
+Кнопка в email-уведомлениях об окончании подписки открывает Web App через вход по email-коду: код отправляется сразу, после подтверждения пользователь попадает к продлению подписки. Для бывшей платной подписки выбирается тот же тариф, для trial - стандартный, тариф по умолчанию или первый доступный тариф.
-- `SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED=True`;
-- настроенный SMTP-контур;
-- email у пользователя.
+Остальные полезные пользовательские события - успешные и неуспешные оплаты, докупки трафика/HWID, платное повышение тарифа, реферальные бонусы, предупреждения по трафику и привязка способа оплаты - тоже отправляются на email, если SMTP настроен и у пользователя есть привязанная почта. Ручные админские рассылки и прямые сообщения остаются Telegram-only, потому что их содержимое и аудитория управляются администратором отдельно и могут включать Telegram-специфичный формат.
+
+Для пользовательских email-уведомлений нужен настроенный SMTP-контур и email у пользователя. Уведомления жизненного цикла подписки дополнительно требуют `SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED=True`; support-уведомления администраторам дополнительно управляются `SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_ENABLED`.
## Синхронизация каналов
@@ -59,3 +72,5 @@ Remnawave webhook и локальный `SubscriptionNotificationWorker` исп
Дедупликация ведется отдельно по каналам: например, `before_3d:telegram` и `before_3d:email`. Благодаря этому сбой одного канала не блокирует второй, а повторное событие из webhook или worker не отправляет уже доставленное уведомление повторно.
Старые записи без канала, например `before_3d`, считаются Telegram-историей. Они блокируют повторное Telegram-сообщение, но не мешают отправить email после обновления.
+
+Если webhook от Remnawave приходит по пользователю без локальной подписки, используется legacy fallback: отправляется только Telegram по `telegram_id` из payload, без email и без канальной дедупликации.
diff --git a/locales/en.json b/locales/en.json
index c9dd006..80ef00d 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -595,18 +595,41 @@
"email_payment_success_heading": "Payment received",
"email_payment_success_intro_subscription": "Thanks! Your subscription has been extended by {months} month(s).",
"email_payment_success_intro_traffic": "Thanks! We added {traffic_gb} GB of traffic to your account.",
+ "email_payment_success_intro_premium_topup": "Thanks! We added {traffic_gb} GB of premium traffic to your account.",
+ "email_payment_success_intro_hwid": "Thanks! We added extra HWID devices to your subscription: +{count}.",
+ "email_payment_success_intro_tariff_upgrade": "Thanks! Your paid plan upgrade has been applied.",
"email_payment_success_row_period": "Period",
"email_payment_success_row_traffic": "Traffic added",
+ "email_payment_success_row_hwid": "HWID devices",
+ "email_payment_success_row_operation": "Operation",
"email_payment_success_row_amount": "Amount",
"email_payment_success_row_end_date": "Active until",
"email_payment_success_row_method": "Method",
"email_payment_success_period_value": "{months} month(s)",
"email_payment_success_traffic_value": "{traffic_gb} GB",
+ "email_payment_success_hwid_value": "+{count}",
+ "email_payment_success_tariff_upgrade_value": "Plan upgrade",
"email_payment_success_cta": "Open dashboard",
"email_payment_success_footer_note": "We'll remind you a few days before the subscription ends.",
"email_payment_success_text_subscription": "Payment received: {amount}.\nSubscription extended by {months} month(s).\nActive until: {end_date}.",
"email_payment_success_text_traffic": "Payment received: {amount}.\nTraffic added: {traffic_gb} GB.\nActive until: {end_date}.",
+ "email_payment_success_text_hwid": "Payment received: {amount}.\nExtra HWID devices added: +{count}.\nActive until: {end_date}.",
+ "email_payment_success_text_tariff_upgrade": "Payment received: {amount}.\nPlan upgrade applied.\nActive until: {end_date}.",
"email_payment_success_text_dashboard": "Dashboard: {url}",
+ "email_user_notification_subject": "Notification",
+ "email_user_notification_intro": "An important event happened on your account.",
+ "email_user_notification_cta": "Open dashboard",
+ "email_user_notification_text_dashboard": "Dashboard: {url}",
+ "email_payment_failed_subject": "Payment failed",
+ "email_payment_method_bound_subject": "Payment method added",
+ "email_referral_bonus_subject": "Referral bonus credited",
+ "email_trial_traffic_depleted_subject": "Trial traffic used up",
+ "email_traffic_warning_regular_almost_subject": "Plan traffic is almost used up",
+ "email_traffic_warning_regular_depleted_subject": "Plan traffic is used up",
+ "email_traffic_warning_premium_almost_subject": "Premium traffic is almost used up",
+ "email_traffic_warning_premium_depleted_subject": "Premium traffic is used up",
+ "email_traffic_warning_regular_cta": "Buy traffic",
+ "email_traffic_warning_premium_cta": "Buy premium traffic",
"email_subscription_expiring_subject_today": "Your subscription ends today",
"email_subscription_expiring_subject_tomorrow": "1 day left on your subscription",
"email_subscription_expiring_subject_days": "{days} days left on your subscription",
diff --git a/locales/ru.json b/locales/ru.json
index 6e724d9..558a0d0 100644
--- a/locales/ru.json
+++ b/locales/ru.json
@@ -595,18 +595,41 @@
"email_payment_success_heading": "Платёж получен",
"email_payment_success_intro_subscription": "Спасибо! Ваша подписка продлена на {months} мес.",
"email_payment_success_intro_traffic": "Спасибо! Мы зачислили {traffic_gb} ГБ трафика на ваш аккаунт.",
+ "email_payment_success_intro_premium_topup": "Спасибо! Мы зачислили {traffic_gb} ГБ premium-трафика на ваш аккаунт.",
+ "email_payment_success_intro_hwid": "Спасибо! Мы добавили HWID-устройства к вашей подписке: +{count}.",
+ "email_payment_success_intro_tariff_upgrade": "Спасибо! Платное повышение тарифа применено.",
"email_payment_success_row_period": "Период",
"email_payment_success_row_traffic": "Трафик",
+ "email_payment_success_row_hwid": "HWID-устройства",
+ "email_payment_success_row_operation": "Операция",
"email_payment_success_row_amount": "Сумма",
"email_payment_success_row_end_date": "Действует до",
"email_payment_success_row_method": "Способ оплаты",
"email_payment_success_period_value": "{months} мес.",
"email_payment_success_traffic_value": "{traffic_gb} ГБ",
+ "email_payment_success_hwid_value": "+{count}",
+ "email_payment_success_tariff_upgrade_value": "Повышение тарифа",
"email_payment_success_cta": "Открыть кабинет",
"email_payment_success_footer_note": "За несколько дней до окончания мы напомним о продлении.",
"email_payment_success_text_subscription": "Платёж получен: {amount}.\nПодписка продлена на {months} мес.\nДействует до: {end_date}.",
"email_payment_success_text_traffic": "Платёж получен: {amount}.\nЗачислено трафика: {traffic_gb} ГБ.\nДействует до: {end_date}.",
+ "email_payment_success_text_hwid": "Платёж получен: {amount}.\nДобавлено HWID-устройств: +{count}.\nДействует до: {end_date}.",
+ "email_payment_success_text_tariff_upgrade": "Платёж получен: {amount}.\nПовышение тарифа применено.\nДействует до: {end_date}.",
"email_payment_success_text_dashboard": "Кабинет: {url}",
+ "email_user_notification_subject": "Уведомление",
+ "email_user_notification_intro": "Важное событие по вашему аккаунту.",
+ "email_user_notification_cta": "Открыть кабинет",
+ "email_user_notification_text_dashboard": "Кабинет: {url}",
+ "email_payment_failed_subject": "Оплата не удалась",
+ "email_payment_method_bound_subject": "Способ оплаты добавлен",
+ "email_referral_bonus_subject": "Реферальный бонус начислен",
+ "email_trial_traffic_depleted_subject": "Трафик пробного периода израсходован",
+ "email_traffic_warning_regular_almost_subject": "Трафик тарифа почти израсходован",
+ "email_traffic_warning_regular_depleted_subject": "Трафик тарифа израсходован",
+ "email_traffic_warning_premium_almost_subject": "Premium-трафик почти израсходован",
+ "email_traffic_warning_premium_depleted_subject": "Premium-трафик израсходован",
+ "email_traffic_warning_regular_cta": "Докупить трафик",
+ "email_traffic_warning_premium_cta": "Докупить premium-трафик",
"email_subscription_expiring_subject_today": "Подписка заканчивается сегодня",
"email_subscription_expiring_subject_tomorrow": "Подписка заканчивается завтра",
"email_subscription_expiring_subject_days": "До конца подписки осталось {days} дн.",
diff --git a/tests/test_user_email_notifications.py b/tests/test_user_email_notifications.py
new file mode 100644
index 0000000..5b888ac
--- /dev/null
+++ b/tests/test_user_email_notifications.py
@@ -0,0 +1,107 @@
+import unittest
+from types import SimpleNamespace
+from typing import List
+from unittest.mock import patch
+
+from bot.services import user_email_notifications as module
+
+
+class _FakeI18n:
+ def gettext(self, language, key, **kwargs):
+ values = {
+ "email_payment_failed_subject": "Payment failed",
+ "email_user_notification_intro": "Account notification.",
+ "email_user_notification_cta": "Open dashboard",
+ "email_user_notification_text_dashboard": "Dashboard: {url}",
+ "email_footer_auto": "Sent automatically by {brand}.",
+ }
+ return values.get(key, key).format(**kwargs)
+
+
+class _FakeEmailService:
+ instances: List["_FakeEmailService"] = []
+
+ def __init__(self, settings, i18n=None):
+ self.settings = settings
+ self.i18n = i18n
+ self.sent: List[dict] = []
+ _FakeEmailService.instances.append(self)
+
+ async def send_rendered_email(self, *, email, content):
+ self.sent.append({"email": email, "content": content})
+
+
+def _settings(**overrides):
+ values = {
+ "email_auth_configured": True,
+ "DEFAULT_LANGUAGE": "en",
+ "WEBAPP_PRIMARY_COLOR": "#00fe7a",
+ "WEBAPP_TITLE": "Mini Shop",
+ "WEBAPP_LOGO_URL": "",
+ "WEBAPP_LOGO_USE_EMOJI": False,
+ }
+ values.update(overrides)
+ return SimpleNamespace(**values)
+
+
+class SendUserNotificationEmailTests(unittest.IsolatedAsyncioTestCase):
+ def setUp(self) -> None:
+ _FakeEmailService.instances = []
+
+ async def test_sends_rendered_notification_to_linked_email(self):
+ user = SimpleNamespace(email="user@example.com", language_code="en")
+
+ with patch.object(module, "EmailAuthService", _FakeEmailService):
+ sent = await module.send_user_notification_email(
+ settings=_settings(),
+ i18n=_FakeI18n(),
+ user=user,
+ subject_key="email_payment_failed_subject",
+ message_text="Important\n",
+ dashboard_url="https://app.example.com/account",
+ )
+
+ self.assertTrue(sent)
+ self.assertEqual(len(_FakeEmailService.instances), 1)
+ payload = _FakeEmailService.instances[0].sent[0]
+ self.assertEqual(payload["email"], "user@example.com")
+ content = payload["content"]
+ self.assertEqual(content.subject, "Payment failed")
+ self.assertIn("Important", content.text)
+ self.assertIn("Dashboard: https://app.example.com/account", content.text)
+ self.assertIn("Important", content.html)
+ self.assertIn("<script>alert(1)</script>", content.html)
+
+ async def test_skips_when_smtp_is_not_configured(self):
+ user = SimpleNamespace(email="user@example.com", language_code="en")
+
+ with patch.object(module, "EmailAuthService", _FakeEmailService):
+ sent = await module.send_user_notification_email(
+ settings=_settings(email_auth_configured=False),
+ i18n=_FakeI18n(),
+ user=user,
+ subject_key="email_payment_failed_subject",
+ message_text="Payment failed",
+ )
+
+ self.assertFalse(sent)
+ self.assertEqual(_FakeEmailService.instances, [])
+
+ async def test_skips_when_user_has_no_email(self):
+ user = SimpleNamespace(email=" ", language_code="en")
+
+ with patch.object(module, "EmailAuthService", _FakeEmailService):
+ sent = await module.send_user_notification_email(
+ settings=_settings(),
+ i18n=_FakeI18n(),
+ user=user,
+ subject_key="email_payment_failed_subject",
+ message_text="Payment failed",
+ )
+
+ self.assertFalse(sent)
+ self.assertEqual(_FakeEmailService.instances, [])
+
+
+if __name__ == "__main__": # pragma: no cover
+ unittest.main()