Уведомления в Django: типичные ошибки и как их не совершать
Отправка письма прямо во вьюхе тормозит запрос, дубли уходят при ретраях, а на 10 000 адресатов почта падает по таймауту. Разбираем частые ошибки в системе уведомлений на Django и что с ними делать.

Уведомления кажутся простой задачей: отправил письмо при регистрации, показал сообщение об успехе. Но как только пользователей становится больше сотни, а событий — десятки в минуту, наружу лезут проблемы, которых не видно в разработке. Запрос висит секунды, пока SMTP-сервер думает. Пользователь получает три одинаковых письма после ретрая. Тесты падают, потому что реально ходят на почтовый сервер. Разбираем ошибки, которые чаще всего встречаются в проектах на Django 4.2 и 5.x, и как их обойти.
Это разбор частых ошибок, а не введение в почту Django и не гайд по установке Celery. Предполагается, что вы уже пишете на Django и хотя бы раз отправляли письмо через send_mail.
Ошибка 1. Отправка уведомления прямо в обработчике запроса
Самый частый паттерн у новичка: пользователь нажал кнопку, во вьюхе вызывается send_mail, и только потом возвращается ответ. Пока Django ждёт ответа от SMTP-сервера, HTTP-запрос заблокирован. Если почтовый сервер отвечает за 300 мс — это заметно. Если он недоступен и ждёт таймаута — пользователь смотрит на крутящийся браузер 30 секунд, а потом получает ошибку 500, хотя действие (например, заказ) уже сохранилось в базе.
# Django 5.x — так делать не стоит
def create_order(request):
order = Order.objects.create(user=request.user, ...)
# запрос висит, пока SMTP не ответит
send_mail(
"Заказ оформлен",
f"Ваш заказ {order.id} принят",
"shop@example.com",
[request.user.email],
)
return redirect("order_detail", pk=order.id)
Отправку нужно вынести из цикла запрос-ответ. Правильный инструмент для этого — фоновая очередь: Celery, Django-Q2 или dramatiq. Вьюха ставит задачу в очередь и сразу возвращает ответ, а письмо уходит отдельным воркером.
# tasks.py
from celery import shared_task
from django.core.mail import send_mail
@shared_task
def send_order_email(order_id, email):
send_mail(
"Заказ оформлен",
f"Ваш заказ {order_id} принят",
"shop@example.com",
[email],
)
# views.py
def create_order(request):
order = Order.objects.create(user=request.user, ...)
send_order_email.delay(order.id, request.user.email) # мгновенно
return redirect("order_detail", pk=order.id)
Если очередь ставить не хочется (маленький проект, редкие письма), минимальный компромисс — send_mail(..., fail_silently=True), чтобы падение почты не роняло сам запрос. Но это лечит симптом, а не задержку: пользователь всё равно ждёт таймаута.
Ошибка 2. Дубли при ретраях фоновой задачи
Как только уведомления ушли в Celery, появляется новая проблема. Воркер отправил письмо, но при подтверждении задачи упала связь с брокером. Брокер считает задачу невыполненной и отдаёт её другому воркеру — пользователь получает второе письмо. Это не баг Celery, а прямое следствие гарантии «at least once»: задача выполнится хотя бы раз, но может и больше.
Лечится идемпотентностью — задача должна безопасно выполняться повторно. Заведите таблицу или поле, которое фиксирует факт отправки, и проверяйте его до отправки.
@shared_task
def send_order_email(order_id, email):
order = Order.objects.get(pk=order_id)
# атомарно помечаем: отправляем, только если ещё не отправляли
updated = Order.objects.filter(
pk=order_id, email_sent=False
).update(email_sent=True)
if not updated:
return # письмо уже ушло, повторно не шлём
send_mail("Заказ оформлен", f"Заказ {order.id}", "shop@example.com", [email])
Флаг ставится до отправки, а не после. Если ставить после, окно между отправкой и записью флага снова допускает дубль при падении воркера. Обратная сторона: если письмо реально не ушло, а флаг уже стоит, пользователь ничего не получит. Здесь выбор между риском дубля и риском потери — для транзакционных писем чаще предпочитают не потерять, для маркетинговых наоборот.
Ошибка 3. Отправка внутри транзакции базы данных
Даже с фоновой задачей есть тонкость. Вьюха обёрнута в транзакцию (например, ATOMIC_REQUESTS=True или явный transaction.atomic()), внутри создаётся заказ и тут же вызывается send_order_email.delay(). Задача улетает в брокер мгновенно, воркер хватает её и пытается прочитать заказ из базы — а транзакция во вьюхе ещё не закоммичена. Воркер видит Order.DoesNotExist.
Задача в очереди быстрее коммита в базе. Ставьте отправку после успешной транзакции, а не внутри неё — иначе воркер гоняется за данными, которых пока нет.
Решение встроено в Django: transaction.on_commit. Callback вызовется только после успешного коммита текущей транзакции.
from django.db import transaction
def create_order(request):
with transaction.atomic():
order = Order.objects.create(user=request.user, ...)
transaction.on_commit(
lambda: send_order_email.delay(order.id, request.user.email)
)
return redirect("order_detail", pk=order.id)
Если транзакция откатится, задача не поставится вообще — и это правильно: не было заказа, не нужно письмо.
Ошибка 4. Массовая рассылка в один вызов
Нужно уведомить 10 000 пользователей об акции. Соблазн — собрать все адреса и отправить одним send_mail со списком получателей или пройти циклом в одной задаче. Оба варианта плохи. Список из 10 000 адресов в одном письме — это спам-триггер и утечка адресов через поле To. Цикл в одной задаче — это одна длинная задача, которая при падении на 7000-м адресате начнётся заново с первого.
Дробите на батчи и отправляйте каждый батч отдельной задачей. Внутри батча используйте одно SMTP-соединение через get_connection, чтобы не открывать сокет на каждое письмо.
from django.core.mail import get_connection, EmailMessage
@shared_task
def send_campaign_batch(user_ids, subject, body):
emails = User.objects.filter(id__in=user_ids).values_list("email", flat=True)
with get_connection() as connection: # одно соединение на весь батч
for addr in emails:
EmailMessage(subject, body, "shop@example.com", [addr],
connection=connection).send()
# постановка батчей
def start_campaign(subject, body):
ids = list(User.objects.values_list("id", flat=True))
for i in range(0, len(ids), 100): # батч по 100 адресатов
send_campaign_batch.delay(ids[i:i + 100], subject, body)
Ошибка 5. Тесты, которые ходят на реальный SMTP
Если в тестах письма реально уходят, тесты становятся медленными и зависят от внешнего сервера. Django для этого предоставляет backend, который складывает письма в память. В настройках для тестов укажите EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend", а в самом тесте проверяйте django.core.mail.outbox.
from django.core import mail
from django.test import TestCase, override_settings
@override_settings(EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend")
class OrderEmailTest(TestCase):
def test_order_sends_email(self):
# ... создаём заказ, вызываем отправку синхронно ...
self.assertEqual(len(mail.outbox), 1)
self.assertIn("Заказ оформлен", mail.outbox[0].subject)
Для Celery в тестах включите CELERY_TASK_ALWAYS_EAGER = True — задачи выполнятся синхронно в том же процессе, и outbox заполнится.
Ошибка 6. Хардкод текста и адресов в коде
Текст письма прямо в вызове send_mail — путь к тому, что маркетолог не сможет поменять формулировку без релиза, а адрес отправителя придётся искать по всему проекту. Тексты выносите в шаблоны (render_to_string), адрес отправителя — в DEFAULT_FROM_EMAIL. Для писем с HTML используйте EmailMultiAlternatives, а не склейку строк.
Сводка типичных ошибок
| Ошибка | Симптом | Решение |
|---|---|---|
| Отправка во вьюхе | Запрос висит, 500 при недоступной почте | Фоновая очередь (Celery/Django-Q2) |
| Ретраи без идемпотентности | Дубли писем | Флаг отправки, проставленный до send |
| Отправка в транзакции | DoesNotExist в воркере | transaction.on_commit |
| Рассылка одним вызовом | Спам-флаг, рестарт всей задачи | Батчи + get_connection |
| Тесты на реальном SMTP | Медленно, хрупко | locmem backend, ALWAYS_EAGER |
Что делать дальше
Начните с малого: вынесите отправку из вьюх в фоновую задачу и оберните постановку в on_commit — это закрывает две самые частые проблемы. Дальше добавьте идемпотентность там, где дубль критичен (платежи, заказы). Для мониторинга в проде смотрите на длину очереди брокера и долю задач в ретраях — резкий рост ретраев обычно означает, что почтовый провайдер начал ограничивать вас по скорости. Инвалидация и логирование доставки (webhook'и от почтового провайдера о bounce и открытиях) — отдельная тема, здесь её не разбираем.
Точные имена настроек Celery между версиями менялись (в Celery 5.x конфигурация в нижнем регистре с префиксом), поэтому ключи вроде task_always_eager сверяйте с документацией вашей версии.
Проверка рекламы на риски: Что в макете вызовет вопросы ФАС: превосходные степени, сравнения, обещания результата, маркировка.
Спросить за 40 ₽Источники: Sending email — Django documentation, Database transactions — transaction.on_commit
Частые вопросы
А если у меня нет Celery и ставить его не хочется?
Почему флаг отправки нужно ставить до send_mail, а не после?
У меня Django 4.2, а не 5.x — примеры подойдут?
Как проверить, что письмо реально дошло, а не просто ушло из Django?
Что делать, если письма всё равно уходят дублями после внедрения флага?
Тесты падают с ошибкой, что письмо не отправилось при Celery — почему?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.