<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Tuan Le Anh]]></title><description><![CDATA[Notes from a full stack developer in Vietnam on email delivery, multi-tenant SaaS and AI in products, written from real production code. Portfolio and case stud]]></description><link>https://tuanleanh.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Mon, 02 Nov 2026 13:58:56 GMT</lastBuildDate><atom:link href="https://tuanleanh.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Email Bounce Handling: Verify, Classify, Suppress Safely]]></title><description><![CDATA[How a Go email platform verifies, classifies and suppresses bounces without killing live addresses: SMTP codes, soft-bounce counters, circuit breakers.]]></description><link>https://tuanleanh.hashnode.dev/email-bounce-handling-verify-classify-suppress-safely</link><guid isPermaLink="true">https://tuanleanh.hashnode.dev/email-bounce-handling-verify-classify-suppress-safely</guid><category><![CDATA[golang]]></category><category><![CDATA[email]]></category><category><![CDATA[email marketing]]></category><category><![CDATA[backend]]></category><category><![CDATA[Software Engineering]]></category><dc:creator><![CDATA[Tuan Le Anh]]></dc:creator><pubDate>Wed, 14 Oct 2026 09:23:34 GMT</pubDate><content:encoded><![CDATA[<p>An email platform can get bounces wrong in two directions. Keep mailing addresses that do not exist, and the bounce rate climbs until mailbox providers start junking everything the sender sends. Suppress an address that was fine, and a real customer silently stops hearing from the business, with nothing in any dashboard to say so. On the email automation platform I worked on, most of the bounce work was about the second mistake, because it is the one nobody notices.</p>
<p>This post walks through the pipeline as it ended up: an optional verification before each send, classification of what comes back, a counter for soft bounces, and two circuit breakers that stop a sender before the damage spreads. The backend is Go, with asynq on Redis for the send queue; the code below is trimmed from it.</p>
<h2>One queued job per recipient</h2>
<p>Pressing Send does not send anything. The <code>sendCampaign</code> mutation runs pre-flight checks, marks the campaign as sending and enqueues a single <code>campaign:dispatch</code> job. That job resolves the audience, drops every suppressed address with a recorded reason, and enqueues one <code>email:send</code> job per recipient, spaced out at the sender's configured rate (ten per second by default).</p>
<pre><code class="language-go">// Spread the sends over time instead of handing the relay a burst.
processAt := batchStart.Add(time.Duration(enqueuedCount/ratePS) * time.Second)
task := asynq.NewTask("email:send", payload, asynq.Queue("high"))
_, err := client.Enqueue(task, asynq.ProcessAt(processAt))
</code></pre>
<p>Because every recipient is its own job, every recipient gets its own outcome: sent, skipped with a reason, bounced or retried. That is what makes the rest of the pipeline possible.</p>
<h2>Verify before sending, but read the verifier carefully</h2>
<p>A campaign can opt into checking each address with an email verification API just before it is sent. A hard result skips that recipient and suppresses the address; anything else lets the send go ahead.</p>
<p>The first version treated every non-valid answer as a hard bounce. That suppressed live contacts whenever the verifier itself timed out. The fix that followed still checked the status label first, so a result like status "Invalid" with the message "Unable to verify: Server disconnected" was still read as hard. Worse, a live iCloud address with dozens of opens was suppressed because the verifier called it "disposable", an opinion about the address rather than a finding about the mailbox.</p>
<p>The rule that came out of it: read the message before the status. The message says what happened at the mailbox; the status is a lossy bucket that labels a network timeout, a full mailbox and a missing user all as "Invalid".</p>
<pre><code class="language-go">switch {
// The verifier failed. That says nothing about the address.
case containsAny(msg, "unable to verify", "disconnect", "timeout", "no response"):
	return VerdictInconclusive
// The mailbox exists but cannot take mail right now.
case containsAny(msg, "mailbox full", "over quota", "greylist", "try again"):
	return VerdictSoft
// An opinion about the address, never a reason to suppress it.
case containsAny(msg, "disposable", "role account", "high risk"):
	return VerdictInconclusive
// A genuine non-existence finding.
case containsAny(msg, "no mx", "mailbox not found", "user unknown", "no such"):
	return VerdictHard
}
// Not confident: send, and let a real bounce decide.
return VerdictInconclusive
</code></pre>
<p>Only <code>VerdictHard</code> suppresses. Everything uncertain falls through to sending, because a genuinely bad address will produce a real bounce a minute later, and a real bounce is better evidence than any prediction.</p>
<p>Why a verifier's answers deserve that much suspicion is a post of its own: <a href="https://tuanleanh.com/blog/email-verification-false-results">five ways an email verifier lies</a>.</p>
<h2>Classify what actually comes back</h2>
<p>Real bounces arrive two ways. Some are synchronous: the receiving server rejects the recipient during the SMTP conversation. Others arrive later as events from the relay's webhook. Both go through the same classifier, keyed on the SMTP code and the reason text.</p>
<ul>
<li>A 5xx rejection during the send is recorded as a hard bounce and the job returns without an error, so the queue does not retry a permanent failure.</li>
<li>A 4xx or a connection error is returned as an error, and the queue retries the job with backoff.</li>
<li>552 and 452 (mailbox full) are soft, even though 552 starts with a 5.</li>
<li>Webhooks often carry the enhanced status code, such as <code>5.2.2</code>, instead of the three-digit reply code. A prefix match on "5" reads that as hard; it is a full mailbox, so it is matched explicitly as soft.</li>
</ul>
<h2>Soft bounces count, they do not kill</h2>
<p>One soft bounce means nothing; a mailbox that has been full for weeks probably is not coming back. Each contact carries a counter of consecutive soft bounces. Seven in a row with no delivery in between promotes the next one to a hard bounce, and a single delivered event resets the counter to zero.</p>
<p>A hard bounce suppresses the address for that organisation only, never globally, since one tenant's bad list says nothing about another tenant's customer. It also cancels the contact's active automation flows, rather than letting each queued step fail one by one for the length of the sequence.</p>
<h2>Circuit breakers for the sender</h2>
<p>Per-address handling cannot protect against a bad list. Two breakers sit above it, using the thresholds mailbox providers themselves enforce:</p>
<ul>
<li>Per campaign: once 100 messages have gone out, a bounce rate above 10% or a complaint rate above 0.3% pauses the campaign.</li>
<li>Per organisation: over a rolling seven-day window, and only after 500 sends, 5% bounces or 0.1% complaints raises a warning, and 10% or 0.3% suspends sending for the organisation.</li>
</ul>
<p>The minimum volumes matter as much as the rates. Three bounces out of the first twenty sends is 15%, and pausing on that would stop every new customer's first campaign.</p>
<h2>Make leaving easy</h2>
<p>Every message carries one-click unsubscribe headers (RFC 8058), which the large mailbox providers now require for bulk senders. An unsubscribe that works from the inbox is far cheaper than a spam complaint.</p>
<pre><code class="language-go">b.WriteString(fmt.Sprintf("List-Unsubscribe: &lt;%s&gt;, &lt;mailto:%s&gt;\r\n", unsubURL, unsubEmail))
b.WriteString("List-Unsubscribe-Post: List-Unsubscribe=One-Click\r\n")
</code></pre>
<h2>What I would keep</h2>
<ul>
<li>Suppression is permanent, so only suppress on evidence about the mailbox, never on a guess about the address.</li>
<li>Read a verifier's message before its status label.</li>
<li>Do not retry a 5xx; do retry a 4xx; and match enhanced status codes explicitly.</li>
<li>Count soft bounces and reset the count on delivery.</li>
<li>Scope suppressions to the tenant, and put rate-based breakers above per-address rules, with a minimum volume before either one fires.</li>
</ul>
<p>The rest of the platform, from the visual builder to the tracking endpoints, is in the <a href="https://tuanleanh.com/work/email-automation-platform">Email Automation Platform case study</a>.</p>
<hr />
<p>Related: <a href="https://tuanleanh.com/blog/email-verification-false-results">Five ways an email verifier lies, and how we caught them</a>.</p>
<p><em>Originally published at <a href="https://tuanleanh.com/blog/email-bounce-handling">tuanleanh.com</a>.</em></p>
]]></content:encoded></item><item><title><![CDATA[Why Email Verification Gets It Wrong: 5 False Verdicts]]></title><description><![CDATA[Five false verdicts from building an email verifier: Microsoft 365 550s, Yahoo accepting all, fake No MX, dropped connections and stale caches.]]></description><link>https://tuanleanh.hashnode.dev/why-email-verification-gets-it-wrong-5-false-verdicts</link><guid isPermaLink="true">https://tuanleanh.hashnode.dev/why-email-verification-gets-it-wrong-5-false-verdicts</guid><category><![CDATA[Python]]></category><category><![CDATA[email]]></category><category><![CDATA[email marketing]]></category><category><![CDATA[smtp]]></category><category><![CDATA[Software Engineering]]></category><dc:creator><![CDATA[Tuan Le Anh]]></dc:creator><pubDate>Wed, 14 Oct 2026 09:22:34 GMT</pubDate><content:encoded><![CDATA[<p>An email verifier answers one question: does this mailbox exist? It asks the mail server directly, the way a sender would, and stops before sending anything. The server's reply looks like a verdict. Very often it is not one.</p>
<p>I worked on an email verification service (FastAPI, Celery on Redis, MySQL) and on the email platform that calls it before every send. Most of the hard work on the verifier was not the SMTP check itself. It was learning which answers to distrust. These are the five that mattered most, because each one either told a customer to delete a live address or charged them for an answer that was not real.</p>
<h2>1. Microsoft 365 says 550 to everyone</h2>
<p>Some corporate Microsoft 365 tenants answer <code>550 Mailbox not found</code> for every recipient when they do not trust the IP that is asking. That reply looks exactly like a real hard bounce, so the customer sees a red Invalid and removes a working address.</p>
<p>The test that exposed it was cheap: ask the same server about a local part invented on the spot. When the reported address, <code>info@</code> and a random string all get the identical 550, the server is not discriminating, and its answer is worthless. Across production data, domains where every address hard-bounced were far more likely to be hosted on Microsoft 365 than healthy domains, while Google-hosted domains almost never showed the pattern.</p>
<p>So hard bounces from Microsoft 365 and iCloud mail hosts are re-checked from a different network before they are believed. The list of hosts is short on purpose, and every entry has a measurement behind it.</p>
<h2>2. Yahoo accepts everything</h2>
<p>Yahoo-operated mail hosts accept every recipient during the SMTP conversation, then bounce later. A plain check cannot learn anything there. The upstream checker answered these with a synthetic "default pass", which would have been a valid result for an address that might not exist.</p>
<p>We record those as Unknown and do not charge for them, because no check happened. They are then re-checked by a prober that can get a real answer from Yahoo, and only a real answer is billed.</p>
<h2>3. "No MX records" under load is not a verdict</h2>
<p>A domain with no MX records cannot receive mail, so "No MX" reads like a final answer. Under load, though, the DNS lookup behind it fails transiently, and the checker reports the failure as missing records. It once told us gmail.com had no MX records, hundreds of times over two months.</p>
<p>The fix is a small retry: "No MX" is re-asked twice before it is accepted. Each retry is another slow round trip, so the count stays low, but it is enough to turn most of these back into real answers.</p>
<h2>4. A dropped connection says nothing about the mailbox</h2>
<p>Many servers simply close the connection on an IP they dislike. "Server disconnected" is a fact about the asker, not the address. It is stored as Unknown with a reason code (blocked, greylisted, provider refuses verification, and so on) rather than folded into Invalid, and the blocked ones are the only ones worth re-checking from another network. A timeout, by contrast, would time out from anywhere.</p>
<p>The same rule applies one step downstream. The email platform that consumes these results reads the message before the status label, and never suppresses an address on an "unable to verify". That side is in <a href="https://tuanleanh.com/blog/email-bounce-handling">the bounce handling post</a>.</p>
<h2>5. A cache that remembers the wrong question</h2>
<p>Looking up a domain's MX hosts is slow, so the answer "does this domain match the distrusted-host list?" was cached for seven days. Then iCloud was added to the list. iCloud kept returning its cached "no match" for a week, and every iCloud hard bounce in that week skipped the re-check.</p>
<p>The cache key now carries a fingerprint of the list itself, so changing the list changes every key and old answers are simply never read again. The raw MX hosts are cached separately, because they are true no matter what anyone is matching against them.</p>
<pre><code class="language-python"># The verdict depends on the suffix list, so the key does too.
fingerprint = hashlib.sha1(",".join(blocking_suffixes()).encode()).hexdigest()[:10]
key = f"mxblock:v2:{fingerprint}:{domain}"
</code></pre>
<h2>What carries over</h2>
<ul>
<li>Test a server with an address you just invented. If it answers the same as for the real one, it is not telling you anything.</li>
<li>Keep "unknown" as a first-class answer, with a reason, instead of rounding it to valid or invalid.</li>
<li>Separate facts about the asker (blocked, timed out) from facts about the mailbox.</li>
<li>Only add a provider to a special-case list when a measurement puts it there.</li>
<li>Put everything a cached answer depends on into its key.</li>
</ul>
<hr />
<p>Related: <a href="https://tuanleanh.com/blog/email-bounce-handling">Suppressing bad email addresses without killing live ones</a>.</p>
<p><em>Originally published at <a href="https://tuanleanh.com/blog/email-verification-false-results">tuanleanh.com</a>.</em></p>
]]></content:encoded></item></channel></rss>