Para validar una dirección de correo electrónico en ASP.NET Core, la mayoría de
las aplicaciones se apoyan en la anotación de datos integrada [EmailAddress],
dejan que el enlace de modelos la ejecute y comprueban ModelState.IsValid. Es
un buen primer paso —el atributo viene con el framework y rechaza basura sin una
expresión regular hecha a mano—, pero solo responde a la primera de las tres
preguntas que plantea una validación real. ¿Tiene la dirección la forma
correcta? ¿Puede su dominio recibir correo? ¿Existe realmente el buzón? ASP.NET
Core responde la primera de fábrica, necesita un paquete NuGet conocido para la
segunda y deja la tercera en manos de un problema de red que conviene delegar.
Esta guía construye la comprobación como tres capas conectadas a la propia
canalización de validación del framework y muestra exactamente dónde se detiene
[EmailAddress]. Es el complemento para frameworks web de la
guía de validación de correo en C#, que
cubre las mismas primitivas fuera de la canalización de peticiones.
La respuesta corta#
Usa [EmailAddress] (o el .EmailAddress() de FluentValidation) para la
sintaxis, DnsClient.NET para la búsqueda de MX y una API de verificación para la
comprobación del buzón por SMTP: primero lo más barato, cortocircuitando en
cuanto una capa sea decisiva. Las capas de DNS y de API son E/S asíncronas, así
que no encajan en un ValidationAttribute síncrono; su hogar idiomático es un
validador de FluentValidation con los servicios inyectados mediante DI. No abras
sockets SMTP en crudo desde tu aplicación para sondear buzones por tu cuenta: el
puerto 25 de salida está bloqueado en la mayoría de los hosts, y la respuesta
depende de la reputación de la IP emisora y del greylisting que no querrás
reimplementar. Cada capa descarta direcciones de forma más barata que la
anterior; solo la última puede dar por buena una dirección. Si la canalización
es nueva para ti, qué es la verificación de correo
cubre primero los términos.
Capa 1: validación de formato con DataAnnotations#
La comprobación integrada de primera capa es el atributo [EmailAddress] de
System.ComponentModel.DataAnnotations. Ponlo en tu modelo de petición junto a
[Required], y el enlace de modelos lo ejecuta en cada enlace:
using System.ComponentModel.DataAnnotations;
public class SignupRequest
{
[Required]
[EmailAddress]
public string Email { get; init; } = string.Empty;
}
En un controlador MVC o de API marcado con [ApiController], un atributo fallido
cortocircuita en una respuesta 400 ValidationProblemDetails antes de que se
ejecute el cuerpo de tu acción: nunca tienes que inspeccionar ModelState a
mano:
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("signup")]
public class SignupController : ControllerBase
{
[HttpPost]
public IActionResult Register(SignupRequest request)
{
// With [ApiController], an invalid [EmailAddress] already returned 400.
// Reaching here means the string is well-formed.
return Ok();
}
}
Ten claro exactamente qué te aporta esto. EmailAddressAttribute comprueba la
forma —de hecho solo exige una única @ con algo a cada lado— y nada más.
Nunca resuelve DNS, nunca abre un socket y no tiene ni idea de si el dominio
existe. definitely-fake@gmail.com pasa. info@company-that-folded.com pasa.
typo@gmial.com pasa. Las tres son inentregables, y ningún atributo que solo
lea la cadena te lo dirá jamás: es la misma razón por la que
la validación de correo con regex falla:
la forma y la entregabilidad son preguntas distintas, una es un hecho sobre la
cadena y la otra un hecho sobre internet.
Por qué las dos capas siguientes no pueden ser atributos#
Aquí está el detalle que da forma al resto de esta guía: la validación con
DataAnnotations es síncrona. ValidationAttribute.IsValid devuelve un
ValidationResult, no un Task, así que no hay una forma limpia de hacer
await de una consulta DNS o de una llamada HTTP dentro de uno. Bloquear con
.Result o .GetAwaiter().GetResult() para forzar una llamada asíncrona dentro
de un atributo síncrono invita a la inanición del grupo de subprocesos y a
interbloqueos bajo carga: exactamente lo que no quieres en una ruta de registro.
Por eso la respuesta idiomática de ASP.NET Core para las capas asíncronas es
FluentValidation: sus reglas MustAsync son validadores de primera clase que
devuelven Task, y resuelve dependencias a través del mismo contenedor de DI
que el resto de tu aplicación, de modo que un validador puede recibir un
HttpClient o un servicio de DNS en su constructor. Puedes conservar
[EmailAddress] para la primera capa y añadir FluentValidation para la segunda y
la tercera, o —como abajo— dejar que el propio .EmailAddress() de
FluentValidation cubra también la sintaxis y mantener las tres capas en un solo
lugar.
Capa 2: ¿puede el dominio recibir correo?#
Un dominio sin ruta de correo no puede aceptar correo para nadie, así que una
búsqueda elimina dominios muertos, nombres de empresa mal escritos y TLD
inventados. El detalle: ASP.NET Core no tiene un resolvedor de MX integrado.
System.Net.Dns resuelve registros A y AAAA, pero no MX, así que el enfoque
estándar es DnsClient.NET, un paquete NuGet muy usado. Envuélvelo tras una
pequeña interfaz para que el validador dependa de una abstracción, no de la
biblioteca:
using DnsClient; // dotnet add package DnsClient
using System.Linq;
public interface IMailRouteChecker
{
Task<bool> HasMailRouteAsync(string email, CancellationToken ct = default);
}
public sealed class DnsMailRouteChecker : IMailRouteChecker
{
private readonly ILookupClient _dns;
public DnsMailRouteChecker(ILookupClient dns) => _dns = dns;
public async Task<bool> HasMailRouteAsync(string email, CancellationToken ct = default)
{
var domain = email[(email.LastIndexOf('@') + 1)..];
try
{
var response = await _dns.QueryAsync(domain, QueryType.MX, cancellationToken: ct);
return response.Answers.MxRecords().Any();
}
catch (DnsResponseException)
{
// No reachable resolver, SERVFAIL, and similar — treat as "check later".
return false;
}
}
}
Separa el dominio en la última @ con una expresión de rango para no analizar
mal nunca una dirección que legalmente contenga más de una. Registra un único
LookupClient como singleton: es seguro para subprocesos y almacena en caché las
respuestas, así que una instancia por petición simplemente tira esa caché a la
basura. Algunos dominios aceptan correo en un registro A sin MX; si quieres
respetar ese caso límite, recurre a QueryType.A cuando el conjunto de MX esté
vacío.
Capa 3: ¿existe realmente el buzón?#
Las capas uno y dos solo pueden descartar una dirección. Un dominio puede
publicar registros MX perfectos y aun así no tener buzón en la dirección que
tienes entre manos: noreply-9f2x@gmail.com es sintaxis válida en una ruta de
correo activa, y sigue siendo un buzón que nunca se creó. Confirmar un buzón
concreto significa la conversación de entrega SMTP: conectar con el host de
correo, emitir RCPT TO, leer la respuesta y desconectar antes de enviar nada.
Cómo funciona la verificación de correo
recorre esa canalización completa, dominios catch-all incluidos.
En principio puedes programar esto en C# con un TcpClient y comandos SMTP en
crudo. En la práctica no deberías ejecutarlo desde tu servidor de aplicaciones:
la mayoría de los proveedores en la nube bloquean el puerto 25 de salida, la
respuesta depende de la reputación de la IP desde la que conectas, y los
servidores receptores aplican greylisting y limitan la tasa a remitentes
desconocidos. Esta es la capa que vale la pena delegar en una infraestructura
construida para ello.
El endpoint de verificación de Qualisend ejecuta toda la canalización desde una
infraestructura con reputación gestionada y devuelve un veredicto en tiempo real.
Modélalo como un HttpClient tipado para que la URL base, el tiempo de espera y
la clave vivan en un solo lugar, y deserializa el sobre de la respuesta con
System.Text.Json:
using System.Net.Http.Json;
using System.Text.Json.Serialization;
public interface IEmailVerifier
{
Task<bool> IsDeliverableAsync(string email, CancellationToken ct = default);
}
public sealed class QualisendVerifier : IEmailVerifier
{
private readonly HttpClient _http;
public QualisendVerifier(HttpClient http) => _http = http;
public async Task<bool> IsDeliverableAsync(string email, CancellationToken ct = default)
{
using var response = await _http.PostAsJsonAsync("verify", new { email }, ct);
// On our own outage or a rate limit, don't block a real signup.
if (!response.IsSuccessStatusCode)
return true;
var body = await response.Content.ReadFromJsonAsync<VerifyResponse>(ct);
return body?.Result.Status != "undeliverable";
}
}
public record VerifyResponse(
[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);
La respuesta vuelve dentro de un sobre result:
{
"result": {
"status": "deliverable",
"score": 95,
"reason": null,
"sub_flags": { "role": false, "disposable": false, "free": false, "catch_all": false }
}
}
status es tu veredicto principal: deliverable, risky, undeliverable o
unknown. El ayudante de arriba solo falla en firme con undeliverable y deja
pasar todo lo demás, pero score, reason y sub_flags (role, disposable,
free, catch-all) están ahí para que apliques tu propia política: retén risky
para revisión, marca disposable en el registro.
POST https://api.qualisend.com/v1/verify es una forma de ejemplo; consulta la
referencia de la API en /developers para conocer el endpoint
exacto, el formato de clave con alcance y todos los campos. No inventes campos a
partir del ejemplo de arriba: léelos de la documentación.
Uniendo las capas para validar una dirección de correo en ASP.NET Core#
Ahora compón las tres en un validador de FluentValidation. Encadena las reglas
empezando por la más barata y establece CascadeMode.Stop para que se detenga en
la primera capa que falle: las reglas asíncronas de DNS y de API solo se ejecutan
en direcciones que ya superaron la sintaxis:
using FluentValidation;
public class SignupRequestValidator : AbstractValidator<SignupRequest>
{
public SignupRequestValidator(IMailRouteChecker dns, IEmailVerifier verifier)
{
RuleFor(x => x.Email)
.Cascade(CascadeMode.Stop) // bail at the first failing layer
.NotEmpty()
.EmailAddress() // layer 1: syntax
.MustAsync((email, ct) => dns.HasMailRouteAsync(email, ct))
.WithMessage("Enter an email on a domain that can receive mail.")
.MustAsync((email, ct) => verifier.IsDeliverableAsync(email, ct))
.WithMessage("We couldn't confirm a mailbox at this address.");
}
}
Conecta los servicios en Program.cs: el LookupClient como singleton, el
verificador como cliente tipado con la clave leída desde la configuración (una
variable de entorno o user-secrets en producción, nunca escrita en el código) y
el validador mediante escaneo de ensamblado:
using DnsClient;
using FluentValidation;
using System.Net.Http.Headers;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<ILookupClient>(new LookupClient());
builder.Services.AddScoped<IMailRouteChecker, DnsMailRouteChecker>();
builder.Services.AddHttpClient<IEmailVerifier, QualisendVerifier>(client =>
{
client.BaseAddress = new Uri("https://api.qualisend.com/v1/");
client.Timeout = TimeSpan.FromSeconds(10);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"Bearer", builder.Configuration["Qualisend:ApiKey"]);
});
builder.Services.AddValidatorsFromAssemblyContaining<SignupRequestValidator>();
var app = builder.Build();
app.MapPost("/signup", async (
SignupRequest request,
IValidator<SignupRequest> validator,
CancellationToken ct) =>
{
var result = await validator.ValidateAsync(request, ct);
if (!result.IsValid)
return Results.ValidationProblem(result.ToDictionary());
// Every layer passed — safe to persist.
return Results.Ok();
});
app.Run();
Inyectar IValidator<SignupRequest> y llamar a ValidateAsync en el endpoint es
el patrón recomendado actualmente: ejecuta las reglas asíncronas correctamente,
allí donde la antigua integración automática de MVC nunca lo hizo. El mismo
validador encaja sin cambios en un controlador MVC; resuélvelo desde el
constructor y llama a ValidateAsync antes de tocar la base de datos.
Ese orden es todo el truco: la regla de sintaxis cortocircuita la basura gratis, la comprobación local de DNS descarta dominios muertos por nada, y la API solo se consulta para las direcciones que superaron ambas. Es la misma estructura de tres capas que las guías de Node.js y de C#: lo que hace fiable la validación es la estratificación, no el framework. Ejecuta esta canalización rápida de forma síncrona en el registro y reserva la verificación más profunda y por lotes para limpiar una lista existente, donde la latencia no importa y la exhaustividad sí.
Preguntas frecuentes#
¿Basta con el atributo EmailAddress para validar un correo en ASP.NET Core?#
Para la sintaxis, sí: es la comprobación adecuada de primera capa y una apuesta
mejor que una expresión regular hecha a mano. Pero EmailAddressAttribute valida
la forma (en realidad solo una única @ con texto a ambos lados); nunca
resuelve DNS ni contacta con un servidor de correo, así que que
ModelState.IsValid dé el visto bueno significa "parece un correo", no "se
entregará". Combínalo con una búsqueda de MX y una comprobación del buzón por
SMTP antes de fiarte de la dirección.
¿Cómo ejecuto la validación asíncrona de correo en ASP.NET Core?#
Los atributos de DataAnnotations son síncronos, así que una consulta DNS o una
llamada a una API no encajan ahí: bloquear a la espera de la llamada asíncrona
arriesga interbloqueos. Usa FluentValidation en su lugar: escribe reglas
MustAsync, inyecta tus servicios de DNS y de verificación en el validador y
llama a await validator.ValidateAsync(request) desde tu endpoint o controlador.
Ese es el enfoque recomendado actualmente, ya que la antigua integración
automática de MVC nunca ejecutaba validadores asíncronos.
¿Tiene ASP.NET Core una forma integrada de buscar registros MX?#
No. System.Net.Dns resuelve registros de host A y AAAA, pero no admite MX, y
por eso el enfoque estándar es el paquete NuGet DnsClient.NET. Consulta
QueryType.MX, registra un único LookupClient como singleton y trata un
conjunto de respuestas vacío —o una DnsResponseException— como "sin ruta de
correo" en lugar de dejar que lance una excepción.
¿Debo validar los correos en el registro o al limpiar una lista?#
Ambos, con distinta profundidad. Ejecuta la sintaxis y la búsqueda de MX de forma síncrona en el registro —son lo bastante rápidas para bloquear la petición y dar retroalimentación instantánea— y actúa también allí sobre el veredicto inmediato de la API; la guía de registro serverless muestra el patrón de principio a fin. Reserva la verificación más profunda y por lotes para limpiar una lista existente, donde la latencia no importa.
¿Listo para añadir la capa SMTP? La referencia de la API tiene la
forma exacta de /verify, las claves con alcance y ejemplos para copiar y pegar,
o introduce una dirección en el
verificador de correo gratuito para ver cómo una cadena
aprobada por [EmailAddress] vuelve como undeliverable.