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
+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}"
_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,
*,
+55 -15
View File
@@ -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,
+139 -82
View File
@@ -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