first commit
This commit is contained in:
202
backend/services/email.py
Normal file
202
backend/services/email.py
Normal file
@@ -0,0 +1,202 @@
|
||||
"""Абстракция отправки 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()
|
||||
Reference in New Issue
Block a user