Per validare un indirizzo email in C#, la maggior parte degli sviluppatori ricorre a
System.Net.Mail.MailAddress e passa oltre. Come prima mossa è ragionevole, perché il tipo
è integrato nel framework e scarta la spazzatura senza una regex scritta a mano, ma
risponde solo alla prima delle tre domande che pone una vera validazione. L'indirizzo ha la forma corretta? Il suo dominio può ricevere posta? La casella esiste davvero? La
libreria di classi di base di .NET risponde alla prima in modo pulito, richiede un noto
pacchetto NuGet per la seconda e lascia la terza a un problema di rete che conviene
delegare. Questa guida costruisce la validazione delle email in C# in tre livelli, con
codice async/await idiomatico per ciascuno, e mostra esattamente dove si ferma
MailAddress.
In breve#
Usa MailAddress.TryCreate per la sintassi, il pacchetto DnsClient.NET per la ricerca
MX e un'API di verifica per il controllo SMTP della casella: si parte dal più economico
e ci si ferma non appena un livello è risolutivo. Non aprire socket SMTP grezzi dalla tua app per testare direttamente le caselle: la porta 25 in uscita è bloccata dalla maggior parte degli host e la risposta dipende dalla reputazione dell'IP di invio e dal greylisting,
cose che non vuoi reimplementare. Ogni livello esclude indirizzi a un costo minore del successivo; solo l'ultimo può confermarne uno. Se ti serve solo un esito
per un singolo indirizzo adesso, incollalo nella
verifica email gratuita e salta il codice: il resto della guida
serve per integrare il controllo in un'app. Per i concetti alla base di ogni fase, leggi
cos'è la verifica email.
Livello 1: sintassi con MailAddress.TryCreate#
MailAddress è lo strumento giusto per il primo livello. Preferisci l'overload
TryCreate aggiunto in .NET 5: restituisce un bool invece di lanciare una
FormatException su un input errato, quindi puoi usarlo come filtro senza racchiudere
ogni chiamata in un try/catch.
using System.Net.Mail;
public static bool IsValidSyntax(string email)
{
return !string.IsNullOrWhiteSpace(email)
&& email.Length <= 320
&& MailAddress.TryCreate(email, out var parsed)
&& parsed.Address == email;
}
IsValidSyntax("jane@example.com"); // true
IsValidSyntax("not-an-email"); // false
IsValidSyntax("Jane <jane@example.com>"); // false
Quell'ultimo confronto è importante. MailAddress è un parser di posta, non un
validatore rigoroso: accetta senza problemi forme con nome visualizzato come
Jane <jane@example.com> ed espone la parte dell'indirizzo in parsed.Address.
Confrontare parsed.Address con l'input grezzo scarta quelle forme e gli spazi di
troppo, ed è ciò che vuoi da un filtro sì/no sulla sintassi per l'input dell'utente. Il
limite di 320 caratteri è una precauzione in più: niente di più lungo può essere un
indirizzo reale, e scartarlo subito costa meno che passare una stringa enorme a ciò che
viene dopo.
Su .NET Framework o su qualsiasi target precedente a .NET 5, TryCreate non esiste,
quindi ripiega sul costruttore e intercetta l'eccezione:
try { _ = new MailAddress(email); /* syntax ok */ }
catch (FormatException) { /* reject */ }
Se usi già le data annotation su un modello,
System.ComponentModel.DataAnnotations.EmailAddressAttribute ([EmailAddress]) fa lo
stesso lavoro in modo dichiarativo ed è una buona scelta nella validazione dei modelli
di ASP.NET Core. Qualunque cosa tu scelga, considera un esito positivo come "merita un
controllo serio", mai come "valido". Un controllo di sintassi non sa nulla di DNS o
caselle: è lo stesso motivo per cui
la validazione delle email con regex fallisce.
Forma e deliverability sono domande diverse: la prima riguarda la stringa, la seconda
internet.
Livello 2: il dominio può ricevere posta?#
Un dominio senza instradamento della posta non può accettare email per nessuno, quindi questa
sola ricerca elimina domini morti, nomi di aziende scritti male e TLD inventati prima
ancora di toccare davvero la rete. Il problema: .NET non ha un resolver MX integrato.
System.Net.Dns risolve i record A e AAAA ma non gli MX, quindi la risposta idiomatica
è DnsClient.NET, un pacchetto NuGet molto diffuso e ben mantenuto a cui ricorre la
maggior parte dei progetti .NET quando serve un DNS che vada oltre la risoluzione degli
host. Installalo e interroga il tipo di record MX:
using System.Linq;
using DnsClient; // dotnet add package DnsClient
// LookupClient is thread-safe and caches responses — create one and reuse it.
private static readonly LookupClient Dns = new();
public static async Task<bool> HasMailRouteAsync(string domain)
{
try
{
var response = await Dns.QueryAsync(domain, QueryType.MX);
return response.Answers.MxRecords().Any();
}
catch (DnsResponseException)
{
// No reachable resolver, SERVFAIL, and similar — treat as "check later".
return false;
}
}
await HasMailRouteAsync("gmail.com"); // true
await HasMailRouteAsync("company-that-folded.com"); // false
Due abitudini da copiare. Crea il LookupClient una volta sola e riutilizzalo: è
thread-safe e mette in cache le risposte, quindi un'istanza per richiesta butta via
quella cache. E separa il dominio dall'indirizzo all'ultima @ con un'espressione di
intervallo, così non interpreti mai male un indirizzo che (lecitamente) ne contiene più
di una:
var domain = email[(email.LastIndexOf('@') + 1)..];
Alcuni domini accettano posta su un record A senza MX (un MX implicito). Se vuoi gestire
questo caso limite, ripiega su QueryType.A quando l'insieme degli MX è vuoto, ma per
la stragrande maggioranza degli indirizzi reali un controllo MX è il filtro giusto. Una
cautela per il percorso di iscrizione: una query DNS resta in attesa di un resolver,
quindi un intoppo DNS passeggero non deve portare al rifiuto definitivo di un cliente
reale. Tratta una ricerca fallita come "ricontrolla più tardi", non come "non valido".
Livello 3: la casella esiste davvero?#
I livelli uno e due possono solo escludere un indirizzo. Un dominio può pubblicare
record MX perfetti e non avere comunque alcuna casella all'indirizzo che hai in mano:
noreply-9f2x@gmail.com ha una sintassi valida su un dominio con un instradamento della posta attivo, ma è una casella mai creata. Confermare che una casella specifica esista
richiede la conversazione di recapito SMTP: connettersi all'host di posta, inviare
RCPT TO, leggere la risposta e disconnettersi prima di spedire qualsiasi cosa.
Come funziona la verifica email ripercorre l'intero
processo, compresi i domini catch-all che accettano ogni indirizzo e vanificano un test
ingenuo.
In linea di principio puoi scriverlo in C# con un TcpClient e comandi SMTP grezzi. In
pratica non dovresti eseguirlo dal tuo server applicativo. La maggior parte dei provider
cloud blocca la porta 25 in uscita, quindi un test che funziona sul tuo portatile, in
produzione fallisce in silenzio. Il risultato dipende dalla reputazione dell'IP da cui
ti connetti, non solo dall'indirizzo che stai controllando. E i server riceventi
applicano greylisting e limiti di frequenza ai mittenti sconosciuti, quindi con qualsiasi
volume di test vieni rinviato o finisci in blacklist. Questo è il livello che conviene
delegare a un'infrastruttura costruita apposta.
Chiamare un'API di verifica da C##
L'endpoint di verifica di Qualisend esegue l'intero processo (sintassi, DNS e test SMTP
della casella) da un'infrastruttura con reputazione gestita e restituisce un esito in
tempo reale. Ti bastano HttpClient e System.Text.Json: modella l'involucro della
risposta come record e lascia che a riempirli sia il generatore di sorgenti JSON o il
deserializzatore basato su reflection:
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using System.Threading.Channels;
public record VerifyResponse(
[property: JsonPropertyName("job_id")] string JobId,
[property: JsonPropertyName("result")] VerifyResult Result);
public record VerifyResult(
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("score")] int Score,
[property: JsonPropertyName("reason")] string? Reason,
[property: JsonPropertyName("sub_flags")] IReadOnlyDictionary<string, bool> SubFlags,
[property: JsonPropertyName("smtp")] string? Smtp = null); // pending | cached | skipped
// One HttpClient for the whole app — don't new one up per request.
private static readonly HttpClient Http = new()
{
BaseAddress = new Uri("https://app.qualisend.com/api/v1/"),
Timeout = TimeSpan.FromSeconds(5), // fail fast: the default is 100 s
};
// Fail open: when the API can't answer, let the address through and re-check it later.
private static readonly VerifyResult FailOpen =
new("unknown", 0, null, new Dictionary<string, bool>());
// (email, job id) pairs whose mailbox probe is still running. A background
// worker drains this channel; use a durable queue in production.
private static readonly Channel<(string Email, string JobId)> PendingProbes =
Channel.CreateUnbounded<(string Email, string JobId)>();
public static async Task<VerifyResult> VerifyAsync(string email)
{
using var request = new HttpRequestMessage(HttpMethod.Post, "verify")
{
Content = JsonContent.Create(new { email }),
};
request.Headers.Authorization = new AuthenticationHeaderValue(
"Bearer", Environment.GetEnvironmentVariable("QUALISEND_API_KEY"));
try
{
using var response = await Http.SendAsync(request);
var code = (int)response.StatusCode;
if (code == 402 || code == 429 || code >= 500)
return FailOpen; // out of credits, rate limited, or a server error
response.EnsureSuccessStatusCode(); // 400/401/403: fix the request or the key
var body = await response.Content.ReadFromJsonAsync<VerifyResponse>();
if (body!.Result.Smtp == "pending")
PendingProbes.Writer.TryWrite((email, body.JobId)); // finished off the request path
return body.Result; // status: deliverable | risky | undeliverable | unknown
}
catch (TaskCanceledException)
{
return FailOpen; // timed out
}
}
L'esempio chiama POST https://app.qualisend.com/api/v1/verify; il
riferimento API su /developers spiega il formato delle chiavi con scope
limitati e ogni campo. Leggi la chiave da una variabile d'ambiente invece di scrivere
YOUR_API_KEY nel codice e riutilizza un solo HttpClient per evitare l'esaurimento dei
socket. In un modulo di iscrizione, VerifyAsync lascia passare in caso di errore: un
402 (crediti esauriti), un 429 (limite di frequenza raggiunto), un 5xx o un timeout
restituiscono unknown, così un cliente reale va avanti e tu ricontrolli l'indirizzo in
seguito. La risposta arriva dentro un involucro result:
{
"job_id": "3f9a6c1e-5b7d-4e2a-9c8f-1d2e3a4b5c6d",
"result": {
"status": "unknown",
"score": 61,
"reason": null,
"smtp": "pending",
"sub_flags": { "role": false, "disposable": false, "free_mail": false, "catch_all": false }
}
}
status è il tuo esito principale: deliverable, risky, undeliverable o unknown.
score indica il grado di affidabilità, reason spiega un risultato negativo e
sub_flags dettaglia segnali come role, disposable, free_mail e catch_all, così
puoi applicare la tua policy (rifiutare undeliverable, mettere in revisione risky,
segnalare disposable all'iscrizione).
Controlla smtp prima di considerare definitivo lo status. Con cached o skipped,
l'esito è già definitivo. Con pending, il test della casella è ancora in corso:
status è un unknown provvisorio, reason è null e l'esito finale arriva su
GET /jobs/{job_id}. Ecco perché VerifyAsync mette in coda i job in sospeso invece di
aspettarli. Un worker in background (ad esempio un hosted service) legge PendingProbes
e interroga ogni job a intervalli di pochi secondi, fuori dal percorso della richiesta;
non richiama mai /verify per lo stesso indirizzo:
public record JobResponse(
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("results")] IReadOnlyList<VerifyResult>? Results);
// Runs in the background worker, never in the web request. Returns null while
// the probe is still running: call it again in a few seconds.
public static async Task<VerifyResult?> GetFinalVerdictAsync(string jobId)
{
using var request = new HttpRequestMessage(HttpMethod.Get, $"jobs/{jobId}?include=results");
request.Headers.Authorization = new AuthenticationHeaderValue(
"Bearer", Environment.GetEnvironmentVariable("QUALISEND_API_KEY"));
using var response = await Http.SendAsync(request);
response.EnsureSuccessStatusCode();
var job = await response.Content.ReadFromJsonAsync<JobResponse>();
return job!.Status switch
{
"completed" => job.Results?.FirstOrDefault(), // the final, post-SMTP verdict
"queued" or "processing" => null, // not finished yet
_ => throw new InvalidOperationException($"Job {jobId} {job.Status}"), // canceled | failed
};
}
Mettere insieme i livelli per validare un indirizzo email in C##
Parti dal più economico e fermati appena hai una risposta:
public static async Task<VerifyResult> ValidateEmailAsync(string email)
{
if (!IsValidSyntax(email))
return new VerifyResult("undeliverable", 0, "invalid_email",
new Dictionary<string, bool>());
var domain = email[(email.LastIndexOf('@') + 1)..];
if (!await HasMailRouteAsync(domain))
return new VerifyResult("undeliverable", 0, "invalid_domain",
new Dictionary<string, bool>());
return await VerifyAsync(email); // deliverable | risky | undeliverable | unknown
}
I due livelli locali non costano nulla e intercettano subito la maggior parte degli indirizzi spazzatura; il livello API viene eseguito solo sugli indirizzi che meritano il round-trip di rete. Tutto il trucco sta in quest'ordine: è la stessa forma che trovi nelle versioni Node.js, Python e PHP di questa guida, perché è la struttura a livelli, non il linguaggio, a far funzionare la validazione.
Domande frequenti
01MailAddress o EmailAddressAttribute bastano per validare un'email in C#?
Per la sintassi, sì: sono entrambi solidi controlli di primo livello e una scelta migliore di una regex scritta a mano, con MailAddress.TryCreate che ti dà un booleano senza eccezioni. Ma validano la forma, non la deliverability: nessuno dei due interroga il DNS o contatta un server di posta, quindi un esito positivo significa "sembra un'email", non "verrà recapitata". Abbina al controllo di sintassi una ricerca MX e un controllo SMTP della casella prima di fidarti dell'indirizzo.
02.NET ha un modo integrato per cercare i record MX?
No. System.Net.Dns risolve i record host A e AAAA ma non supporta gli MX, ed è per questo che l'approccio standard è il pacchetto NuGet DnsClient.NET. Interroga QueryType.MX, riutilizza un solo LookupClient e tratta un insieme di risposte vuoto (o una DnsResponseException) come assenza di instradamento della posta, invece di lasciar propagare l'eccezione.
03Posso controllare se una casella esiste in C# senza un'API?
Solo in parte. DnsClient.NET conferma che il dominio accetta posta, il che esclude a basso costo i domini morti. Confermare la casella vera e propria richiede una conversazione SMTP che puoi tentare con un TcpClient ma che non dovresti eseguire dal tuo server applicativo: la porta 25 in uscita è ampiamente bloccata e il risultato dipende dalla reputazione del tuo IP di invio. È il livello per cui esiste un servizio di verifica, e conviene confrontare i provider prima di costruirlo da solo.
04Devo validare le email all'iscrizione o quando pulisco una lista?
Entrambe le cose, con una profondità diversa. Esegui il controllo di sintassi e la ricerca MX in modo sincrono all'iscrizione (sono abbastanza veloci da bloccare la richiesta e dare un riscontro immediato) e usa lì anche l'esito immediato dell'API; la guida all'iscrizione serverless mostra lo schema dall'inizio alla fine. Riserva la verifica più approfondita, in batch, alla pulizia di una lista esistente, dove la latenza non conta e la completezza sì.
Vuoi aggiungere il livello SMTP? Il riferimento API contiene la forma
esatta di /verify, le chiavi con scope limitati ed esempi pronti da copiare, oppure
inserisci un indirizzo nella verifica email gratuita e guarda una
stringa approvata da MailAddress tornare come undeliverable.



