The Bug That Changed Our Architecture
We had a campaign set to send 50 emails per day. At the end of the day, only 24 had been delivered. No errors in the logs. No crashes. The system just… stopped early.
This wasn’t a one-off. Every campaign with a daily limit was underdelivering by 30-50%. For a product built on reliable outreach, this was unacceptable.
Five Root Causes, One Architecture Problem
When we dug in, we found five distinct issues — all stemming from the same flawed assumption: that creating an outreach record and sending an email were the same event.
1. The Daily Count Was Wrong
The daily limit checked: “how many outreach records were created today?” But records get created for drafts, bounced emails, and failed sends too. A campaign with 10 bounces and 5 failures only had budget for 35 actual sends, not 50.
2. Prospects Without Email Were Counted
The queue processor picked up all enrolled prospects, including ones where email enrichment hadn’t completed yet. These prospects consumed daily budget but never received an email.
3. No Deduplication Guard
If the queue processor ran twice in quick succession (overlapping Celery tasks), the same prospect could get two outreach records for the same step. Both counted toward the daily limit, but only one email was actually useful.
4. Step Timing Was Wrong
When a prospect moved from Step 1 to Step 2, the delay was calculated from when the outreach record was created, not when the email was sent. If the queue created the record at 9:00 AM but the email wasn’t sent until 11:00 AM (due to rate limiting), the 4-day delay started 2 hours early. Small errors, but they compound across multi-step campaigns.
5. Silent Failures
Template rendering errors (missing variables, malformed Jinja2) would silently skip the prospect without logging why. The system moved on, but the prospect never got their email — and nobody knew.
The Fix: Two-Stage Send
The fundamental insight was that qualifying a send and actually sending an email are two different operations with different failure modes, and they should be separated.
Stage 1: Qualify and Lock
Runs every 30 minutes. For each active campaign:
- Count sent-today correctly — only records with
status='sent'count toward the daily limit. Drafts, bounces, and failures don’t consume budget. - Find eligible enrollments — active status,
next_send_athas passed, prospect has a valid email, not opted out. - Dedup check — if an
approvedorsentrecord already exists for this step + prospect, skip. - Render the template — Jinja2 substitution with prospect variables. Log and skip on errors.
- Create an approved record — the outreach log gets
status='approved', butsent_atstays NULL. The email hasn’t been sent yet.
Stage 2: Send and Advance
Immediately after Stage 1, or as a separate process:
- Send via SMTP — take each approved log and actually deliver the email.
- On success — set
status='sent'andsent_at=now(). This is the only moment the email counts as delivered. Advance to the next step usingsent_at + delay_days. - On bounce — set
status='bounced', auto-pause the enrollment. Don’t waste future sends on a dead address. - On transient failure — increment retry counter. The log stays
approvedso the next run picks it up again. After 5 retries, give up and pause the enrollment.
The Critical Design Decision: sent_at
This is the one change that fixed almost everything:
# BEFORE (broken)
sent_at = models.DateTimeField(auto_now_add=True)
# Created when the record is made — even for drafts and failures
# AFTER (fixed)
created_at = models.DateTimeField(auto_now_add=True)
sent_at = models.DateTimeField(null=True, blank=True)
# NULL until the email actually leaves the server
With this separation, sent_at means exactly one thing: the email was delivered. Everything else — daily counts, step timing, analytics — derives from this single source of truth.
Results
After deploying the two-stage architecture:
- 50 sends = 50 delivered — daily limits now work exactly as configured
- Zero duplicate sends — dedup guard prevents concurrent queue races
- Accurate step timing — delays anchored to actual delivery, not queue time
- Automatic bounce handling — dead addresses stop consuming budget immediately
- Full visibility — every failure is logged with a reason, nothing is silent
Try It
The two-stage send pipeline is the core of the AI Campaign Designer, now available as part of the EchoForgeX AI Workforce. It’s the same code we run in production — extracted and made configurable for any business.
Pricing starts at $89/month for the Starter tier (3 active campaigns, 1,000 sends/month).
At EchoForgeX, we build AI-powered tools and help businesses integrate AI into their workflows. Get in touch to learn how we can help your team work smarter with AI.