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.
189 lines
8.2 KiB
Python
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()
|