Tutorial · C# & ASP.NET Core

C# email validation: from regex to mailbox check

Four checks, each with working .NET 10 code: a format regex, the built-in parsers and their surprises, an MX lookup, and a mailbox check wired into ASP.NET Core model validation.

Updated October 29, 2026 · 11 min read

C# email validation: the [EmailAddress] attribute only checks for one @, and only the regex rejects every malformed address in the test

C# email validation usually starts and ends with new MailAddress(input) or the [EmailAddress] attribute, and both accept far more than a sign-up form should. This guide shows what each built-in check really does, with results from running the code, and adds the two checks that catch real bounces: an MX lookup and a mailbox check. All code below was tested on .NET 10, the current long-term support release.

1. C# email validation with a regex

A regex is the fastest first check. This is the tested pattern from our email regex guide, written for .NET:

using System.Text.RegularExpressions;

public static class EmailFormat
{
    // \A and \z anchor the whole string: with ^ and $, "jane@example.com\n" would match.
    public const string Pattern =
        @"\A(?=.{1,254}\z)(?=.{1,64}@)[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+)*"
        + @"@(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+(?:[A-Za-z]{2,63}|xn--[A-Za-z0-9-]{2,59})\z";

    private static readonly Regex Regex = new(Pattern, RegexOptions.CultureInvariant);

    public static bool IsValid(string? value) => value is not null && Regex.IsMatch(value);
}

It enforces the length limits (64 characters before the @, 254 in total), allows plus tags and apostrophes, and rejects dots at the edges of the local part, double dots, and domains without a top-level domain. The rules behind it are in the guide to the email address format.

Use \A and \z, not ^ and $. In .NET, $ also matches before a final \n, so a version of the same pattern written with ^ and $ accepted "jane@example.com\n" in our test. Pattern is a const so the ASP.NET Core model in section 5 can reuse it in an attribute.

Two details before you call IsValid:

2. Validate an email address in C# with MailAddress and [EmailAddress]

.NET has two built-in candidates, and neither is a format validator in the sense a sign-up form needs.

MailAddress.TryCreate

System.Net.Mail.MailAddress parses an address the way it appears in an email header. Its documentation says the input “can contain a display name and the associated email address if you enclose the address in angle brackets”. So Jane Doe <jane@example.com> is valid input, and so, in our test, was jane doe@example.com: MailAddress read jane as the display name and returned the address doe@example.com.

If you use it, use TryCreate (no exception on bad input) and accept only input that is exactly the parsed address:

using System.Net.Mail;

public static class MailAddressCheck
{
    // MailAddress also parses "Jane Doe <jane@example.com>", so compare the result with the input.
    public static bool IsBareAddress(string input) =>
        MailAddress.TryCreate(input, out var address) && address.Address == input;
}

That removes the display-name cases, but MailAddress still accepts a dot right before the @, a trailing dot after the domain, hyphens at the start of the domain and jane@localhost (table below).

The [EmailAddress] attribute

System.ComponentModel.DataAnnotations.EmailAddressAttribute looks like a validator, but its .NET 10 source checks very little:

null                      -> valid
contains \r or \n         -> invalid
exactly one @, not first,
not last                  -> valid

There is no regex behind it. !@# and jane@ example.com both passed in our test, and an empty string fails while null passes (pair it with [Required]).

What each check accepts

Every address below went through the four checks on .NET 10. ✓ means the check accepts the address, ✗ that it rejects it.

AddressShould pass?RegexMailAddressIsBareAddress[EmailAddress]
jane@example.comYes✓✓✓✓
jane.doe+news@example.comYes✓✓✓✓
o'brien@example.ieYes✓✓✓✓
jane@exampleNo✗✓✓✓
jane@example.cNo✗✓✓✓
jane.@example.comNo✗✓✓✓
.jane@example.comNo✗✗✗✓
jane..doe@example.comNo✗✗✗✓
jane@example.com.No✗✓✓✓
jane@-example.comNo✗✓✓✓
jane@exa_mple.comNo✗✓✓✓
jane doe@example.comNo✗✓✗✓
"jane doe"@example.comNo (RFC-valid)✗✓✓✓
jane@[192.0.2.10]No (RFC-valid)✗✓✓✓
josé@example.comNo (needs SMTPUTF8)✗✓✓✓
jane@bücher.example.comAfter IDN conversion✗✓✓✓
jane@xn--bcher-kva.example.comYes✓✓✓✓
Jane Doe <jane@example.com>No (display name)✗✓✗✓
jane@example.com with spaces around itAfter trimming✗✓✗✓
jane@example.com + line breakNo✗✗✗✗
aaa…a@example.com (65 a’s)No✗✓✓✓
jane@localhostNo✗✓✓✓

The regex is the only check that rejects every broken address in the list. MailAddress and [EmailAddress] are fine for parsing a header or for a quick “has an @” hint, not for deciding whether to store an address.

3. MX lookup with DnsClient

A correct format says nothing about the domain. System.Net.Dns resolves host names to IP addresses; for MX records most projects use the DnsClient package (dotnet add package DnsClient). (The upcoming .NET 11 adds Dns.ResolveMxAsync; the API reference lists it for .NET 11 only, checked October 2026.)

using DnsClient;

public static class MailDomain
{
    // False only when DNS says the domain takes no email: NXDOMAIN, a null MX,
    // or no MX and no A/AAAA record. DNS errors (SERVFAIL, timeout) count as "maybe".
    public static async Task<bool> CanReceiveMailAsync(ILookupClient dns, string domain)
    {
        try
        {
            var response = await dns.QueryAsync(domain, QueryType.MX);
            if (response.Header.ResponseCode == DnsHeaderResponseCode.NotExistentDomain)
                return false;
            if (response.HasError)
                return true;

            var mx = response.Answers.MxRecords().ToList();
            if (mx.Count == 1 && mx[0].Exchange.Value == ".")
                return false; // null MX (RFC 7505)
            if (mx.Count > 0)
                return true;

            // No MX record: mail goes to the A/AAAA record (implicit MX).
            var a = await dns.QueryAsync(domain, QueryType.A);
            var aaaa = await dns.QueryAsync(domain, QueryType.AAAA);
            return a.Answers.ARecords().Any() || aaaa.Answers.AaaaRecords().Any();
        }
        catch (DnsResponseException)
        {
            return true; // timeout: unknown, don't reject
        }
    }
}

Create one LookupClient for the app and reuse it, since it caches answers for their TTL (DnsClient README). Its defaults (5 seconds per query, 2 retries) are slow for a form, so shorten them:

var dns = new LookupClient(new LookupClientOptions { Timeout = TimeSpan.FromSeconds(2), Retries = 1 });
bool ok = await MailDomain.CanReceiveMailAsync(dns, "example.org");

Three cases need care, and the code handles each:

We ran the function against a local test DNS server: a domain with two MX records, a null MX, a domain with only an A record, one with only an AAAA record, a non-existent domain and a SERVFAIL answer returned True, False, True, True, False and True; a timeout also returned True. More on how MX records work in the MX record guide, or look up a domain by hand with the MX lookup tool.

4. Check that the mailbox exists

An MX record only proves that the domain takes email. Whether anna@example.org exists is something only the receiving mail server knows, and asking it from your own server is unreliable: outbound port 25 is often blocked, and catch-all domains accept every address. The email validation API runs the syntax, DNS, MX and mailbox checks for you and returns one JSON result:

using System.Net.Http.Json;
using System.Text.Json;

public sealed record EmailResult(
    string Email, bool FormatValid, bool MxFound, bool SmtpCheck, bool Disposable,
    bool Role, bool Free, double Score, string State, string Reason, string DidYouMean,
    string Status, string? SubStatus, string RecommendedAction);

public sealed class EmailValidationClient(HttpClient http)
{
    private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web)
    {
        PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, // format_valid -> FormatValid
    };

    public async Task<EmailResult> ValidateAsync(string email, CancellationToken ct = default)
    {
        // timeout=7: the API waits up to 7 seconds for the mail server, then answers "unknown"
        var url = "https://api.emailvalidation.io/v1/info?timeout=7&email=" + Uri.EscapeDataString(email);
        using var request = new HttpRequestMessage(HttpMethod.Get, url);
        request.Headers.Add("apikey", Environment.GetEnvironmentVariable("EMAILVALIDATION_KEY"));

        using var response = await http.SendAsync(request, ct);
        response.EnsureSuccessStatusCode(); // 401, 422, 429 ... throw HttpRequestException
        return (await response.Content.ReadFromJsonAsync<EmailResult>(Json, ct))!;
    }
}

Keep the key in the EMAILVALIDATION_KEY environment variable (or your secret store), never in client-side code. Uri.EscapeDataString matters: an unescaped + in jane+news@example.com can be read as a space. The API’s timeout parameter (3 to 60 seconds, default 30) caps how long it waits for a slow mail server; the 10-second HttpClient timeout in section 5 leaves room for the network.

EnsureSuccessStatusCode throws for every answer other than 200: 401 for a missing or wrong key, and 429 when the monthly quota is used up, when the free plan’s 10 requests a minute are exceeded, or when more checks run at the same time than the plan allows (5 on the free plan, 25 on paid plans). The Retry-After header of a 429 says when to try again.

What to do with the result:

statusstate / reasonWhat to do at sign-up
validdeliverable / valid_mailbox, or low_quality for a role addressAccept.
catch_alldeliverable / low_deliverabilityThe domain accepts every address, so the mailbox can’t be confirmed. Accept, or flag it.
riskydeliverable / low_deliverabilityThe mailbox is full. Accept; mail bounces until its owner frees up space.
do_not_maildeliverable / low_deliverabilityA disposable address. Ask for a permanent one.
invalidundeliverable / invalid_mailbox, invalid_mx or invalid_formatReject and ask the user to check the address.
unknownunknown / timeout, no_connect, unavailable_smtp or unexpected_errorThe mail server gave no clear answer. Accept; don’t block a real customer.

SubStatus says why (for example mailbox_not_found or accept_all; null for a plain valid result), and RecommendedAction sums the result up as send, send_with_caution, suppress or retry_later; the result statuses in the Documentation list every value. Show DidYouMean when it isn’t empty, for example gmail.com for a typed gmial.com. Catch-all email explains why those domains can’t be verified per mailbox.

The free plan includes 100 checks a month at up to 10 requests a minute, and only definitive results (deliverable or undeliverable) count against it. Paid plans have no per-minute limit.

5. ASP.NET Core email validator: attributes plus API check

In ASP.NET Core, the format checks belong on the model and the mailbox check in the action. With [ApiController], the framework returns an automatic HTTP 400 when the attributes fail, before your action runs.

using System.ComponentModel.DataAnnotations;

public sealed class SignupRequest
{
    private string _email = "";

    [Required]
    [EmailAddress]
    [RegularExpression(EmailFormat.Pattern, ErrorMessage = "Please enter a valid email address.")]
    public string Email
    {
        get => _email;
        set => _email = value?.Trim() ?? ""; // people paste addresses with spaces
    }
}

[RegularExpression] only passes when the pattern matches the whole value (source), and the setter trims before validation runs. The controller then asks the API and reports problems through ModelState, so every error reaches the client in the same format:

using Microsoft.AspNetCore.Mvc;

[ApiController]
[Route("signup")]
public sealed class SignupController(EmailValidationClient emails, ILogger<SignupController> log) : ControllerBase
{
    [HttpPost]
    public async Task<IActionResult> Post(SignupRequest request, CancellationToken ct)
    {
        // [ApiController] has already returned 400 if the attributes failed.
        try
        {
            var result = await emails.ValidateAsync(request.Email, ct);
            if (result.Status == "invalid")
                ModelState.AddModelError(nameof(request.Email), "This email address doesn't exist. Please check it.");
            else if (result.Status == "do_not_mail")
                ModelState.AddModelError(nameof(request.Email), "Please use a permanent email address.");
        }
        catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
        {
            log.LogWarning(ex, "Email check skipped"); // API down, slow or out of quota: don't block the sign-up
        }

        if (!ModelState.IsValid)
            return ValidationProblem(ModelState);

        // create the account ...
        return Ok();
    }
}

The action decides on Status rather than on the Disposable flag: privacy relay addresses such as Apple’s Hide My Email are on some disposable lists, but they forward to the owner’s real inbox, and the API returns them as valid (relay_address) even when disposable is true. A missing or wrong key (401) also ends up in the catch block, so alert on that warning.

Register the typed HttpClient with a timeout in Program.cs:

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddHttpClient<EmailValidationClient>(c => c.Timeout = TimeSpan.FromSeconds(10));

var app = builder.Build();
app.MapControllers();
app.Run();

We ran the app against a local mock of the API and posted JSON to /signup:

Posted emailResponse
anna@example.org200
anna@example.org with spaces around it200 (trimmed)
jane@example400, “Please enter a valid email address.”
.jane@example.com400, “Please enter a valid email address.”
An address the API reports as invalid400, “This email address doesn’t exist. Please check it.”
An address the API reports as do_not_mail (disposable)400, “Please use a permanent email address.”
A relay address (valid, relay_address, disposable: true) or a catch_all address200
Any address while the API answers 429 or is too slow, or with no key set200 (check skipped, warning logged)

The 400 responses are the standard validation problem details, with the messages under errors.Email. For Razor Pages or MVC views, the same attributes drive the form; check ModelState.IsValid yourself, since the automatic 400 only applies to [ApiController].

  1. In the browser: type="email" for instant feedback (the server still decides).
  2. On the server: trim, then the regex via [RegularExpression].
  3. On submit: the API check. Block invalid and do_not_mail results, accept the rest, and accept everything when the API is unreachable.
  4. For existing lists, check the whole file instead of one address at a time with the bulk email verifier.

To see the API’s answer for a single address before you write code, paste it into the free email validator. The same checks in other languages: Java email validation and email validation in Go.

Sources

Checked October 29, 2026: MailAddress constructor and Dns class (Microsoft Learn); EmailAddressAttribute and RegularExpressionAttribute source (dotnet/runtime, release/10.0); Anchors in regular expressions; Model validation in ASP.NET Core; DnsClient.NET; RFC 7505 (null MX); the emailvalidation.io Documentation (/v1/info, timeout, status fields). Code tested with the .NET 10 SDK (10.0.403, runtime 10.0.14) and DnsClient 1.8.0 against a local DNS server and a local mock of the API.

Validate email in other languages

Frequently asked questions

How do I validate an email address in C#?

Trim the input, check the format with a regex anchored with \A and \z, and if the address matters (sign-ups, imports), check the mailbox on the server with an email validation API. MailAddress and [EmailAddress] alone let through addresses such as jane.@example.com and jane@example.

Is the [EmailAddress] attribute enough in ASP.NET Core?

No. In .NET 10 it only checks that the value contains exactly one @ that is neither the first nor the last character and no line break. Combine it with a [RegularExpression] attribute for the format and an API check for the mailbox.

Does MailAddress validate email addresses?

MailAddress parses the address header syntax, so it accepts display names (Jane Doe <jane@example.com>), a dot right before the @ and domains without a dot. Use MailAddress.TryCreate and compare the parsed Address with the input if you use it as a check at all.

Can C# check if an email address exists?

Not reliably on its own. An SMTP probe from your server is often blocked on port 25, greylisted or answered by a catch-all domain. An email validation API runs the DNS, MX and mailbox checks and returns one result per address.

Email validation API

Validate emails in your app

emailvalidation.io checks syntax, MX records and the mailbox over SMTP, flags disposable, role and free addresses and returns a quality score, in one request.

/v1/info Email validation API Read the documentation

100 free validations every month. No credit card required.

GET https://api.emailvalidation.io/v1/info?email=support@emailvalidation.io

{
  "email": "support@emailvalidation.io",
  "user": "support",
  "tag": "",
  "domain": "emailvalidation.io",
  "format_valid": true,
  "mx_found": true,
  "smtp_check": true,
  "catch_all": null,
  "role": true,
  "disposable": false,
  "free": false,
  "score": 0.64,
  "state": "deliverable",
  "reason": "low_quality",
  "did_you_mean": "",
  "catch_all_resolution": null,
  "bounce_probability": null,
  "status": "valid",
  "sub_status": "role_based",
  "explanation": "The mailbox exists, but it is a role address (such as info@ or support@), usually read by a team rather than one person.",
  "recommended_action": "send_with_caution"
}

Keep reading

Start using our email validation software today!

Get 100 validations per month for free