365 lines
13 KiB
Python
365 lines
13 KiB
Python
"""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)
|