Send newsletter campaigns from a management command

Python · Django · advanced · modification

Moves newsletter sending off the ESP's web UI and into the app. `send_campaign` sends one message per active subscriber over a single reused SMTP connection, and the new `Delivery` table records each send, so a run that dies halfway (relay outage, deploy) can simply be started again; anyone whose delivery was recorded is skipped, so nobody already marked sent gets it again. Addresses the relay refuses are recorded on their delivery row with the server's reply and counted in the run summary. Tested locally against a debug SMTP server with a few hundred subscribers.

Django 5.1 on PostgreSQL 16, `newsletters` app. `Subscriber` and `Campaign` exist (migration `0001_initial`); `newsletters/management/commands/` already exists with an `import_subscribers` command. About 40,000 active subscribers, emails validated by `EmailField`. `NEWSLETTER_FROM_EMAIL` is already set in settings. Mail goes through Django's SMTP backend (`django.core.mail.backends.smtp.EmailBackend`) to the ESP's relay with `EMAIL_TIMEOUT = 10`; the relay only answers 421 at connect or `MAIL FROM`, never per recipient; the relay adds the `List-Unsubscribe` headers itself and every `body_text` ends with the unsubscribe footer. The command is started by hand from the job runner, which allows only one running instance, so two runs never overlap. Subscriptions are frozen while a campaign is `sending` (sign-ups and unsubscribes queue until it finishes), so a re-run sees the same audience.

Requirements

Files touched

--- newsletters/models.py
 class Campaign(models.Model):
     DRAFT = "draft"
+    SENDING = "sending"
     SENT = "sent"
-    STATUS_CHOICES = [(DRAFT, "Draft"), (SENT, "Sent")]
+    STATUS_CHOICES = [(DRAFT, "Draft"), (SENDING, "Sending"), (SENT, "Sent")]
 
     subject = models.CharField(max_length=200)
         return self.subject
 
+
+class Delivery(models.Model):
+    campaign = models.ForeignKey(Campaign, on_delete=models.CASCADE, related_name="deliveries")
+    subscriber = models.ForeignKey(Subscriber, on_delete=models.CASCADE, related_name="deliveries")
+    sent_at = models.DateTimeField(null=True, blank=True)
+    error = models.TextField(blank=True)
+
+    class Meta:
+        constraints = [
+            models.UniqueConstraint(fields=["campaign", "subscriber"], name="unique_delivery_per_campaign"),
+        ]
+
+    def __str__(self):
+        return f"{self.campaign} -> {self.subscriber}"
+
--- newsletters/migrations/0002_delivery.py
+# Generated by Django 5.1.15 on 2026-10-21 16:31
+
+import django.db.models.deletion

Review this PR

Python practice