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"", 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("").lower() + mapped = _ALLOWED_INLINE_TAGS.get(tag_name) + if mapped: + chunks.append(f"" 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()