Validate an email address in C#
Three layers — a built-in attribute for syntax, DnsClient.NET for MX, an API for disposable and role accounts — and a plain account of what each one proves.
Contents
When you need to validate email in C#, you're really choosing how many layers deep to go: a regex, the built-in attribute, a live DNS check, or an API. The honest answer is that .NET already covers the first layer for free, and every layer past it costs something concrete — a NuGet package, then a network call. Here's each one, what it actually proves, and where you can stop.
Syntax: use the built-in attribute, add nothing
System.ComponentModel.DataAnnotations.EmailAddressAttribute ships with .NET. No NuGet package, no regex to keep alive.
using System.ComponentModel.DataAnnotations;
static bool IsValidSyntax(string email) =>
new EmailAddressAttribute().IsValid(email);
Two things to know before you trust it. First, IsValid(null) returns true — DataAnnotations treats absence as valid, so pair the attribute with [Required] on your model or guard null yourself. Second, it is a structural check only: it confirms the string looks like local@domain, and says nothing about whether that domain exists or accepts mail.
Do not reach for System.Net.Mail.MailAddress as a validator instead. It is lenient by design — MailAddress.TryCreate happily accepts user@localhost (no dot, no TLD) and even parses display names — so it says "yes" to strings you never meant to allow.
using System.Net.Mail;
// Returns true for "user@localhost" — too loose for validation.
bool looksOk = MailAddress.TryCreate("user@localhost", out _);
For a form field where you'll send a confirmation link anyway, the attribute is genuinely all you need. Stop here.
Does the domain accept mail? A live MX check with DnsClient.NET
Syntax tells you the string is shaped right. It doesn't tell you that @gnail.com — a typo — can receive anything. For that, look up the domain's MX records. .NET has no first-party DNS client, so add the DnsClient.NET package:
dotnet add package DnsClient
using System.Linq;
using DnsClient;
static async Task<bool> HasMxRecord(string domain)
{
var lookup = new LookupClient();
var result = await lookup.QueryAsync(domain, QueryType.MX);
return result.Answers.MxRecords().Any();
}
There's a synchronous lookup.Query(domain, QueryType.MX) too if you're not in an async context. Either way, an MX lookup tells you the domain publishes mail servers. It does not tell you a specific mailbox exists — that's a heavier, separate check. Treat a missing MX as a strong reject (typo'd or parked domain) and a present MX as "worth proceeding".
Disposable and role accounts: a maintained list
Two things neither syntax nor MX will catch, and both need data that goes stale every week: disposable domains (mailinator.com and thousands of throwaways) and role accounts (info@, support@, admin@ — real inboxes, but not a person you can hold a conversation with). Curating those lists yourself is a standing chore. This is where an API earns its place.
Boundstone's POST /v1/verify/email runs the syntax and MX checks above, plus a disposable-domain list and a role-account list, and returns one JSON contract you can share across every service that touches email — no per-language reimplementation.
using System.Net.Http.Headers;
using System.Net.Http.Json;
var http = new HttpClient { BaseAddress = new Uri("https://api.boundstone.io") };
http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", "bs_live_YOUR_KEY");
var resp = await http.PostAsJsonAsync("/v1/verify/email",
new { email = "you@example.com" });
var result = await resp.Content.ReadFromJsonAsync<EmailResult>();
Console.WriteLine($"Checked: {string.Join(", ", result!.checks.performed)}");
Console.WriteLine($"Not checked: {string.Join(", ", result.checks.not_performed)}");
if (result.disposable || result.role_account)
{
// A real inbox, likely — but route it to review, don't add it to your main list.
}
record EmailResult(
bool valid_syntax, string domain, bool mx_found,
bool disposable, bool role_account, bool free_provider,
Checks checks);
record Checks(string[] performed, string[] not_performed);
Every response states exactly what it checked and what it didn't:
Checked: syntax, mx, disposable_list, role_list
Not checked: smtp_mailbox, catch_all
Boundstone does not open an SMTP session to the mailbox, and does not probe catch-all domains — those two stay not_performed, plainly, on every response. That's the point, not a footnote to bury: a valid_syntax: true with mx_found: true means the domain can receive mail and isn't a known throwaway — it does not mean a human will read it. Nothing in the payload pretends to prove more than it did. (The response also carries a free_provider flag — true for gmail.com and the like — if you want to treat consumer addresses differently. It isn't part of the checks.performed list; it's just a returned property.)
The free tier is 250 checks a month, no card, and credits never expire. If you'd rather paste a few addresses in a browser before writing any code, the free email validator runs the same checks with no signup.
Validating a whole list
For a CSV of addresses, POST the raw file to /v1/bulk/email and poll for results:
curl -X POST https://api.boundstone.io/v1/bulk/email \
-H "Authorization: Bearer bs_live_YOUR_KEY" \
-H "Content-Type: text/csv" \
--data-binary @emails.csv
# -> {"job_id":"..."} (HTTP 202)
curl https://api.boundstone.io/v1/bulk/JOB_ID/results.csv \
-H "Authorization: Bearer bs_live_YOUR_KEY"
The free tier caps a job at 250 rows (paid tiers go to 10,000). A credit is reserved per row and refunded for any row that errors, so a malformed line never quietly costs you. There's also a paste box and CSV upload in the dashboard if you'd rather not script it.
The short version
- Syntax only, form field:
new EmailAddressAttribute().IsValid(email). No package. Don't useMailAddress— it's too lenient, and rememberIsValid(null)returnstrue. - Catch typo'd or dead domains: add DnsClient.NET, look up MX records, reject when there are none.
- Catch disposable and role addresses, one contract across services:
POST /v1/verify/email, and readchecks.not_performedso you always know what a "valid" does and doesn't cover. - No mailbox-level or catch-all check exists here. If a vendor claims per-mailbox certainty, ask how — because Boundstone won't, and says so on every call.
Same layering in other languages: validate an email in Python and validate an email in Java.