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
- `python manage.py send_campaign <campaign_id>` emails the campaign (`subject`, `body_text`, from `settings.NEWSLETTER_FROM_EMAIL`) to every subscriber who is active when the run starts: one message per subscriber with only their address in `To`, all over one SMTP connection. A campaign that is already `sent` is refused with an error; while a run is in progress the campaign is `sending`.
- A subscriber whose address the relay refuses (`smtplib.SMTPRecipientsRefused`, e.g. `550 5.1.1 mailbox unavailable` for an address that no longer exists; every campaign has a few) is a failure for that subscriber only: record the refusal on their `Delivery` row (`sent_at` empty, `error` set) and carry on with the next subscriber. Any other error (relay unreachable, connection dropped, login failure) stops the run and leaves the campaign `sending`.
- Re-running a campaign that is still `sending` skips every subscriber whose `Delivery` has `sent_at` set and tries everyone else again, refused addresses included. (A message the relay accepted just before a crash, before its row was saved, may go out twice; that is accepted.)
- Mark the campaign `sent` (with `sent_at`) only once every subscriber in the run has been attempted, then print `Sent <n>, failed <m>` for that run.
Files touched
- newsletters/models.py
- newsletters/migrations/0002_delivery.py
- newsletters/management/commands/send_campaign.py
--- 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