Документация / FastAPI

Отправка email в FastAPI через SMTP

FastAPI асинхронный, а smtplib из стандартной библиотеки — нет: вызов из обработчика блокирует цикл событий для всех остальных запросов воркера. Ниже — отправка через fmailer с aiosmtplib, возврат ответа до отправки письма и неблокирующая отрисовка шаблонов.

Логин и пароль можно получить на странице управления токенами домена.

Параметры подключения

ПараметрЗначение
SMTP-хостsmtp.fmailer.ru
Порт587
ШифрованиеSTARTTLS (соединение переводится в TLS до авторизации)
ЛогинЛогин SMTP-токена вашего домена
ПарольПароль этого токена — показывается один раз, при создании
Каждый режим доступен и на высоком порту: 8465 (SSL/TLS), 8587 (STARTTLS) и 8025 (без шифрования). Многие хостинги и облака блокируют исходящие 25, 465 и 587 — если соединение отваливается по таймауту, используйте соответствующий высокий порт

Установка

bash
pip install aiosmtplib jinja2 pydantic-settings

Настройки

settings.pypython
# 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.pypython
# 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 используется другой флаг:

python
    # Порт 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,
    )
Синхронная отправка удерживает цикл событий на всё время обмена с сервером, поэтому все конкурентные запросы этого воркера встают в очередь. Это самая частая проблема производительности в приложениях FastAPI, которые отправляют почту.

Отправка без ожидания клиентом

BackgroundTasks выполняет корутину после того, как ответ уже отправлен:

main.pypython
# 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}
Задачи живут в процессе. Рестарт, падение или выкладка теряют всё, что не успело выполниться, и повторов нет. Для восстановления пароля и чеков нужна настоящая очередь — Celery, ARQ или Dramatiq.

HTML-шаблоны

templates.pypython
# 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)

Обработка ошибок

У исключения в фоновой задаче нет получателя — ответ уже ушёл. Логируйте явно, иначе сбои будут невидимы:

python
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 в FastAPI?

smtplib синхронный, поэтому каждая отправка блокирует цикл событий на всё время обмена с сервером: подключение, TLS-рукопожатие, авторизация, доставка. При конкурентной нагрузке за ней встают все остальные запросы того же воркера. Используйте aiosmtplib — тот же интерфейс сообщений, но с await, — либо выносите отправку в пул потоков.

Достаточно ли BackgroundTasks для отправки писем?

Для некритичных писем да: задача выполняется после возврата ответа, в том же процессе. Но она не переживает перезапуск: рестарт, падение или выкладка теряют всё, что не успело выполниться, и повторов нет. Для восстановления пароля, чеков и всего, чего ждёт пользователь, нужна очередь — Celery, ARQ или Dramatiq.

Чем отличаются start_tls и use_tls в aiosmtplib?

start_tls=True открывает обычное соединение на порту 587 и переводит его в TLS до авторизации. use_tls=True открывает уже зашифрованное соединение — это порт 465. Включение обоих сразу или неверный флаг для порта приводит к ошибке рукопожатия.

Почему фоновая отправка молча не работает?

BackgroundTasks не пробрасывает исключения — ответ уже отправлен, и ошибке некуда всплыть. Оборачивайте отправку в try/except и логируйте, иначе сломанная конфигурация SMTP выглядит точно так же, как рабочая.

Как отрисовывать HTML-шаблоны писем в FastAPI?

Собственного шаблонизатора для писем в FastAPI нет, поэтому используйте Jinja2 напрямую. Создавайте Environment с enable_async=True и вызывайте render_async, чтобы отрисовка не блокировала цикл, и обязательно передавайте select_autoescape, иначе данные пользователя смогут внедрить разметку.

Нужно ли открывать новое соединение на каждое письмо?

Для редких отправок да — aiosmtplib.send() сам подключается, авторизуется и отключается. Для пачек создайте клиент aiosmtplib.SMTP, подключитесь один раз и отправляйте в цикле, чтобы не повторять TLS-рукопожатие для каждого письма.

Что дальше