Sending email is easy until you send millions. At scale, deliverability is the whole game; the architecture exists to protect your sender reputation. This post is the working design.

Use a provider

SendGrid, Postmark, AWS SES, Resend, Mailgun. They handle:

  • IP warming.
  • Reputation tracking with ISPs.
  • Bounce / complaint webhooks.
  • DKIM / SPF / DMARC plumbing.
  • Compliance (CAN-SPAM, GDPR).

Build the orchestration that uses them. Don’t build the SMTP gateway.

The architecture

Newsletter authored
Schedule
Render per recipient (Liquid / Handlebars)
Suppression check
Provider (SendGrid / Postmark)
Recipient inbox
Webhook events (delivery / bounce / open / click)
Update DB

Each step is a place to be careful.

Suppression list

CREATE TABLE suppression (
    email TEXT PRIMARY KEY,
    reason TEXT NOT NULL,        -- 'hard_bounce' | 'complaint' | 'unsubscribe'
    suppressed_at TIMESTAMPTZ DEFAULT now()
);

Before every send: check suppression. Sending to a suppressed address = lower reputation.

Bounce handling

ESPs send webhook events. Process:

@app.post("/webhook/sendgrid")
async def sg_webhook(request: Request):
    events = await request.json()
    for ev in events:
        if ev["event"] == "bounce" and ev["type"] == "hard":
            await suppress(ev["email"], "hard_bounce")
        elif ev["event"] == "complaint":
            await suppress(ev["email"], "complaint")
        elif ev["event"] == "unsubscribe":
            await suppress(ev["email"], "unsubscribe")

Every event gets a row. Aggregate metrics from there.

Throttling

ESPs have rate limits. Hit them and bursts get queued or rejected:

async def send_throttled(emails):
    async with throttle(max_per_second=200):
        for email in emails:
            await provider.send(email)

Standard pattern: ~200/sec for shared IPs; thousands for dedicated IPs after warmup.

Per-recipient throttling

Don’t spam a single inbox. If you send 100 emails/day total, sending 10 to one user looks like spam. Cap per-user/day.

Templating

Hi {{ user.name }},

Your weekly summary:
{% for item in summary.items %}
  - {{ item.title }} ({{ item.url }})
{% endfor %}

Render server-side. Each recipient gets personalized content. Cache the unsubscribe link.

Unsubscribe

CAN-SPAM and GDPR require easy unsubscribe. One-click in the email; respected immediately:

List-Unsubscribe: <https://example.com/unsub?token=...>, <mailto:[email protected]>
List-Unsubscribe-Post: List-Unsubscribe=One-Click

Modern email clients show a one-click unsubscribe button when these headers are present. Use them.

Capacity

For 100M sends/month at peak burst of 1M/hour:

  • Provider must handle the burst (verify SLA).
  • Suppression check: indexed lookup, microseconds each.
  • Render: parallel; bounded by CPU.
  • Webhook ingest: peak after sends; size accordingly.

Common mistakes

1. No suppression check

Sending to bounced addresses kills reputation in days.

2. Reusing IPs without warmup

A new IP sending 1M emails on day 1 = blocked. Warm gradually over weeks.

3. No DMARC

Without DMARC, spammers can spoof your domain. Set policy at least to quarantine.

4. Bouncing on every transient error

Some bounces are temporary. Don’t permanently suppress on a 4xx; only 5xx hard bounces.

5. Ignoring engagement signals

Continuing to send to addresses that haven’t engaged in 6 months = inactive list = ISP penalties. Sunset cold subscribers.

What I’d build today

  • Postmark or SendGrid for transactional.
  • Resend or SendGrid Marketing for newsletters.
  • Postgres for suppression, events, send history.
  • Per-user preferences UI.
  • Engagement tracking + cold-list pruning.
  • DMARC / DKIM / SPF properly configured.

Solid base. Scales to millions of sends.

Read this next

If you want a Postgres + Postmark newsletter system reference, it’s at rajpoot.dev .


Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .