"""Абстракция отправки email: контракт `EmailBackend`, dev- и SMTP-реализации. Выбор бэкенда (`console`|`smtp`) — переменная окружения `EMAIL_BACKEND` (`core/config.py`), не настройка в БД: секреты SMTP — только в `.env`, а `SettingsOut` админки их не должен видеть. """ from __future__ import annotations import logging from collections.abc import Sequence from dataclasses import dataclass from email.message import EmailMessage from typing import TYPE_CHECKING, Protocol import aiosmtplib if TYPE_CHECKING: from core.config import Settings logger = logging.getLogger(__name__) @dataclass(frozen=True) class EmailAttachment: """Вложение письма (например, `.ics`-приглашение, `text/calendar; method=REQUEST`).""" filename: str content: bytes mime_type: str class EmailSendError(Exception): """Ошибка отправки письма транспортом. `retryable=True` — временный сбой транспорта (сервер недоступен/оборвал соединение/таймаут): вызывающая Celery-задача должна повторить попытку. `retryable=False` — конкретный получатель отклонён сервером (повторять без изменения адреса бессмысленно) — вызывающая сторона пропускает его, не роняя всю рассылку (см. `workers/tasks/notify.py`). """ def __init__(self, message: str, *, retryable: bool) -> None: super().__init__(message) self.retryable = retryable class EmailBackend(Protocol): """Контракт отправки email; новые бэкенды подставляются без правки ядра.""" async def send( self, *, to: str, subject: str, body: str, html_body: str | None = None, attachments: Sequence[EmailAttachment] = (), reply_to: str | None = None, ) -> None: """Отправить письмо получателю `to` (plaintext body обязателен, HTML — альтернатива). `reply_to` — необязательный контактный адрес инстанса (см. `services/instance_settings.py::InstanceConfig.contact_email`), проставляется заголовком `Reply-To`, если задан. """ ... class ConsoleEmailBackend: """Бэкенд для разработки: пишет письмо в лог вместо реальной отправки.""" async def send( self, *, to: str, subject: str, body: str, html_body: str | None = None, attachments: Sequence[EmailAttachment] = (), reply_to: str | None = None, ) -> None: """Залогировать письмо (вложения — только имена файлов, без содержимого).""" attachment_names = ", ".join(a.filename for a in attachments) or "нет" logger.info( "EMAIL to=%s subject=%s reply_to=%s attachments=[%s]\n%s", to, subject, reply_to or "нет", attachment_names, body, ) class SmtpEmailBackend: """Бэкенд реальной отправки email через SMTP (`aiosmtplib.send`).""" def __init__( self, *, hostname: str, port: int, username: str | None, password: str | None, start_tls: bool, use_tls: bool, timeout: float, sender: str, ) -> None: self._hostname = hostname self._port = port self._username = username self._password = password self._start_tls = start_tls self._use_tls = use_tls self._timeout = timeout self._sender = sender async def send( self, *, to: str, subject: str, body: str, html_body: str | None = None, attachments: Sequence[EmailAttachment] = (), reply_to: str | None = None, ) -> None: """Отправить письмо; ошибки транспорта транслируются в `EmailSendError`.""" message = _build_message( sender=self._sender, to=to, subject=subject, body=body, html_body=html_body, attachments=attachments, reply_to=reply_to, ) try: await aiosmtplib.send( message, hostname=self._hostname, port=self._port, username=self._username or None, password=self._password or None, start_tls=self._start_tls, use_tls=self._use_tls, timeout=self._timeout, ) except aiosmtplib.SMTPRecipientsRefused as exc: # Сервер отклонил конкретного получателя — повтор не поможет без # изменения адреса; вызывающая сторона (notify_session) пропускает # только его, не роняя рассылку остальным получателям. logger.warning("SMTP: получатель %s отклонён сервером: %s", to, exc) raise EmailSendError(f"получатель отклонён сервером: {to}", retryable=False) from exc except ( aiosmtplib.SMTPConnectError, aiosmtplib.SMTPServerDisconnected, aiosmtplib.SMTPTimeoutError, aiosmtplib.SMTPAuthenticationError, ) as exc: # Временный сбой транспорта — стоит повторить попытку позже. # `SMTPTimeoutError` после отправки DATA — доставка неизвестна: # принят at-least-once, редкий дубль # предпочтительнее потери письма. logger.warning("SMTP: временный сбой при отправке на %s: %s", to, exc) raise EmailSendError(f"временный сбой SMTP: {exc}", retryable=True) from exc def _build_message( *, sender: str, to: str, subject: str, body: str, html_body: str | None, attachments: Sequence[EmailAttachment], reply_to: str | None = None, ) -> EmailMessage: """Собрать `EmailMessage`: plaintext (+ HTML-альтернатива) + вложения.""" message = EmailMessage() message["From"] = sender message["To"] = to message["Subject"] = subject if reply_to: message["Reply-To"] = reply_to message.set_content(body) if html_body is not None: message.add_alternative(html_body, subtype="html") for attachment in attachments: maintype, _, rest = attachment.mime_type.partition("/") subtype = rest.split(";", 1)[0].strip() or "octet-stream" message.add_attachment( attachment.content, maintype=maintype or "application", subtype=subtype, filename=attachment.filename, ) return message def create_email_backend(settings: Settings) -> EmailBackend: """Собрать бэкенд отправки email по `settings.email_backend` (`console` по умолчанию).""" if settings.email_backend == "smtp": return SmtpEmailBackend( hostname=settings.smtp_host, port=settings.smtp_port, username=settings.smtp_username, password=settings.smtp_password, start_tls=settings.smtp_start_tls, use_tls=settings.smtp_use_tls, timeout=settings.smtp_timeout_s, sender=settings.smtp_from, ) return ConsoleEmailBackend()