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.",
},
)
from django.apps import AppConfig
class AccountsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "accounts"
def ready(self):
from . import signals # noqa: F401 registers post_save receiver
from django.db import models, transaction
from django.utils import timezone
from datetime import timedelta
class EmailOutbox(models.Model):
PENDING, SENT, FAILED = "pending", "sent", "failed"
STATUS_CHOICES = [(PENDING, "Pending"), (SENT, "Sent"), (FAILED, "Failed")]
dedupe_key = models.CharField(max_length=200, unique=True)
to_email = models.EmailField()
subject = models.CharField(max_length=255)
body = models.TextField()
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default=PENDING)
attempts = models.PositiveIntegerField(default=0)
last_error = models.TextField(blank=True, default="")
locked_until = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
indexes = [models.Index(fields=["status", "locked_until"])]
@classmethod
def claim_batch(cls, size=20, lease_seconds=60):
now = timezone.now()
with transaction.atomic():
rows = list(
cls.objects.select_for_update(skip_locked=True)
.filter(status=cls.PENDING)
.filter(models.Q(locked_until__isnull=True) | models.Q(locked_until__lt=now))
.order_by("created_at")[:size]
)
ids = [r.pk for r in rows]
cls.objects.filter(pk__in=ids).update(
locked_until=now + timedelta(seconds=lease_seconds)
)
return rows
def mark_sent(self):
self.status = self.SENT
self.locked_until = None
self.save(update_fields=["status", "locked_until"])
def mark_failed(self, error):
self.attempts += 1
self.last_error = str(error)[:2000]
self.status = self.FAILED if self.attempts >= 5 else self.PENDING
self.locked_until = None
self.save(update_fields=["attempts", "last_error", "status", "locked_until"])
import time
from django.core.mail import send_mail
from django.core.management.base import BaseCommand
from accounts.models import EmailOutbox
class Command(BaseCommand):
help = "Deliver queued outbox emails"
def add_arguments(self, parser):
parser.add_argument("--once", action="store_true", help="Run a single pass then exit")
parser.add_argument("--batch", type=int, default=20)
parser.add_argument("--interval", type=float, default=5.0)
def handle(self, *args, **options):
while True:
processed = self._drain(options["batch"])
if options["once"]:
break
if processed == 0:
time.sleep(options["interval"])
def _drain(self, batch):
rows = EmailOutbox.claim_batch(size=batch)
for row in rows:
try:
send_mail(
row.subject,
row.body,
None, # uses DEFAULT_FROM_EMAIL
[row.to_email],
fail_silently=False,
)
row.mark_sent()
except Exception as exc:
row.mark_failed(exc)
self.stderr.write(f"failed {row.pk}: {exc}")
if rows:
self.stdout.write(f"processed {len(rows)} email(s)")
return len(rows)
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
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
class Product(models.Model):
name = models.CharField(max_length=200)
slug = models.SlugField(blank=True)
price = models.DecimalField(max_digits=10, decimal_places=2)
cost = models.DecimalField(max_digits=10, decimal_places=2)
margin = models.DecimalField(max_digits=5, decimal_places=2, blank=True)
Django model signals vs overriding save
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
class SignupForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :account_name, :string
attribute :email, :string
Shallow Controller, Deep Params: Form Object Pattern
from django.urls import path
from . import views
app_name = 'blog'
urlpatterns = [
Django URL namespacing and reverse lookups
Share this code
Here's the card — post it anywhere.