Files
vidconf/backend/services/email.py
Max Ronzhin 74de51dbd8 feat(email): контактный адрес инстанса, Reply-To в письмах и тестовая отправка
Новая настройка instance_settings.contact_email (включён/адрес, с
валидацией формата) — подставляется в заголовок Reply-To писем
подтверждения регистрации, приглашений и саммари. Админ-эндпоинт
POST /admin/settings/test-email отправляет проверочное письмо синхронно
и возвращает внятный результат (успех либо текст ошибки транспорта),
не раскрывая логин/пароль SMTP.
2026-07-27 00:35:47 +03:00

216 lines
8.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Абстракция отправки 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()