feat: design for emails
This commit is contained in:
@@ -13,6 +13,8 @@ node_modules/
|
||||
# WebApp build artifacts (regenerated by `npm run build:webapp` / Docker build)
|
||||
bot/app/web/templates/subscription_webapp.css
|
||||
bot/app/web/templates/subscription_webapp.min.*.js
|
||||
tmp
|
||||
.claude
|
||||
|
||||
# Игнорировать кэш Python
|
||||
__pycache__/
|
||||
|
||||
@@ -29,6 +29,7 @@ from bot.app.web.webapp_auth import (
|
||||
)
|
||||
from bot.services.crypto_pay_service import CryptoPayService
|
||||
from bot.services.email_auth_service import EmailAuthService, normalize_email
|
||||
from bot.services.email_templates import render_account_merged
|
||||
from bot.services.freekassa_service import FreeKassaService
|
||||
from bot.services.platega_service import PlategaService
|
||||
from bot.services.promo_code_service import PromoCodeService
|
||||
@@ -1019,15 +1020,21 @@ async def account_email_verify_route(request: web.Request) -> web.Response:
|
||||
|
||||
email_service: EmailAuthService = request.app.get("email_auth_service")
|
||||
if email_service and final_email:
|
||||
email_payload = _build_account_merge_email(
|
||||
merge_notice.get("language") or settings.DEFAULT_LANGUAGE,
|
||||
merge_notice,
|
||||
email_content = render_account_merged(
|
||||
settings,
|
||||
language_code=merge_notice.get("language") or settings.DEFAULT_LANGUAGE,
|
||||
primary_user_id=merge_notice.get("primary_user_id"),
|
||||
removed_user_id=merge_notice.get("removed_user_id"),
|
||||
final_end_date_text=str(
|
||||
merge_notice.get("final_end_date_text")
|
||||
or merge_notice.get("final_end_date")
|
||||
or ""
|
||||
),
|
||||
)
|
||||
try:
|
||||
await email_service.send_custom_email(
|
||||
await email_service.send_rendered_email(
|
||||
email=final_email,
|
||||
subject=email_payload["subject"],
|
||||
body=email_payload["body"],
|
||||
content=email_content,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
@@ -1143,15 +1150,21 @@ async def account_telegram_link_route(request: web.Request) -> web.Response:
|
||||
|
||||
email_service: EmailAuthService = request.app.get("email_auth_service")
|
||||
if email_service and final_email:
|
||||
email_payload = _build_account_merge_email(
|
||||
merge_notice.get("language") or settings.DEFAULT_LANGUAGE,
|
||||
merge_notice,
|
||||
email_content = render_account_merged(
|
||||
settings,
|
||||
language_code=merge_notice.get("language") or settings.DEFAULT_LANGUAGE,
|
||||
primary_user_id=merge_notice.get("primary_user_id"),
|
||||
removed_user_id=merge_notice.get("removed_user_id"),
|
||||
final_end_date_text=str(
|
||||
merge_notice.get("final_end_date_text")
|
||||
or merge_notice.get("final_end_date")
|
||||
or ""
|
||||
),
|
||||
)
|
||||
try:
|
||||
await email_service.send_custom_email(
|
||||
await email_service.send_rendered_email(
|
||||
email=final_email,
|
||||
subject=email_payload["subject"],
|
||||
body=email_payload["body"],
|
||||
content=email_content,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
@@ -1575,39 +1588,6 @@ async def _build_account_merge_notice(
|
||||
}
|
||||
|
||||
|
||||
def _build_account_merge_email(language: str, merge_info: Dict[str, Any]) -> Dict[str, str]:
|
||||
lang = _normalize_language(language)
|
||||
primary_user_id = merge_info.get("primary_user_id")
|
||||
removed_user_id = merge_info.get("removed_user_id")
|
||||
final_end_date_text = (
|
||||
merge_info.get("final_end_date_text")
|
||||
or merge_info.get("final_end_date")
|
||||
or "N/A"
|
||||
)
|
||||
if lang == "en":
|
||||
return {
|
||||
"subject": "Accounts merged",
|
||||
"body": (
|
||||
"We merged your accounts into one profile.\n\n"
|
||||
f"Kept account: #{primary_user_id}\n"
|
||||
f"Removed account: #{removed_user_id}\n"
|
||||
f"Paid periods were combined. New subscription end date: {final_end_date_text}.\n"
|
||||
"Your subscription link stayed the same, and the later account was removed from Remnawave automatically."
|
||||
),
|
||||
}
|
||||
|
||||
return {
|
||||
"subject": "Аккаунты объединены",
|
||||
"body": (
|
||||
"Мы объединили ваши аккаунты в один профиль.\n\n"
|
||||
f"Оставлен аккаунт: #{primary_user_id}\n"
|
||||
f"Удалён аккаунт: #{removed_user_id}\n"
|
||||
f"Оплаченные периоды сложились. Новая дата окончания подписки: {final_end_date_text}.\n"
|
||||
"Ссылка на подписку осталась прежней, а более поздний аккаунт был удалён из Remnawave автоматически."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _telegram_photo_url_value(telegram_user: Dict[str, Any]) -> Optional[str]:
|
||||
raw_value = telegram_user.get("photo_url")
|
||||
if not raw_value:
|
||||
|
||||
@@ -18,6 +18,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from config.settings import Settings
|
||||
from db.dal import security_dal
|
||||
from db.models import EmailVerificationCode
|
||||
from bot.services.email_templates import EmailContent, render_login_code
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -323,12 +324,27 @@ class EmailAuthService:
|
||||
email: str,
|
||||
subject: str,
|
||||
body: str,
|
||||
html_body: Optional[str] = None,
|
||||
) -> None:
|
||||
await asyncio.to_thread(
|
||||
self._send_custom_email_sync,
|
||||
email=email,
|
||||
subject=subject,
|
||||
body=body,
|
||||
html_body=html_body,
|
||||
)
|
||||
|
||||
async def send_rendered_email(
|
||||
self,
|
||||
*,
|
||||
email: str,
|
||||
content: EmailContent,
|
||||
) -> None:
|
||||
await self.send_custom_email(
|
||||
email=email,
|
||||
subject=content.subject,
|
||||
body=content.text,
|
||||
html_body=content.html,
|
||||
)
|
||||
|
||||
def _send_code_email_sync(
|
||||
@@ -338,22 +354,14 @@ class EmailAuthService:
|
||||
code: str,
|
||||
language_code: str,
|
||||
) -> None:
|
||||
lang = (language_code or self.settings.DEFAULT_LANGUAGE or "ru").split("-")[0]
|
||||
if lang == "en":
|
||||
subject = "Your login code"
|
||||
body = (
|
||||
f"Your verification code: {code}\n\n"
|
||||
f"The code expires in {max(1, int(self.settings.EMAIL_CODE_TTL_SECONDS) // 60)} minutes."
|
||||
)
|
||||
else:
|
||||
subject = "Код подтверждения"
|
||||
body = (
|
||||
f"Ваш код подтверждения: {code}\n\n"
|
||||
f"Код действует {max(1, int(self.settings.EMAIL_CODE_TTL_SECONDS) // 60)} мин."
|
||||
content = render_login_code(
|
||||
self.settings,
|
||||
code=code,
|
||||
language_code=language_code,
|
||||
)
|
||||
|
||||
message = EmailMessage()
|
||||
message["Subject"] = subject
|
||||
message["Subject"] = content.subject
|
||||
message["From"] = formataddr(
|
||||
(
|
||||
self.settings.SMTP_FROM_NAME or self.settings.WEBAPP_TITLE,
|
||||
@@ -361,7 +369,8 @@ class EmailAuthService:
|
||||
)
|
||||
)
|
||||
message["To"] = email
|
||||
message.set_content(body)
|
||||
message.set_content(content.text)
|
||||
message.add_alternative(content.html, subtype="html")
|
||||
|
||||
context = ssl.create_default_context()
|
||||
smtp_host = self.settings.SMTP_HOST
|
||||
@@ -411,6 +420,7 @@ class EmailAuthService:
|
||||
email: str,
|
||||
subject: str,
|
||||
body: str,
|
||||
html_body: Optional[str] = None,
|
||||
) -> None:
|
||||
message = EmailMessage()
|
||||
message["Subject"] = subject
|
||||
@@ -422,6 +432,8 @@ class EmailAuthService:
|
||||
)
|
||||
message["To"] = email
|
||||
message.set_content(body)
|
||||
if html_body:
|
||||
message.add_alternative(html_body, subtype="html")
|
||||
|
||||
context = ssl.create_default_context()
|
||||
smtp_host = self.settings.SMTP_HOST
|
||||
|
||||
@@ -0,0 +1,454 @@
|
||||
"""Branded HTML email templates that mirror the subscription Mini App look.
|
||||
|
||||
The web app uses a dark theme with a configurable accent colour
|
||||
(`WEBAPP_PRIMARY_COLOR`) and an optional logo (`WEBAPP_LOGO_URL`). The same
|
||||
accent + logo are reused here so emails feel like part of the product. All
|
||||
copy goes through the shared `JsonI18n` instance so translations live in
|
||||
``locales/<lang>.json`` next to the rest of the bot strings.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Sequence, Tuple
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from bot.middlewares.i18n import JsonI18n, get_i18n_instance
|
||||
from config.settings import Settings
|
||||
|
||||
|
||||
_BG = "#05070a"
|
||||
_CARD_BG = "#0e1116"
|
||||
_BORDER = "#1a1f27"
|
||||
_TEXT = "#e6e9ef"
|
||||
_TEXT_MUTED = "#9aa3b2"
|
||||
_TEXT_DIM = "#5d6573"
|
||||
_DEFAULT_ACCENT = "#00fe7a"
|
||||
_HEX_RE = re.compile(r"^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EmailContent:
|
||||
subject: str
|
||||
text: str
|
||||
html: str
|
||||
|
||||
|
||||
def _safe_color(value: Optional[str]) -> str:
|
||||
if not value:
|
||||
return _DEFAULT_ACCENT
|
||||
candidate = value.strip()
|
||||
if _HEX_RE.match(candidate):
|
||||
return candidate
|
||||
return _DEFAULT_ACCENT
|
||||
|
||||
|
||||
def _public_logo_url(settings: Settings) -> Optional[str]:
|
||||
"""Email recipients can't reach the in-app /webapp-logo proxy, so the
|
||||
raw https URL from the env is used directly. Anything else is dropped."""
|
||||
raw = (settings.WEBAPP_LOGO_URL or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
parsed = urlsplit(raw)
|
||||
if parsed.scheme != "https" or not parsed.hostname:
|
||||
return None
|
||||
return raw
|
||||
|
||||
|
||||
def _brand_title(settings: Settings) -> str:
|
||||
title = (settings.WEBAPP_TITLE or "").strip()
|
||||
return title or "Subscription"
|
||||
|
||||
|
||||
def _normalize_lang(language_code: Optional[str], settings: Settings) -> str:
|
||||
return (language_code or settings.DEFAULT_LANGUAGE or "ru").split("-")[0]
|
||||
|
||||
|
||||
def _resolve_i18n(i18n: Optional[JsonI18n]) -> JsonI18n:
|
||||
return i18n or get_i18n_instance()
|
||||
|
||||
|
||||
def _t_html(i18n: JsonI18n, lang: str, key: str, **kwargs) -> str:
|
||||
"""Translate for HTML context: format args are HTML-escaped, the
|
||||
translated template itself is treated as already-safe HTML (locale files
|
||||
are author-controlled and may include simple inline tags like <strong>)."""
|
||||
safe_kwargs = {k: html.escape(str(v)) for k, v in kwargs.items()}
|
||||
return i18n.gettext(lang, key, **safe_kwargs)
|
||||
|
||||
|
||||
def _t_text(i18n: JsonI18n, lang: str, key: str, **kwargs) -> str:
|
||||
return i18n.gettext(lang, key, **kwargs)
|
||||
|
||||
|
||||
def _layout(
|
||||
*,
|
||||
settings: Settings,
|
||||
preheader: str,
|
||||
heading: str,
|
||||
intro_html: str,
|
||||
body_html: str,
|
||||
footer_html: str,
|
||||
) -> str:
|
||||
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
|
||||
brand_title = html.escape(_brand_title(settings))
|
||||
logo_url = _public_logo_url(settings)
|
||||
logo_block = ""
|
||||
if logo_url:
|
||||
logo_block = (
|
||||
f'<img src="{html.escape(logo_url, quote=True)}" width="64" height="64" '
|
||||
f'alt="" style="display:block;border:0;outline:none;text-decoration:none;'
|
||||
f'border-radius:16px;background:{_CARD_BG};">'
|
||||
)
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<meta name="supported-color-schemes" content="dark">
|
||||
<title>{html.escape(heading)}</title>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background:{_BG};font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:{_TEXT};">
|
||||
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;mso-hide:all;">{html.escape(preheader)}</div>
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:{_BG};">
|
||||
<tr>
|
||||
<td align="center" style="padding:32px 16px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:480px;">
|
||||
<tr>
|
||||
<td align="center" style="padding-bottom:24px;">
|
||||
{logo_block}
|
||||
<div style="margin-top:14px;font-family:'JetBrains Mono','SFMono-Regular',Menlo,Consolas,monospace;font-weight:800;font-size:22px;line-height:1.05;color:{accent};letter-spacing:0;">{brand_title}</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="background:{_CARD_BG};border:1px solid {_BORDER};border-radius:18px;padding:28px;">
|
||||
<h1 style="margin:0 0 10px 0;font-size:20px;line-height:1.25;font-weight:700;color:#ffffff;">{html.escape(heading)}</h1>
|
||||
<div style="margin:0 0 20px 0;font-size:14px;line-height:1.55;color:{_TEXT_MUTED};">{intro_html}</div>
|
||||
{body_html}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="padding-top:20px;">
|
||||
<div style="font-size:11px;line-height:1.55;color:{_TEXT_DIM};">{footer_html}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def _info_rows_html(rows: Sequence[Tuple[str, str]]) -> str:
|
||||
if not rows:
|
||||
return ""
|
||||
last = len(rows) - 1
|
||||
cells = []
|
||||
for index, (label, value) in enumerate(rows):
|
||||
border = "" if index == last else f"border-bottom:1px solid {_BORDER};"
|
||||
cells.append(
|
||||
f'<tr>'
|
||||
f'<td style="padding:11px 0;{border}font-size:12px;color:{_TEXT_DIM};text-transform:uppercase;letter-spacing:0.04em;">{html.escape(label)}</td>'
|
||||
f'<td align="right" style="padding:11px 0;{border}font-family:\'JetBrains Mono\',\'SFMono-Regular\',Menlo,Consolas,monospace;font-size:14px;font-weight:600;color:{_TEXT};">{html.escape(value)}</td>'
|
||||
f'</tr>'
|
||||
)
|
||||
return (
|
||||
f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" '
|
||||
f'style="margin:0 0 16px 0;background:{_BG};border:1px solid {_BORDER};border-radius:14px;padding:6px 16px;">'
|
||||
+ "".join(cells)
|
||||
+ "</table>"
|
||||
)
|
||||
|
||||
|
||||
def _cta_button_html(*, label: str, url: str, accent: str) -> str:
|
||||
safe_label = html.escape(label)
|
||||
safe_url = html.escape(url, quote=True)
|
||||
# Accent green is light, so contrast text is dark; works for the default and similar light accents.
|
||||
return (
|
||||
f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" '
|
||||
f'style="width:100%;margin:22px 0 18px 0;">'
|
||||
f'<tr><td align="center" bgcolor="{accent}" style="background:{accent};border-radius:12px;">'
|
||||
f'<a href="{safe_url}" target="_blank" rel="noopener" '
|
||||
f'style="display:block;width:100%;box-sizing:border-box;padding:15px 22px;'
|
||||
f'font-family:-apple-system,BlinkMacSystemFont,\'Segoe UI\',Roboto,Helvetica,Arial,sans-serif;'
|
||||
f'font-size:15px;font-weight:700;color:#05070a;text-decoration:none;letter-spacing:0.02em;text-align:center;">{safe_label}</a>'
|
||||
f'</td></tr></table>'
|
||||
)
|
||||
|
||||
|
||||
def _format_amount(amount: float, currency: str) -> str:
|
||||
rounded = round(float(amount), 2)
|
||||
if rounded.is_integer():
|
||||
body = f"{int(rounded)}"
|
||||
else:
|
||||
body = f"{rounded:.2f}"
|
||||
suffix = (currency or "").strip()
|
||||
return f"{body} {suffix}".strip()
|
||||
|
||||
|
||||
def _format_traffic(traffic_gb: Optional[float]) -> str:
|
||||
if traffic_gb is None:
|
||||
return "—"
|
||||
value = float(traffic_gb)
|
||||
return str(int(value)) if value.is_integer() else f"{value:g}"
|
||||
|
||||
|
||||
def _format_minutes(seconds: int) -> int:
|
||||
return max(1, int(seconds) // 60)
|
||||
|
||||
|
||||
def render_login_code(
|
||||
settings: Settings,
|
||||
*,
|
||||
code: str,
|
||||
language_code: Optional[str],
|
||||
i18n: Optional[JsonI18n] = None,
|
||||
) -> EmailContent:
|
||||
i18n = _resolve_i18n(i18n)
|
||||
lang = _normalize_lang(language_code, settings)
|
||||
minutes = _format_minutes(settings.EMAIL_CODE_TTL_SECONDS)
|
||||
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
|
||||
brand = _brand_title(settings)
|
||||
|
||||
subject = _t_text(i18n, lang, "email_login_code_subject", code=code)
|
||||
preheader = _t_text(i18n, lang, "email_login_code_preheader", minutes=minutes)
|
||||
heading = _t_text(i18n, lang, "email_login_code_heading")
|
||||
intro = _t_text(i18n, lang, "email_login_code_intro")
|
||||
expiry_html = _t_html(i18n, lang, "email_login_code_expiry_html", minutes=minutes)
|
||||
security = _t_text(i18n, lang, "email_login_code_security")
|
||||
footer = _t_html(i18n, lang, "email_footer_auto", brand=brand)
|
||||
text = _t_text(i18n, lang, "email_login_code_text", code=code, minutes=minutes)
|
||||
|
||||
body_html = f"""
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 18px 0;">
|
||||
<tr>
|
||||
<td align="center" style="background:{_BG};border:1px solid {_BORDER};border-radius:14px;padding:22px 16px;">
|
||||
<div style="font-family:'JetBrains Mono','SFMono-Regular',Menlo,Consolas,monospace;font-size:36px;line-height:1;font-weight:700;letter-spacing:10px;color:{accent};">{html.escape(code)}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="margin:0 0 8px 0;font-size:13px;line-height:1.55;color:{_TEXT_MUTED};">{expiry_html}</p>
|
||||
<p style="margin:0;font-size:12px;line-height:1.55;color:{_TEXT_DIM};">{html.escape(security)}</p>
|
||||
"""
|
||||
|
||||
rendered = _layout(
|
||||
settings=settings,
|
||||
preheader=preheader,
|
||||
heading=heading,
|
||||
intro_html=html.escape(intro),
|
||||
body_html=body_html,
|
||||
footer_html=footer,
|
||||
)
|
||||
return EmailContent(subject=subject, text=text, html=rendered)
|
||||
|
||||
|
||||
def render_account_merged(
|
||||
settings: Settings,
|
||||
*,
|
||||
language_code: Optional[str],
|
||||
primary_user_id: Optional[int],
|
||||
removed_user_id: Optional[int],
|
||||
final_end_date_text: str,
|
||||
i18n: Optional[JsonI18n] = None,
|
||||
) -> EmailContent:
|
||||
i18n = _resolve_i18n(i18n)
|
||||
lang = _normalize_lang(language_code, settings)
|
||||
brand = _brand_title(settings)
|
||||
primary = "—" if primary_user_id is None else f"#{primary_user_id}"
|
||||
removed = "—" if removed_user_id is None else f"#{removed_user_id}"
|
||||
end_date = final_end_date_text or "—"
|
||||
|
||||
subject = _t_text(i18n, lang, "email_account_merged_subject")
|
||||
preheader = _t_text(i18n, lang, "email_account_merged_preheader")
|
||||
heading = _t_text(i18n, lang, "email_account_merged_heading")
|
||||
intro = _t_text(i18n, lang, "email_account_merged_intro")
|
||||
note = _t_text(i18n, lang, "email_account_merged_note")
|
||||
footer = _t_html(i18n, lang, "email_footer_auto", brand=brand)
|
||||
text = _t_text(
|
||||
i18n,
|
||||
lang,
|
||||
"email_account_merged_text",
|
||||
primary=primary,
|
||||
removed=removed,
|
||||
end_date=end_date,
|
||||
)
|
||||
|
||||
rows = [
|
||||
(_t_text(i18n, lang, "email_account_merged_row_kept"), primary),
|
||||
(_t_text(i18n, lang, "email_account_merged_row_removed"), removed),
|
||||
(_t_text(i18n, lang, "email_account_merged_row_end_date"), end_date),
|
||||
]
|
||||
body_html = (
|
||||
_info_rows_html(rows)
|
||||
+ f'<p style="margin:0;font-size:12px;line-height:1.55;color:{_TEXT_DIM};">{html.escape(note)}</p>'
|
||||
)
|
||||
|
||||
rendered = _layout(
|
||||
settings=settings,
|
||||
preheader=preheader,
|
||||
heading=heading,
|
||||
intro_html=html.escape(intro),
|
||||
body_html=body_html,
|
||||
footer_html=footer,
|
||||
)
|
||||
return EmailContent(subject=subject, text=text, html=rendered)
|
||||
|
||||
|
||||
def render_payment_success(
|
||||
settings: Settings,
|
||||
*,
|
||||
language_code: Optional[str],
|
||||
sale_mode: str,
|
||||
months: int,
|
||||
traffic_gb: Optional[float],
|
||||
amount: float,
|
||||
currency: str,
|
||||
end_date_text: str,
|
||||
dashboard_url: Optional[str],
|
||||
provider_label: Optional[str] = None,
|
||||
i18n: Optional[JsonI18n] = None,
|
||||
) -> EmailContent:
|
||||
i18n = _resolve_i18n(i18n)
|
||||
lang = _normalize_lang(language_code, settings)
|
||||
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
|
||||
brand = _brand_title(settings)
|
||||
is_traffic = sale_mode == "traffic"
|
||||
amount_text = _format_amount(amount, currency)
|
||||
safe_dashboard_url = (dashboard_url or "").strip()
|
||||
end_date = end_date_text or "—"
|
||||
traffic_label = _format_traffic(traffic_gb)
|
||||
|
||||
subject = _t_text(i18n, lang, "email_payment_success_subject")
|
||||
preheader = _t_text(i18n, lang, "email_payment_success_preheader")
|
||||
heading = _t_text(i18n, lang, "email_payment_success_heading")
|
||||
footer_note = _t_text(i18n, lang, "email_payment_success_footer_note")
|
||||
footer = _t_html(i18n, lang, "email_footer_auto", brand=brand)
|
||||
cta_label = _t_text(i18n, lang, "email_payment_success_cta")
|
||||
|
||||
if is_traffic:
|
||||
intro = _t_text(i18n, lang, "email_payment_success_intro_traffic", traffic_gb=traffic_label)
|
||||
period_label = _t_text(i18n, lang, "email_payment_success_row_traffic")
|
||||
period_value = _t_text(i18n, lang, "email_payment_success_traffic_value", traffic_gb=traffic_label)
|
||||
text = _t_text(
|
||||
i18n,
|
||||
lang,
|
||||
"email_payment_success_text_traffic",
|
||||
amount=amount_text,
|
||||
traffic_gb=traffic_label,
|
||||
end_date=end_date,
|
||||
)
|
||||
else:
|
||||
months_int = int(months or 0)
|
||||
intro = _t_text(i18n, lang, "email_payment_success_intro_subscription", months=months_int)
|
||||
period_label = _t_text(i18n, lang, "email_payment_success_row_period")
|
||||
period_value = _t_text(
|
||||
i18n,
|
||||
lang,
|
||||
"email_payment_success_period_value",
|
||||
months=months_int,
|
||||
)
|
||||
text = _t_text(
|
||||
i18n,
|
||||
lang,
|
||||
"email_payment_success_text_subscription",
|
||||
amount=amount_text,
|
||||
months=months_int,
|
||||
end_date=end_date,
|
||||
)
|
||||
|
||||
rows: list[Tuple[str, str]] = [
|
||||
(period_label, period_value),
|
||||
(_t_text(i18n, lang, "email_payment_success_row_amount"), amount_text),
|
||||
(_t_text(i18n, lang, "email_payment_success_row_end_date"), end_date),
|
||||
]
|
||||
if provider_label:
|
||||
rows.append((_t_text(i18n, lang, "email_payment_success_row_method"), provider_label))
|
||||
|
||||
text_lines = [text]
|
||||
if safe_dashboard_url:
|
||||
text_lines.append(_t_text(i18n, lang, "email_payment_success_text_dashboard", url=safe_dashboard_url))
|
||||
|
||||
body_parts = [_info_rows_html(rows)]
|
||||
if safe_dashboard_url:
|
||||
body_parts.append(_cta_button_html(label=cta_label, url=safe_dashboard_url, accent=accent))
|
||||
body_parts.append(
|
||||
f'<p style="margin:6px 0 0 0;font-size:12px;line-height:1.55;color:{_TEXT_DIM};">{html.escape(footer_note)}</p>'
|
||||
)
|
||||
|
||||
rendered = _layout(
|
||||
settings=settings,
|
||||
preheader=preheader,
|
||||
heading=heading,
|
||||
intro_html=html.escape(intro),
|
||||
body_html="".join(body_parts),
|
||||
footer_html=footer,
|
||||
)
|
||||
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
|
||||
|
||||
|
||||
def render_subscription_expiring(
|
||||
settings: Settings,
|
||||
*,
|
||||
language_code: Optional[str],
|
||||
days_left: int,
|
||||
end_date_text: str,
|
||||
dashboard_url: Optional[str],
|
||||
i18n: Optional[JsonI18n] = None,
|
||||
) -> EmailContent:
|
||||
i18n = _resolve_i18n(i18n)
|
||||
lang = _normalize_lang(language_code, settings)
|
||||
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
|
||||
brand = _brand_title(settings)
|
||||
safe_dashboard_url = (dashboard_url or "").strip()
|
||||
days = max(0, int(days_left))
|
||||
end_date = end_date_text or "—"
|
||||
|
||||
if days == 0:
|
||||
suffix = "today"
|
||||
elif days == 1:
|
||||
suffix = "tomorrow"
|
||||
else:
|
||||
suffix = "days"
|
||||
|
||||
subject = _t_text(i18n, lang, f"email_subscription_expiring_subject_{suffix}", days=days)
|
||||
heading = _t_text(i18n, lang, f"email_subscription_expiring_heading_{suffix}", days=days)
|
||||
preheader = _t_text(i18n, lang, f"email_subscription_expiring_preheader_{suffix}", days=days)
|
||||
intro = _t_text(i18n, lang, f"email_subscription_expiring_intro_{suffix}", days=days)
|
||||
note = _t_text(i18n, lang, "email_subscription_expiring_note")
|
||||
footer = _t_html(i18n, lang, "email_footer_auto", brand=brand)
|
||||
cta_label = _t_text(i18n, lang, "email_subscription_expiring_cta")
|
||||
|
||||
rows = [
|
||||
(_t_text(i18n, lang, "email_subscription_expiring_row_days_left"), str(days)),
|
||||
(_t_text(i18n, lang, "email_subscription_expiring_row_end_date"), end_date),
|
||||
]
|
||||
|
||||
text_lines = [
|
||||
_t_text(i18n, lang, "email_subscription_expiring_text", heading=heading, end_date=end_date),
|
||||
]
|
||||
if safe_dashboard_url:
|
||||
text_lines.append(
|
||||
_t_text(i18n, lang, "email_subscription_expiring_text_renew", url=safe_dashboard_url)
|
||||
)
|
||||
|
||||
body_parts = [_info_rows_html(rows)]
|
||||
if safe_dashboard_url:
|
||||
body_parts.append(_cta_button_html(label=cta_label, url=safe_dashboard_url, accent=accent))
|
||||
body_parts.append(
|
||||
f'<p style="margin:6px 0 0 0;font-size:12px;line-height:1.55;color:{_TEXT_DIM};">{html.escape(note)}</p>'
|
||||
)
|
||||
|
||||
rendered = _layout(
|
||||
settings=settings,
|
||||
preheader=preheader,
|
||||
heading=heading,
|
||||
intro_html=html.escape(intro),
|
||||
body_html="".join(body_parts),
|
||||
footer_html=footer,
|
||||
)
|
||||
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
|
||||
@@ -9,6 +9,8 @@ from sqlalchemy.orm import sessionmaker
|
||||
from typing import Optional
|
||||
from config.settings import Settings
|
||||
from .panel_api_service import PanelApiService
|
||||
from .email_auth_service import EmailAuthService
|
||||
from .email_templates import render_subscription_expiring
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup, get_autorenew_cancel_keyboard
|
||||
from db.dal import user_dal
|
||||
@@ -64,6 +66,7 @@ class PanelWebhookService:
|
||||
internal_user_id = db_user.user_id if db_user else user_id
|
||||
lang = db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE
|
||||
first_name = db_user.first_name or f"User {user_id}" if db_user else f"User {user_id}"
|
||||
user_email = (db_user.email or "").strip() if db_user else ""
|
||||
|
||||
markup = get_subscribe_only_markup(lang, self.i18n)
|
||||
|
||||
@@ -122,6 +125,13 @@ class PanelWebhookService:
|
||||
user_name=first_name,
|
||||
end_date=user_payload.get("expireAt", "")[:10],
|
||||
)
|
||||
if days_left == 3 and user_email:
|
||||
await self._send_subscription_expiring_email(
|
||||
recipient=user_email,
|
||||
lang=lang,
|
||||
days_left=days_left,
|
||||
end_date_text=user_payload.get("expireAt", "")[:10],
|
||||
)
|
||||
elif event_name == "user.expired":
|
||||
if self.settings.SUBSCRIPTION_NOTIFY_ON_EXPIRE:
|
||||
await self._send_message(
|
||||
@@ -142,6 +152,30 @@ class PanelWebhookService:
|
||||
end_date=user_payload.get("expireAt", "")[:10],
|
||||
)
|
||||
|
||||
async def _send_subscription_expiring_email(
|
||||
self,
|
||||
*,
|
||||
recipient: str,
|
||||
lang: str,
|
||||
days_left: int,
|
||||
end_date_text: str,
|
||||
) -> None:
|
||||
"""Best-effort branded reminder; silently no-ops without SMTP config."""
|
||||
if not self.settings.email_auth_configured:
|
||||
return
|
||||
try:
|
||||
content = render_subscription_expiring(
|
||||
self.settings,
|
||||
language_code=lang,
|
||||
days_left=days_left,
|
||||
end_date_text=end_date_text,
|
||||
dashboard_url=(self.settings.SUBSCRIPTION_MINI_APP_URL or "").strip() or None,
|
||||
)
|
||||
email_service = EmailAuthService(self.settings)
|
||||
await email_service.send_rendered_email(email=recipient, content=content)
|
||||
except Exception:
|
||||
logging.exception("Failed to send subscription-expiring email to %s", recipient)
|
||||
|
||||
async def handle_webhook(self, raw_body: bytes, signature_header: Optional[str]) -> web.Response:
|
||||
if not self.settings.PANEL_WEBHOOK_SECRET:
|
||||
return web.Response(status=401, text="unauthorized")
|
||||
|
||||
@@ -12,6 +12,8 @@ from db.models import User, Subscription
|
||||
|
||||
from config.settings import Settings
|
||||
from .panel_api_service import PanelApiService
|
||||
from .email_auth_service import EmailAuthService
|
||||
from .email_templates import render_payment_success
|
||||
|
||||
|
||||
class SubscriptionService:
|
||||
@@ -574,6 +576,16 @@ class SubscriptionService:
|
||||
final_subscription_url = updated_panel_user.get("subscriptionUrl")
|
||||
final_panel_short_uuid = updated_panel_user.get("shortUuid", panel_short_uuid)
|
||||
|
||||
await self._send_payment_success_email(
|
||||
db_user=db_user,
|
||||
sale_mode="traffic",
|
||||
months=0,
|
||||
traffic_gb=float(traffic_gb),
|
||||
payment_amount=payment_amount,
|
||||
end_date=None,
|
||||
provider=provider,
|
||||
)
|
||||
|
||||
return {
|
||||
"subscription_id": new_or_updated_sub.subscription_id,
|
||||
"end_date": final_end_date,
|
||||
@@ -736,6 +748,16 @@ class SubscriptionService:
|
||||
final_subscription_url = updated_panel_user.get("subscriptionUrl")
|
||||
final_panel_short_uuid = updated_panel_user.get("shortUuid", panel_short_uuid)
|
||||
|
||||
await self._send_payment_success_email(
|
||||
db_user=db_user,
|
||||
sale_mode="subscription",
|
||||
months=months_int,
|
||||
traffic_gb=None,
|
||||
payment_amount=payment_amount,
|
||||
end_date=final_end_date,
|
||||
provider=provider,
|
||||
)
|
||||
|
||||
return {
|
||||
"subscription_id": new_or_updated_sub.subscription_id,
|
||||
"end_date": final_end_date,
|
||||
@@ -1067,6 +1089,61 @@ class SubscriptionService:
|
||||
logging.info(f"Auto-renew initiated for user {sub.user_id} payment_id={resp.get('id')}")
|
||||
return True
|
||||
|
||||
_PROVIDER_LABELS = {
|
||||
"yookassa": "YooKassa",
|
||||
"freekassa": "FreeKassa",
|
||||
"platega": "Platega",
|
||||
"severpay": "SeverPay",
|
||||
"cryptopay": "Crypto Pay",
|
||||
"crypto_pay": "Crypto Pay",
|
||||
"stars": "Telegram Stars",
|
||||
"tribute": "Tribute",
|
||||
}
|
||||
|
||||
async def _send_payment_success_email(
|
||||
self,
|
||||
*,
|
||||
db_user: User,
|
||||
sale_mode: str,
|
||||
months: int,
|
||||
traffic_gb: Optional[float],
|
||||
payment_amount: float,
|
||||
end_date: Optional[datetime],
|
||||
provider: str,
|
||||
) -> None:
|
||||
"""Best-effort branded email confirming the payment. No-op if SMTP or
|
||||
the user's email aren't set. Failures are logged and swallowed so the
|
||||
payment flow is never blocked by mail delivery."""
|
||||
if not self.settings.email_auth_configured:
|
||||
return
|
||||
recipient = (db_user.email or "").strip() if db_user else ""
|
||||
if not recipient:
|
||||
return
|
||||
|
||||
end_date_text = end_date.strftime("%Y-%m-%d") if end_date else ""
|
||||
provider_label = self._PROVIDER_LABELS.get((provider or "").lower())
|
||||
dashboard_url = (self.settings.SUBSCRIPTION_MINI_APP_URL or "").strip() or None
|
||||
|
||||
try:
|
||||
content = render_payment_success(
|
||||
self.settings,
|
||||
language_code=db_user.language_code or self.settings.DEFAULT_LANGUAGE,
|
||||
sale_mode=sale_mode,
|
||||
months=int(months or 0),
|
||||
traffic_gb=traffic_gb,
|
||||
amount=float(payment_amount or 0),
|
||||
currency=self.settings.DEFAULT_CURRENCY_SYMBOL,
|
||||
end_date_text=end_date_text,
|
||||
dashboard_url=dashboard_url,
|
||||
provider_label=provider_label,
|
||||
)
|
||||
email_service = EmailAuthService(self.settings)
|
||||
await email_service.send_rendered_email(email=recipient, content=content)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"Failed to send payment success email to user %s", db_user.user_id
|
||||
)
|
||||
|
||||
async def update_last_notification_sent(
|
||||
self, session: AsyncSession, user_id: int, subscription_end_date: datetime
|
||||
):
|
||||
|
||||
+58
-1
@@ -518,5 +518,62 @@
|
||||
"admin_ads_delete_confirm": "Are you sure you want to delete campaign #{id}? This action is irreversible.",
|
||||
"admin_ads_deleted_success": "Campaign deleted.",
|
||||
"admin_ads_not_found": "Campaign not found.",
|
||||
"free_kassa_order_full": "Order #{order_id} from {date}\n\n"
|
||||
"free_kassa_order_full": "Order #{order_id} from {date}\n\n",
|
||||
|
||||
"email_footer_auto": "Sent automatically by {brand}. Please don't reply.",
|
||||
|
||||
"email_login_code_subject": "{code} — your sign-in code",
|
||||
"email_login_code_preheader": "Your one-time code expires in {minutes} min.",
|
||||
"email_login_code_heading": "Confirm your sign-in",
|
||||
"email_login_code_intro": "Use this 6-digit code to finish signing in to your subscription dashboard.",
|
||||
"email_login_code_expiry_html": "The code is valid for <strong style=\"color:#e6e9ef;\">{minutes} min</strong>.",
|
||||
"email_login_code_security": "If you didn't request this code, you can ignore this email — your account stays safe.",
|
||||
"email_login_code_text": "Your verification code: {code}\n\nThe code is valid for {minutes} min.\nIf you didn't request this code, ignore this message.",
|
||||
|
||||
"email_account_merged_subject": "Accounts merged",
|
||||
"email_account_merged_preheader": "Your accounts were combined into one profile.",
|
||||
"email_account_merged_heading": "Accounts merged",
|
||||
"email_account_merged_intro": "We merged your accounts into a single profile so you keep one subscription and one history of payments.",
|
||||
"email_account_merged_row_kept": "Kept account",
|
||||
"email_account_merged_row_removed": "Removed account",
|
||||
"email_account_merged_row_end_date": "New end date",
|
||||
"email_account_merged_note": "Your subscription link is unchanged. The duplicate panel user was removed automatically.",
|
||||
"email_account_merged_text": "We merged your accounts into one profile.\n\nKept account: {primary}\nRemoved account: {removed}\nPaid periods were combined. New subscription end date: {end_date}.\nYour subscription link stayed the same, and the later account was removed from Remnawave automatically.",
|
||||
|
||||
"email_payment_success_subject": "Payment received",
|
||||
"email_payment_success_preheader": "Thanks — your subscription has been activated.",
|
||||
"email_payment_success_heading": "Payment received",
|
||||
"email_payment_success_intro_subscription": "Thanks! Your subscription has been extended by {months} month(s).",
|
||||
"email_payment_success_intro_traffic": "Thanks! We added {traffic_gb} GB of traffic to your account.",
|
||||
"email_payment_success_row_period": "Period",
|
||||
"email_payment_success_row_traffic": "Traffic added",
|
||||
"email_payment_success_row_amount": "Amount",
|
||||
"email_payment_success_row_end_date": "Active until",
|
||||
"email_payment_success_row_method": "Method",
|
||||
"email_payment_success_period_value": "{months} month(s)",
|
||||
"email_payment_success_traffic_value": "{traffic_gb} GB",
|
||||
"email_payment_success_cta": "Open dashboard",
|
||||
"email_payment_success_footer_note": "We'll remind you a few days before the subscription ends.",
|
||||
"email_payment_success_text_subscription": "Payment received: {amount}.\nSubscription extended by {months} month(s).\nActive until: {end_date}.",
|
||||
"email_payment_success_text_traffic": "Payment received: {amount}.\nTraffic added: {traffic_gb} GB.\nActive until: {end_date}.",
|
||||
"email_payment_success_text_dashboard": "Dashboard: {url}",
|
||||
|
||||
"email_subscription_expiring_subject_today": "Your subscription ends today",
|
||||
"email_subscription_expiring_subject_tomorrow": "1 day left on your subscription",
|
||||
"email_subscription_expiring_subject_days": "{days} days left on your subscription",
|
||||
"email_subscription_expiring_heading_today": "Subscription ends today",
|
||||
"email_subscription_expiring_heading_tomorrow": "1 day left on your subscription",
|
||||
"email_subscription_expiring_heading_days": "{days} days left on your subscription",
|
||||
"email_subscription_expiring_preheader_today": "Renew today to avoid disconnection.",
|
||||
"email_subscription_expiring_preheader_tomorrow": "Your subscription expires tomorrow.",
|
||||
"email_subscription_expiring_preheader_days": "Your subscription expires in {days} days.",
|
||||
"email_subscription_expiring_intro_today": "Your subscription is about to expire. Renew now to keep your connection active without interruption.",
|
||||
"email_subscription_expiring_intro_tomorrow": "Your subscription expires tomorrow. Renew now to avoid losing access.",
|
||||
"email_subscription_expiring_intro_days": "A heads-up so you can renew in advance and avoid any interruption.",
|
||||
"email_subscription_expiring_row_days_left": "Days left",
|
||||
"email_subscription_expiring_row_end_date": "Active until",
|
||||
"email_subscription_expiring_cta": "Renew subscription",
|
||||
"email_subscription_expiring_note": "If you've already renewed or use auto-renewal, you can ignore this email.",
|
||||
"email_subscription_expiring_text": "{heading}.\nActive until: {end_date}.",
|
||||
"email_subscription_expiring_text_renew": "Renew: {url}"
|
||||
}
|
||||
|
||||
+58
-1
@@ -518,5 +518,62 @@
|
||||
"admin_ads_delete_confirm": "Вы уверены, что хотите удалить кампанию #{id}? Это действие необратимо.",
|
||||
"admin_ads_deleted_success": "Кампания удалена.",
|
||||
"admin_ads_not_found": "Кампания не найдена.",
|
||||
"free_kassa_order_full": "Заказ №{order_id} от {date}\n\n"
|
||||
"free_kassa_order_full": "Заказ №{order_id} от {date}\n\n",
|
||||
|
||||
"email_footer_auto": "Это автоматическое письмо от {brand}. Отвечать на него не нужно.",
|
||||
|
||||
"email_login_code_subject": "{code} — код для входа",
|
||||
"email_login_code_preheader": "Одноразовый код действует {minutes} мин.",
|
||||
"email_login_code_heading": "Подтвердите вход",
|
||||
"email_login_code_intro": "Введите этот 6-значный код, чтобы завершить вход в личный кабинет подписки.",
|
||||
"email_login_code_expiry_html": "Код действует <strong style=\"color:#e6e9ef;\">{minutes} мин</strong>.",
|
||||
"email_login_code_security": "Если вы не запрашивали код, просто проигнорируйте это письмо — ваш аккаунт в безопасности.",
|
||||
"email_login_code_text": "Ваш код подтверждения: {code}\n\nКод действует {minutes} мин.\nЕсли вы не запрашивали код, проигнорируйте это письмо.",
|
||||
|
||||
"email_account_merged_subject": "Аккаунты объединены",
|
||||
"email_account_merged_preheader": "Мы объединили ваши аккаунты в один профиль.",
|
||||
"email_account_merged_heading": "Аккаунты объединены",
|
||||
"email_account_merged_intro": "Мы объединили ваши аккаунты в один профиль — теперь у вас одна подписка и общая история оплат.",
|
||||
"email_account_merged_row_kept": "Оставлен аккаунт",
|
||||
"email_account_merged_row_removed": "Удалён аккаунт",
|
||||
"email_account_merged_row_end_date": "Новая дата окончания",
|
||||
"email_account_merged_note": "Ссылка на подписку осталась прежней. Дублирующий пользователь панели был удалён автоматически.",
|
||||
"email_account_merged_text": "Мы объединили ваши аккаунты в один профиль.\n\nОставлен аккаунт: {primary}\nУдалён аккаунт: {removed}\nОплаченные периоды сложились. Новая дата окончания подписки: {end_date}.\nСсылка на подписку осталась прежней, а более поздний аккаунт был удалён из Remnawave автоматически.",
|
||||
|
||||
"email_payment_success_subject": "Платёж получен",
|
||||
"email_payment_success_preheader": "Спасибо — подписка активирована.",
|
||||
"email_payment_success_heading": "Платёж получен",
|
||||
"email_payment_success_intro_subscription": "Спасибо! Ваша подписка продлена на {months} мес.",
|
||||
"email_payment_success_intro_traffic": "Спасибо! Мы зачислили {traffic_gb} ГБ трафика на ваш аккаунт.",
|
||||
"email_payment_success_row_period": "Период",
|
||||
"email_payment_success_row_traffic": "Трафик",
|
||||
"email_payment_success_row_amount": "Сумма",
|
||||
"email_payment_success_row_end_date": "Действует до",
|
||||
"email_payment_success_row_method": "Способ оплаты",
|
||||
"email_payment_success_period_value": "{months} мес.",
|
||||
"email_payment_success_traffic_value": "{traffic_gb} ГБ",
|
||||
"email_payment_success_cta": "Открыть кабинет",
|
||||
"email_payment_success_footer_note": "За несколько дней до окончания мы напомним о продлении.",
|
||||
"email_payment_success_text_subscription": "Платёж получен: {amount}.\nПодписка продлена на {months} мес.\nДействует до: {end_date}.",
|
||||
"email_payment_success_text_traffic": "Платёж получен: {amount}.\nЗачислено трафика: {traffic_gb} ГБ.\nДействует до: {end_date}.",
|
||||
"email_payment_success_text_dashboard": "Кабинет: {url}",
|
||||
|
||||
"email_subscription_expiring_subject_today": "Подписка заканчивается сегодня",
|
||||
"email_subscription_expiring_subject_tomorrow": "Подписка заканчивается завтра",
|
||||
"email_subscription_expiring_subject_days": "До конца подписки осталось {days} дн.",
|
||||
"email_subscription_expiring_heading_today": "Подписка заканчивается сегодня",
|
||||
"email_subscription_expiring_heading_tomorrow": "Подписка заканчивается завтра",
|
||||
"email_subscription_expiring_heading_days": "До конца подписки {days} дн.",
|
||||
"email_subscription_expiring_preheader_today": "Продлите сегодня, чтобы не потерять доступ.",
|
||||
"email_subscription_expiring_preheader_tomorrow": "Подписка истекает завтра.",
|
||||
"email_subscription_expiring_preheader_days": "Подписка заканчивается через {days} дн.",
|
||||
"email_subscription_expiring_intro_today": "Ваша подписка вот-вот истечёт. Продлите её, чтобы соединение не прерывалось.",
|
||||
"email_subscription_expiring_intro_tomorrow": "Ваша подписка истекает завтра. Продлите её, чтобы не потерять доступ.",
|
||||
"email_subscription_expiring_intro_days": "Напоминаем заранее, чтобы вы успели продлить подписку без перерыва в работе.",
|
||||
"email_subscription_expiring_row_days_left": "Осталось дней",
|
||||
"email_subscription_expiring_row_end_date": "Действует до",
|
||||
"email_subscription_expiring_cta": "Продлить подписку",
|
||||
"email_subscription_expiring_note": "Если вы уже продлили или включили автопродление — просто проигнорируйте это письмо.",
|
||||
"email_subscription_expiring_text": "{heading}.\nДействует до: {end_date}.",
|
||||
"email_subscription_expiring_text_renew": "Продлить: {url}"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user