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