feature: sync user email notifications

This commit is contained in:
3252a8
2026-05-30 11:46:07 +03:00
parent 09be82aad5
commit c3e55bc853
16 changed files with 775 additions and 163 deletions
+23 -12
View File
@@ -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),
)
+41 -8
View File
@@ -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
+129 -2
View File
@@ -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,
*, *,
+55 -15
View File
@@ -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,
+139 -82
View File
@@ -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
+30 -15
View File
@@ -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 и без канальной дедупликации.
+23
View File
@@ -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",
+23
View File
@@ -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} дн.",
+107
View File
@@ -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("&lt;script&gt;alert(1)&lt;/script&gt;", 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()