Email Verification in .NET
This tutorial integrates Mailbeam into an ASP.NET Core application using idiomatic .NET patterns: a typed HttpClient, the options pattern for configuration, dependency injection, and a reusable validation attribute. It works the same in minimal APIs and MVC controllers.
What you'll build
- An
IEmailVerifierservice backed by a typedHttpClient - A
[VerifiedEmail]validation attribute for model binding - A minimal API signup endpoint that rejects undeliverable addresses
Prerequisites
- .NET 8 SDK or later
- A Mailbeam API key (sign up free)
- Basic familiarity with dependency injection in ASP.NET Core
Step 1 — Configure the key
Store the key in user secrets locally and in environment variables / Key Vault in production:
dotnet user-secrets init
dotnet user-secrets set "Mailbeam:ApiKey" "mb_live_xxxxxxxxxxxxxxxxxxxx"Bind it with the options pattern:
// Options/MailbeamOptions.cs
public sealed class MailbeamOptions
{
public string ApiKey { get; set; } = "";
public int MinScore { get; set; } = 60;
}Step 2 — Register a typed HttpClient
// Program.cs
builder.Services.Configure<MailbeamOptions>(
builder.Configuration.GetSection("Mailbeam"));
builder.Services.AddHttpClient<IEmailVerifier, MailbeamVerifier>((sp, client) =>
{
var opts = sp.GetRequiredService<IOptions<MailbeamOptions>>().Value;
client.BaseAddress = new Uri("https://api.mailbeam.dev/");
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", opts.ApiKey);
client.Timeout = TimeSpan.FromSeconds(5);
});Step 3 — Build the verification service
// Services/IEmailVerifier.cs
public interface IEmailVerifier
{
Task<bool> IsAcceptableAsync(string email, CancellationToken ct = default);
}
// Services/MailbeamVerifier.cs
public sealed class MailbeamVerifier : IEmailVerifier
{
private readonly HttpClient _http;
private readonly MailbeamOptions _opts;
private readonly ILogger<MailbeamVerifier> _log;
public MailbeamVerifier(
HttpClient http,
IOptions<MailbeamOptions> opts,
ILogger<MailbeamVerifier> log)
{
_http = http;
_opts = opts.Value;
_log = log;
}
private sealed record VerifyResponse(bool Valid, int Score, string? Reason);
public async Task<bool> IsAcceptableAsync(string email, CancellationToken ct = default)
{
try
{
var resp = await _http.PostAsJsonAsync(
"v1/verify", new { email = email.Trim().ToLowerInvariant() }, ct);
resp.EnsureSuccessStatusCode();
var result = await resp.Content.ReadFromJsonAsync<VerifyResponse>(ct);
return result is not null
&& result.Valid
&& result.Score >= _opts.MinScore;
}
catch (Exception ex)
{
// Fail open: an API error shouldn't block signups
_log.LogError(ex, "Mailbeam verification failed for {Email}", email);
return true;
}
}
}Step 4 — Add a validation attribute
For MVC and model binding, a custom attribute keeps validation declarative:
// Validation/VerifiedEmailAttribute.cs
public sealed class VerifiedEmailAttribute : ValidationAttribute
{
protected override ValidationResult? IsValid(
object? value, ValidationContext context)
{
if (value is not string email || string.IsNullOrWhiteSpace(email))
return ValidationResult.Success; // let [Required] handle empties
var verifier = context.GetRequiredService<IEmailVerifier>();
// Attributes are sync; block briefly on the async call
var ok = verifier.IsAcceptableAsync(email).GetAwaiter().GetResult();
return ok
? ValidationResult.Success
: new ValidationResult("Please provide a valid, deliverable email address.");
}
}Step 5 — Verify in a minimal API endpoint
// Program.cs
app.MapPost("/api/signup", async (
SignupRequest req,
IEmailVerifier verifier,
CancellationToken ct) =>
{
if (!await verifier.IsAcceptableAsync(req.Email, ct))
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["email"] = ["Please provide a valid, deliverable email address."]
});
var user = await CreateUserAsync(req.Email, req.Password, ct);
return Results.Created($"/users/{user.Id}", user);
});
public record SignupRequest(string Email, string Password);Step 6 — Add caching
Use IMemoryCache (or a distributed cache) to avoid repeat lookups:
public async Task<bool> IsAcceptableAsync(string email, CancellationToken ct = default)
{
var key = $"mb:{email.Trim().ToLowerInvariant()}";
if (_cache.TryGetValue<bool>(key, out var cached))
return cached;
// ... perform the verify call as in Step 3 ...
var ok = result is not null && result.Valid && result.Score >= _opts.MinScore;
_cache.Set(key, ok, TimeSpan.FromHours(24));
return ok;
}Testing
Mock the typed client or point tests at Mailbeam's test domains, which are deterministic and don't consume quota:
[Fact]
public async Task Disposable_Email_Is_Rejected()
{
var verifier = CreateVerifier(); // wired to test config
var ok = await verifier.IsAcceptableAsync("temp@disposable.mailbeam-test.dev");
Assert.False(ok);
}
[Fact]
public async Task Valid_Email_Is_Accepted()
{
var verifier = CreateVerifier();
var ok = await verifier.IsAcceptableAsync("user@valid.mailbeam-test.dev");
Assert.True(ok);
}Best practices
| Practice | Why |
|---|---|
Typed HttpClient via AddHttpClient | Pooling, resilience, easy mocking |
| Options pattern for the key | Clean config binding, no magic strings |
| Fail open on exceptions | API outages don't break signups |
Set a 5s Timeout | Don't hang the request on a slow probe |
Cache with IMemoryCache | Cuts duplicate calls on retries |
Production checklist
- API key in user secrets / Key Vault, never in source
-
HttpClienttimeout configured - Logging on verification failures
- Distributed cache for multi-instance deployments
- Test domains used in unit tests