diff --git a/.gitignore b/.gitignore index af83b61..bc2510c 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,8 @@ node_modules/ # WebApp build artifacts (regenerated by `npm run build:webapp` / Docker build) bot/app/web/templates/subscription_webapp.css bot/app/web/templates/subscription_webapp.min.*.js +tmp +.claude # Игнорировать кэш Python __pycache__/ diff --git a/bot/app/web/subscription_webapp.py b/bot/app/web/subscription_webapp.py index c02d34e..f63ebca 100644 --- a/bot/app/web/subscription_webapp.py +++ b/bot/app/web/subscription_webapp.py @@ -29,6 +29,7 @@ from bot.app.web.webapp_auth import ( ) from bot.services.crypto_pay_service import CryptoPayService from bot.services.email_auth_service import EmailAuthService, normalize_email +from bot.services.email_templates import render_account_merged from bot.services.freekassa_service import FreeKassaService from bot.services.platega_service import PlategaService from bot.services.promo_code_service import PromoCodeService @@ -1019,15 +1020,21 @@ async def account_email_verify_route(request: web.Request) -> web.Response: email_service: EmailAuthService = request.app.get("email_auth_service") if email_service and final_email: - email_payload = _build_account_merge_email( - merge_notice.get("language") or settings.DEFAULT_LANGUAGE, - merge_notice, + email_content = render_account_merged( + settings, + language_code=merge_notice.get("language") or settings.DEFAULT_LANGUAGE, + primary_user_id=merge_notice.get("primary_user_id"), + removed_user_id=merge_notice.get("removed_user_id"), + final_end_date_text=str( + merge_notice.get("final_end_date_text") + or merge_notice.get("final_end_date") + or "" + ), ) try: - await email_service.send_custom_email( + await email_service.send_rendered_email( email=final_email, - subject=email_payload["subject"], - body=email_payload["body"], + content=email_content, ) except Exception as exc: logger.warning( @@ -1143,15 +1150,21 @@ async def account_telegram_link_route(request: web.Request) -> web.Response: email_service: EmailAuthService = request.app.get("email_auth_service") if email_service and final_email: - email_payload = _build_account_merge_email( - merge_notice.get("language") or settings.DEFAULT_LANGUAGE, - merge_notice, + email_content = render_account_merged( + settings, + language_code=merge_notice.get("language") or settings.DEFAULT_LANGUAGE, + primary_user_id=merge_notice.get("primary_user_id"), + removed_user_id=merge_notice.get("removed_user_id"), + final_end_date_text=str( + merge_notice.get("final_end_date_text") + or merge_notice.get("final_end_date") + or "" + ), ) try: - await email_service.send_custom_email( + await email_service.send_rendered_email( email=final_email, - subject=email_payload["subject"], - body=email_payload["body"], + content=email_content, ) except Exception as exc: logger.warning( @@ -1575,39 +1588,6 @@ async def _build_account_merge_notice( } -def _build_account_merge_email(language: str, merge_info: Dict[str, Any]) -> Dict[str, str]: - lang = _normalize_language(language) - primary_user_id = merge_info.get("primary_user_id") - removed_user_id = merge_info.get("removed_user_id") - final_end_date_text = ( - merge_info.get("final_end_date_text") - or merge_info.get("final_end_date") - or "N/A" - ) - if lang == "en": - return { - "subject": "Accounts merged", - "body": ( - "We merged your accounts into one profile.\n\n" - f"Kept account: #{primary_user_id}\n" - f"Removed account: #{removed_user_id}\n" - f"Paid periods were combined. New subscription end date: {final_end_date_text}.\n" - "Your subscription link stayed the same, and the later account was removed from Remnawave automatically." - ), - } - - return { - "subject": "Аккаунты объединены", - "body": ( - "Мы объединили ваши аккаунты в один профиль.\n\n" - f"Оставлен аккаунт: #{primary_user_id}\n" - f"Удалён аккаунт: #{removed_user_id}\n" - f"Оплаченные периоды сложились. Новая дата окончания подписки: {final_end_date_text}.\n" - "Ссылка на подписку осталась прежней, а более поздний аккаунт был удалён из Remnawave автоматически." - ), - } - - def _telegram_photo_url_value(telegram_user: Dict[str, Any]) -> Optional[str]: raw_value = telegram_user.get("photo_url") if not raw_value: diff --git a/bot/services/email_auth_service.py b/bot/services/email_auth_service.py index 2c55f4f..2fad7dc 100644 --- a/bot/services/email_auth_service.py +++ b/bot/services/email_auth_service.py @@ -18,6 +18,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from config.settings import Settings from db.dal import security_dal from db.models import EmailVerificationCode +from bot.services.email_templates import EmailContent, render_login_code logger = logging.getLogger(__name__) @@ -323,12 +324,27 @@ class EmailAuthService: email: str, subject: str, body: str, + html_body: Optional[str] = None, ) -> None: await asyncio.to_thread( self._send_custom_email_sync, email=email, subject=subject, body=body, + html_body=html_body, + ) + + async def send_rendered_email( + self, + *, + email: str, + content: EmailContent, + ) -> None: + await self.send_custom_email( + email=email, + subject=content.subject, + body=content.text, + html_body=content.html, ) def _send_code_email_sync( @@ -338,22 +354,14 @@ class EmailAuthService: code: str, language_code: str, ) -> None: - lang = (language_code or self.settings.DEFAULT_LANGUAGE or "ru").split("-")[0] - if lang == "en": - subject = "Your login code" - body = ( - f"Your verification code: {code}\n\n" - f"The code expires in {max(1, int(self.settings.EMAIL_CODE_TTL_SECONDS) // 60)} minutes." - ) - else: - subject = "Код подтверждения" - body = ( - f"Ваш код подтверждения: {code}\n\n" - f"Код действует {max(1, int(self.settings.EMAIL_CODE_TTL_SECONDS) // 60)} мин." - ) + content = render_login_code( + self.settings, + code=code, + language_code=language_code, + ) message = EmailMessage() - message["Subject"] = subject + message["Subject"] = content.subject message["From"] = formataddr( ( self.settings.SMTP_FROM_NAME or self.settings.WEBAPP_TITLE, @@ -361,7 +369,8 @@ class EmailAuthService: ) ) message["To"] = email - message.set_content(body) + message.set_content(content.text) + message.add_alternative(content.html, subtype="html") context = ssl.create_default_context() smtp_host = self.settings.SMTP_HOST @@ -411,6 +420,7 @@ class EmailAuthService: email: str, subject: str, body: str, + html_body: Optional[str] = None, ) -> None: message = EmailMessage() message["Subject"] = subject @@ -422,6 +432,8 @@ class EmailAuthService: ) message["To"] = email message.set_content(body) + if html_body: + message.add_alternative(html_body, subtype="html") context = ssl.create_default_context() smtp_host = self.settings.SMTP_HOST diff --git a/bot/services/email_templates.py b/bot/services/email_templates.py new file mode 100644 index 0000000..ca54cdf --- /dev/null +++ b/bot/services/email_templates.py @@ -0,0 +1,454 @@ +"""Branded HTML email templates that mirror the subscription Mini App look. + +The web app uses a dark theme with a configurable accent colour +(`WEBAPP_PRIMARY_COLOR`) and an optional logo (`WEBAPP_LOGO_URL`). The same +accent + logo are reused here so emails feel like part of the product. All +copy goes through the shared `JsonI18n` instance so translations live in +``locales/.json`` next to the rest of the bot strings. +""" + +from __future__ import annotations + +import html +import re +from dataclasses import dataclass +from typing import Optional, Sequence, Tuple +from urllib.parse import urlsplit + +from bot.middlewares.i18n import JsonI18n, get_i18n_instance +from config.settings import Settings + + +_BG = "#05070a" +_CARD_BG = "#0e1116" +_BORDER = "#1a1f27" +_TEXT = "#e6e9ef" +_TEXT_MUTED = "#9aa3b2" +_TEXT_DIM = "#5d6573" +_DEFAULT_ACCENT = "#00fe7a" +_HEX_RE = re.compile(r"^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$") + + +@dataclass(frozen=True) +class EmailContent: + subject: str + text: str + html: str + + +def _safe_color(value: Optional[str]) -> str: + if not value: + return _DEFAULT_ACCENT + candidate = value.strip() + if _HEX_RE.match(candidate): + return candidate + return _DEFAULT_ACCENT + + +def _public_logo_url(settings: Settings) -> Optional[str]: + """Email recipients can't reach the in-app /webapp-logo proxy, so the + raw https URL from the env is used directly. Anything else is dropped.""" + raw = (settings.WEBAPP_LOGO_URL or "").strip() + if not raw: + return None + parsed = urlsplit(raw) + if parsed.scheme != "https" or not parsed.hostname: + return None + return raw + + +def _brand_title(settings: Settings) -> str: + title = (settings.WEBAPP_TITLE or "").strip() + return title or "Subscription" + + +def _normalize_lang(language_code: Optional[str], settings: Settings) -> str: + return (language_code or settings.DEFAULT_LANGUAGE or "ru").split("-")[0] + + +def _resolve_i18n(i18n: Optional[JsonI18n]) -> JsonI18n: + return i18n or get_i18n_instance() + + +def _t_html(i18n: JsonI18n, lang: str, key: str, **kwargs) -> str: + """Translate for HTML context: format args are HTML-escaped, the + translated template itself is treated as already-safe HTML (locale files + are author-controlled and may include simple inline tags like ).""" + safe_kwargs = {k: html.escape(str(v)) for k, v in kwargs.items()} + return i18n.gettext(lang, key, **safe_kwargs) + + +def _t_text(i18n: JsonI18n, lang: str, key: str, **kwargs) -> str: + return i18n.gettext(lang, key, **kwargs) + + +def _layout( + *, + settings: Settings, + preheader: str, + heading: str, + intro_html: str, + body_html: str, + footer_html: str, +) -> str: + accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR) + brand_title = html.escape(_brand_title(settings)) + logo_url = _public_logo_url(settings) + logo_block = "" + if logo_url: + logo_block = ( + f'' + ) + + return f""" + + + + + + +{html.escape(heading)} + + +
{html.escape(preheader)}
+ + + + +
+ + + + + + + + + + +
+ {logo_block} +
{brand_title}
+
+

{html.escape(heading)}

+
{intro_html}
+ {body_html} +
+
{footer_html}
+
+
+ + +""" + + +def _info_rows_html(rows: Sequence[Tuple[str, str]]) -> str: + if not rows: + return "" + last = len(rows) - 1 + cells = [] + for index, (label, value) in enumerate(rows): + border = "" if index == last else f"border-bottom:1px solid {_BORDER};" + cells.append( + f'' + f'{html.escape(label)}' + f'{html.escape(value)}' + f'' + ) + return ( + f'' + + "".join(cells) + + "
" + ) + + +def _cta_button_html(*, label: str, url: str, accent: str) -> str: + safe_label = html.escape(label) + safe_url = html.escape(url, quote=True) + # Accent green is light, so contrast text is dark; works for the default and similar light accents. + return ( + f'' + f'
' + f'{safe_label}' + f'
' + ) + + +def _format_amount(amount: float, currency: str) -> str: + rounded = round(float(amount), 2) + if rounded.is_integer(): + body = f"{int(rounded)}" + else: + body = f"{rounded:.2f}" + suffix = (currency or "").strip() + return f"{body} {suffix}".strip() + + +def _format_traffic(traffic_gb: Optional[float]) -> str: + if traffic_gb is None: + return "—" + value = float(traffic_gb) + return str(int(value)) if value.is_integer() else f"{value:g}" + + +def _format_minutes(seconds: int) -> int: + return max(1, int(seconds) // 60) + + +def render_login_code( + settings: Settings, + *, + code: str, + language_code: Optional[str], + i18n: Optional[JsonI18n] = None, +) -> EmailContent: + i18n = _resolve_i18n(i18n) + lang = _normalize_lang(language_code, settings) + minutes = _format_minutes(settings.EMAIL_CODE_TTL_SECONDS) + accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR) + brand = _brand_title(settings) + + subject = _t_text(i18n, lang, "email_login_code_subject", code=code) + preheader = _t_text(i18n, lang, "email_login_code_preheader", minutes=minutes) + heading = _t_text(i18n, lang, "email_login_code_heading") + intro = _t_text(i18n, lang, "email_login_code_intro") + expiry_html = _t_html(i18n, lang, "email_login_code_expiry_html", minutes=minutes) + security = _t_text(i18n, lang, "email_login_code_security") + footer = _t_html(i18n, lang, "email_footer_auto", brand=brand) + text = _t_text(i18n, lang, "email_login_code_text", code=code, minutes=minutes) + + body_html = f""" + + + + +
+
{html.escape(code)}
+
+

{expiry_html}

+

{html.escape(security)}

+""" + + rendered = _layout( + settings=settings, + preheader=preheader, + heading=heading, + intro_html=html.escape(intro), + body_html=body_html, + footer_html=footer, + ) + return EmailContent(subject=subject, text=text, html=rendered) + + +def render_account_merged( + settings: Settings, + *, + language_code: Optional[str], + primary_user_id: Optional[int], + removed_user_id: Optional[int], + final_end_date_text: str, + i18n: Optional[JsonI18n] = None, +) -> EmailContent: + i18n = _resolve_i18n(i18n) + lang = _normalize_lang(language_code, settings) + brand = _brand_title(settings) + primary = "—" if primary_user_id is None else f"#{primary_user_id}" + removed = "—" if removed_user_id is None else f"#{removed_user_id}" + end_date = final_end_date_text or "—" + + subject = _t_text(i18n, lang, "email_account_merged_subject") + preheader = _t_text(i18n, lang, "email_account_merged_preheader") + heading = _t_text(i18n, lang, "email_account_merged_heading") + intro = _t_text(i18n, lang, "email_account_merged_intro") + note = _t_text(i18n, lang, "email_account_merged_note") + footer = _t_html(i18n, lang, "email_footer_auto", brand=brand) + text = _t_text( + i18n, + lang, + "email_account_merged_text", + primary=primary, + removed=removed, + end_date=end_date, + ) + + rows = [ + (_t_text(i18n, lang, "email_account_merged_row_kept"), primary), + (_t_text(i18n, lang, "email_account_merged_row_removed"), removed), + (_t_text(i18n, lang, "email_account_merged_row_end_date"), end_date), + ] + body_html = ( + _info_rows_html(rows) + + f'

{html.escape(note)}

' + ) + + rendered = _layout( + settings=settings, + preheader=preheader, + heading=heading, + intro_html=html.escape(intro), + body_html=body_html, + footer_html=footer, + ) + return EmailContent(subject=subject, text=text, html=rendered) + + +def render_payment_success( + settings: Settings, + *, + language_code: Optional[str], + sale_mode: str, + months: int, + traffic_gb: Optional[float], + amount: float, + currency: str, + end_date_text: str, + dashboard_url: Optional[str], + provider_label: Optional[str] = None, + i18n: Optional[JsonI18n] = None, +) -> EmailContent: + i18n = _resolve_i18n(i18n) + lang = _normalize_lang(language_code, settings) + accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR) + brand = _brand_title(settings) + is_traffic = sale_mode == "traffic" + amount_text = _format_amount(amount, currency) + safe_dashboard_url = (dashboard_url or "").strip() + end_date = end_date_text or "—" + traffic_label = _format_traffic(traffic_gb) + + subject = _t_text(i18n, lang, "email_payment_success_subject") + preheader = _t_text(i18n, lang, "email_payment_success_preheader") + heading = _t_text(i18n, lang, "email_payment_success_heading") + footer_note = _t_text(i18n, lang, "email_payment_success_footer_note") + footer = _t_html(i18n, lang, "email_footer_auto", brand=brand) + cta_label = _t_text(i18n, lang, "email_payment_success_cta") + + if is_traffic: + intro = _t_text(i18n, lang, "email_payment_success_intro_traffic", traffic_gb=traffic_label) + period_label = _t_text(i18n, lang, "email_payment_success_row_traffic") + period_value = _t_text(i18n, lang, "email_payment_success_traffic_value", traffic_gb=traffic_label) + text = _t_text( + i18n, + lang, + "email_payment_success_text_traffic", + amount=amount_text, + traffic_gb=traffic_label, + end_date=end_date, + ) + else: + months_int = int(months or 0) + intro = _t_text(i18n, lang, "email_payment_success_intro_subscription", months=months_int) + period_label = _t_text(i18n, lang, "email_payment_success_row_period") + period_value = _t_text( + i18n, + lang, + "email_payment_success_period_value", + months=months_int, + ) + text = _t_text( + i18n, + lang, + "email_payment_success_text_subscription", + amount=amount_text, + months=months_int, + end_date=end_date, + ) + + rows: list[Tuple[str, str]] = [ + (period_label, period_value), + (_t_text(i18n, lang, "email_payment_success_row_amount"), amount_text), + (_t_text(i18n, lang, "email_payment_success_row_end_date"), end_date), + ] + if provider_label: + rows.append((_t_text(i18n, lang, "email_payment_success_row_method"), provider_label)) + + text_lines = [text] + if safe_dashboard_url: + text_lines.append(_t_text(i18n, lang, "email_payment_success_text_dashboard", url=safe_dashboard_url)) + + body_parts = [_info_rows_html(rows)] + if safe_dashboard_url: + body_parts.append(_cta_button_html(label=cta_label, url=safe_dashboard_url, accent=accent)) + body_parts.append( + f'

{html.escape(footer_note)}

' + ) + + rendered = _layout( + settings=settings, + preheader=preheader, + heading=heading, + intro_html=html.escape(intro), + body_html="".join(body_parts), + footer_html=footer, + ) + return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered) + + +def render_subscription_expiring( + settings: Settings, + *, + language_code: Optional[str], + days_left: int, + end_date_text: str, + dashboard_url: Optional[str], + 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() + days = max(0, int(days_left)) + end_date = end_date_text or "—" + + if days == 0: + suffix = "today" + elif days == 1: + suffix = "tomorrow" + else: + suffix = "days" + + subject = _t_text(i18n, lang, f"email_subscription_expiring_subject_{suffix}", days=days) + heading = _t_text(i18n, lang, f"email_subscription_expiring_heading_{suffix}", days=days) + preheader = _t_text(i18n, lang, f"email_subscription_expiring_preheader_{suffix}", days=days) + intro = _t_text(i18n, lang, f"email_subscription_expiring_intro_{suffix}", days=days) + note = _t_text(i18n, lang, "email_subscription_expiring_note") + footer = _t_html(i18n, lang, "email_footer_auto", brand=brand) + cta_label = _t_text(i18n, lang, "email_subscription_expiring_cta") + + rows = [ + (_t_text(i18n, lang, "email_subscription_expiring_row_days_left"), str(days)), + (_t_text(i18n, lang, "email_subscription_expiring_row_end_date"), end_date), + ] + + text_lines = [ + _t_text(i18n, lang, "email_subscription_expiring_text", heading=heading, end_date=end_date), + ] + if safe_dashboard_url: + text_lines.append( + _t_text(i18n, lang, "email_subscription_expiring_text_renew", url=safe_dashboard_url) + ) + + body_parts = [_info_rows_html(rows)] + if safe_dashboard_url: + body_parts.append(_cta_button_html(label=cta_label, url=safe_dashboard_url, accent=accent)) + body_parts.append( + f'

{html.escape(note)}

' + ) + + rendered = _layout( + settings=settings, + preheader=preheader, + heading=heading, + intro_html=html.escape(intro), + body_html="".join(body_parts), + footer_html=footer, + ) + return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered) diff --git a/bot/services/panel_webhook_service.py b/bot/services/panel_webhook_service.py index 6feefd2..44ad7a7 100644 --- a/bot/services/panel_webhook_service.py +++ b/bot/services/panel_webhook_service.py @@ -9,6 +9,8 @@ from sqlalchemy.orm import sessionmaker from typing import Optional from config.settings import Settings from .panel_api_service import PanelApiService +from .email_auth_service import EmailAuthService +from .email_templates import render_subscription_expiring from bot.middlewares.i18n import JsonI18n from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup, get_autorenew_cancel_keyboard from db.dal import user_dal @@ -64,6 +66,7 @@ class PanelWebhookService: internal_user_id = db_user.user_id if db_user else user_id 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 "" markup = get_subscribe_only_markup(lang, self.i18n) @@ -122,6 +125,13 @@ class PanelWebhookService: user_name=first_name, end_date=user_payload.get("expireAt", "")[:10], ) + 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], + ) elif event_name == "user.expired": if self.settings.SUBSCRIPTION_NOTIFY_ON_EXPIRE: await self._send_message( @@ -142,6 +152,30 @@ class PanelWebhookService: end_date=user_payload.get("expireAt", "")[:10], ) + async def _send_subscription_expiring_email( + 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, + ) + email_service = EmailAuthService(self.settings) + await email_service.send_rendered_email(email=recipient, content=content) + except Exception: + logging.exception("Failed to send subscription-expiring email to %s", recipient) + async def handle_webhook(self, raw_body: bytes, signature_header: Optional[str]) -> web.Response: if not self.settings.PANEL_WEBHOOK_SECRET: return web.Response(status=401, text="unauthorized") diff --git a/bot/services/subscription_service.py b/bot/services/subscription_service.py index 5ac74a0..b20ee34 100644 --- a/bot/services/subscription_service.py +++ b/bot/services/subscription_service.py @@ -12,6 +12,8 @@ from db.models import User, Subscription from config.settings import Settings from .panel_api_service import PanelApiService +from .email_auth_service import EmailAuthService +from .email_templates import render_payment_success class SubscriptionService: @@ -574,6 +576,16 @@ class SubscriptionService: final_subscription_url = updated_panel_user.get("subscriptionUrl") final_panel_short_uuid = updated_panel_user.get("shortUuid", panel_short_uuid) + await self._send_payment_success_email( + db_user=db_user, + sale_mode="traffic", + months=0, + traffic_gb=float(traffic_gb), + payment_amount=payment_amount, + end_date=None, + provider=provider, + ) + return { "subscription_id": new_or_updated_sub.subscription_id, "end_date": final_end_date, @@ -736,6 +748,16 @@ class SubscriptionService: final_subscription_url = updated_panel_user.get("subscriptionUrl") final_panel_short_uuid = updated_panel_user.get("shortUuid", panel_short_uuid) + await self._send_payment_success_email( + db_user=db_user, + sale_mode="subscription", + months=months_int, + traffic_gb=None, + payment_amount=payment_amount, + end_date=final_end_date, + provider=provider, + ) + return { "subscription_id": new_or_updated_sub.subscription_id, "end_date": final_end_date, @@ -1067,6 +1089,61 @@ class SubscriptionService: logging.info(f"Auto-renew initiated for user {sub.user_id} payment_id={resp.get('id')}") return True + _PROVIDER_LABELS = { + "yookassa": "YooKassa", + "freekassa": "FreeKassa", + "platega": "Platega", + "severpay": "SeverPay", + "cryptopay": "Crypto Pay", + "crypto_pay": "Crypto Pay", + "stars": "Telegram Stars", + "tribute": "Tribute", + } + + async def _send_payment_success_email( + self, + *, + db_user: User, + sale_mode: str, + months: int, + traffic_gb: Optional[float], + payment_amount: float, + end_date: Optional[datetime], + provider: str, + ) -> None: + """Best-effort branded email confirming the payment. No-op if SMTP or + the user's email aren't set. Failures are logged and swallowed so the + payment flow is never blocked by mail delivery.""" + if not self.settings.email_auth_configured: + return + recipient = (db_user.email or "").strip() if db_user else "" + if not recipient: + return + + end_date_text = end_date.strftime("%Y-%m-%d") if end_date else "" + provider_label = self._PROVIDER_LABELS.get((provider or "").lower()) + dashboard_url = (self.settings.SUBSCRIPTION_MINI_APP_URL or "").strip() or None + + try: + content = render_payment_success( + self.settings, + language_code=db_user.language_code or self.settings.DEFAULT_LANGUAGE, + sale_mode=sale_mode, + months=int(months or 0), + traffic_gb=traffic_gb, + amount=float(payment_amount or 0), + currency=self.settings.DEFAULT_CURRENCY_SYMBOL, + end_date_text=end_date_text, + dashboard_url=dashboard_url, + provider_label=provider_label, + ) + email_service = EmailAuthService(self.settings) + await email_service.send_rendered_email(email=recipient, content=content) + except Exception: + logging.exception( + "Failed to send payment success email to user %s", db_user.user_id + ) + async def update_last_notification_sent( self, session: AsyncSession, user_id: int, subscription_end_date: datetime ): diff --git a/locales/en.json b/locales/en.json index 8620e89..1d727c8 100644 --- a/locales/en.json +++ b/locales/en.json @@ -518,5 +518,62 @@ "admin_ads_delete_confirm": "Are you sure you want to delete campaign #{id}? This action is irreversible.", "admin_ads_deleted_success": "Campaign deleted.", "admin_ads_not_found": "Campaign not found.", - "free_kassa_order_full": "Order #{order_id} from {date}\n\n" + "free_kassa_order_full": "Order #{order_id} from {date}\n\n", + + "email_footer_auto": "Sent automatically by {brand}. Please don't reply.", + + "email_login_code_subject": "{code} — your sign-in code", + "email_login_code_preheader": "Your one-time code expires in {minutes} min.", + "email_login_code_heading": "Confirm your sign-in", + "email_login_code_intro": "Use this 6-digit code to finish signing in to your subscription dashboard.", + "email_login_code_expiry_html": "The code is valid for {minutes} min.", + "email_login_code_security": "If you didn't request this code, you can ignore this email — your account stays safe.", + "email_login_code_text": "Your verification code: {code}\n\nThe code is valid for {minutes} min.\nIf you didn't request this code, ignore this message.", + + "email_account_merged_subject": "Accounts merged", + "email_account_merged_preheader": "Your accounts were combined into one profile.", + "email_account_merged_heading": "Accounts merged", + "email_account_merged_intro": "We merged your accounts into a single profile so you keep one subscription and one history of payments.", + "email_account_merged_row_kept": "Kept account", + "email_account_merged_row_removed": "Removed account", + "email_account_merged_row_end_date": "New end date", + "email_account_merged_note": "Your subscription link is unchanged. The duplicate panel user was removed automatically.", + "email_account_merged_text": "We merged your accounts into one profile.\n\nKept account: {primary}\nRemoved account: {removed}\nPaid periods were combined. New subscription end date: {end_date}.\nYour subscription link stayed the same, and the later account was removed from Remnawave automatically.", + + "email_payment_success_subject": "Payment received", + "email_payment_success_preheader": "Thanks — your subscription has been activated.", + "email_payment_success_heading": "Payment received", + "email_payment_success_intro_subscription": "Thanks! Your subscription has been extended by {months} month(s).", + "email_payment_success_intro_traffic": "Thanks! We added {traffic_gb} GB of traffic to your account.", + "email_payment_success_row_period": "Period", + "email_payment_success_row_traffic": "Traffic added", + "email_payment_success_row_amount": "Amount", + "email_payment_success_row_end_date": "Active until", + "email_payment_success_row_method": "Method", + "email_payment_success_period_value": "{months} month(s)", + "email_payment_success_traffic_value": "{traffic_gb} GB", + "email_payment_success_cta": "Open dashboard", + "email_payment_success_footer_note": "We'll remind you a few days before the subscription ends.", + "email_payment_success_text_subscription": "Payment received: {amount}.\nSubscription extended by {months} month(s).\nActive until: {end_date}.", + "email_payment_success_text_traffic": "Payment received: {amount}.\nTraffic added: {traffic_gb} GB.\nActive until: {end_date}.", + "email_payment_success_text_dashboard": "Dashboard: {url}", + + "email_subscription_expiring_subject_today": "Your subscription ends today", + "email_subscription_expiring_subject_tomorrow": "1 day left on your subscription", + "email_subscription_expiring_subject_days": "{days} days left on your subscription", + "email_subscription_expiring_heading_today": "Subscription ends today", + "email_subscription_expiring_heading_tomorrow": "1 day left on your subscription", + "email_subscription_expiring_heading_days": "{days} days left on your subscription", + "email_subscription_expiring_preheader_today": "Renew today to avoid disconnection.", + "email_subscription_expiring_preheader_tomorrow": "Your subscription expires tomorrow.", + "email_subscription_expiring_preheader_days": "Your subscription expires in {days} days.", + "email_subscription_expiring_intro_today": "Your subscription is about to expire. Renew now to keep your connection active without interruption.", + "email_subscription_expiring_intro_tomorrow": "Your subscription expires tomorrow. Renew now to avoid losing access.", + "email_subscription_expiring_intro_days": "A heads-up so you can renew in advance and avoid any interruption.", + "email_subscription_expiring_row_days_left": "Days left", + "email_subscription_expiring_row_end_date": "Active until", + "email_subscription_expiring_cta": "Renew subscription", + "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}" } diff --git a/locales/ru.json b/locales/ru.json index 4127d7b..c511650 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -518,5 +518,62 @@ "admin_ads_delete_confirm": "Вы уверены, что хотите удалить кампанию #{id}? Это действие необратимо.", "admin_ads_deleted_success": "Кампания удалена.", "admin_ads_not_found": "Кампания не найдена.", - "free_kassa_order_full": "Заказ №{order_id} от {date}\n\n" + "free_kassa_order_full": "Заказ №{order_id} от {date}\n\n", + + "email_footer_auto": "Это автоматическое письмо от {brand}. Отвечать на него не нужно.", + + "email_login_code_subject": "{code} — код для входа", + "email_login_code_preheader": "Одноразовый код действует {minutes} мин.", + "email_login_code_heading": "Подтвердите вход", + "email_login_code_intro": "Введите этот 6-значный код, чтобы завершить вход в личный кабинет подписки.", + "email_login_code_expiry_html": "Код действует {minutes} мин.", + "email_login_code_security": "Если вы не запрашивали код, просто проигнорируйте это письмо — ваш аккаунт в безопасности.", + "email_login_code_text": "Ваш код подтверждения: {code}\n\nКод действует {minutes} мин.\nЕсли вы не запрашивали код, проигнорируйте это письмо.", + + "email_account_merged_subject": "Аккаунты объединены", + "email_account_merged_preheader": "Мы объединили ваши аккаунты в один профиль.", + "email_account_merged_heading": "Аккаунты объединены", + "email_account_merged_intro": "Мы объединили ваши аккаунты в один профиль — теперь у вас одна подписка и общая история оплат.", + "email_account_merged_row_kept": "Оставлен аккаунт", + "email_account_merged_row_removed": "Удалён аккаунт", + "email_account_merged_row_end_date": "Новая дата окончания", + "email_account_merged_note": "Ссылка на подписку осталась прежней. Дублирующий пользователь панели был удалён автоматически.", + "email_account_merged_text": "Мы объединили ваши аккаунты в один профиль.\n\nОставлен аккаунт: {primary}\nУдалён аккаунт: {removed}\nОплаченные периоды сложились. Новая дата окончания подписки: {end_date}.\nСсылка на подписку осталась прежней, а более поздний аккаунт был удалён из Remnawave автоматически.", + + "email_payment_success_subject": "Платёж получен", + "email_payment_success_preheader": "Спасибо — подписка активирована.", + "email_payment_success_heading": "Платёж получен", + "email_payment_success_intro_subscription": "Спасибо! Ваша подписка продлена на {months} мес.", + "email_payment_success_intro_traffic": "Спасибо! Мы зачислили {traffic_gb} ГБ трафика на ваш аккаунт.", + "email_payment_success_row_period": "Период", + "email_payment_success_row_traffic": "Трафик", + "email_payment_success_row_amount": "Сумма", + "email_payment_success_row_end_date": "Действует до", + "email_payment_success_row_method": "Способ оплаты", + "email_payment_success_period_value": "{months} мес.", + "email_payment_success_traffic_value": "{traffic_gb} ГБ", + "email_payment_success_cta": "Открыть кабинет", + "email_payment_success_footer_note": "За несколько дней до окончания мы напомним о продлении.", + "email_payment_success_text_subscription": "Платёж получен: {amount}.\nПодписка продлена на {months} мес.\nДействует до: {end_date}.", + "email_payment_success_text_traffic": "Платёж получен: {amount}.\nЗачислено трафика: {traffic_gb} ГБ.\nДействует до: {end_date}.", + "email_payment_success_text_dashboard": "Кабинет: {url}", + + "email_subscription_expiring_subject_today": "Подписка заканчивается сегодня", + "email_subscription_expiring_subject_tomorrow": "Подписка заканчивается завтра", + "email_subscription_expiring_subject_days": "До конца подписки осталось {days} дн.", + "email_subscription_expiring_heading_today": "Подписка заканчивается сегодня", + "email_subscription_expiring_heading_tomorrow": "Подписка заканчивается завтра", + "email_subscription_expiring_heading_days": "До конца подписки {days} дн.", + "email_subscription_expiring_preheader_today": "Продлите сегодня, чтобы не потерять доступ.", + "email_subscription_expiring_preheader_tomorrow": "Подписка истекает завтра.", + "email_subscription_expiring_preheader_days": "Подписка заканчивается через {days} дн.", + "email_subscription_expiring_intro_today": "Ваша подписка вот-вот истечёт. Продлите её, чтобы соединение не прерывалось.", + "email_subscription_expiring_intro_tomorrow": "Ваша подписка истекает завтра. Продлите её, чтобы не потерять доступ.", + "email_subscription_expiring_intro_days": "Напоминаем заранее, чтобы вы успели продлить подписку без перерыва в работе.", + "email_subscription_expiring_row_days_left": "Осталось дней", + "email_subscription_expiring_row_end_date": "Действует до", + "email_subscription_expiring_cta": "Продлить подписку", + "email_subscription_expiring_note": "Если вы уже продлили или включили автопродление — просто проигнорируйте это письмо.", + "email_subscription_expiring_text": "{heading}.\nДействует до: {end_date}.", + "email_subscription_expiring_text_renew": "Продлить: {url}" }