DEV Community

Cover image for Building an Email Broadcast Engine That Survives Retries
Nasrul Hazim
Nasrul Hazim

Posted on

Building an Email Broadcast Engine That Survives Retries

TL;DR

  • Shipped an email broadcast feature: audience filter → suppression gate → one queued job per recipient → per-recipient status ledger.
  • The whole design rests on one idea: the recipient row is the source of truth, so retries and double dispatches are harmless.
  • Delivery webhooks sync back onto that ledger with a rank guard so a late event never downgrades a status.

The shape of it

Sending one email is easy. Sending 5,000 and being able to answer "what happened to contact #4127?" is the actual problem.

  Campaign (draft)
      |
      v
  Audience filter  ->  Suppression gate  ->  CampaignRecipient rows (pending)
                                                    |
                                              dispatch 1 job each
                                                    v
                                              SendCampaignEmail
                                                    |
                                        mail sent -> status: sent
                                                    |
                       webhook: delivered / opened / clicked / bounced
                                                    v
                                          recipient ledger updated
Enter fullscreen mode Exit fullscreen mode

One job per recipient, not one job per campaign. A 5,000-recipient blast becomes 5,000 small units of work — the queue throttles naturally, and a failure blast-radius is one email, not the whole campaign.

The suppression gate

Audience filtering and eligibility are two different questions, and mixing them is how you email someone who unsubscribed last week.

Query Includes Used for
baseQuery() matches the filter preview counts in the UI
eligibleQuery() filter + reachable the actual send
public function eligibleQuery(array $audience): Builder
{
    return $this->baseQuery($audience)->emailReachable();
}
Enter fullscreen mode Exit fullscreen mode

emailReachable() is a scope on the contact model — not do-not-contact, still subscribed, has an email. One scope, one place to change the rules, impossible to forget at the call site.

Idempotency lives in the row

The job takes IDs, not models, and every send re-checks the recipient's status before doing anything:

class SendCampaignEmail implements ShouldQueue
{
    public int $tries = 3;
    public int $backoff = 30;

    public function __construct(
        public readonly int $recipientId,
        public readonly int $tenantId,
    ) {}

    // ...
}
Enter fullscreen mode Exit fullscreen mode

If the queue redelivers, the status guard makes the second attempt a no-op. That's the whole trick — no distributed lock, no dedupe table, just a state machine on a row you already have.

The failed() hook matters just as much. Without it, a recipient stuck in pending after retries are exhausted leaves the campaign hanging in sending forever. Mark it failed, recompute stats, let the campaign finish.

Never downgrade a status

Delivery webhooks arrive out of order. A delivered event landing after opened should not walk the status backwards.

private const RANK = [
    'sent' => 1, 'delivered' => 2, 'opened' => 3, 'clicked' => 4,
];
Enter fullscreen mode Exit fullscreen mode

Monotonic progression, terminal off-ramps (bounced, unsubscribed, failed) handled separately. Two enums carry the vocabulary — one for the campaign lifecycle, one for the per-recipient state, both with label(), color() and description() so the UI never hardcodes a string.

Unsubscribe is a public route

The tokenised opt-out link has no auth, so it always renders the same generic confirmation — valid token or not. Otherwise you've built an oracle that tells an attacker which tokens exist.

The opt-out also back-fills any still-pending recipient rows to unsubscribed, so reports stay honest instead of counting a send that will never happen.

it('suppresses pending recipients when a contact unsubscribes', function () {
    $recipient = pendingRecipient();

    $recipient->contact->unsubscribeFromEmail('via link');

    expect($recipient->fresh()->status)
        ->toBe(CampaignRecipientStatus::UNSUBSCRIBED);
});
Enter fullscreen mode Exit fullscreen mode

Takeaway

Broadcast systems get complicated because people try to make the sending reliable. Make the ledger reliable instead, keep every job small and re-runnable, and reliability falls out for free.

Top comments (0)