python 121 lines · 4 tabs

Sending Welcome Emails via a Django post_save Signal and Outbox Worker

Shared by codesnips Jul 2026
4 tabs
from django.conf import settings
from django.db.models.signals import post_save
from django.dispatch import receiver

from .models import EmailOutbox


@receiver(post_save, sender=settings.AUTH_USER_MODEL)
def create_welcome_email(sender, instance, created, **kwargs):
    if not created or not instance.email:
        return

    name = instance.get_short_name() or instance.get_username()
    EmailOutbox.objects.get_or_create(
        dedupe_key=f"welcome:{instance.pk}",
        defaults={
            "to_email": instance.email,
            "subject": "Welcome aboard!",
            "body": f"Hi {name},\n\nThanks for signing up. We're glad you're here.",
        },
    )
4 files · python Explain with highlit

Sending a welcome email directly inside a post_save signal is tempting but fragile: SMTP can be slow or down, and raising there can abort the request or, worse, leave the row committed while the email silently fails. The pattern shown here decouples the two by writing a durable row and letting a separate worker deliver it, which is a lightweight transactional-outbox approach.

In signals.py, create_welcome_email listens for post_save on the user model and only fires when created is true, so updates never re-trigger it. Instead of calling send_mail, it enqueues an EmailOutbox record via get_or_create. The dedupe_key (welcome:<pk>) makes the insert idempotent: if the signal runs twice — for example a retried request or a double save — the unique key prevents a second queued email. The signal is registered in apps.py through ready(), the canonical Django hook for wiring signals so they load exactly once.

models.py defines the outbox as a queue table. Each row carries a status, a dedupe_key with a unique=True constraint, an attempts counter, and a locked_until timestamp used for a simple lease. claim_batch is the heart of the concurrency story: it runs inside transaction.atomic, selects pending or expired rows with select_for_update(skip_locked=True), and stamps locked_until into the future. skip_locked lets multiple workers run in parallel without fighting over the same rows — each grabs a disjoint batch. mark_sent and mark_failed record the outcome, with mark_failed capping retries at five before parking the row as failed.

send_emails.py is the worker, an ordinary management command so it can run under cron, systemd, or a container. Its loop claims a batch, renders each message, calls send_mail, and updates status per row so one bad address never blocks the rest. The --once flag supports cron-style single passes, while the default loop sleeps between polls for a long-lived process.

The trade-off is eventual delivery rather than instant, plus a table to monitor — but requests stay fast and no welcome email is ever lost to a transient SMTP failure. This is the go-to shape whenever a side effect must be reliable but need not be synchronous.


Related snips

Share this code

Here's the card — post it anywhere.

Sending Welcome Emails via a Django post_save Signal and Outbox Worker — share card
Link copied