Files
darko fcafe1ae15 feat: аккаунты по почте с подтверждением, сбросом пароля и синхронизацией прогресса
API аккаунтов на чистой стандартной библиотеке Python (server/splitapi):
- регистрация по почте: пароль задаётся по ссылке из письма, аккаунт создаётся только после подтверждения
- вход, выход, сброс пароля по почте (токены одноразовые, в базе только SHA-256), удаление аккаунта
- пароли scrypt, лимиты попыток по паре «адрес + IP», IP и аккаунту, защита от CSRF
- хранение прогресса пользователя с оптимистичной блокировкой версии
- SMTP через переменные окружения; без SMTP на публичном адресе API не стартует

Клиент: public/auth.js (вход, регистрация, сброс, синхронизация), public/sync.js
(чистое слияние прогресса). Без API сайт работает как раньше.

Развёртывание: Dockerfile.api, сервис splitapi в docker-compose, проксирование /api/
в nginx с ограничением частоты, deploy/install-api-debian.sh (systemd), .env.example.

Тесты: 216 для бэкенда (unittest, покрытие около 97%), 16 для клиентского слияния.
Документация: раздел «Аккаунты и почта» в docs/DEPLOY.md, раздел 9 в docs/ИНСТРУКЦИЯ.md.
2026-10-16 11:25:09 +03:00

189 lines
8.2 KiB
Python

"""Отправка писем: SMTP, вывод в лог (для разработки), память (для тестов), фоновая очередь."""
from __future__ import annotations
import logging
import queue
import smtplib
import ssl
import threading
import time
from dataclasses import dataclass
from email.message import EmailMessage
from email.utils import formatdate, make_msgid, parseaddr
from typing import Protocol
from .config import Config
log = logging.getLogger("splitapi.mail")
@dataclass(frozen=True)
class Mail:
to: str
subject: str
body: str
class Mailer(Protocol):
def send(self, mail: Mail) -> None: ...
class MailQueueFull(RuntimeError):
"""Очередь отправки переполнена: письмо не принято (API ответит «сервер занят», а не «проверьте почту»)."""
def _plural(n: int, one: str, few: str, many: str) -> str:
if 11 <= n % 100 <= 14:
return many
return one if n % 10 == 1 else few if 2 <= n % 10 <= 4 else many
def human_duration(seconds: int) -> str:
"""«24 часа», «3 дня», «30 минут»: срок действия ссылки в тексте письма берётся из настроек."""
if seconds >= 2 * 86400 and seconds % 86400 == 0:
n = seconds // 86400
return f"{n} {_plural(n, 'день', 'дня', 'дней')}"
if seconds >= 3600 and seconds % 3600 == 0:
n = seconds // 3600
return f"{n} {_plural(n, 'час', 'часа', 'часов')}"
n = max(1, round(seconds / 60))
return f"{n} {_plural(n, 'минута', 'минуты', 'минут')}"
# --- шаблоны писем ---
def verify_mail(cfg: Config, to: str, token: str) -> Mail:
# Токен во фрагменте (#): он не уходит на сервер, не попадает в логи и заголовок Referer.
link = f"{cfg.base_url}#verify={token}"
return Mail(
to,
"Подтвердите почту в Сплит-тренере",
"Здравствуйте!\n\nЧтобы завершить регистрацию в Сплит-тренере, подтвердите адрес почты и задайте пароль. "
f"Откройте ссылку:\n\n{link}\n\nСсылка действует {human_duration(cfg.verify_ttl)} и работает один раз.\n\n"
"Если вы не регистрировались, просто проигнорируйте это письмо: без перехода по ссылке учётная запись не создаётся.\n",
)
def reset_mail(cfg: Config, to: str, token: str) -> Mail:
link = f"{cfg.base_url}#reset={token}"
return Mail(
to,
"Сброс пароля в Сплит-тренере",
"Здравствуйте!\n\nМы получили запрос на сброс пароля. Чтобы задать новый пароль, откройте ссылку:\n\n"
f"{link}\n\nСсылка действует {human_duration(cfg.reset_ttl)} и работает один раз.\n\n"
"Если вы не запрашивали сброс, проигнорируйте это письмо: пароль останется прежним.\n",
)
def already_registered_mail(cfg: Config, to: str) -> Mail:
return Mail(
to,
"Вы уже зарегистрированы в Сплит-тренере",
"Здравствуйте!\n\nКто-то (возможно, вы) пытался зарегистрировать этот адрес, но он уже есть в системе.\n\n"
f"Если это были вы и вы забыли пароль, на странице входа нажмите «Забыли пароль?»: {cfg.base_url}\n\n"
"Если это были не вы, ничего делать не нужно.\n",
)
def password_changed_mail(cfg: Config, to: str) -> Mail:
return Mail(
to,
"Пароль в Сплит-тренере изменён",
"Здравствуйте!\n\nПароль вашей учётной записи изменён, все прежние входы завершены.\n\n"
f"Если это были не вы, срочно запросите сброс пароля: {cfg.base_url}\n",
)
def build_message(mail: Mail, from_addr: str) -> EmailMessage:
"""Собирает письмо. Переводы строк в заголовках запрещены самим EmailMessage (ValueError): инъекция невозможна."""
msg = EmailMessage()
msg["From"] = from_addr
msg["To"] = mail.to
msg["Subject"] = mail.subject
msg["Date"] = formatdate()
msg["Message-ID"] = make_msgid(domain=parseaddr(from_addr)[1].rpartition("@")[2] or "localhost")
msg.set_content(mail.body)
return msg
# --- способы доставки ---
class MemoryMailer:
def __init__(self) -> None:
self.sent: list[Mail] = []
def send(self, mail: Mail) -> None:
self.sent.append(mail)
class ConsoleMailer:
"""Только для разработки: письмо с ссылкой попадает в лог. Не используйте на боевом сервере."""
def send(self, mail: Mail) -> None:
log.warning("[DEV: SMTP не настроен] письмо для %s\nТема: %s\n%s", mail.to, mail.subject, mail.body)
class SmtpMailer:
def __init__(self, cfg: Config) -> None:
self._cfg = cfg
def send(self, mail: Mail) -> None:
cfg = self._cfg
msg = build_message(mail, cfg.mail_from)
if cfg.smtp_security == "ssl":
client = smtplib.SMTP_SSL(cfg.smtp_host, cfg.smtp_port, timeout=15, context=ssl.create_default_context())
else:
client = smtplib.SMTP(cfg.smtp_host, cfg.smtp_port, timeout=15)
with client as server:
if cfg.smtp_security == "starttls":
server.starttls(context=ssl.create_default_context())
if cfg.smtp_user:
server.login(cfg.smtp_user, cfg.smtp_password)
server.send_message(msg)
class AsyncMailer:
"""Отправка в фоне: ответ API не ждёт SMTP, а время ответа не выдаёт, существует ли адрес."""
def __init__(self, inner: Mailer, maxsize: int = 100) -> None:
self._inner = inner
self._queue: queue.Queue[Mail | None] = queue.Queue(maxsize=maxsize)
self._thread = threading.Thread(target=self._run, name="mailer", daemon=True)
self._thread.start()
def send(self, mail: Mail) -> None:
try:
self._queue.put_nowait(mail)
except queue.Full:
log.error("очередь писем переполнена, письмо не принято (домен получателя: %s)", mail.to.rpartition("@")[2])
raise MailQueueFull from None
def _run(self) -> None:
while True:
mail = self._queue.get()
try:
if mail is None:
return
self._inner.send(mail)
except Exception as exc: # одно неудачное письмо не должно останавливать отправку остальных
# Текст исключения не пишем: в нём бывает адрес получателя (например, при отказе SMTP)
log.error("не удалось отправить письмо (домен получателя: %s): %s", mail.to.rpartition("@")[2], type(exc).__name__)
finally:
self._queue.task_done()
def flush(self, timeout: float = 10) -> None:
deadline = time.monotonic() + timeout
while self._queue.unfinished_tasks and time.monotonic() < deadline:
time.sleep(0.01)
def close(self) -> None:
try:
self._queue.put(None, timeout=1)
except queue.Full:
return
self._thread.join(timeout=5)
def make_mailer(cfg: Config) -> Mailer:
if cfg.mail_backend == "smtp":
return AsyncMailer(SmtpMailer(cfg))
return ConsoleMailer()