diff --git a/backend/bot/app/web/admin_settings_manifest.py b/backend/bot/app/web/admin_settings_manifest.py index e56c67a..fb2ebde 100644 --- a/backend/bot/app/web/admin_settings_manifest.py +++ b/backend/bot/app/web/admin_settings_manifest.py @@ -396,6 +396,13 @@ SETTINGS_MANIFEST: List[SettingField] = [ "notifications", "Включены уведомления о подписке", ), + SettingField( + "SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED", + "bool", + "notifications", + "Дублировать уведомления о подписке на email", + "Письма отправляются только пользователям с привязанным email и рабочим SMTP.", + ), SettingField( "SUBSCRIPTION_NOTIFY_ON_EXPIRE", "bool", "notifications", "Уведомлять об истечении" ), diff --git a/backend/bot/services/email_templates.py b/backend/bot/services/email_templates.py index 187fa78..c714038 100644 --- a/backend/bot/services/email_templates.py +++ b/backend/bot/services/email_templates.py @@ -497,6 +497,101 @@ def render_subscription_expiring( return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered) +def _subscription_lifecycle_title( + i18n: JsonI18n, + lang: str, + notification_key: str, + *, + days_left: Optional[int], + hours_before: Optional[int], +) -> str: + if notification_key == "before_2d_autorenew": + return _t_text(i18n, lang, "email_subscription_lifecycle_subject_autorenew") + if notification_key == "expired": + return _t_text(i18n, lang, "email_subscription_lifecycle_subject_expired") + if notification_key == "expired_24h_after": + return _t_text(i18n, lang, "email_subscription_lifecycle_subject_expired_after") + if hours_before is not None: + return _t_text( + i18n, + lang, + "email_subscription_lifecycle_subject_before_hours", + hours=hours_before, + ) + return _t_text( + i18n, + lang, + "email_subscription_lifecycle_subject_before_days", + days=max(0, int(days_left or 0)), + ) + + +def render_subscription_lifecycle_notification( + settings: Settings, + *, + language_code: Optional[str], + notification_key: str, + message_text: str, + end_date_text: str, + dashboard_url: Optional[str], + days_left: Optional[int] = None, + hours_before: Optional[int] = 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() + end_date = end_date_text or "—" + subject = _subscription_lifecycle_title( + i18n, + lang, + notification_key, + days_left=days_left, + hours_before=hours_before, + ) + intro = _t_text(i18n, lang, "email_subscription_lifecycle_intro") + footer = _t_html(i18n, lang, "email_footer_auto", brand=brand) + cta_label = _t_text(i18n, lang, "email_subscription_lifecycle_cta") + + rows = [ + (_t_text(i18n, lang, "email_subscription_lifecycle_row_end_date"), end_date), + ] + message_html = ( + f'
{html.escape(message_text or "")}
' + ) + body_parts = [_info_rows_html(rows), message_html] + if safe_dashboard_url: + body_parts.append(_cta_button_html(label=cta_label, url=safe_dashboard_url, accent=accent)) + + rendered = _layout( + settings=settings, + preheader=subject, + heading=subject, + intro_html=html.escape(intro), + body_html="".join(body_parts), + footer_html=footer, + ) + + text_lines = [subject, "", message_text] + if safe_dashboard_url: + text_lines.extend( + [ + "", + _t_text( + i18n, + lang, + "email_subscription_lifecycle_text_renew", + url=safe_dashboard_url, + ), + ] + ) + return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered) + + def _support_email( settings: Settings, i18n: Optional[JsonI18n], diff --git a/backend/bot/services/panel_webhook_service.py b/backend/bot/services/panel_webhook_service.py index 1f12b62..c2d1c26 100644 --- a/backend/bot/services/panel_webhook_service.py +++ b/backend/bot/services/panel_webhook_service.py @@ -3,12 +3,15 @@ import hashlib import hmac import json import logging +from datetime import datetime, timedelta, timezone from typing import Optional from aiogram import Bot from aiogram.types import InlineKeyboardMarkup from aiohttp import web -from sqlalchemy.orm import sessionmaker +from sqlalchemy import or_, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload, sessionmaker from bot.infra.webhook_queue import enqueue_webhook_event from bot.keyboards.inline.user_keyboards import ( @@ -16,17 +19,32 @@ from bot.keyboards.inline.user_keyboards import ( get_subscribe_only_markup, ) from bot.middlewares.i18n import JsonI18n +from bot.services.subscription_lifecycle_notifications import ( + SubscriptionLifecycleNotificationService, + SubscriptionNotificationStage, +) from config.settings import Settings -from db.dal import tariff_dal, user_dal +from db.dal import subscription_dal, tariff_dal, user_dal +from db.models import Subscription, User -from .email_auth_service import EmailAuthService -from .email_templates import render_subscription_expiring from .panel_api_service import PanelApiService EVENT_MAP = { - "user.expires_in_72_hours": (3, "subscription_72h_notification"), - "user.expires_in_48_hours": (2, "subscription_48h_notification"), - "user.expires_in_24_hours": (1, "subscription_24h_notification"), + "user.expires_in_72_hours": SubscriptionNotificationStage( + key="before_3d", + message_key="subscription_72h_notification", + days_left=3, + ), + "user.expires_in_48_hours": SubscriptionNotificationStage( + key="before_2d", + message_key="subscription_48h_notification", + days_left=2, + ), + "user.expires_in_24_hours": SubscriptionNotificationStage( + key="before_1d", + message_key="subscription_24h_notification", + days_left=1, + ), } @@ -48,6 +66,11 @@ class PanelWebhookService: self.i18n = i18n self.async_session_factory = async_session_factory self.panel_service = panel_service + self.lifecycle_notifications = SubscriptionLifecycleNotificationService( + settings, + bot, + i18n, + ) self._event_semaphore = asyncio.Semaphore(self._MAX_CONCURRENT_EVENTS) if not self.settings.PANEL_WEBHOOK_SECRET: logging.error( @@ -102,113 +125,182 @@ class PanelWebhookService: ) async def handle_event(self, event_name: str, user_payload: dict): - telegram_id = user_payload.get("telegramId") - if not telegram_id: - logging.warning("Panel webhook without telegramId received") - return - user_id = int(telegram_id) - if not self.settings.SUBSCRIPTION_NOTIFICATIONS_ENABLED: return async with self.async_session_factory() as session: - db_user = await user_dal.get_user_by_telegram_id(session, user_id) - if not db_user: - db_user = await user_dal.get_user_by_id(session, user_id) - internal_user_id = db_user.user_id if db_user else user_id + db_user = await self._user_for_payload(session, user_payload) + sub = await self._subscription_for_payload(session, user_payload, db_user) + telegram_id = self._payload_telegram_id(user_payload) + internal_user_id = ( + int(db_user.user_id) + if db_user + else int(getattr(sub, "user_id", 0) or telegram_id or 0) + ) lang = ( db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE ) - first_name = db_user.first_name or f"User {user_id}" if db_user else f"User {user_id}" - user_email = (db_user.email or "").strip() if db_user else "" + if not sub: + if not telegram_id: + logging.warning("Panel webhook event %s has no local subscription", event_name) + return + await self._send_legacy_without_dedupe( + event_name, + user_payload, + int(telegram_id), + lang, + db_user, + ) + return - markup = get_subscribe_only_markup(lang, self.i18n) + markup = get_subscribe_only_markup(lang, self.i18n) + end_date_text = self._payload_expire_date(user_payload) - if event_name in EVENT_MAP: - days_left, msg_key = EVENT_MAP[event_name] - hwid_renewal_note = await self._hwid_renewal_note(internal_user_id, lang) - if days_left == 1: - # Trigger auto-renew via SubscriptionService (wired in at factory) - try: - subscription_service = getattr(self, "subscription_service", None) - if subscription_service: - async with self.async_session_factory() as session: - from db.dal import subscription_dal - - sub = await subscription_dal.get_active_subscription_by_user_id( - session, internal_user_id - ) - if sub and sub.auto_renew_enabled and sub.provider == "yookassa": - try: - ok = await subscription_service.charge_subscription_renewal( - session, sub + if event_name in EVENT_MAP: + stage = EVENT_MAP[event_name] + days_left = int(stage.days_left or 0) + hwid_renewal_note = await self._hwid_renewal_note(internal_user_id, lang) + if days_left == 1: + # Trigger auto-renew via SubscriptionService (wired in at factory) + try: + subscription_service = getattr(self, "subscription_service", None) + if subscription_service: + async with self.async_session_factory() as renewal_session: + active_sub = ( + await subscription_dal.get_active_subscription_by_user_id( + renewal_session, + internal_user_id, ) - # If initiation succeeded, suppress the 24h reminder by returning early # noqa: E501 - if ok: - await session.commit() - return - else: - await session.rollback() - except Exception: - await session.rollback() - logging.exception("Auto-renew attempt (24h) failed") - except Exception: - logging.exception("Auto-renew trigger (24h) failed pre-check") - if days_left <= self.settings.SUBSCRIPTION_NOTIFY_DAYS_BEFORE: - # For 48h event, if auto-renew is enabled, show special notice with cancel button - if days_left == 2: - async with self.async_session_factory() as session: - from db.dal import subscription_dal - - sub = await subscription_dal.get_active_subscription_by_user_id( - session, internal_user_id + ) + if ( + active_sub + and active_sub.auto_renew_enabled + and active_sub.provider == "yookassa" + ): + try: + ok = await subscription_service.charge_subscription_renewal( + renewal_session, + active_sub, + ) + # If initiation succeeded, suppress the 24h reminder by returning early # noqa: E501 + if ok: + await renewal_session.commit() + return + await renewal_session.rollback() + except Exception: + await renewal_session.rollback() + logging.exception("Auto-renew attempt (24h) failed") + except Exception: + logging.exception("Auto-renew trigger (24h) failed pre-check") + if days_left <= self.settings.SUBSCRIPTION_NOTIFY_DAYS_BEFORE: + # For 48h, auto-renew users get a cancel button instead. + if days_left == 2: + active_sub = await subscription_dal.get_active_subscription_by_user_id( + session, + internal_user_id, ) logging.info( "48h webhook check: user_id=%s sub_found=%s auto_renew=%s provider=%s", - user_id, - bool(sub), - getattr(sub, "auto_renew_enabled", None) if sub else None, - getattr(sub, "provider", None) if sub else None, + internal_user_id, + bool(active_sub), + getattr(active_sub, "auto_renew_enabled", None) if active_sub else None, + getattr(active_sub, "provider", None) if active_sub else None, ) - if sub and sub.auto_renew_enabled and sub.provider == "yookassa": + if ( + active_sub + and active_sub.auto_renew_enabled + and active_sub.provider == "yookassa" + ): cancel_kb = get_autorenew_cancel_keyboard(lang, self.i18n) - await self._send_message( - user_id, - lang, - "autorenew_48h_charge_tomorrow_notice", - reply_markup=cancel_kb, - user_name=first_name, + await self.lifecycle_notifications.send_stage( + session, + sub, + SubscriptionNotificationStage( + key="before_2d_autorenew", + message_key="autorenew_48h_charge_tomorrow_notice", + days_left=2, + ), + user=db_user, + telegram_markup=cancel_kb, extra_text=hwid_renewal_note, + end_date_text=end_date_text, ) + await session.commit() return - await self._send_message( - user_id, - lang, - msg_key, - reply_markup=markup, - user_name=first_name, - end_date=user_payload.get("expireAt", "")[:10], - extra_text=hwid_renewal_note, - ) - if days_left == 3 and user_email: - await self._send_subscription_expiring_email( - recipient=user_email, - lang=lang, - days_left=days_left, - end_date_text=user_payload.get("expireAt", "")[:10], + await self.lifecycle_notifications.send_stage( + session, + sub, + stage, + user=db_user, + telegram_markup=markup, + extra_text=hwid_renewal_note, + end_date_text=end_date_text, ) - elif event_name == "user.expired": - if self.settings.SUBSCRIPTION_NOTIFY_ON_EXPIRE: - await self._send_message( - user_id, - lang, - "subscription_expired_notification", - reply_markup=markup, - user_name=first_name, - end_date=user_payload.get("expireAt", "")[:10], + await session.commit() + elif event_name == "user.expired": + if self.settings.SUBSCRIPTION_NOTIFY_ON_EXPIRE: + await self.lifecycle_notifications.send_stage( + session, + sub, + SubscriptionNotificationStage( + key="expired", + message_key="subscription_expired_notification", + days_left=0, + ), + user=db_user, + telegram_markup=markup, + end_date_text=end_date_text, + ) + await session.commit() + elif ( + event_name == "user.expired_24_hours_ago" + and self.settings.SUBSCRIPTION_NOTIFY_AFTER_EXPIRE + ): + await self.lifecycle_notifications.send_stage( + session, + sub, + SubscriptionNotificationStage( + key="expired_24h_after", + message_key="subscription_expired_yesterday_notification", + days_left=0, + ), + user=db_user, + telegram_markup=markup, + end_date_text=end_date_text, ) + await session.commit() + + async def _send_legacy_without_dedupe( + self, + event_name: str, + user_payload: dict, + user_id: int, + lang: str, + db_user: Optional[User], + ) -> None: + first_name = getattr(db_user, "first_name", None) or f"User {user_id}" + markup = get_subscribe_only_markup(lang, self.i18n) + if event_name in EVENT_MAP: + stage = EVENT_MAP[event_name] + await self._send_message( + user_id, + lang, + stage.message_key, + reply_markup=markup, + user_name=first_name, + end_date=self._payload_expire_date(user_payload), + ) + elif event_name == "user.expired" and self.settings.SUBSCRIPTION_NOTIFY_ON_EXPIRE: + await self._send_message( + user_id, + lang, + "subscription_expired_notification", + reply_markup=markup, + user_name=first_name, + end_date=self._payload_expire_date(user_payload), + ) elif ( event_name == "user.expired_24_hours_ago" and self.settings.SUBSCRIPTION_NOTIFY_AFTER_EXPIRE @@ -219,33 +311,113 @@ class PanelWebhookService: "subscription_expired_yesterday_notification", reply_markup=markup, user_name=first_name, - end_date=user_payload.get("expireAt", "")[:10], + end_date=self._payload_expire_date(user_payload), ) - async def _send_subscription_expiring_email( + async def _user_for_payload( self, - *, - recipient: str, - lang: str, - days_left: int, - end_date_text: str, - ) -> None: - """Best-effort branded reminder; silently no-ops without SMTP config.""" - if not self.settings.email_auth_configured: - return - try: - content = render_subscription_expiring( - self.settings, - language_code=lang, - days_left=days_left, - end_date_text=end_date_text, - dashboard_url=(self.settings.SUBSCRIPTION_MINI_APP_URL or "").strip() or None, - i18n=self.i18n, + session: AsyncSession, + user_payload: dict, + ) -> Optional[User]: + telegram_id = self._payload_telegram_id(user_payload) + if telegram_id: + user = await user_dal.get_user_by_telegram_id(session, telegram_id) + if user: + return user + user = await user_dal.get_user_by_id(session, telegram_id) + if user: + return user + + panel_uuid = self._payload_panel_uuid(user_payload) + if panel_uuid: + user = await user_dal.get_user_by_panel_uuid(session, panel_uuid) + if user: + return user + + email = str(user_payload.get("email") or "").strip() + if email: + return await user_dal.get_user_by_email(session, email) + return None + + async def _subscription_for_payload( + self, + session: AsyncSession, + user_payload: dict, + db_user: Optional[User], + ) -> Optional[Subscription]: + conditions = [] + if db_user: + conditions.append(Subscription.user_id == db_user.user_id) + panel_uuid = self._payload_panel_uuid(user_payload) + if panel_uuid: + conditions.append(Subscription.panel_user_uuid == panel_uuid) + if not conditions: + return None + base_stmt = ( + select(Subscription) + .where( + Subscription.skip_notifications == False, + or_(*conditions), ) - email_service = EmailAuthService(self.settings, self.i18n) - await email_service.send_rendered_email(email=recipient, content=content) - except Exception: - logging.exception("Failed to send subscription-expiring email to %s", recipient) + .options(selectinload(Subscription.user)) + ) + + expire_at = self._payload_expire_datetime(user_payload) + if expire_at is not None: + window_stmt = ( + base_stmt.where( + Subscription.end_date >= expire_at - timedelta(days=1), + Subscription.end_date <= expire_at + timedelta(days=1), + ) + .order_by(Subscription.end_date.desc()) + .limit(1) + ) + result = await session.execute(window_stmt) + found = result.scalars().first() + if found: + return found + + stmt = base_stmt.order_by(Subscription.end_date.desc()).limit(1) + result = await session.execute(stmt) + return result.scalars().first() + + @staticmethod + def _payload_telegram_id(user_payload: dict) -> Optional[int]: + raw = user_payload.get("telegramId") + try: + value = int(raw or 0) + except (TypeError, ValueError): + return None + return value if value > 0 else None + + @staticmethod + def _payload_panel_uuid(user_payload: dict) -> str: + return str( + user_payload.get("uuid") + or user_payload.get("userUuid") + or user_payload.get("shortUuid") + or "" + ).strip() + + @staticmethod + def _payload_expire_date(user_payload: dict) -> str: + return str(user_payload.get("expireAt") or "")[:10] + + @staticmethod + def _payload_expire_datetime(user_payload: dict) -> Optional[datetime]: + raw = str(user_payload.get("expireAt") or "").strip() + if not raw: + return None + try: + value = datetime.fromisoformat(raw.replace("Z", "+00:00")) + except ValueError: + try: + value = datetime.fromisoformat(raw[:10]) + except ValueError: + return None + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) async def handle_webhook( self, raw_body: bytes, signature_header: Optional[str] diff --git a/backend/bot/services/subscription_lifecycle_notifications.py b/backend/bot/services/subscription_lifecycle_notifications.py new file mode 100644 index 0000000..13385db --- /dev/null +++ b/backend/bot/services/subscription_lifecycle_notifications.py @@ -0,0 +1,239 @@ +import logging +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Optional + +from aiogram import Bot +from aiogram.types import InlineKeyboardMarkup +from sqlalchemy.ext.asyncio import AsyncSession + +from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup +from bot.middlewares.i18n import JsonI18n +from bot.services.email_auth_service import EmailAuthService +from bot.services.email_templates import render_subscription_lifecycle_notification +from config.settings import Settings +from db.dal import subscription_dal +from db.models import Subscription, User + + +@dataclass(frozen=True) +class SubscriptionNotificationStage: + key: str + message_key: str + days_left: Optional[int] = None + hours_before: Optional[int] = None + + +@dataclass(frozen=True) +class SubscriptionNotificationDelivery: + telegram_sent: bool = False + email_sent: bool = False + + @property + def any_sent(self) -> bool: + return self.telegram_sent or self.email_sent + + +class SubscriptionLifecycleNotificationService: + def __init__( + self, + settings: Settings, + bot: Bot, + i18n: JsonI18n, + *, + email_service: Optional[EmailAuthService] = None, + ) -> None: + self.settings = settings + self.bot = bot + self.i18n = i18n + self.email_service = email_service + + async def send_stage( + self, + session: AsyncSession, + sub: Subscription, + stage: SubscriptionNotificationStage, + *, + user: Optional[User] = None, + telegram_markup: Optional[InlineKeyboardMarkup] = None, + extra_text: str = "", + end_date_text: Optional[str] = None, + sent_at: Optional[datetime] = None, + ) -> SubscriptionNotificationDelivery: + if sent_at is None: + sent_at = datetime.now(timezone.utc) + + resolved_user = user or getattr(sub, "user", None) + lang = getattr(resolved_user, "language_code", None) or self.settings.DEFAULT_LANGUAGE + user_id = int(getattr(sub, "user_id", 0) or 0) + user_name = getattr(resolved_user, "first_name", None) or f"User {user_id}" + final_end_date_text = end_date_text + if final_end_date_text is None: + end_date = self._as_utc(getattr(sub, "end_date", None)) + final_end_date_text = end_date.strftime("%Y-%m-%d") if end_date else "" + + kwargs = {"user_name": user_name, "end_date": final_end_date_text} + if stage.hours_before is not None: + kwargs["hours"] = stage.hours_before + + message_text = self.i18n.gettext(lang, stage.message_key, **kwargs) + final_extra_text = str(extra_text or "").strip() + if final_extra_text: + message_text = f"{message_text}\n\n{final_extra_text}" + + telegram_sent = await self._send_telegram( + session, + sub, + stage, + resolved_user, + lang=lang, + message_text=message_text, + markup=telegram_markup or get_subscribe_only_markup(lang, self.i18n), + sent_at=sent_at, + ) + email_sent = await self._send_email( + session, + sub, + stage, + resolved_user, + lang=lang, + message_text=message_text, + end_date_text=final_end_date_text, + sent_at=sent_at, + ) + return SubscriptionNotificationDelivery( + telegram_sent=telegram_sent, + email_sent=email_sent, + ) + + async def _send_telegram( + self, + session: AsyncSession, + sub: Subscription, + stage: SubscriptionNotificationStage, + user: Optional[User], + *, + lang: str, + message_text: str, + markup: Optional[InlineKeyboardMarkup], + sent_at: datetime, + ) -> bool: + chat_id = self._telegram_chat_id(user, getattr(sub, "user_id", None)) + if chat_id is None: + return False + if await self._already_sent(session, sub.subscription_id, stage.key, "telegram"): + return False + try: + await self.bot.send_message(chat_id, message_text, reply_markup=markup) + except Exception: + logging.exception( + "Failed to send subscription notification %s to Telegram user %s", + stage.key, + chat_id, + ) + return False + await subscription_dal.record_subscription_notification( + session, + sub.subscription_id, + self._channel_key(stage.key, "telegram"), + sent_at=sent_at, + ) + return True + + async def _send_email( + self, + session: AsyncSession, + sub: Subscription, + stage: SubscriptionNotificationStage, + user: Optional[User], + *, + lang: str, + message_text: str, + end_date_text: str, + sent_at: datetime, + ) -> bool: + if not getattr(self.settings, "SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED", True): + return False + if not getattr(self.settings, "email_auth_configured", False): + return False + recipient = str(getattr(user, "email", "") or "").strip() if user else "" + if not recipient: + return False + if await self._already_sent(session, sub.subscription_id, stage.key, "email"): + return False + + try: + content = render_subscription_lifecycle_notification( + self.settings, + language_code=lang, + notification_key=stage.key, + message_text=message_text, + end_date_text=end_date_text, + dashboard_url=(self.settings.SUBSCRIPTION_MINI_APP_URL or "").strip() or None, + days_left=stage.days_left, + hours_before=stage.hours_before, + i18n=self.i18n, + ) + email_service = self.email_service or EmailAuthService(self.settings, self.i18n) + await email_service.send_rendered_email(email=recipient, content=content) + except Exception: + logging.exception( + "Failed to send subscription notification %s to email %s", + stage.key, + recipient, + ) + return False + await subscription_dal.record_subscription_notification( + session, + sub.subscription_id, + self._channel_key(stage.key, "email"), + sent_at=sent_at, + ) + return True + + async def _already_sent( + self, + session: AsyncSession, + subscription_id: int, + stage_key: str, + channel: str, + ) -> bool: + channel_key = self._channel_key(stage_key, channel) + if await subscription_dal.has_subscription_notification( + session, + subscription_id, + channel_key, + ): + return True + + # Legacy rows were stored without a channel. Treat them as Telegram-only + # history so existing installs do not re-send old bot messages, while + # still allowing the newly introduced email channel to catch up. + return channel == "telegram" and await subscription_dal.has_subscription_notification( + session, + subscription_id, + stage_key, + ) + + @staticmethod + def _channel_key(stage_key: str, channel: str) -> str: + return f"{stage_key}:{channel}" + + @staticmethod + def _telegram_chat_id(user: Optional[User], fallback_user_id: Optional[int]) -> Optional[int]: + for candidate in (getattr(user, "telegram_id", None), fallback_user_id): + try: + chat_id = int(candidate or 0) + except (TypeError, ValueError): + continue + if chat_id > 0: + return chat_id + return None + + @staticmethod + def _as_utc(value: Optional[datetime]) -> Optional[datetime]: + if value is None: + return None + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) diff --git a/backend/bot/services/subscription_notification_worker.py b/backend/bot/services/subscription_notification_worker.py index 636d9a8..6fadb04 100644 --- a/backend/bot/services/subscription_notification_worker.py +++ b/backend/bot/services/subscription_notification_worker.py @@ -1,7 +1,6 @@ import asyncio import logging import time -from dataclasses import dataclass from datetime import datetime, timedelta, timezone from typing import Optional @@ -15,6 +14,10 @@ from bot.infra.redis import redis_lock from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup from bot.middlewares.i18n import JsonI18n from bot.services.panel_api_service import PanelApiService +from bot.services.subscription_lifecycle_notifications import ( + SubscriptionLifecycleNotificationService, + SubscriptionNotificationStage, +) from bot.services.subscription_service import SubscriptionService from config.settings import Settings from db.dal import subscription_dal @@ -26,13 +29,6 @@ EXPIRED_NOTIFICATION_WINDOW = timedelta(hours=24) EXPIRED_AFTER_NOTIFICATION_WINDOW = timedelta(hours=48) -@dataclass(frozen=True) -class SubscriptionNotificationStage: - key: str - message_key: str - hours_before: Optional[int] = None - - class SubscriptionNotificationWorker: def __init__( self, @@ -49,6 +45,11 @@ class SubscriptionNotificationWorker: self.i18n = i18n self.panel_service = panel_service self.subscription_service = subscription_service + self.lifecycle_notifications = SubscriptionLifecycleNotificationService( + settings, + bot, + i18n, + ) self._stopped = asyncio.Event() async def run(self) -> None: @@ -114,18 +115,10 @@ class SubscriptionNotificationWorker: stage = self.stage_for_subscription(sub, now) if stage is None: continue - if await subscription_dal.has_subscription_notification( + await self.lifecycle_notifications.send_stage( session, - sub.subscription_id, - stage.key, - ): - continue - if not await self._send_expiry_notification(sub, stage): - continue - await subscription_dal.record_subscription_notification( - session, - sub.subscription_id, - stage.key, + sub, + stage, sent_at=now, ) @@ -164,6 +157,7 @@ class SubscriptionNotificationWorker: return SubscriptionNotificationStage( key=f"before_{days_before}d", message_key=message_key, + days_left=days_before, ) return None @@ -175,6 +169,7 @@ class SubscriptionNotificationWorker: return SubscriptionNotificationStage( key="expired", message_key="subscription_expired_notification", + days_left=0, ) if ( getattr(self.settings, "SUBSCRIPTION_NOTIFY_AFTER_EXPIRE", True) @@ -183,6 +178,7 @@ class SubscriptionNotificationWorker: return SubscriptionNotificationStage( key="expired_24h_after", message_key="subscription_expired_yesterday_notification", + days_left=0, ) return None @@ -257,38 +253,6 @@ class SubscriptionNotificationWorker: return None return data if isinstance(data, dict) else None - async def _send_expiry_notification( - self, - sub: Subscription, - stage: SubscriptionNotificationStage, - ) -> 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 - user_name = getattr(user, "first_name", None) or f"User {user_id}" - end_date = self._as_utc(getattr(sub, "end_date", None)) - end_date_text = end_date.strftime("%Y-%m-%d") if end_date else "" - translate = lambda k, **kw: self.i18n.gettext(lang, k, **kw) - kwargs = {"user_name": user_name, "end_date": end_date_text} - if stage.hours_before is not None: - kwargs["hours"] = stage.hours_before - try: - await self.bot.send_message( - user_id, - translate(stage.message_key, **kwargs), - reply_markup=get_subscribe_only_markup(lang, self.i18n), - ) - return True - except Exception: - logging.exception( - "Failed to send subscription notification %s to user %s", - stage.key, - user_id, - ) - return False - async def _send_trial_traffic_depleted( self, sub: Subscription, diff --git a/backend/config/settings.py b/backend/config/settings.py index 93d93a6..f7959ab 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -240,6 +240,7 @@ class Settings(BaseSettings): ) SUBSCRIPTION_NOTIFICATIONS_ENABLED: bool = Field(default=True) + SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED: bool = Field(default=True) SUBSCRIPTION_NOTIFY_ON_EXPIRE: bool = Field(default=True) SUBSCRIPTION_NOTIFY_AFTER_EXPIRE: bool = Field(default=True) SUBSCRIPTION_NOTIFY_DAYS_BEFORE: int = Field(default=3) diff --git a/docs/configuration/env-vars.md b/docs/configuration/env-vars.md index 07e9a58..aff39a2 100644 --- a/docs/configuration/env-vars.md +++ b/docs/configuration/env-vars.md @@ -385,6 +385,7 @@ PAYMENT_HELEKET_TELEGRAM_EMOJI | `REFERRAL_BONUS_DAYS_1_MONTH`, `REFERRAL_BONUS_DAYS_3_MONTHS`, `REFERRAL_BONUS_DAYS_6_MONTHS`, `REFERRAL_BONUS_DAYS_12_MONTHS` | Legacy-бонусы пригласившему. | | `REFEREE_BONUS_DAYS_1_MONTH`, `REFEREE_BONUS_DAYS_3_MONTHS`, `REFEREE_BONUS_DAYS_6_MONTHS`, `REFEREE_BONUS_DAYS_12_MONTHS` | Legacy-бонусы приглашенному. | | `SUBSCRIPTION_NOTIFICATIONS_ENABLED` | Включает напоминания о подписке. | +| `SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED` | Дублирует пользовательские уведомления жизненного цикла подписки на email, если SMTP настроен и у пользователя есть email. | | `SUBSCRIPTION_NOTIFY_ON_EXPIRE` | Уведомлять в день окончания. | | `SUBSCRIPTION_NOTIFY_AFTER_EXPIRE` | Уведомлять после окончания. | | `SUBSCRIPTION_NOTIFY_DAYS_BEFORE` | За сколько дней предупреждать. | diff --git a/docs/features/email-login.md b/docs/features/email-login.md index 720100f..c200518 100644 --- a/docs/features/email-login.md +++ b/docs/features/email-login.md @@ -105,4 +105,4 @@ docker compose logs -f backend - Код сразу устаревает: проверьте `EMAIL_CODE_TTL_SECONDS` и время на сервере. - Пользователь получает `rate_limited`: подождите `EMAIL_CODE_RESEND_SECONDS` или проверьте brute-force настройки. -Email-уведомления поддержки и платежей используют тот же SMTP-контур. Сценарий поддержки описан в [разделе тикетов](support.md). +Email-уведомления поддержки, платежей и жизненного цикла подписки используют тот же SMTP-контур. Сценарий поддержки описан в [разделе тикетов](support.md), сводка по каналам - в разделе [уведомления](notifications.md). diff --git a/docs/features/notifications.md b/docs/features/notifications.md index 67c332e..66dc5bd 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 у получателя. +Для уведомлений жизненного цикла подписки есть отдельный флаг `SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED`. Если он включен, пользовательские уведомления об окончании подписки отправляются в Telegram при наличии привязанного Telegram-аккаунта и на email при наличии привязанной почты. + ## Сводная таблица | Событие | Получатель | Telegram | Email | Условия и ограничения | @@ -13,13 +15,13 @@ Minishop отправляет уведомления в Telegram и на email. | Успешная покупка premium-трафика | Пользователь | ✓ | ✓ | Email отправляется, если SMTP настроен и у пользователя есть email. | | Успешная покупка HWID-устройств | Пользователь | ✓ | - | Отправляется после оплаты `hwid_devices` или `hwid_devices_renewal`. | | Ошибка оплаты по webhook провайдера | Пользователь | ✓ | - | Отправляется, когда платежный провайдер сообщает о неуспешном платеже. | -| Напоминание за 3 дня до окончания подписки | Пользователь | ✓ | ✓ | Telegram отправляет Remnawave webhook или локальный worker. Email отправляет только webhook `user.expires_in_72_hours`. | -| Напоминание за 2 дня до окончания подписки | Пользователь | ✓ | - | Работает, если `SUBSCRIPTION_NOTIFY_DAYS_BEFORE >= 2`. | -| Предупреждение о списании автопродления за 48 часов | Пользователь | ✓ | - | Отправляется вместо обычного 48-часового напоминания для YooKassa-подписок с включенным автопродлением. | -| Напоминание за 1 день до окончания подписки | Пользователь | ✓ | - | Работает, если `SUBSCRIPTION_NOTIFY_DAYS_BEFORE >= 1`. Для YooKassa auto-renew 24-часовой webhook сначала пытается списать продление и может не отправить напоминание. | -| Напоминание за несколько часов до окончания подписки | Пользователь | ✓ | - | Отправляется локальным worker, если `SUBSCRIPTION_NOTIFY_HOURS_BEFORE` задан от 1 до 23. | -| Уведомление в день окончания подписки | Пользователь | ✓ | - | Управляется `SUBSCRIPTION_NOTIFY_ON_EXPIRE`. | -| Уведомление через сутки после окончания подписки | Пользователь | ✓ | - | Управляется `SUBSCRIPTION_NOTIFY_AFTER_EXPIRE`. | +| Напоминание за 3 дня до окончания подписки | Пользователь | ✓ | ✓ | Управляется `SUBSCRIPTION_NOTIFY_DAYS_BEFORE` и `SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED`. | +| Напоминание за 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. | +| Уведомление в день окончания подписки | Пользователь | ✓ | ✓ | Управляется `SUBSCRIPTION_NOTIFY_ON_EXPIRE`; email требует `SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED=True`. | +| Уведомление через сутки после окончания подписки | Пользователь | ✓ | ✓ | Управляется `SUBSCRIPTION_NOTIFY_AFTER_EXPIRE`; email требует `SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED=True`. | | Исчерпан трафик пробного периода | Пользователь | ✓ | - | Отправляется локальным worker для trial-подписок. | | Ответ администратора в тикете поддержки | Пользователь | ✓ | ✓ | Telegram отправляется пользователям с Telegram-аккаунтом, email - пользователям с привязанным email. | | Закрытие тикета поддержки | Пользователь | ✓ | ✓ | Telegram отправляется пользователям с Telegram-аккаунтом, email - пользователям с привязанным email. | @@ -42,14 +44,18 @@ Minishop отправляет уведомления в Telegram и на email. ## Важно про окончание подписки -Если пользователь привязал email к Telegram-аккаунту, уведомления об окончании подписки не дублируются в почту полностью. Сейчас email получает только напоминание за 3 дня до окончания, и только когда оно пришло из Remnawave Panel webhook `user.expires_in_72_hours`. Остальные стадии - 48 часов, 24 часа, почасовое предупреждение, день окончания и уведомление через сутки после окончания - отправляются только в Telegram. +Если пользователь привязал email к Telegram-аккаунту, уведомления об окончании подписки дублируются в оба канала: Telegram и email. Если привязан только Telegram - уйдет только Telegram. Если есть только email - уйдет только email. -Если Remnawave webhook не настроен или используется только локальный `SubscriptionNotificationWorker`, даже напоминание за 3 дня уйдет только в Telegram. +Email-доставка требует одновременно: -## Рекомендации по синхронизации каналов +- `SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED=True`; +- настроенный SMTP-контур; +- email у пользователя. -- Для пользовательских уведомлений о подписке лучше выбрать единую политику: либо email дублирует все важные стадии, либо email используется только для мягкого раннего напоминания за 3 дня. Текущее поведение ближе ко второму варианту. -- Если нужно полное дублирование, стоит вынести отправку lifecycle-уведомлений подписки в общий сервис и вызывать его и из Remnawave webhook, и из локального worker. Тогда одна и та же стадия будет одинаково обрабатываться для Telegram и email. -- Дедупликацию лучше вести отдельно по каналу, например `before_3d:telegram` и `before_3d:email`, чтобы сбой Telegram не блокировал email и наоборот. -- Для email-канала стоит добавить отдельный флаг вроде `SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED` и, при необходимости, настройки по стадиям: за 3 дня, за 1 день, в день окончания, после окончания. -- Если одновременно включены Remnawave webhook и локальный worker, стоит синхронизировать их через одну таблицу отправленных уведомлений. Сейчас worker пишет историю `subscription_notifications`, а webhook-уведомления обрабатываются отдельно, поэтому при близких расписаниях возможны повторы в Telegram. +## Синхронизация каналов + +Remnawave webhook и локальный `SubscriptionNotificationWorker` используют общий сервис отправки и общую таблицу дедупликации `subscription_notifications`. + +Дедупликация ведется отдельно по каналам: например, `before_3d:telegram` и `before_3d:email`. Благодаря этому сбой одного канала не блокирует второй, а повторное событие из webhook или worker не отправляет уже доставленное уведомление повторно. + +Старые записи без канала, например `before_3d`, считаются Telegram-историей. Они блокируют повторное Telegram-сообщение, но не мешают отправить email после обновления. diff --git a/locales/en.json b/locales/en.json index a35660e..b76173f 100644 --- a/locales/en.json +++ b/locales/en.json @@ -623,6 +623,15 @@ "email_subscription_expiring_note": "If you've already renewed or use auto-renewal, you can ignore this email.", "email_subscription_expiring_text": "{heading}.\nActive until: {end_date}.", "email_subscription_expiring_text_renew": "Renew: {url}", + "email_subscription_lifecycle_subject_before_days": "{days} days left on your subscription", + "email_subscription_lifecycle_subject_before_hours": "{hours} hours left on your subscription", + "email_subscription_lifecycle_subject_expired": "Your subscription has expired", + "email_subscription_lifecycle_subject_expired_after": "Your subscription expired yesterday", + "email_subscription_lifecycle_subject_autorenew": "Auto-renewal runs tomorrow", + "email_subscription_lifecycle_intro": "This notification is mirrored from Telegram so you do not miss an important subscription event.", + "email_subscription_lifecycle_row_end_date": "Active until", + "email_subscription_lifecycle_cta": "Open dashboard", + "email_subscription_lifecycle_text_renew": "Dashboard: {url}", "wa_loading": "Loading...", "wa_back": "Back", "wa_next": "Next", @@ -1558,6 +1567,8 @@ "admin_settings_field_referral_bonus_days_referee_6_months_label": "Referral Bonus Days Referee 6 Months", "admin_settings_field_referral_bonus_days_referee_12_months_label": "Referral Bonus Days Referee 12 Months", "admin_settings_field_subscription_notifications_enabled_label": "Subscription Notifications Enabled", + "admin_settings_field_subscription_email_notifications_enabled_label": "Subscription email notifications", + "admin_settings_field_subscription_email_notifications_enabled_description": "When enabled, subscription lifecycle notifications are mirrored to linked user email addresses.", "admin_settings_field_subscription_notify_on_expire_label": "Subscription Notify On Expire", "admin_settings_field_subscription_notify_after_expire_label": "Subscription Notify After Expire", "admin_settings_field_subscription_notify_days_before_label": "Subscription Notify Days Before", diff --git a/locales/ru.json b/locales/ru.json index db400ea..6e26064 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -623,6 +623,15 @@ "email_subscription_expiring_note": "Если вы уже продлили или включили автопродление — просто проигнорируйте это письмо.", "email_subscription_expiring_text": "{heading}.\nДействует до: {end_date}.", "email_subscription_expiring_text_renew": "Продлить: {url}", + "email_subscription_lifecycle_subject_before_days": "До конца подписки осталось {days} дн.", + "email_subscription_lifecycle_subject_before_hours": "До конца подписки осталось {hours} ч.", + "email_subscription_lifecycle_subject_expired": "Подписка закончилась", + "email_subscription_lifecycle_subject_expired_after": "Подписка закончилась сутки назад", + "email_subscription_lifecycle_subject_autorenew": "Завтра автопродление подписки", + "email_subscription_lifecycle_intro": "Это уведомление продублировано из Telegram, чтобы вы не пропустили важное событие по подписке.", + "email_subscription_lifecycle_row_end_date": "Действует до", + "email_subscription_lifecycle_cta": "Открыть кабинет", + "email_subscription_lifecycle_text_renew": "Кабинет: {url}", "wa_loading": "Загрузка...", "wa_back": "Назад", "wa_next": "Далее", @@ -1558,6 +1567,8 @@ "admin_settings_field_referral_bonus_days_referee_6_months_label": "Бонус приглашённому: 6 мес.", "admin_settings_field_referral_bonus_days_referee_12_months_label": "Бонус приглашённому: 12 мес.", "admin_settings_field_subscription_notifications_enabled_label": "Включены уведомления о подписке", + "admin_settings_field_subscription_email_notifications_enabled_label": "Email-уведомления о подписке", + "admin_settings_field_subscription_email_notifications_enabled_description": "Если включено, уведомления жизненного цикла подписки дублируются на email пользователям с привязанной почтой.", "admin_settings_field_subscription_notify_on_expire_label": "Уведомлять об истечении", "admin_settings_field_subscription_notify_after_expire_label": "Уведомлять после истечения", "admin_settings_field_subscription_notify_days_before_label": "За сколько дней предупреждать", diff --git a/tests/test_settings.py b/tests/test_settings.py index 0cb713e..95414d4 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -282,3 +282,4 @@ class SettingsTests(unittest.TestCase): self.assertEqual(settings.SUBSCRIPTION_NOTIFY_HOURS_BEFORE, 3) self.assertEqual(settings.SUBSCRIPTION_NOTIFICATION_WORKER_TICK_SECONDS, 300) + self.assertTrue(settings.SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED) diff --git a/tests/test_subscription_lifecycle_notifications.py b/tests/test_subscription_lifecycle_notifications.py new file mode 100644 index 0000000..a0c6295 --- /dev/null +++ b/tests/test_subscription_lifecycle_notifications.py @@ -0,0 +1,174 @@ +import asyncio +from datetime import datetime, timezone +from types import SimpleNamespace + +from bot.services import subscription_lifecycle_notifications as lifecycle +from bot.services.subscription_lifecycle_notifications import ( + SubscriptionLifecycleNotificationService, + SubscriptionNotificationStage, +) + + +class FakeI18n: + def gettext(self, lang_code, key, **kwargs): + messages = { + "subscription_72h_notification": "Hi {user_name}, expires on {end_date}", + "email_subscription_lifecycle_subject_before_days": "{days} days left", + "email_subscription_lifecycle_subject_before_hours": "{hours} hours left", + "email_subscription_lifecycle_subject_expired": "Expired", + "email_subscription_lifecycle_subject_expired_after": "Expired yesterday", + "email_subscription_lifecycle_subject_autorenew": "Auto-renewal tomorrow", + "email_subscription_lifecycle_intro": "Subscription notice", + "email_subscription_lifecycle_row_end_date": "Active until", + "email_subscription_lifecycle_cta": "Open dashboard", + "email_subscription_lifecycle_text_renew": "Dashboard: {url}", + "email_footer_auto": "Sent by {brand}", + } + return messages.get(key, key).format(**kwargs) + + +class FakeBot: + def __init__(self): + self.messages = [] + + async def send_message(self, chat_id, text, reply_markup=None): + self.messages.append( + { + "chat_id": chat_id, + "text": text, + "reply_markup": reply_markup, + } + ) + + +class FakeEmailService: + def __init__(self): + self.messages = [] + + async def send_rendered_email(self, *, email, content): + self.messages.append({"email": email, "content": content}) + + +def _settings(**overrides): + return SimpleNamespace( + DEFAULT_LANGUAGE="ru", + SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED=True, + SUBSCRIPTION_MINI_APP_URL="https://app.example.test/", + WEBAPP_PRIMARY_COLOR="#00fe7a", + WEBAPP_TITLE="Minishop", + WEBAPP_LOGO_USE_EMOJI=False, + WEBAPP_LOGO_URL="", + email_auth_configured=True, + **overrides, + ) + + +def _subscription(): + return SimpleNamespace( + subscription_id=42, + user_id=123, + end_date=datetime(2026, 6, 1, tzinfo=timezone.utc), + ) + + +def _user(**overrides): + return SimpleNamespace( + user_id=123, + telegram_id=555, + email="user@example.test", + language_code="ru", + first_name="Ada", + **overrides, + ) + + +def test_send_stage_records_telegram_and_email_channel_keys(monkeypatch): + recorded = [] + + async def fake_has(session, subscription_id, notification_key): + return notification_key in recorded + + async def fake_record(session, subscription_id, notification_key, *, sent_at=None): + recorded.append(notification_key) + + monkeypatch.setattr(lifecycle.subscription_dal, "has_subscription_notification", fake_has) + monkeypatch.setattr(lifecycle.subscription_dal, "record_subscription_notification", fake_record) + + bot = FakeBot() + email_service = FakeEmailService() + service = SubscriptionLifecycleNotificationService( + _settings(), + bot, + FakeI18n(), + email_service=email_service, + ) + + async def run(): + return await service.send_stage( + object(), + _subscription(), + SubscriptionNotificationStage( + key="before_3d", + message_key="subscription_72h_notification", + days_left=3, + ), + user=_user(), + telegram_markup="markup", + ) + + delivery = asyncio.run(run()) + + assert delivery.telegram_sent is True + assert delivery.email_sent is True + assert bot.messages == [ + { + "chat_id": 555, + "text": "Hi Ada, expires on 2026-06-01", + "reply_markup": "markup", + } + ] + assert email_service.messages[0]["email"] == "user@example.test" + assert recorded == ["before_3d:telegram", "before_3d:email"] + + +def test_legacy_stage_key_suppresses_only_telegram(monkeypatch): + recorded = ["before_3d"] + + async def fake_has(session, subscription_id, notification_key): + return notification_key in recorded + + async def fake_record(session, subscription_id, notification_key, *, sent_at=None): + recorded.append(notification_key) + + monkeypatch.setattr(lifecycle.subscription_dal, "has_subscription_notification", fake_has) + monkeypatch.setattr(lifecycle.subscription_dal, "record_subscription_notification", fake_record) + + bot = FakeBot() + email_service = FakeEmailService() + service = SubscriptionLifecycleNotificationService( + _settings(), + bot, + FakeI18n(), + email_service=email_service, + ) + + async def run(): + return await service.send_stage( + object(), + _subscription(), + SubscriptionNotificationStage( + key="before_3d", + message_key="subscription_72h_notification", + days_left=3, + ), + user=_user(), + telegram_markup="markup", + ) + + delivery = asyncio.run(run()) + + assert delivery.telegram_sent is False + assert delivery.email_sent is True + assert bot.messages == [] + assert email_service.messages[0]["email"] == "user@example.test" + assert recorded == ["before_3d", "before_3d:email"]