Decision: budget connections, not only batch size
In 3.11.2, _send_bulk() starts at most min(THREADS_PER_PROCESS, email_count) worker threads, and _send_email() obtains the configured connection inside its worker. The documented default for THREADS_PER_PROCESS is 5. The management command can also start multiple processes.
per_run_slots = min(queued_emails, processes * threads_per_process)
potential_peak = active_runs * per_run_slots
active_runs must include overlapping scheduler invocations and independently scheduled containers or replicas. A lock file stored on each container's private filesystem does not serialize the other replicas.
Browser-local capacity budget
Compare the modeled peak with the provider cap
No SMTP host, sender, recipient, credential, IP, queue content, provider payload, configuration file, or raw log is requested or transmitted.
- Per-run slots
- Potential peak
- Provider limit
- Headroom
- Lock boundary
- Decision
- Failed or unproved boundary
- Next action
- Exit condition
- Safety
What changed in 3.11.2
The 3.11.2 release says that _send_email should dispatch mail with its own connection. The merged change resolves connections[email.backend_alias or "default"] in the worker thread and passes it into Email.dispatch(). Its regression test changed from expecting one shared backend connection to expecting one backend per thread.
This addresses cross-thread use of a backend that is not guaranteed to be thread-safe. Downgrading to 3.11.1 can reduce connection creation, but it also restores the behavior that the change was intended to fix. A measured concurrency limit on 3.11.2 is the safer first containment.
Contain the queue without creating a retry storm
- Pause overlapping scheduler launches while leaving queued rows intact.
- Set
POST_OFFICE["THREADS_PER_PROCESS"] = 1and invokesend_queued_mail --processes=1. - Prove one deployment-wide scheduler owner. If each replica has a private filesystem, move serialization to a shared lock, leader election, or a single dedicated worker.
- Submit one controlled queued message and count active SMTP connections around connect, dispatch, and close.
- Increase one dimension at a time only after the measured peak remains below the provider limit.
SMTP 421 is a transient service-unavailable response. Preserve or requeue the current work with bounded exponential backoff and jitter. Do not let every scheduler, worker, and user retry create another immediate fan-out.
A local lock is not a deployment-wide lock
Django Post Office wraps queue sending in a file lock, but the guarantee follows the filesystem. Processes sharing the same lock path and filesystem can serialize; containers with isolated writable layers can each acquire their own file and run at the same time.
Record scheduler owner, deployment or replica ID, command start and finish UTC time, claimed email count, and bounded active-connection count. That is enough to prove overlap without publishing queue contents, addresses, credentials, hostnames, IPs, or raw exception logs.
Prove the provider budget in a regression test
Use a test backend whose connection context increments a thread-safe active counter, waits on a barrier long enough for workers to overlap, records the peak, and decrements in finally. Run the command with the intended process and thread settings, plus an intentional second scheduler invocation.
The release gate is not merely that every queued email was attempted. Assert that the peak never exceeds the configured provider budget, each queued row reaches one bounded outcome, and retryable failures do not create duplicate active attempts.
Check public sender controls only after one controlled submission
A public scan can verify MX, SPF, DKIM, DMARC, PTR, and transport-policy signals. It cannot see a private Post Office queue, scheduler overlap, the provider's connection cap, or which thread opened which socket.
Primary references
- django-post_office issue 528: 3.11.2 opens too many concurrent SMTP connections
- django-post_office 3.11.2 release
- django-post_office 3.11.1 to 3.11.2 source comparison
- django-post_office pull request 521: thread-local backend connections
- django-post_office 3.11.2 queue and worker implementation
- django-post_office 3.11.2 thread default
- django-post_office 3.11.2 process and file-lock command
- RFC 5321 section 4.2.4: retrying transient SMTP failures