FastAPI асинхронный, а smtplib из стандартной библиотеки — нет: вызов из обработчика блокирует цикл событий для всех остальных запросов воркера. Ниже — отправка через fmailer с aiosmtplib, возврат ответа до отправки письма и неблокирующая отрисовка шаблонов.
| Параметр | Значение |
|---|---|
| SMTP-хост | smtp.fmailer.ru |
| Порт | 587 |
| Шифрование | STARTTLS (соединение переводится в TLS до авторизации) |
| Логин | Логин SMTP-токена вашего домена |
| Пароль | Пароль этого токена — показывается один раз, при создании |
pip install aiosmtplib jinja2 pydantic-settings# settings.py
from pydantic_settings import BaseSettings
class MailSettings(BaseSettings):
smtp_host: str = "smtp.fmailer.ru"
smtp_port: int = 587
smtp_user: str
smtp_pass: str
mail_from: str = "Магазин <noreply@mail.example.ru>"
class Config:
env_file = ".env"
mail_settings = MailSettings()# mailer.py
import aiosmtplib
from email.message import EmailMessage
from settings import mail_settings
async def send_email(
to: str,
subject: str,
text: str,
html: str | None = None,
) -> None:
msg = EmailMessage()
msg["From"] = mail_settings.mail_from
msg["To"] = to
msg["Subject"] = subject
msg.set_content(text)
if html:
msg.add_alternative(html, subtype="html")
await aiosmtplib.send(
msg,
hostname=mail_settings.smtp_host,
port=mail_settings.smtp_port,
start_tls=True, # STARTTLS на 587
username=mail_settings.smtp_user,
password=mail_settings.smtp_pass,
timeout=10,
)Для порта 465 используется другой флаг:
# Порт 465 — другой флаг
await aiosmtplib.send(
msg,
hostname=mail_settings.smtp_host,
port=465,
use_tls=True, # не start_tls
username=mail_settings.smtp_user,
password=mail_settings.smtp_pass,
)BackgroundTasks выполняет корутину после того, как ответ уже отправлен:
# main.py
from fastapi import BackgroundTasks, FastAPI
from mailer import send_email
app = FastAPI()
@app.post("/orders", status_code=201)
async def create_order(payload: OrderIn, background: BackgroundTasks):
order = await save_order(payload)
# Выполнится после отправки ответа — клиент не ждёт SMTP.
background.add_task(
send_email,
to=order.customer_email,
subject=f"Подтверждение заказа №{order.id}",
text="Заказ принят. Доставка — завтра до 18:00.",
html="<h1>Заказ принят</h1><p>Доставка — завтра до 18:00.</p>",
)
return {"id": order.id}# templates.py
from jinja2 import Environment, FileSystemLoader, select_autoescape
env = Environment(
loader=FileSystemLoader("templates/email"),
autoescape=select_autoescape(["html"]), # данные пользователя всегда экранируем
enable_async=True,
)
async def render(name: str, **context) -> str:
return await env.get_template(name).render_async(**context)У исключения в фоновой задаче нет получателя — ответ уже ушёл. Логируйте явно, иначе сбои будут невидимы:
import logging
import aiosmtplib
log = logging.getLogger(__name__)
async def send_email_safe(**kwargs) -> bool:
"""BackgroundTasks проглатывает исключения — логируем сами."""
try:
await send_email(**kwargs)
return True
except aiosmtplib.SMTPAuthenticationError:
log.exception("SMTP отклонил доступы")
except aiosmtplib.SMTPRecipientsRefused:
log.warning("Адрес отклонён: %s", kwargs.get("to"))
except (aiosmtplib.SMTPException, OSError):
log.exception("Временный сбой SMTP")
return False| Симптом | Причина и решение |
|---|---|
| Запросы замедляются при отправке писем | Синхронный smtplib в async-обработчике. Перейдите на aiosmtplib. |
| Писем нет, ошибок в логах тоже | Исключение BackgroundTasks проглочено. Оборачивайте в try/except. |
SMTPAuthenticationError | Неверный логин или пароль токена. |
SMTPConnectError, таймаут | Порт заблокирован. Используйте 8587 или 8465. |
| Ошибка TLS-рукопожатия | use_tls на 587 или start_tls на 465. |
| Письма теряются после выкладки | Фоновые задачи живут в процессе. Перейдите на устойчивую очередь. |
smtplib синхронный, поэтому каждая отправка блокирует цикл событий на всё время обмена с сервером: подключение, TLS-рукопожатие, авторизация, доставка. При конкурентной нагрузке за ней встают все остальные запросы того же воркера. Используйте aiosmtplib — тот же интерфейс сообщений, но с await, — либо выносите отправку в пул потоков.
Для некритичных писем да: задача выполняется после возврата ответа, в том же процессе. Но она не переживает перезапуск: рестарт, падение или выкладка теряют всё, что не успело выполниться, и повторов нет. Для восстановления пароля, чеков и всего, чего ждёт пользователь, нужна очередь — Celery, ARQ или Dramatiq.
start_tls=True открывает обычное соединение на порту 587 и переводит его в TLS до авторизации. use_tls=True открывает уже зашифрованное соединение — это порт 465. Включение обоих сразу или неверный флаг для порта приводит к ошибке рукопожатия.
BackgroundTasks не пробрасывает исключения — ответ уже отправлен, и ошибке некуда всплыть. Оборачивайте отправку в try/except и логируйте, иначе сломанная конфигурация SMTP выглядит точно так же, как рабочая.
Собственного шаблонизатора для писем в FastAPI нет, поэтому используйте Jinja2 напрямую. Создавайте Environment с enable_async=True и вызывайте render_async, чтобы отрисовка не блокировала цикл, и обязательно передавайте select_autoescape, иначе данные пользователя смогут внедрить разметку.
Для редких отправок да — aiosmtplib.send() сам подключается, авторизуется и отключается. Для пачек создайте клиент aiosmtplib.SMTP, подключитесь один раз и отправляйте в цикле, чтобы не повторять TLS-рукопожатие для каждого письма.