# Validate an email address in C#

_2026-10-05 · Boundstone (https://boundstone.io/blog/validate-email-csharp)_


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.

```csharp
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.

```csharp
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:

```bash
dotnet add package DnsClient
```

```csharp
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.

```csharp
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:

```text
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](/tools/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:

```bash
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 use `MailAddress` — it's too lenient, and remember `IsValid(null)` returns `true`.
- **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 read `checks.not_performed` so 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](/blog/validate-email-python) and [validate an email in Java](/blog/validate-email-java).
