203 lines
7.5 KiB
Python
203 lines
7.5 KiB
Python
"""Абстракция отправки 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] = (),
|
||
) -> None:
|
||
"""Отправить письмо получателю `to` (plaintext body обязателен, HTML — альтернатива)."""
|
||
...
|
||
|
||
|
||
class ConsoleEmailBackend:
|
||
"""Бэкенд для разработки: пишет письмо в лог вместо реальной отправки."""
|
||
|
||
async def send(
|
||
self,
|
||
*,
|
||
to: str,
|
||
subject: str,
|
||
body: str,
|
||
html_body: str | None = None,
|
||
attachments: Sequence[EmailAttachment] = (),
|
||
) -> None:
|
||
"""Залогировать письмо (вложения — только имена файлов, без содержимого)."""
|
||
attachment_names = ", ".join(a.filename for a in attachments) or "нет"
|
||
logger.info(
|
||
"EMAIL to=%s subject=%s attachments=[%s]\n%s",
|
||
to,
|
||
subject,
|
||
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] = (),
|
||
) -> None:
|
||
"""Отправить письмо; ошибки транспорта транслируются в `EmailSendError`."""
|
||
message = _build_message(
|
||
sender=self._sender,
|
||
to=to,
|
||
subject=subject,
|
||
body=body,
|
||
html_body=html_body,
|
||
attachments=attachments,
|
||
)
|
||
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],
|
||
) -> EmailMessage:
|
||
"""Собрать `EmailMessage`: plaintext (+ HTML-альтернатива) + вложения."""
|
||
message = EmailMessage()
|
||
message["From"] = sender
|
||
message["To"] = to
|
||
message["Subject"] = subject
|
||
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()
|