45명의 구독자가 있었고 그들에게 0개의 이메일을 보냈습니다.

작성자

카테고리:

← 피드로
DEV Community · Petteri Pucilowski · 2026-08-18 개발(SW)

We had 45 people on our email list and had sent them, in total, zero emails.

Not zero this month. Zero ever. I found it while checking something else, and the reason is boring enough that I suspect it is sitting in your app too.

How a form ends up promising something no code owns

Our subscribe endpoint did exactly what I wrote it to do:

@router.post("/newsletter/subscribe")
async def subscribe(request: Request, body: SubscribeBody) -> dict:
    ...
    result = await resend_audience_client.add_contact(
        audience_id=settings.resend_newsletter_audience_id,
        email=email,
    )
    return {"ok": True}

Enter fullscreen mode Exit fullscreen mode

Add the contact to the audience, return 200. That is a complete, correct, tested implementation of “subscribe”. It is also the whole bug, because nothing else in the system ever mails that audience except a weekly broadcast I had paused a month earlier.

Meanwhile the frontend was making promises. The exit intent popup said:

get one email when the next release is live

and the blog form said:

one tactical post a week

+ a free domain audit when you sign up

Three promises. A per release email, a weekly post, and an audit. Grep the backend for the code that keeps any of them and you get one hit: the weekly broadcast, behind a feature flag that was set to false. The audit never existed at all. I had written it as copy, liked how it read, and shipped it.

That is the actual failure mode. Not a bug in a function. A promise that lives in JSX with no counterpart in the backend, so no test can fail and no error can fire. The system was 100% healthy while doing nothing.

The first touch has to exist, and exactly once

The fix is a welcome email, which sounds trivial until you write down what it has to survive: a resubscribe must not send a second one, a Resend hiccup during the request must not lose the only email that subscriber ever gets, and a retry must not send two.

I already had a delayed_events table for scheduling emails after a purchase, with this index:

CREATE UNIQUE INDEX uq_delayed_events_email_event
  ON delayed_events(email, event_name);

Enter fullscreen mode Exit fullscreen mode

That index is the whole dedupe. schedule() returns the new row id, or None if a row for that pair already exists. So “have we ever welcomed this address” is not a new question, it is the insert:

async def _welcome_once(email: str) -> str:
    event_id = delayed_events_service.schedule(
        email, delayed_events_service.NEWSLETTER_WELCOME_EVENT, 0
    )
    if event_id is None:
        return "already_welcomed"          # resubscribe, ever, forever
    sent = await send_newsletter_welcome_email(email)
    if not sent:
        return "queued"                    # row stays due, poller retries
    delayed_events_service.mark_sent(event_id)
    return "sent"

Enter fullscreen mode Exit fullscreen mode

Sending inline matters more than it looks. A welcome email that arrives five minutes after signup is a worse welcome, and the poller interval was five minutes. So the row is a receipt, not a queue: written first, stamped after delivery, and if delivery fails it is simply left due for the poller that already exists.

mark_sent is guarded so a poller tick that fires in the same second cannot double send:

cur = conn.execute(
    "UPDATE delayed_events SET sent_at = ?, last_error = NULL "
    "WHERE id = ? AND sent_at IS NULL",
    (now, event_id),
)
return cur.rowcount > 0

Enter fullscreen mode Exit fullscreen mode

Seeding a ledger so shipping the feature does not fire it

The popup promise was per release: our data comes from Common Crawl, which republishes its hyperlink graph about every three months, and people signed up to hear when a fresh one lands.

So: a table keyed by release id, a poller check, one broadcast per new id. Easy, except for the first run. The moment that code deploys it finds the current release, sees no row, and mails 45 people about a graph that went live three weeks ago.

The fix is one line in ensure_tables, and it is the part I would have forgotten:

empty = conn.execute("SELECT COUNT(*) FROM release_announcements").fetchone()[0] == 0
...
if empty:
    conn.execute(
        "INSERT OR IGNORE INTO release_announcements "
        "(release_id, status, created_at, sent_at) VALUES (?, 'seeded', ?, ?)",
        (current_release_id, now, now),
    )

Enter fullscreen mode Exit fullscreen mode

Bootstrapping the ledger with “already handled” means only genuinely new state can trigger the side effect. Any time you add “notify on change” to a system that has been running for a while, the current value is not a change.

The other decision worth naming: this is deliberately not behind the same flag as the weekly newsletter. I had paused a weekly column because I did not have time to write it. That is my choice to make. Cancelling a per release notification someone explicitly opted into is not the same choice, and one boolean should not silently do both.

The bounce nobody handled

While in there I pulled the last 100 sends from the Resend API. Six bounces, from two addresses. Both were junk domains someone typed into the form. Both had then received the entire rest of the sequence.

2026-08-06  bounced  your free CrawlGraph API key
2026-08-08  bounced  the question your free API key can't answer
2026-08-12  bounced  your API key works with hosted MCP

Enter fullscreen mode Exit fullscreen mode

One signup, three bounces, on a schedule. At my volume that is cosmetic. Bounce rate is also the single number mailbox providers use to decide whether you are a sender or a problem, and the list only grows.

The interesting part is that the fix was already half built. Resend was posting webhooks to an endpoint I had written for inbound support email, and that handler said:

if payload.get("type") != "email.received":
    return {"status": "ignored", "type": payload.get("type")}

Enter fullscreen mode Exit fullscreen mode

Every bounce notification Resend ever sent me hit that line and was thrown away with a cheerful 200. Now email.bounced and email.complained route to a suppression table, and permanent failures cancel anything already scheduled for that address.

Two details worth stealing. Transient bounces do not suppress, because a full mailbox is not a reason to stop mailing someone forever. And the marketing sender reports success when it skips a suppressed address:

if email_suppression_service.is_suppressed(email):
    logger.info("skipping %s to a suppressed address", kind)
    return True   # terminal: the durable sender must retire the row, not retry

Enter fullscreen mode Exit fullscreen mode

Returning False there would be more honest looking and completely wrong: every durable sender I have treats False as “retry later”, so a suppressed address would spin until it hit the attempt cap.

Ten minutes on your own funnel

Three queries, and you do not need my stack to run the equivalent:

  1. Every collection point, and what it promises. Grep the frontend for your form components and read the copy out loud. Every promise is a claim about backend behaviour.
  2. For each promise, the code that keeps it. If you cannot point at a function, the promise is decoration. Delete the copy or write the function.
  3. Your provider’s recent sends, grouped by event. delivered, bounced, complained. Then check whether anything that bounced got mailed again afterwards.

The thing that stung was that every dashboard was green. Signups were arriving, the API returned 200, tests passed, the provider reported no errors. There is no monitor for “the email that was never designed.”

I build CrawlGraph, backlink data on Common Crawl’s open web graph. The free API key is 15 calls a month with no card if you want to poke at link graphs: crawlgraph.com/docs/api. But mostly, go read your own signup form’s copy and then go looking for the function that keeps it.

원문에서 계속 ↗