Template
Initial commit: VPN Telegram bot (sanitized defaults)
This commit is contained in:
@@ -0,0 +1,364 @@
|
||||
"""SMTP-рассылка важных уведомлений пользователям."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import html as html_lib
|
||||
import logging
|
||||
import re
|
||||
import smtplib
|
||||
from email.message import EmailMessage
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from config import Settings
|
||||
from services.db import Database
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_TAG_RE = re.compile(r"<[^>]+>")
|
||||
_WS_RE = re.compile(r"[ \t]+\n")
|
||||
|
||||
# Фирменные цвета VPN Service (email-safe, без «фиолетового AI»)
|
||||
_ACCENT = "#0d9f6e"
|
||||
_ACCENT_DARK = "#0a7a54"
|
||||
_BG = "#eef3f0"
|
||||
_CARD = "#ffffff"
|
||||
_TEXT = "#12201a"
|
||||
_MUTED = "#5c7167"
|
||||
_LINE = "#d5e0da"
|
||||
_WARN_BG = "#fff8e8"
|
||||
_WARN_BORDER = "#e8c96a"
|
||||
_WARN_TEXT = "#6b5420"
|
||||
|
||||
|
||||
def html_to_plain(html: str) -> str:
|
||||
text = (html or "").replace("<br>", "\n").replace("<br/>", "\n").replace("<br />", "\n")
|
||||
text = text.replace("</p>", "\n").replace("</div>", "\n").replace("</li>", "\n")
|
||||
text = _TAG_RE.sub("", text)
|
||||
text = (
|
||||
text.replace(" ", " ")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("&", "&")
|
||||
.replace(""", '"')
|
||||
)
|
||||
text = _WS_RE.sub("\n", text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
def _e(value: str) -> str:
|
||||
return html_lib.escape(value or "", quote=True)
|
||||
|
||||
|
||||
def smtp_configured(settings: Settings) -> bool:
|
||||
return bool((settings.smtp_host or "").strip() and (settings.smtp_from or "").strip())
|
||||
|
||||
|
||||
def render_email_layout(
|
||||
*,
|
||||
title: str,
|
||||
body_html: str,
|
||||
preheader: str = "",
|
||||
footer_note: str = "",
|
||||
webapp_url: str = "",
|
||||
cta_url: str = "",
|
||||
cta_label: str = "",
|
||||
) -> str:
|
||||
"""Брендированная HTML-обёртка для писем (таблицы + inline CSS)."""
|
||||
title_e = _e(title)
|
||||
pre_e = _e(preheader or title)
|
||||
footer_e = _e(
|
||||
footer_note
|
||||
or "Это письмо отправлено автоматически. Отвечать на него не нужно."
|
||||
)
|
||||
brand_url = (webapp_url or "").rstrip("/")
|
||||
brand_link = (
|
||||
f'<a href="{_e(brand_url)}" style="color:{_ACCENT};text-decoration:none;font-weight:600;">'
|
||||
f"Открыть кабинет</a>"
|
||||
if brand_url
|
||||
else ""
|
||||
)
|
||||
|
||||
cta_block = ""
|
||||
if cta_url and cta_label:
|
||||
cta_block = f"""
|
||||
<tr>
|
||||
<td style="padding:8px 28px 20px;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td style="border-radius:10px;background:{_ACCENT};">
|
||||
<a href="{_e(cta_url)}"
|
||||
style="display:inline-block;padding:14px 28px;font-family:Segoe UI,Roboto,Helvetica,Arial,sans-serif;
|
||||
font-size:15px;font-weight:700;color:#ffffff;text-decoration:none;border-radius:10px;">
|
||||
{_e(cta_label)}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>"""
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="light" />
|
||||
<meta name="supported-color-schemes" content="light" />
|
||||
<title>{title_e}</title>
|
||||
<!--[if mso]><style>table,td{{font-family:Arial,sans-serif!important}}</style><![endif]-->
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background:{_BG};">
|
||||
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;">
|
||||
{pre_e}
|
||||
</div>
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"
|
||||
style="background:{_BG};padding:24px 12px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"
|
||||
style="max-width:560px;background:{_CARD};border-radius:16px;overflow:hidden;
|
||||
border:1px solid {_LINE};box-shadow:0 8px 28px rgba(18,32,26,0.08);">
|
||||
<tr>
|
||||
<td style="background:linear-gradient(135deg,{_ACCENT_DARK} 0%,{_ACCENT} 100%);
|
||||
background-color:{_ACCENT};padding:22px 28px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td style="font-family:Segoe UI,Roboto,Helvetica,Arial,sans-serif;
|
||||
font-size:22px;font-weight:800;color:#ffffff;letter-spacing:-0.02em;">
|
||||
🦊 VPN Service
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding-top:4px;font-family:Segoe UI,Roboto,Helvetica,Arial,sans-serif;
|
||||
font-size:13px;color:rgba(255,255,255,0.85);">
|
||||
Безопасный доступ · личный кабинет
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:28px 28px 8px;font-family:Segoe UI,Roboto,Helvetica,Arial,sans-serif;
|
||||
color:{_TEXT};">
|
||||
<h1 style="margin:0 0 16px;font-size:20px;line-height:1.3;font-weight:700;color:{_TEXT};">
|
||||
{title_e}
|
||||
</h1>
|
||||
<div style="font-size:15px;line-height:1.55;color:{_TEXT};">
|
||||
{body_html}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{cta_block}
|
||||
<tr>
|
||||
<td style="padding:8px 28px 24px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"
|
||||
style="border-top:1px solid {_LINE};">
|
||||
<tr>
|
||||
<td style="padding-top:16px;font-family:Segoe UI,Roboto,Helvetica,Arial,sans-serif;
|
||||
font-size:12px;line-height:1.5;color:{_MUTED};">
|
||||
{footer_e}
|
||||
{"<br/>" + brand_link if brand_link else ""}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="margin:16px 0 0;font-family:Segoe UI,Roboto,Helvetica,Arial,sans-serif;
|
||||
font-size:11px;color:{_MUTED};">
|
||||
© VPN Service
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
def render_info_box(html_inner: str, *, kind: str = "info") -> str:
|
||||
if kind == "warn":
|
||||
bg, border, color = _WARN_BG, _WARN_BORDER, _WARN_TEXT
|
||||
else:
|
||||
bg, border, color = "#f0faf5", _LINE, _MUTED
|
||||
return f"""
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"
|
||||
style="margin:16px 0;background:{bg};border:1px solid {border};border-radius:12px;">
|
||||
<tr>
|
||||
<td style="padding:14px 16px;font-family:Segoe UI,Roboto,Helvetica,Arial,sans-serif;
|
||||
font-size:13px;line-height:1.5;color:{color};">
|
||||
{html_inner}
|
||||
</td>
|
||||
</tr>
|
||||
</table>"""
|
||||
|
||||
|
||||
def render_password_reset_email(*, reset_url: str, webapp_url: str = "") -> str:
|
||||
body = f"""
|
||||
<p style="margin:0 0 12px;">Мы получили запрос на сброс пароля для вашего аккаунта VPN Service.</p>
|
||||
<p style="margin:0 0 4px;">Нажмите кнопку ниже, чтобы задать новый пароль. Ссылка действует <strong>1 час</strong>.</p>
|
||||
{render_info_box(
|
||||
"<strong>Безопасность.</strong> Если вы не запрашивали сброс — просто проигнорируйте это письмо. "
|
||||
"Пароль не изменится, пока вы сами не подтвердите действие по ссылке.",
|
||||
kind="warn",
|
||||
)}
|
||||
<p style="margin:0;font-size:12px;color:{_MUTED};">
|
||||
Кнопка не работает? Скопируйте ссылку в браузер:<br/>
|
||||
<a href="{_e(reset_url)}" style="color:{_ACCENT};word-break:break-all;">{_e(reset_url)}</a>
|
||||
</p>
|
||||
"""
|
||||
return render_email_layout(
|
||||
title="Сброс пароля",
|
||||
preheader="Ссылка для сброса пароля действует 1 час",
|
||||
body_html=body,
|
||||
cta_url=reset_url,
|
||||
cta_label="Сбросить пароль",
|
||||
webapp_url=webapp_url,
|
||||
footer_note="Письмо отправлено, потому что для аккаунта указан этот email.",
|
||||
)
|
||||
|
||||
|
||||
def render_smtp_test_email(*, webapp_url: str = "") -> str:
|
||||
body = f"""
|
||||
<p style="margin:0 0 12px;">Поздравляем — SMTP настроен правильно.</p>
|
||||
<p style="margin:0 0 12px;">Это тестовое письмо из админ-панели VPN Service. Теперь можно отправлять
|
||||
сброс пароля и важные уведомления пользователям.</p>
|
||||
{render_info_box(
|
||||
"<strong>Что дальше.</strong> Убедитесь, что у пользователей сохранён email в кабинете, "
|
||||
"и проверьте папку «Спам», если письмо не видно во входящих.",
|
||||
kind="info",
|
||||
)}
|
||||
"""
|
||||
return render_email_layout(
|
||||
title="SMTP работает",
|
||||
preheader="Тестовое письмо — почта настроена верно",
|
||||
body_html=body,
|
||||
webapp_url=webapp_url,
|
||||
cta_url=(webapp_url or "").rstrip("/") + "/admin" if webapp_url else "",
|
||||
cta_label="Открыть админку" if webapp_url else "",
|
||||
footer_note="Тест из раздела Админка → SMTP.",
|
||||
)
|
||||
|
||||
|
||||
def render_notification_email(
|
||||
*,
|
||||
title: str,
|
||||
body_html: str,
|
||||
webapp_url: str = "",
|
||||
preheader: str = "",
|
||||
) -> str:
|
||||
"""Обернуть произвольное HTML-уведомление в фирменный шаблон."""
|
||||
return render_email_layout(
|
||||
title=title,
|
||||
preheader=preheader or title,
|
||||
body_html=f'<div style="font-size:15px;line-height:1.55;">{body_html}</div>',
|
||||
webapp_url=webapp_url,
|
||||
footer_note="Уведомление аккаунта VPN Service. Отключить email можно в кабинете → Аккаунт.",
|
||||
)
|
||||
|
||||
|
||||
def send_email_sync(
|
||||
settings: Settings,
|
||||
*,
|
||||
to: str,
|
||||
subject: str,
|
||||
html: str,
|
||||
plain: str | None = None,
|
||||
) -> None:
|
||||
host = (settings.smtp_host or "").strip()
|
||||
from_addr = (settings.smtp_from or "").strip()
|
||||
to_addr = (to or "").strip()
|
||||
if not host or not from_addr or not to_addr:
|
||||
raise ValueError("SMTP не настроен или нет получателя")
|
||||
|
||||
msg = EmailMessage()
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = from_addr
|
||||
msg["To"] = to_addr
|
||||
body_plain = plain if plain is not None else html_to_plain(html)
|
||||
msg.set_content(body_plain or subject)
|
||||
if html.strip():
|
||||
msg.add_alternative(html, subtype="html")
|
||||
|
||||
port = int(settings.smtp_port or 587)
|
||||
user = (settings.smtp_user or "").strip()
|
||||
password = settings.smtp_password or ""
|
||||
use_ssl = bool(settings.smtp_ssl)
|
||||
use_tls = bool(settings.smtp_tls) and not use_ssl
|
||||
|
||||
if use_ssl:
|
||||
with smtplib.SMTP_SSL(host, port, timeout=30) as smtp:
|
||||
if user:
|
||||
smtp.login(user, password)
|
||||
smtp.send_message(msg)
|
||||
return
|
||||
|
||||
with smtplib.SMTP(host, port, timeout=30) as smtp:
|
||||
smtp.ehlo()
|
||||
if use_tls:
|
||||
smtp.starttls()
|
||||
smtp.ehlo()
|
||||
if user:
|
||||
smtp.login(user, password)
|
||||
smtp.send_message(msg)
|
||||
|
||||
|
||||
async def send_email(
|
||||
settings: Settings,
|
||||
*,
|
||||
to: str,
|
||||
subject: str,
|
||||
html: str,
|
||||
plain: str | None = None,
|
||||
) -> None:
|
||||
await asyncio.to_thread(
|
||||
send_email_sync,
|
||||
settings,
|
||||
to=to,
|
||||
subject=subject,
|
||||
html=html,
|
||||
plain=plain,
|
||||
)
|
||||
|
||||
|
||||
async def notify_important(
|
||||
*,
|
||||
db: Database,
|
||||
settings: Settings,
|
||||
telegram_id: int,
|
||||
html: str,
|
||||
subject: str,
|
||||
bot=None,
|
||||
) -> None:
|
||||
"""Telegram (если есть) + email (если SMTP и email пользователя заданы)."""
|
||||
user = db.get_user(telegram_id)
|
||||
if user and user.banned:
|
||||
return
|
||||
|
||||
want_tg = True if not user else bool(user.notify_telegram)
|
||||
want_email = True if not user else bool(user.notify_email)
|
||||
|
||||
if want_tg and bot is not None and telegram_id > 0:
|
||||
try:
|
||||
await bot.send_message(telegram_id, html, parse_mode="HTML")
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("telegram notify failed tg=%s", telegram_id)
|
||||
|
||||
if not want_email or not smtp_configured(settings):
|
||||
return
|
||||
email = (user.email if user else None) or ""
|
||||
if not email.strip():
|
||||
return
|
||||
try:
|
||||
# Telegram получает компактный HTML; в письме — фирменный шаблон
|
||||
title = (subject or "VPN Service").replace("— VPN Service", "").strip() or "Уведомление"
|
||||
mail_html = render_notification_email(
|
||||
title=title,
|
||||
body_html=html,
|
||||
webapp_url=settings.webapp_url or "",
|
||||
preheader=html_to_plain(html)[:120],
|
||||
)
|
||||
await send_email(settings, to=email.strip(), subject=subject, html=mail_html)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("email notify failed tg=%s to=%s", telegram_id, email, exc_info=True)
|
||||
Reference in New Issue
Block a user