From 37797ba3f6cb3f87eef5befdd50422eaa6222fdf Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 23 Jan 2026 11:26:42 +0300 Subject: [PATCH] Add files via upload --- app/cabinet/auth/jwt_handler.py | 9 +- app/cabinet/dependencies.py | 69 +- app/cabinet/routes/admin_promo_offers.py | 26 +- app/cabinet/routes/admin_stats.py | 20 +- app/cabinet/routes/admin_tickets.py | 4 +- app/cabinet/routes/admin_users.py | 14 +- app/cabinet/routes/auth.py | 127 +++- app/cabinet/routes/subscription.py | 24 +- app/cabinet/routes/websocket.py | 240 +++++- app/cabinet/schemas/auth.py | 11 +- app/cabinet/schemas/campaigns.py | 2 +- app/cabinet/schemas/users.py | 6 +- app/cabinet/services/email_templates.py | 911 +++++++++++++++++++++++ 13 files changed, 1401 insertions(+), 62 deletions(-) create mode 100644 app/cabinet/services/email_templates.py diff --git a/app/cabinet/auth/jwt_handler.py b/app/cabinet/auth/jwt_handler.py index dcd9ffa6..ec36d780 100644 --- a/app/cabinet/auth/jwt_handler.py +++ b/app/cabinet/auth/jwt_handler.py @@ -9,13 +9,13 @@ from app.config import settings JWT_ALGORITHM = "HS256" -def create_access_token(user_id: int, telegram_id: int) -> str: +def create_access_token(user_id: int, telegram_id: Optional[int] = None) -> str: """ Create a short-lived access token. Args: user_id: Database user ID - telegram_id: Telegram user ID + telegram_id: Telegram user ID (optional for email-only users) Returns: Encoded JWT access token @@ -25,12 +25,15 @@ def create_access_token(user_id: int, telegram_id: int) -> str: payload = { "sub": str(user_id), - "telegram_id": telegram_id, "type": "access", "exp": expires, "iat": datetime.utcnow(), } + # Добавляем telegram_id только если он есть + if telegram_id is not None: + payload["telegram_id"] = telegram_id + secret = settings.get_cabinet_jwt_secret() return jwt.encode(payload, secret, algorithm=JWT_ALGORITHM) diff --git a/app/cabinet/dependencies.py b/app/cabinet/dependencies.py index a0cdd776..90489983 100644 --- a/app/cabinet/dependencies.py +++ b/app/cabinet/dependencies.py @@ -103,7 +103,12 @@ async def get_current_cabinet_user( # Check maintenance mode (allow admins to pass) if maintenance_service.is_maintenance_active(): - if not settings.is_admin(user.telegram_id): + # Проверяем админа по telegram_id ИЛИ email + is_admin = settings.is_admin( + telegram_id=user.telegram_id, + email=user.email if user.email_verified else None + ) + if not is_admin: status_info = maintenance_service.get_status_info() raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, @@ -114,32 +119,38 @@ async def get_current_cabinet_user( }, ) - # Check required channel subscription + # Check required channel subscription - ТОЛЬКО для Telegram юзеров if settings.CHANNEL_IS_REQUIRED_SUB and settings.CHANNEL_SUB_ID: - # Skip check for admins - if not settings.is_admin(user.telegram_id): - try: - bot = _get_channel_check_bot() - chat_member = await bot.get_chat_member( - chat_id=settings.CHANNEL_SUB_ID, - user_id=user.telegram_id - ) - # Не закрываем сессию - бот переиспользуется - - if chat_member.status not in ["member", "administrator", "creator"]: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail={ - "code": "channel_subscription_required", - "message": "Please subscribe to our channel to continue", - "channel_link": settings.CHANNEL_LINK, - }, + # Пропускаем проверку для email-only юзеров (нет telegram_id) + if user.telegram_id is not None: + # Проверяем админа по telegram_id ИЛИ email + is_admin = settings.is_admin( + telegram_id=user.telegram_id, + email=user.email if user.email_verified else None + ) + if not is_admin: + try: + bot = _get_channel_check_bot() + chat_member = await bot.get_chat_member( + chat_id=settings.CHANNEL_SUB_ID, + user_id=user.telegram_id ) - except HTTPException: - raise - except Exception as e: - logger.warning(f"Failed to check channel subscription for user {user.telegram_id}: {e}") - # Don't block user if check fails + # Не закрываем сессию - бот переиспользуется + + if chat_member.status not in ["member", "administrator", "creator"]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={ + "code": "channel_subscription_required", + "message": "Please subscribe to our channel to continue", + "channel_link": settings.CHANNEL_LINK, + }, + ) + except HTTPException: + raise + except Exception as e: + logger.warning(f"Failed to check channel subscription for user {user.telegram_id}: {e}") + # Don't block user if check fails return user @@ -181,7 +192,7 @@ async def get_current_admin_user( """ Get current authenticated admin user. - Checks if the user's telegram_id is in ADMIN_IDS from settings. + Checks if the user is admin by telegram_id or email. Args: user: Authenticated User object @@ -192,7 +203,11 @@ async def get_current_admin_user( Raises: HTTPException: If user is not an admin """ - if not settings.is_admin(user.telegram_id): + is_admin = settings.is_admin( + telegram_id=user.telegram_id, + email=user.email if user.email_verified else None + ) + if not is_admin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required", diff --git a/app/cabinet/routes/admin_promo_offers.py b/app/cabinet/routes/admin_promo_offers.py index 1c6d5cdc..9ca2af1b 100644 --- a/app/cabinet/routes/admin_promo_offers.py +++ b/app/cabinet/routes/admin_promo_offers.py @@ -29,7 +29,7 @@ from app.database.crud.promo_offer_template import ( list_promo_offer_templates, update_promo_offer_template, ) -from app.database.crud.user import get_user_by_telegram_id +from app.database.crud.user import get_user_by_telegram_id, get_user_by_email from app.database.models import DiscountOffer, PromoOfferLog, PromoOfferTemplate, User from app.handlers.admin.messages import get_custom_users, get_target_users from app.utils.miniapp_buttons import build_miniapp_or_callback_button @@ -45,7 +45,8 @@ router = APIRouter(prefix="/admin/promo-offers", tags=["Admin Promo Offers"]) class PromoOfferUserInfo(BaseModel): id: int - telegram_id: int + telegram_id: Optional[int] = None # Can be None for email-only users + email: Optional[str] = None username: Optional[str] = None first_name: Optional[str] = None last_name: Optional[str] = None @@ -121,6 +122,7 @@ class PromoOfferBroadcastRequest(BaseModel): target: Optional[str] = None user_id: Optional[int] = None telegram_id: Optional[int] = None + email: Optional[str] = Field(None, description="User email (for email-only users)") # Telegram notification options send_notification: bool = Field(False, description="Send Telegram notification to users") message_text: Optional[str] = Field(None, description="Custom message text (HTML)") @@ -175,6 +177,7 @@ def _serialize_user(user: Optional[User]) -> Optional[PromoOfferUserInfo]: return PromoOfferUserInfo( id=user.id, telegram_id=user.telegram_id, + email=user.email, username=user.username, first_name=user.first_name, last_name=user.last_name, @@ -422,6 +425,11 @@ async def _send_promo_notifications( semaphore = asyncio.Semaphore(20) async def send_single(user: User, offer: DiscountOffer) -> bool: + # Skip email-only users (no telegram_id) + if not user.telegram_id: + logger.debug(f"Skipping promo notification for email-only user {user.id}") + return False + async with semaphore: try: keyboard = InlineKeyboardMarkup( @@ -506,7 +514,7 @@ async def broadcast_offer( if payload.telegram_id is not None: user = await get_user_by_telegram_id(db, payload.telegram_id) if not user: - raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found by telegram_id") if target_user_id and target_user_id != user.id: raise HTTPException( status.HTTP_400_BAD_REQUEST, @@ -514,6 +522,18 @@ async def broadcast_offer( ) target_user_id = user.id + # Support email lookup for email-only users + if payload.email is not None and user is None: + user = await get_user_by_email(db, payload.email) + if not user: + raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found by email") + if target_user_id and target_user_id != user.id: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + "Provided user_id does not match email", + ) + target_user_id = user.id + if target_user_id is not None: if user is None: user = await db.get(User, target_user_id) diff --git a/app/cabinet/routes/admin_stats.py b/app/cabinet/routes/admin_stats.py index 411cf599..7dbd5671 100644 --- a/app/cabinet/routes/admin_stats.py +++ b/app/cabinet/routes/admin_stats.py @@ -133,7 +133,8 @@ class DashboardStats(BaseModel): class TopReferrerItem(BaseModel): """Single referrer in top list.""" user_id: int - telegram_id: int + telegram_id: Optional[int] = None # Can be None for email-only users + email: Optional[str] = None username: Optional[str] = None display_name: str invited_count: int @@ -182,7 +183,8 @@ class RecentPaymentItem(BaseModel): """Single recent payment.""" id: int user_id: int - telegram_id: int + telegram_id: Optional[int] = None # Can be None for email-only users + email: Optional[str] = None username: Optional[str] = None display_name: str amount_kopeks: int @@ -750,12 +752,17 @@ async def get_top_referrers( display_name += f" {user.last_name}" elif user.username: display_name = f"@{user.username}" - else: + elif user.telegram_id: display_name = f"ID{user.telegram_id}" + elif user.email: + display_name = user.email.split('@')[0] + else: + display_name = f"User#{user.id}" referrer_items.append(TopReferrerItem( user_id=user.id, telegram_id=user.telegram_id, + email=user.email, username=user.username, display_name=display_name, invited_count=data.get('total_invited', 0), @@ -908,13 +915,18 @@ async def get_recent_payments( display_name += f" {user.last_name}" elif user.username: display_name = f"@{user.username}" - else: + elif user.telegram_id: display_name = f"ID{user.telegram_id}" + elif user.email: + display_name = user.email.split('@')[0] + else: + display_name = f"User#{user.id}" payment_items.append(RecentPaymentItem( id=trans.id, user_id=user.id, telegram_id=user.telegram_id, + email=user.email, username=user.username, display_name=display_name, amount_kopeks=trans.amount_kopeks, diff --git a/app/cabinet/routes/admin_tickets.py b/app/cabinet/routes/admin_tickets.py index 9902461d..5672a989 100644 --- a/app/cabinet/routes/admin_tickets.py +++ b/app/cabinet/routes/admin_tickets.py @@ -29,7 +29,8 @@ router = APIRouter(prefix="/admin/tickets", tags=["Cabinet Admin Tickets"]) class AdminTicketUserInfo(BaseModel): """User info for admin view.""" id: int - telegram_id: int + telegram_id: Optional[int] = None # Can be None for email-only users + email: Optional[str] = None username: Optional[str] = None first_name: Optional[str] = None last_name: Optional[str] = None @@ -148,6 +149,7 @@ def _user_to_info(user: User) -> AdminTicketUserInfo: return AdminTicketUserInfo( id=user.id, telegram_id=user.telegram_id, + email=user.email, username=user.username, first_name=user.first_name, last_name=user.last_name, diff --git a/app/cabinet/routes/admin_users.py b/app/cabinet/routes/admin_users.py index 39aa7039..d61c52a5 100644 --- a/app/cabinet/routes/admin_users.py +++ b/app/cabinet/routes/admin_users.py @@ -207,7 +207,7 @@ async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription panel_uuid = user.remnawave_uuid # Try to find existing user - if not panel_uuid: + if not panel_uuid and user.telegram_id: existing_users = await api.get_user_by_telegram_id(user.telegram_id) if existing_users: panel_uuid = existing_users[0].uuid @@ -1341,7 +1341,7 @@ async def get_user_sync_status( from app.services.remnawave_service import RemnaWaveService service = RemnaWaveService() - if service.is_configured: + if service.is_configured and user.telegram_id: async with service.get_api_client() as api: panel_users = await api.get_user_by_telegram_id(user.telegram_id) if panel_users: @@ -1464,6 +1464,14 @@ async def sync_user_from_panel( errors = [] panel_info = None + # Email-only users cannot be synced from panel by telegram_id + if not user.telegram_id: + return SyncFromPanelResponse( + success=False, + message="Cannot sync email-only user", + errors=["Email-only users don't have telegram_id for panel lookup"], + ) + async with service.get_api_client() as api: # Find user in panel panel_users = await api.get_user_by_telegram_id(user.telegram_id) @@ -1697,7 +1705,7 @@ async def sync_user_to_panel( async with service.get_api_client() as api: # Try to find existing user in panel - if not panel_uuid: + if not panel_uuid and user.telegram_id: existing_users = await api.get_user_by_telegram_id(user.telegram_id) if existing_users: panel_uuid = existing_users[0].uuid diff --git a/app/cabinet/routes/auth.py b/app/cabinet/routes/auth.py index 3f5ef9f5..09bb27da 100644 --- a/app/cabinet/routes/auth.py +++ b/app/cabinet/routes/auth.py @@ -11,7 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from app.database.models import User, CabinetRefreshToken -from app.database.crud.user import get_user_by_telegram_id, get_user_by_id, create_user +from app.database.crud.user import get_user_by_telegram_id, get_user_by_id, create_user, create_user_by_email from app.config import settings from ..dependencies import get_cabinet_db, get_current_cabinet_user @@ -19,6 +19,7 @@ from ..schemas.auth import ( TelegramAuthRequest, TelegramWidgetAuthRequest, EmailRegisterRequest, + EmailRegisterStandaloneRequest, EmailVerifyRequest, EmailLoginRequest, RefreshTokenRequest, @@ -67,6 +68,7 @@ def _user_to_response(user: User) -> UserResponse: referral_code=user.referral_code, language=user.language, created_at=user.created_at, + auth_type=getattr(user, 'auth_type', 'telegram'), # Поддержка старых записей ) @@ -315,6 +317,90 @@ async def register_email( } +@router.post("/email/register/standalone", response_model=AuthResponse) +async def register_email_standalone( + request: EmailRegisterStandaloneRequest, + db: AsyncSession = Depends(get_cabinet_db), +): + """ + Register new account with email and password. + + This endpoint creates a new user WITHOUT requiring Telegram authentication. + An email verification link will be sent to confirm the email address. + + The user can login immediately but some features may be restricted + until email is verified. + + If TEST_EMAIL is configured, test email accounts are auto-verified. + """ + # Check if this is a test email registration + is_test_email = settings.is_test_email(request.email) + + if is_test_email: + # Validate test email password + if not settings.validate_test_email_password(request.email, request.password): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid test email password", + ) + logger.info(f"Test email registration: {request.email}") + + # Проверить что email не занят + existing = await db.execute( + select(User).where(User.email == request.email) + ) + if existing.scalar_one_or_none(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="This email is already registered", + ) + + # Хешировать пароль + password_hash = hash_password(request.password) + + # Создать пользователя + user = await create_user_by_email( + db=db, + email=request.email, + password_hash=password_hash, + first_name=request.first_name, + language=request.language, + ) + + # Для тестового email - автоматически верифицировать + if is_test_email: + user.email_verified = True + user.email_verified_at = datetime.utcnow() + await db.commit() + logger.info(f"Test email auto-verified: {request.email}, user_id={user.id}") + else: + # Сгенерировать токен верификации + verification_token = generate_verification_token() + verification_expires = get_verification_expires_at() + + user.email_verification_token = verification_token + user.email_verification_expires = verification_expires + await db.commit() + + # Отправить email верификации + if settings.is_cabinet_email_verification_enabled() and email_service.is_configured(): + cabinet_url = getattr(settings, 'CABINET_URL', 'https://example.com/cabinet') + verification_url = f"{cabinet_url}/verify-email?token={verification_token}" + await asyncio.to_thread( + email_service.send_verification_email, + to_email=request.email, + verification_token=verification_token, + verification_url=verification_url, + username=user.first_name or "User", + ) + + # Создать токены и вернуть ответ + response = _create_auth_response(user) + await _store_refresh_token(db, user.id, response.refresh_token) + + return response + + @router.post("/email/verify") async def verify_email( request: EmailVerifyRequest, @@ -406,7 +492,13 @@ async def login_email( request: EmailLoginRequest, db: AsyncSession = Depends(get_cabinet_db), ): - """Login with email and password.""" + """Login with email and password. + + Test email accounts (configured via TEST_EMAIL) bypass email verification. + """ + # Check if this is a test email login + is_test_email = settings.is_test_email(request.email) + # Find user by email result = await db.execute( select(User).where(User.email == request.email) @@ -414,10 +506,25 @@ async def login_email( user = result.scalar_one_or_none() if not user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid email or password", - ) + # For test email - auto-create user if not exists + if is_test_email and settings.validate_test_email_password(request.email, request.password): + logger.info(f"Test email login - creating new user: {request.email}") + password_hash = hash_password(request.password) + user = await create_user_by_email( + db=db, + email=request.email, + password_hash=password_hash, + first_name="Test User", + language="ru", + ) + user.email_verified = True + user.email_verified_at = datetime.utcnow() + await db.commit() + else: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid email or password", + ) if not user.password_hash: raise HTTPException( @@ -431,7 +538,8 @@ async def login_email( detail="Invalid email or password", ) - if not user.email_verified: + # Test email bypasses verification check + if not user.email_verified and not is_test_email: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Please verify your email first", @@ -621,5 +729,8 @@ async def check_is_admin( user: User = Depends(get_current_cabinet_user), ): """Check if current user is an admin.""" - is_admin = settings.is_admin(user.telegram_id) + is_admin = settings.is_admin( + telegram_id=user.telegram_id, + email=user.email if user.email_verified else None + ) return {"is_admin": is_admin} diff --git a/app/cabinet/routes/subscription.py b/app/cabinet/routes/subscription.py index f9869a6b..ff47bf68 100644 --- a/app/cabinet/routes/subscription.py +++ b/app/cabinet/routes/subscription.py @@ -1561,7 +1561,7 @@ async def purchase_tariff( "description": f"Продление тарифа {tariff.name} на {period_days} дней", } await user_cart_service.save_user_cart(user.id, cart_data) - logger.info(f"Tariff cart saved for auto-renewal (cabinet) user {user.telegram_id}") + logger.info(f"Tariff cart saved for auto-renewal (cabinet) user {user.id}") except Exception as e: logger.error(f"Error saving tariff cart (cabinet): {e}") @@ -1717,7 +1717,7 @@ async def purchase_devices( await db.refresh(user) logger.info( - f"User {user.telegram_id} purchased {request.devices} devices for {price_kopeks} kopeks" + f"User {user.id} purchased {request.devices} devices for {price_kopeks} kopeks" ) return { @@ -3318,9 +3318,12 @@ async def refresh_traffic( detail="No active subscription", ) + # Используем user.id для rate limit и кеша (работает и для email-пользователей) + user_cache_id = user.id + # Check rate limit is_limited = await RateLimitCache.is_rate_limited( - user.telegram_id, + user_cache_id, "traffic_refresh", TRAFFIC_REFRESH_RATE_LIMIT, TRAFFIC_REFRESH_RATE_WINDOW, @@ -3328,7 +3331,7 @@ async def refresh_traffic( if is_limited: # Check if we have cached data - traffic_cache_key = cache_key("traffic", user.telegram_id) + traffic_cache_key = cache_key("traffic", user_cache_id) cached_data = await cache.get(traffic_cache_key) if cached_data: @@ -3349,7 +3352,14 @@ async def refresh_traffic( # Fetch traffic from RemnaWave try: remnawave_service = RemnaWaveService() - traffic_stats = await remnawave_service.get_user_traffic_stats(user.telegram_id) + + # Для email-пользователей (без telegram_id) используем UUID + if user.telegram_id: + traffic_stats = await remnawave_service.get_user_traffic_stats(user.telegram_id) + elif user.remnawave_uuid: + traffic_stats = await remnawave_service.get_user_traffic_stats_by_uuid(user.remnawave_uuid) + else: + traffic_stats = None if not traffic_stats: # Return current database values if RemnaWave unavailable @@ -3399,7 +3409,7 @@ async def refresh_traffic( } # Cache the result - traffic_cache_key = cache_key("traffic", user.telegram_id) + traffic_cache_key = cache_key("traffic", user_cache_id) await cache.set(traffic_cache_key, traffic_data, TRAFFIC_CACHE_TTL) return { @@ -3410,7 +3420,7 @@ async def refresh_traffic( } except Exception as e: - logger.error(f"Error refreshing traffic for user {user.telegram_id}: {e}") + logger.error(f"Error refreshing traffic for user {user.id}: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to refresh traffic data", diff --git a/app/cabinet/routes/websocket.py b/app/cabinet/routes/websocket.py index a1c10816..ae883c56 100644 --- a/app/cabinet/routes/websocket.py +++ b/app/cabinet/routes/websocket.py @@ -147,7 +147,10 @@ async def verify_cabinet_ws_token(token: str) -> tuple[int | None, bool]: if not user or user.status != "active": return None, False - is_admin = settings.is_admin(user.telegram_id) + is_admin = settings.is_admin( + telegram_id=user.telegram_id, + email=user.email if user.email_verified else None + ) return user_id, is_admin @@ -249,3 +252,238 @@ async def notify_admins_ticket_reply(ticket_id: int, message: str, user_id: int) "message": message, "user_id": user_id, }) + + +# ============================================================================ +# Уведомления о балансе +# ============================================================================ + + +async def notify_user_balance_topup( + user_id: int, + amount_kopeks: int, + new_balance_kopeks: int, + description: str = "", +) -> None: + """Уведомить пользователя о пополнении баланса.""" + await cabinet_ws_manager.send_to_user(user_id, { + "type": "balance.topup", + "amount_kopeks": amount_kopeks, + "amount_rubles": amount_kopeks / 100, + "new_balance_kopeks": new_balance_kopeks, + "new_balance_rubles": new_balance_kopeks / 100, + "description": description, + }) + + +async def notify_user_balance_change( + user_id: int, + amount_kopeks: int, + new_balance_kopeks: int, + description: str = "", +) -> None: + """Уведомить пользователя об изменении баланса.""" + await cabinet_ws_manager.send_to_user(user_id, { + "type": "balance.change", + "amount_kopeks": amount_kopeks, + "amount_rubles": amount_kopeks / 100, + "new_balance_kopeks": new_balance_kopeks, + "new_balance_rubles": new_balance_kopeks / 100, + "description": description, + }) + + +# ============================================================================ +# Уведомления о подписке +# ============================================================================ + + +async def notify_user_subscription_activated( + user_id: int, + expires_at: str, + tariff_name: str = "", +) -> None: + """Уведомить пользователя об активации подписки.""" + await cabinet_ws_manager.send_to_user(user_id, { + "type": "subscription.activated", + "expires_at": expires_at, + "tariff_name": tariff_name, + }) + + +async def notify_user_subscription_expiring( + user_id: int, + days_left: int, + expires_at: str, +) -> None: + """Уведомить пользователя о скором истечении подписки.""" + await cabinet_ws_manager.send_to_user(user_id, { + "type": "subscription.expiring", + "days_left": days_left, + "expires_at": expires_at, + }) + + +async def notify_user_subscription_expired(user_id: int) -> None: + """Уведомить пользователя об истечении подписки.""" + await cabinet_ws_manager.send_to_user(user_id, { + "type": "subscription.expired", + }) + + +async def notify_user_subscription_renewed( + user_id: int, + new_expires_at: str, + amount_kopeks: int = 0, +) -> None: + """Уведомить пользователя о продлении подписки.""" + await cabinet_ws_manager.send_to_user(user_id, { + "type": "subscription.renewed", + "new_expires_at": new_expires_at, + "amount_kopeks": amount_kopeks, + "amount_rubles": amount_kopeks / 100, + }) + + +# ============================================================================ +# Уведомления об автопродлении +# ============================================================================ + + +async def notify_user_autopay_success( + user_id: int, + amount_kopeks: int, + new_expires_at: str, +) -> None: + """Уведомить пользователя об успешном автопродлении.""" + await cabinet_ws_manager.send_to_user(user_id, { + "type": "autopay.success", + "amount_kopeks": amount_kopeks, + "amount_rubles": amount_kopeks / 100, + "new_expires_at": new_expires_at, + }) + + +async def notify_user_autopay_failed( + user_id: int, + reason: str = "", +) -> None: + """Уведомить пользователя о неудачном автопродлении.""" + await cabinet_ws_manager.send_to_user(user_id, { + "type": "autopay.failed", + "reason": reason, + }) + + +async def notify_user_autopay_insufficient_funds( + user_id: int, + required_kopeks: int, + balance_kopeks: int, +) -> None: + """Уведомить о недостатке средств для автопродления.""" + await cabinet_ws_manager.send_to_user(user_id, { + "type": "autopay.insufficient_funds", + "required_kopeks": required_kopeks, + "required_rubles": required_kopeks / 100, + "balance_kopeks": balance_kopeks, + "balance_rubles": balance_kopeks / 100, + }) + + +# ============================================================================ +# Уведомления о бане/разбане +# ============================================================================ + + +async def notify_user_ban(user_id: int, reason: str = "") -> None: + """Уведомить пользователя о блокировке.""" + await cabinet_ws_manager.send_to_user(user_id, { + "type": "account.banned", + "reason": reason, + }) + + +async def notify_user_unban(user_id: int) -> None: + """Уведомить пользователя о разблокировке.""" + await cabinet_ws_manager.send_to_user(user_id, { + "type": "account.unbanned", + }) + + +async def notify_user_warning(user_id: int, message: str) -> None: + """Уведомить пользователя о предупреждении.""" + await cabinet_ws_manager.send_to_user(user_id, { + "type": "account.warning", + "message": message, + }) + + +# ============================================================================ +# Уведомления о рефералах +# ============================================================================ + + +async def notify_user_referral_bonus( + user_id: int, + bonus_kopeks: int, + referral_name: str = "", +) -> None: + """Уведомить пользователя о реферальном бонусе.""" + await cabinet_ws_manager.send_to_user(user_id, { + "type": "referral.bonus", + "bonus_kopeks": bonus_kopeks, + "bonus_rubles": bonus_kopeks / 100, + "referral_name": referral_name, + }) + + +async def notify_user_referral_registered( + user_id: int, + referral_name: str = "", +) -> None: + """Уведомить пользователя о регистрации нового реферала.""" + await cabinet_ws_manager.send_to_user(user_id, { + "type": "referral.registered", + "referral_name": referral_name, + }) + + +# ============================================================================ +# Прочие уведомления +# ============================================================================ + + +async def notify_user_daily_debit( + user_id: int, + amount_kopeks: int, + new_balance_kopeks: int, +) -> None: + """Уведомить о ежедневном списании.""" + await cabinet_ws_manager.send_to_user(user_id, { + "type": "subscription.daily_debit", + "amount_kopeks": amount_kopeks, + "amount_rubles": amount_kopeks / 100, + "new_balance_kopeks": new_balance_kopeks, + "new_balance_rubles": new_balance_kopeks / 100, + }) + + +async def notify_user_traffic_reset(user_id: int) -> None: + """Уведомить о сбросе трафика.""" + await cabinet_ws_manager.send_to_user(user_id, { + "type": "subscription.traffic_reset", + }) + + +async def notify_user_payment_received( + user_id: int, + amount_kopeks: int, + payment_method: str = "", +) -> None: + """Уведомить о полученном платеже.""" + await cabinet_ws_manager.send_to_user(user_id, { + "type": "payment.received", + "amount_kopeks": amount_kopeks, + "amount_rubles": amount_kopeks / 100, + "payment_method": payment_method, + }) diff --git a/app/cabinet/schemas/auth.py b/app/cabinet/schemas/auth.py index 02cfe267..b18e6887 100644 --- a/app/cabinet/schemas/auth.py +++ b/app/cabinet/schemas/auth.py @@ -65,7 +65,7 @@ class TokenResponse(BaseModel): class UserResponse(BaseModel): """User data response.""" id: int - telegram_id: int + telegram_id: Optional[int] = None # Nullable для email-only пользователей username: Optional[str] = None first_name: Optional[str] = None last_name: Optional[str] = None @@ -76,11 +76,20 @@ class UserResponse(BaseModel): referral_code: Optional[str] = None language: str = "ru" created_at: datetime + auth_type: str = "telegram" # "telegram" или "email" class Config: from_attributes = True +class EmailRegisterStandaloneRequest(BaseModel): + """Request to register new account with email (no Telegram required).""" + email: EmailStr = Field(..., description="Email address") + password: str = Field(..., min_length=8, max_length=128, description="Password (min 8 chars)") + first_name: Optional[str] = Field(None, max_length=64, description="First name") + language: str = Field("ru", description="Preferred language") + + class AuthResponse(BaseModel): """Full authentication response with tokens and user.""" access_token: str diff --git a/app/cabinet/schemas/campaigns.py b/app/cabinet/schemas/campaigns.py index 751c369c..435c5592 100644 --- a/app/cabinet/schemas/campaigns.py +++ b/app/cabinet/schemas/campaigns.py @@ -144,7 +144,7 @@ class CampaignRegistrationItem(BaseModel): """Campaign registration item.""" id: int user_id: int - telegram_id: int + telegram_id: Optional[int] = None username: Optional[str] = None first_name: Optional[str] = None bonus_type: str diff --git a/app/cabinet/schemas/users.py b/app/cabinet/schemas/users.py index 7c185029..76bbe581 100644 --- a/app/cabinet/schemas/users.py +++ b/app/cabinet/schemas/users.py @@ -63,7 +63,7 @@ class UserPromoGroupInfo(BaseModel): class UserListItem(BaseModel): """User item in list.""" id: int - telegram_id: int + telegram_id: Optional[int] = None username: Optional[str] = None first_name: Optional[str] = None last_name: Optional[str] = None @@ -129,7 +129,7 @@ class UserReferralInfo(BaseModel): class UserDetailResponse(BaseModel): """Detailed user information.""" id: int - telegram_id: int + telegram_id: Optional[int] = None username: Optional[str] = None first_name: Optional[str] = None last_name: Optional[str] = None @@ -429,7 +429,7 @@ class SyncToPanelResponse(BaseModel): class PanelSyncStatusResponse(BaseModel): """Panel sync status for user.""" user_id: int - telegram_id: int + telegram_id: Optional[int] = None remnawave_uuid: Optional[str] = None last_sync: Optional[datetime] = None diff --git a/app/cabinet/services/email_templates.py b/app/cabinet/services/email_templates.py new file mode 100644 index 00000000..51214bae --- /dev/null +++ b/app/cabinet/services/email_templates.py @@ -0,0 +1,911 @@ +""" +Email notification templates for different notification types. + +Supports multiple languages: ru, en, zh, ua +""" + +from typing import Any, Dict, Optional + +from app.config import settings + + +class EmailNotificationTemplates: + """HTML email templates for user notifications.""" + + def __init__(self): + self.service_name = settings.SMTP_FROM_NAME or "VPN Service" + self.cabinet_url = getattr(settings, "CABINET_URL", "") + + def get_template( + self, + notification_type: "NotificationType", + language: str, + context: Dict[str, Any], + ) -> Optional[Dict[str, str]]: + """ + Get email template for notification type. + + Args: + notification_type: Type of notification + language: Language code (ru, en, zh, ua) + context: Context data for template rendering + + Returns: + Dict with 'subject', 'body_html', and optionally 'body_text' + """ + # Import here to avoid circular imports + from app.services.notification_delivery_service import NotificationType + + template_map = { + NotificationType.BALANCE_TOPUP: self._balance_topup_template, + NotificationType.BALANCE_CHANGE: self._balance_change_template, + NotificationType.SUBSCRIPTION_EXPIRING: self._subscription_expiring_template, + NotificationType.SUBSCRIPTION_EXPIRED: self._subscription_expired_template, + NotificationType.SUBSCRIPTION_RENEWED: self._subscription_renewed_template, + NotificationType.SUBSCRIPTION_ACTIVATED: self._subscription_activated_template, + NotificationType.AUTOPAY_SUCCESS: self._autopay_success_template, + NotificationType.AUTOPAY_FAILED: self._autopay_failed_template, + NotificationType.AUTOPAY_INSUFFICIENT_FUNDS: self._autopay_insufficient_funds_template, + NotificationType.DAILY_DEBIT: self._daily_debit_template, + NotificationType.DAILY_INSUFFICIENT_FUNDS: self._daily_insufficient_funds_template, + NotificationType.BAN_NOTIFICATION: self._ban_template, + NotificationType.UNBAN_NOTIFICATION: self._unban_template, + NotificationType.WARNING_NOTIFICATION: self._warning_template, + NotificationType.REFERRAL_BONUS: self._referral_bonus_template, + NotificationType.REFERRAL_REGISTERED: self._referral_registered_template, + NotificationType.TRAFFIC_RESET: self._traffic_reset_template, + NotificationType.PAYMENT_RECEIVED: self._payment_received_template, + } + + template_func = template_map.get(notification_type) + if not template_func: + return None + + return template_func(language, context) + + def _get_base_template(self, content: str) -> str: + """Wrap content in base HTML template.""" + return f""" + + + + + + + + +
+
+

{self.service_name}

+
+
+ {content} +
+ +
+ + +""" + + def _get_cabinet_button(self, language: str) -> str: + """Get cabinet link button HTML.""" + if not self.cabinet_url: + return "" + + texts = { + "ru": "Открыть личный кабинет", + "en": "Open Dashboard", + "zh": "打开控制面板", + "ua": "Відкрити особистий кабінет", + } + text = texts.get(language, texts["en"]) + + return f'

{text}

' + + # ============================================================================ + # Balance Templates + # ============================================================================ + + def _balance_topup_template(self, language: str, context: Dict[str, Any]) -> Dict[str, str]: + """Template for balance top-up notification.""" + amount = context.get("formatted_amount", f"{context.get('amount_rubles', 0):.2f} ₽") + balance = context.get("formatted_balance", f"{context.get('new_balance_rubles', 0):.2f} ₽") + + subjects = { + "ru": f"Баланс пополнен на {amount}", + "en": f"Balance topped up by {amount}", + "zh": f"余额已充值 {amount}", + "ua": f"Баланс поповнено на {amount}", + } + + bodies = { + "ru": f""" +

Баланс успешно пополнен!

+
+

Сумма пополнения: +{amount}

+

Текущий баланс: {balance}

+
+

Спасибо за использование нашего сервиса!

+ {self._get_cabinet_button(language)} + """, + "en": f""" +

Balance Successfully Topped Up!

+
+

Top-up amount: +{amount}

+

Current balance: {balance}

+
+

Thank you for using our service!

+ {self._get_cabinet_button(language)} + """, + "zh": f""" +

充值成功!

+
+

充值金额: +{amount}

+

当前余额: {balance}

+
+

感谢使用我们的服务!

+ {self._get_cabinet_button(language)} + """, + "ua": f""" +

Баланс успішно поповнено!

+
+

Сума поповнення: +{amount}

+

Поточний баланс: {balance}

+
+

Дякуємо за використання нашого сервісу!

+ {self._get_cabinet_button(language)} + """, + } + + return { + "subject": subjects.get(language, subjects["en"]), + "body_html": self._get_base_template(bodies.get(language, bodies["en"])), + } + + def _balance_change_template(self, language: str, context: Dict[str, Any]) -> Dict[str, str]: + """Template for balance change notification.""" + amount = context.get("formatted_amount", f"{context.get('amount_rubles', 0):.2f} ₽") + balance = context.get("formatted_balance", f"{context.get('new_balance_rubles', 0):.2f} ₽") + description = context.get("description", "") + + subjects = { + "ru": "Изменение баланса", + "en": "Balance Changed", + "zh": "余额变动", + "ua": "Зміна балансу", + } + + bodies = { + "ru": f""" +

Изменение баланса

+
+

Сумма: {amount}

+

Текущий баланс: {balance}

+ {f'

Описание: {description}

' if description else ''} +
+ {self._get_cabinet_button(language)} + """, + "en": f""" +

Balance Changed

+
+

Amount: {amount}

+

Current balance: {balance}

+ {f'

Description: {description}

' if description else ''} +
+ {self._get_cabinet_button(language)} + """, + } + + return { + "subject": subjects.get(language, subjects["en"]), + "body_html": self._get_base_template(bodies.get(language, bodies["en"])), + } + + # ============================================================================ + # Subscription Templates + # ============================================================================ + + def _subscription_expiring_template(self, language: str, context: Dict[str, Any]) -> Dict[str, str]: + """Template for subscription expiring notification.""" + days_left = context.get("days_left", 0) + expires_at = context.get("expires_at", "") + + subjects = { + "ru": f"Подписка истекает через {days_left} дн.", + "en": f"Subscription expires in {days_left} day(s)", + "zh": f"订阅将在 {days_left} 天后到期", + "ua": f"Підписка закінчується через {days_left} дн.", + } + + bodies = { + "ru": f""" +

Подписка скоро истекает

+
+

Ваша подписка истекает через {days_left} дн.

+

Дата истечения: {expires_at}

+
+

Продлите подписку, чтобы не потерять доступ к сервису.

+ {self._get_cabinet_button(language)} + """, + "en": f""" +

Subscription Expiring Soon

+
+

Your subscription expires in {days_left} day(s).

+

Expiration date: {expires_at}

+
+

Renew your subscription to maintain access to our service.

+ {self._get_cabinet_button(language)} + """, + "zh": f""" +

订阅即将到期

+
+

您的订阅将在 {days_left} 天后到期。

+

到期日期: {expires_at}

+
+

请续订以保持对服务的访问。

+ {self._get_cabinet_button(language)} + """, + "ua": f""" +

Підписка скоро закінчується

+
+

Ваша підписка закінчується через {days_left} дн.

+

Дата закінчення: {expires_at}

+
+

Продовжіть підписку, щоб не втратити доступ до сервісу.

+ {self._get_cabinet_button(language)} + """, + } + + return { + "subject": subjects.get(language, subjects["en"]), + "body_html": self._get_base_template(bodies.get(language, bodies["en"])), + } + + def _subscription_expired_template(self, language: str, context: Dict[str, Any]) -> Dict[str, str]: + """Template for subscription expired notification.""" + subjects = { + "ru": "Подписка истекла", + "en": "Subscription Expired", + "zh": "订阅已到期", + "ua": "Підписка закінчилась", + } + + bodies = { + "ru": f""" +

Подписка истекла

+
+

Ваша подписка истекла. Доступ к VPN отключён.

+
+

Оформите новую подписку, чтобы продолжить использование сервиса.

+ {self._get_cabinet_button(language)} + """, + "en": f""" +

Subscription Expired

+
+

Your subscription has expired. VPN access has been disabled.

+
+

Purchase a new subscription to continue using our service.

+ {self._get_cabinet_button(language)} + """, + "zh": f""" +

订阅已到期

+
+

您的订阅已到期。VPN访问已被禁用。

+
+

请购买新订阅以继续使用我们的服务。

+ {self._get_cabinet_button(language)} + """, + "ua": f""" +

Підписка закінчилась

+
+

Ваша підписка закінчилась. Доступ до VPN вимкнено.

+
+

Оформіть нову підписку, щоб продовжити використання сервісу.

+ {self._get_cabinet_button(language)} + """, + } + + return { + "subject": subjects.get(language, subjects["en"]), + "body_html": self._get_base_template(bodies.get(language, bodies["en"])), + } + + def _subscription_renewed_template(self, language: str, context: Dict[str, Any]) -> Dict[str, str]: + """Template for subscription renewed notification.""" + new_expires_at = context.get("new_expires_at", "") + + subjects = { + "ru": "Подписка продлена", + "en": "Subscription Renewed", + "zh": "订阅已续订", + "ua": "Підписку продовжено", + } + + bodies = { + "ru": f""" +

Подписка успешно продлена!

+
+

Ваша подписка была успешно продлена.

+

Новая дата истечения: {new_expires_at}

+
+

Спасибо за использование нашего сервиса!

+ {self._get_cabinet_button(language)} + """, + "en": f""" +

Subscription Successfully Renewed!

+
+

Your subscription has been successfully renewed.

+

New expiration date: {new_expires_at}

+
+

Thank you for using our service!

+ {self._get_cabinet_button(language)} + """, + } + + return { + "subject": subjects.get(language, subjects["en"]), + "body_html": self._get_base_template(bodies.get(language, bodies["en"])), + } + + def _subscription_activated_template(self, language: str, context: Dict[str, Any]) -> Dict[str, str]: + """Template for subscription activated notification.""" + expires_at = context.get("expires_at", "") + + subjects = { + "ru": "Подписка активирована", + "en": "Subscription Activated", + "zh": "订阅已激活", + "ua": "Підписку активовано", + } + + bodies = { + "ru": f""" +

Подписка активирована!

+
+

Ваша VPN подписка успешно активирована.

+

Действует до: {expires_at}

+
+

Теперь вы можете пользоваться VPN сервисом.

+ {self._get_cabinet_button(language)} + """, + "en": f""" +

Subscription Activated!

+
+

Your VPN subscription has been successfully activated.

+

Valid until: {expires_at}

+
+

You can now use the VPN service.

+ {self._get_cabinet_button(language)} + """, + } + + return { + "subject": subjects.get(language, subjects["en"]), + "body_html": self._get_base_template(bodies.get(language, bodies["en"])), + } + + # ============================================================================ + # Autopay Templates + # ============================================================================ + + def _autopay_success_template(self, language: str, context: Dict[str, Any]) -> Dict[str, str]: + """Template for successful autopay notification.""" + amount = context.get("formatted_amount", f"{context.get('amount_rubles', 0):.2f} ₽") + new_expires_at = context.get("new_expires_at", "") + + subjects = { + "ru": "Автопродление выполнено", + "en": "Auto-renewal Successful", + "zh": "自动续订成功", + "ua": "Автопродовження виконано", + } + + bodies = { + "ru": f""" +

Автопродление выполнено

+
+

Ваша подписка была автоматически продлена.

+

Списано с баланса: {amount}

+

Новая дата истечения: {new_expires_at}

+
+ {self._get_cabinet_button(language)} + """, + "en": f""" +

Auto-renewal Successful

+
+

Your subscription has been automatically renewed.

+

Charged from balance: {amount}

+

New expiration date: {new_expires_at}

+
+ {self._get_cabinet_button(language)} + """, + } + + return { + "subject": subjects.get(language, subjects["en"]), + "body_html": self._get_base_template(bodies.get(language, bodies["en"])), + } + + def _autopay_failed_template(self, language: str, context: Dict[str, Any]) -> Dict[str, str]: + """Template for failed autopay notification.""" + reason = context.get("reason", "") + + subjects = { + "ru": "Ошибка автопродления", + "en": "Auto-renewal Failed", + "zh": "自动续订失败", + "ua": "Помилка автопродовження", + } + + bodies = { + "ru": f""" +

Ошибка автопродления

+
+

Не удалось автоматически продлить подписку.

+ {f'

Причина: {reason}

' if reason else ''} +
+

Пожалуйста, пополните баланс и продлите подписку вручную.

+ {self._get_cabinet_button(language)} + """, + "en": f""" +

Auto-renewal Failed

+
+

Failed to automatically renew your subscription.

+ {f'

Reason: {reason}

' if reason else ''} +
+

Please top up your balance and renew manually.

+ {self._get_cabinet_button(language)} + """, + } + + return { + "subject": subjects.get(language, subjects["en"]), + "body_html": self._get_base_template(bodies.get(language, bodies["en"])), + } + + def _autopay_insufficient_funds_template(self, language: str, context: Dict[str, Any]) -> Dict[str, str]: + """Template for autopay insufficient funds notification.""" + required = context.get("required_amount", "") + balance = context.get("current_balance", "") + + subjects = { + "ru": "Недостаточно средств для автопродления", + "en": "Insufficient Funds for Auto-renewal", + "zh": "余额不足无法自动续订", + "ua": "Недостатньо коштів для автопродовження", + } + + bodies = { + "ru": f""" +

Недостаточно средств

+
+

Недостаточно средств на балансе для автопродления подписки.

+ {f'

Требуется: {required}

' if required else ''} + {f'

На балансе: {balance}

' if balance else ''} +
+

Пополните баланс, чтобы подписка была продлена автоматически.

+ {self._get_cabinet_button(language)} + """, + "en": f""" +

Insufficient Funds

+
+

Insufficient balance for subscription auto-renewal.

+ {f'

Required: {required}

' if required else ''} + {f'

Balance: {balance}

' if balance else ''} +
+

Top up your balance for automatic renewal.

+ {self._get_cabinet_button(language)} + """, + } + + return { + "subject": subjects.get(language, subjects["en"]), + "body_html": self._get_base_template(bodies.get(language, bodies["en"])), + } + + # ============================================================================ + # Daily Subscription Templates + # ============================================================================ + + def _daily_debit_template(self, language: str, context: Dict[str, Any]) -> Dict[str, str]: + """Template for daily subscription debit notification.""" + amount = context.get("formatted_amount", f"{context.get('amount_rubles', 0):.2f} ₽") + balance = context.get("formatted_balance", f"{context.get('new_balance_rubles', 0):.2f} ₽") + + subjects = { + "ru": f"Списание за подписку: {amount}", + "en": f"Subscription charge: {amount}", + "zh": f"订阅扣费: {amount}", + "ua": f"Списання за підписку: {amount}", + } + + bodies = { + "ru": f""" +

Ежедневное списание

+
+

С вашего баланса списано: {amount}

+

Остаток на балансе: {balance}

+
+ {self._get_cabinet_button(language)} + """, + "en": f""" +

Daily Charge

+
+

Charged from your balance: {amount}

+

Remaining balance: {balance}

+
+ {self._get_cabinet_button(language)} + """, + } + + return { + "subject": subjects.get(language, subjects["en"]), + "body_html": self._get_base_template(bodies.get(language, bodies["en"])), + } + + def _daily_insufficient_funds_template(self, language: str, context: Dict[str, Any]) -> Dict[str, str]: + """Template for daily subscription insufficient funds.""" + subjects = { + "ru": "Недостаточно средств для продления", + "en": "Insufficient Funds", + "zh": "余额不足", + "ua": "Недостатньо коштів", + } + + bodies = { + "ru": f""" +

Недостаточно средств

+
+

На балансе недостаточно средств для продления подписки.

+

Подписка будет приостановлена.

+
+

Пополните баланс, чтобы продолжить использование сервиса.

+ {self._get_cabinet_button(language)} + """, + "en": f""" +

Insufficient Funds

+
+

Insufficient balance to continue subscription.

+

Your subscription will be suspended.

+
+

Please top up your balance to continue using the service.

+ {self._get_cabinet_button(language)} + """, + } + + return { + "subject": subjects.get(language, subjects["en"]), + "body_html": self._get_base_template(bodies.get(language, bodies["en"])), + } + + def _traffic_reset_template(self, language: str, context: Dict[str, Any]) -> Dict[str, str]: + """Template for traffic reset notification.""" + subjects = { + "ru": "Трафик обновлён", + "en": "Traffic Reset", + "zh": "流量已重置", + "ua": "Трафік оновлено", + } + + bodies = { + "ru": f""" +

Трафик обновлён

+
+

Ваш трафик был сброшен. Вы можете продолжить использование VPN.

+
+ {self._get_cabinet_button(language)} + """, + "en": f""" +

Traffic Reset

+
+

Your traffic has been reset. You can continue using the VPN.

+
+ {self._get_cabinet_button(language)} + """, + } + + return { + "subject": subjects.get(language, subjects["en"]), + "body_html": self._get_base_template(bodies.get(language, bodies["en"])), + } + + # ============================================================================ + # Account Status Templates + # ============================================================================ + + def _ban_template(self, language: str, context: Dict[str, Any]) -> Dict[str, str]: + """Template for ban notification.""" + reason = context.get("reason", "") + + subjects = { + "ru": "Аккаунт заблокирован", + "en": "Account Suspended", + "zh": "账户已被封禁", + "ua": "Обліковий запис заблоковано", + } + + bodies = { + "ru": f""" +

Аккаунт заблокирован

+
+

Ваш аккаунт был заблокирован.

+ {f'

Причина: {reason}

' if reason else ''} +
+

Если вы считаете, что это ошибка, обратитесь в поддержку.

+ """, + "en": f""" +

Account Suspended

+
+

Your account has been suspended.

+ {f'

Reason: {reason}

' if reason else ''} +
+

If you believe this is an error, please contact support.

+ """, + } + + return { + "subject": subjects.get(language, subjects["en"]), + "body_html": self._get_base_template(bodies.get(language, bodies["en"])), + } + + def _unban_template(self, language: str, context: Dict[str, Any]) -> Dict[str, str]: + """Template for unban notification.""" + subjects = { + "ru": "Аккаунт разблокирован", + "en": "Account Reactivated", + "zh": "账户已解封", + "ua": "Обліковий запис розблоковано", + } + + bodies = { + "ru": f""" +

Аккаунт разблокирован

+
+

Ваш аккаунт был разблокирован.

+

Вы снова можете пользоваться сервисом.

+
+ {self._get_cabinet_button(language)} + """, + "en": f""" +

Account Reactivated

+
+

Your account has been reactivated.

+

You can use the service again.

+
+ {self._get_cabinet_button(language)} + """, + } + + return { + "subject": subjects.get(language, subjects["en"]), + "body_html": self._get_base_template(bodies.get(language, bodies["en"])), + } + + def _warning_template(self, language: str, context: Dict[str, Any]) -> Dict[str, str]: + """Template for warning notification.""" + message = context.get("message", "") + + subjects = { + "ru": "Предупреждение", + "en": "Warning", + "zh": "警告", + "ua": "Попередження", + } + + bodies = { + "ru": f""" +

Предупреждение

+
+ {f'

{message}

' if message else '

Вы получили предупреждение от администрации.

'} +
+ """, + "en": f""" +

Warning

+
+ {f'

{message}

' if message else '

You have received a warning from the administration.

'} +
+ """, + } + + return { + "subject": subjects.get(language, subjects["en"]), + "body_html": self._get_base_template(bodies.get(language, bodies["en"])), + } + + # ============================================================================ + # Referral Templates + # ============================================================================ + + def _referral_bonus_template(self, language: str, context: Dict[str, Any]) -> Dict[str, str]: + """Template for referral bonus notification.""" + bonus = context.get("formatted_bonus", f"{context.get('bonus_rubles', 0):.2f} ₽") + referral_name = context.get("referral_name", "") + + subjects = { + "ru": f"Реферальный бонус: +{bonus}", + "en": f"Referral bonus: +{bonus}", + "zh": f"推荐奖励: +{bonus}", + "ua": f"Реферальний бонус: +{bonus}", + } + + bodies = { + "ru": f""" +

Реферальный бонус!

+
+

Вы получили реферальный бонус: +{bonus}

+ {f'

Благодаря пользователю: {referral_name}

' if referral_name else ''} +
+

Продолжайте приглашать друзей и зарабатывайте больше!

+ {self._get_cabinet_button(language)} + """, + "en": f""" +

Referral Bonus!

+
+

You received a referral bonus: +{bonus}

+ {f'

Thanks to: {referral_name}

' if referral_name else ''} +
+

Keep inviting friends and earn more!

+ {self._get_cabinet_button(language)} + """, + } + + return { + "subject": subjects.get(language, subjects["en"]), + "body_html": self._get_base_template(bodies.get(language, bodies["en"])), + } + + def _referral_registered_template(self, language: str, context: Dict[str, Any]) -> Dict[str, str]: + """Template for new referral registered notification.""" + referral_name = context.get("referral_name", "") + + subjects = { + "ru": "Новый реферал зарегистрирован", + "en": "New Referral Registered", + "zh": "新推荐用户已注册", + "ua": "Новий реферал зареєстрований", + } + + bodies = { + "ru": f""" +

Новый реферал!

+
+

По вашей ссылке зарегистрировался новый пользователь{f': {referral_name}' if referral_name else ''}.

+
+

Вы будете получать бонусы с его пополнений!

+ {self._get_cabinet_button(language)} + """, + "en": f""" +

New Referral!

+
+

A new user registered using your link{f': {referral_name}' if referral_name else ''}.

+
+

You will receive bonuses from their top-ups!

+ {self._get_cabinet_button(language)} + """, + } + + return { + "subject": subjects.get(language, subjects["en"]), + "body_html": self._get_base_template(bodies.get(language, bodies["en"])), + } + + # ============================================================================ + # Payment Templates + # ============================================================================ + + def _payment_received_template(self, language: str, context: Dict[str, Any]) -> Dict[str, str]: + """Template for payment received notification.""" + amount = context.get("formatted_amount", f"{context.get('amount_rubles', 0):.2f} ₽") + payment_method = context.get("payment_method", "") + + subjects = { + "ru": f"Платёж получен: {amount}", + "en": f"Payment received: {amount}", + "zh": f"收到付款: {amount}", + "ua": f"Платіж отримано: {amount}", + } + + bodies = { + "ru": f""" +

Платёж успешно обработан

+
+

Сумма: +{amount}

+ {f'

Способ оплаты: {payment_method}

' if payment_method else ''} +
+

Спасибо за оплату!

+ {self._get_cabinet_button(language)} + """, + "en": f""" +

Payment Successfully Processed

+
+

Amount: +{amount}

+ {f'

Payment method: {payment_method}

' if payment_method else ''} +
+

Thank you for your payment!

+ {self._get_cabinet_button(language)} + """, + } + + return { + "subject": subjects.get(language, subjects["en"]), + "body_html": self._get_base_template(bodies.get(language, bodies["en"])), + } + + +# Singleton instance +email_notification_templates = EmailNotificationTemplates()