Why 50 Sends Never Equals 50 Delivered — And How We Fixed It

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:

  1. Count sent-today correctly — only records with status='sent' count toward the daily limit. Drafts, bounces, and failures don’t consume budget.
  2. Find eligible enrollments — active status, next_send_at has passed, prospect has a valid email, not opted out.
  3. Dedup check — if an approved or sent record already exists for this step + prospect, skip.
  4. Render the template — Jinja2 substitution with prospect variables. Log and skip on errors.
  5. Create an approved record — the outreach log gets status='approved', but sent_at stays NULL. The email hasn’t been sent yet.

Stage 2: Send and Advance

Immediately after Stage 1, or as a separate process:

  1. Send via SMTP — take each approved log and actually deliver the email.
  2. On success — set status='sent' and sent_at=now(). This is the only moment the email counts as delivered. Advance to the next step using sent_at + delay_days.
  3. On bounce — set status='bounced', auto-pause the enrollment. Don’t waste future sends on a dead address.
  4. On transient failure — increment retry counter. The log stays approved so 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.

Leave a Comment