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