Files

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("&nbsp;", " ")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&amp;", "&")
.replace("&quot;", '"')
)
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)