Email verification in .NET

.NET's hosting model gives verification a natural home away from the request path. A BackgroundService reading a Channel keeps list work off your controllers, and the typed-client registration keeps the HTTP configuration in one place.

If you have arrived from the C# page, the API calls are identical. What follows is the hosting shape: where the work runs, how it is bounded, and how it shuts down without dropping a batch.

Typed HttpClient registered through DI, a bounded Channel<T> as the work queue, and a BackgroundService draining it.

Install

Shell
dotnet add package Microsoft.Extensions.Http

Verify one address

You branch on four results: valid, invalid, catch_all and unknown. Role and disposable are boolean flags beside the result, so test them separately. Unknown is the branch most code forgets, and it is never billed.

C#
// Program.cs — registration, once.
builder.Services.AddHttpClient<IVerificationClient, VerificationClient>(client =>
{
    client.BaseAddress = new Uri("https://api.zapbounce.com/v1/");
    client.DefaultRequestHeaders.Authorization =
        new AuthenticationHeaderValue("Bearer", builder.Configuration["ZapBounce:Key"]);
    client.Timeout = TimeSpan.FromSeconds(15);
})
.SetHandlerLifetime(TimeSpan.FromMinutes(5));   // picks up DNS changes on failover

builder.Services.AddSingleton(Channel.CreateBounded<string>(
    new BoundedChannelOptions(10_000) { FullMode = BoundedChannelFullMode.Wait }));

builder.Services.AddHostedService<VerificationWorker>();

// The interface exists so a test can substitute it without an HTTP stub.
public interface IVerificationClient
{
    Task<VerifyResult> VerifyAsync(string email, CancellationToken ct = default);
    Task<string> SubmitBatchAsync(string name, IReadOnlyCollection<string> emails, CancellationToken ct = default);
}

Verify a list

Submit a batch rather than looping the single endpoint. We pace probes per receiving mail host, which protects the sending reputation your results depend on.

C#
public sealed class VerificationWorker(
    Channel<string> channel,
    IVerificationClient client,
    IServiceScopeFactory scopes,
    ILogger<VerificationWorker> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        // Buffer addresses and submit as batches: one call of 5,000 beats 5,000 calls.
        var buffer = new List<string>(capacity: 5_000);

        await foreach (var email in channel.Reader.ReadAllAsync(stoppingToken))
        {
            buffer.Add(email);
            if (buffer.Count < 5_000) continue;

            await FlushAsync(buffer, stoppingToken);
            buffer.Clear();
        }

        // Shutdown: whatever is buffered still gets submitted.
        if (buffer.Count > 0)
            await FlushAsync(buffer, CancellationToken.None);
    }

    private async Task FlushAsync(List<string> emails, CancellationToken ct)
    {
        try
        {
            var batchId = await client.SubmitBatchAsync(
quot;worker-{DateTime.UtcNow:O}", emails, ct); logger.LogInformation("submitted {Count} addresses as {BatchId}", emails.Count, batchId); } catch (ZapBounceException e) when (e.Code == "insufficient_credits") { logger.LogError("out of credits; {Count} addresses returned to the queue", emails.Count); foreach (var email in emails) await channel.Writer.WriteAsync(email, ct); } } }

Errors and retries

A 429 and a 5xx are worth retrying. A 402 for credits and a 400 for a malformed address will fail the same way on every attempt, so stop.

C#
// Fail open at the edge, and record why. An unverifiable address is not a bad one.
public sealed class SafeVerificationClient(
    IVerificationClient inner,
    ILogger<SafeVerificationClient> logger) : IVerificationClient
{
    public async Task<VerifyResult> VerifyAsync(string email, CancellationToken ct = default)
    {
        try
        {
            return await inner.VerifyAsync(email, ct);
        }
        catch (TaskCanceledException) when (!ct.IsCancellationRequested)
        {
            // HttpClient surfaces its own timeout as TaskCanceledException. The
            // guard is what separates "we timed out" from "the caller canceled".
            logger.LogWarning("verification timed out for {Email}", email);
            return VerifyResult.Unresolved(email);
        }
        catch (ZapBounceException e) when (e.Retryable)
        {
            logger.LogWarning("transient failure {Code} request_id={RequestId}", e.Code, e.RequestId);
            return VerifyResult.Unresolved(email);
        }
    }

    public Task<string> SubmitBatchAsync(string name, IReadOnlyCollection<string> emails, CancellationToken ct = default)
        => inner.SubmitBatchAsync(name, emails, ct);   // batches are queued; let them throw
}

// Registration order matters: the decorator wraps the typed client.
builder.Services.Decorate<IVerificationClient, SafeVerificationClient>();

.NET: common questions

How is this different from the C# page?

Same API calls. This page is about where the work runs: the hosted service, the bounded channel, and shutdown that does not drop a buffered batch.

Why a bounded channel?

An unbounded one turns a backlog into an out-of-memory crash. Bounded with FullMode.Wait applies backpressure to the producer, which is what you want.

Does the handler lifetime matter?

Yes. The default five minutes is what lets a DNS change reach a long-running process. A static HttpClient never picks it up.

Run this .NET code today

100 free checks a month, no card, credits that never expire, and unknown results that cost nothing.