"""Отправка писем: 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()