Gesgocom.GesEmail 1.0.1
GesEmail
Librería .NET 10 para enviar y leer correo con un mismo contrato, tanto si detrás hay
un servidor SMTP/IMAP/POP3 como Gmail o Microsoft 365. El código de la aplicación no
cambia al cambiar de proveedor: solo cambia appsettings.json.
| Proveedor | Envío | Lectura | Librería |
|---|---|---|---|
Smtp |
SMTP | IMAP o POP3 | MailKit |
Gmail |
API de Gmail | API de Gmail | Google.Apis.Gmail.v1 |
Microsoft365 |
Microsoft Graph | Microsoft Graph | Microsoft.Graph + Azure.Identity |
Requisitos e instalación
- .NET 10.
- Credenciales del proveedor elegido; consulta docs/Smtp.md, docs/Gmail.md y docs/Microsoft365.md.
El paquete se distribuye en el NuGet privado de Gesgocom. Registra el origen una vez por máquina y añade la referencia:
dotnet nuget add source https://nuget.gesgocom.es/v3/index.json -n Gesgocom
dotnet add package Gesgocom.GesEmail
Configuración
{
"GesEmail": {
"Provider": "Smtp",
"Reader": "Imap",
"DefaultFrom": { "Address": "no-reply@gesgocom.es", "Name": "Gesgocom" },
"TimeoutSeconds": 60,
"MaxMessageSizeBytes": 26214400,
"GenerateTextAlternative": true,
"TestMode": false,
"RedirectAllTo": null,
"Smtp": { "Host": "smtp.gesgocom.es", "Port": 587, "Security": "StartTls", "Username": "usuario", "Password": "secreto" },
"Imap": { "Host": "imap.gesgocom.es", "Port": 993, "Security": "SslOnConnect", "Username": "usuario", "Password": "secreto" },
"Pop3": { "Host": "pop.gesgocom.es", "Port": 995, "Security": "SslOnConnect", "Username": "usuario", "Password": "secreto" },
"Gmail": { "ServiceAccountJsonPath": "/secretos/gmail.json", "ImpersonatedUser": "avisos@empresa.es" },
"Microsoft365": { "TenantId": "...", "ClientId": "...", "ClientSecret": "...", "UserId": "avisos@empresa.es" }
}
}
using GesEmail.Extensions;
builder.Services.AddGesEmail(builder.Configuration.GetSection("GesEmail"));
Solo se validan las secciones del proveedor activo: con Provider: "Gmail" no hace
falta rellenar Smtp. La validación se ejecuta al arrancar la aplicación, así que una
configuración incompleta falla en el arranque y no en el primer envío.
También se puede configurar en código, forzando el proveedor:
builder.Services.AddGesEmailSmtp(options =>
{
options.Smtp.Host = "smtp.gesgocom.es";
options.Smtp.Username = "usuario";
options.Smtp.Password = builder.Configuration["Smtp:Password"]!;
options.Imap.Host = "imap.gesgocom.es";
options.DefaultFrom = new EmailAddress("no-reply@gesgocom.es", "Gesgocom");
});
No guardes contraseñas ni secretos en el repositorio: usa secretos de usuario, variables de entorno o un almacén de secretos. Para Gmail y Microsoft 365 puedes además inyectar la credencial ya construida, sin pasar por la configuración:
builder.Services.AddGesEmailMicrosoft365(
new DefaultAzureCredential(),
options => options.Microsoft365.UserId = "avisos@empresa.es");
Configuración en base de datos
Cuando cada empresa, cliente o departamento tiene su propia cuenta de correo guardada en
una tabla, la configuración no puede venir de appsettings.json. Para eso está
IEmailServiceFactory: recibe un GesEmailOptions en el momento de la llamada y decide
el proveedor a partir de él, de modo que una misma aplicación puede enviar por SMTP para
una empresa y por Microsoft 365 para otra.
builder.Services.AddGesEmailFactory(options =>
{
// En desarrollo, ninguna configuración de la base de datos envía correo real.
options.ForceTestMode = builder.Environment.IsDevelopment();
});
AddGesEmailFactory no exige ninguna configuración al arrancar, porque no hay cuenta
fija que validar: cada configuración se valida al crear el servicio.
public sealed class Notificador(IEmailServiceFactory factory, IConfigRepositorio repo)
{
public async Task EnviarAsync(int empresaId, EmailMessage mensaje, CancellationToken ct)
{
// Tu repositorio lee la fila y la mapea a GesEmailOptions.
GesEmailOptions configuracion = await repo.ObtenerConfigCorreoAsync(empresaId, ct);
var email = factory.CreateSender(configuracion);
await email.SendAsync(mensaje, ct);
}
}
CreateReader y Create funcionan igual para la lectura y para la fachada completa.
Las dos formas de uso conviven: AddGesEmail registra además la factoría, así que
puedes tener una cuenta corporativa en la configuración y las de cada cliente en la base
de datos.
Guardar y validar la configuración
GesEmailOptions se serializa y deserializa con System.Text.Json sin ceremonias, así
que puedes guardar el objeto entero en una columna o mapearlo campo a campo:
var json = JsonSerializer.Serialize(configuracion);
var recuperada = JsonSerializer.Deserialize<GesEmailOptions>(json)!;
Antes de guardar lo que un usuario introduce en un formulario, valídalo sin llegar a contactar con ningún servidor:
IReadOnlyList<string> errores = factory.Validate(configuracion);
if (errores.Count > 0)
{
return BadRequest(errores);
}
Es exactamente la misma comprobación que se ejecuta al arrancar sobre la configuración
de appsettings.json, así que una fila mal grabada falla con el mismo mensaje.
Rendimiento
Los servicios que devuelve la factoría son objetos ligeros: crear uno por operación es gratis. Lo caro son los clientes de Gmail y de Microsoft Graph, que conservan la caché de tokens y las conexiones HTTP, y por eso se reutilizan entre llamadas agrupados por credenciales:
- Dos configuraciones con las mismas credenciales comparten cliente.
- Varios buzones del mismo inquilino de Microsoft 365 comparten cliente, porque el buzón viaja en la ruta de cada petición y no en la credencial.
- Rotar una contraseña en la base de datos produce un cliente nuevo; el anterior se libera cuando deja de usarse.
MaxCachedClients (64 por defecto) y ClientIdleTimeout (30 minutos) acotan la
memoria. Los clientes de SMTP, IMAP y POP3 no se cachean: se crean y se cierran en cada
operación.
Salvaguardas
ForceTestMode y ForceRedirectAllTo se declaran en el registro y prevalecen sobre
lo que venga de la base de datos. Es la red de seguridad para el caso clásico de
restaurar una copia de producción en un entorno de desarrollo: las configuraciones
traerían servidores y destinatarios reales, y sin esto se enviaría correo a clientes de
verdad.
Las salvaguardas se aplican sobre una copia: el GesEmailOptions que pasas no se
modifica, por si tu capa de datos lo tiene cacheado.
Secretos
La factoría espera las contraseñas y los secretos ya descifrados. Cifrar la columna y descifrarla al leer es responsabilidad de tu capa de datos, que es la que conoce tu almacén de claves.
Enviar correo
Lo habitual cabe en una línea:
using GesEmail.Extensions;
using GesEmail.Interfaces;
using GesEmail.Models;
public sealed class Avisos(IEmailSender email)
{
public Task EnviarFacturaAsync(string destinatario, byte[] pdf, CancellationToken ct) =>
email.SendAsync(
destinatario,
"Su factura de julio",
"<p>Adjuntamos su factura.</p>",
[EmailAttachment.FromBytes(pdf, "factura.pdf")],
ct);
}
Para todo lo demás está el constructor fluido:
var resultado = await email.SendAsync(EmailMessage.Create()
.From("facturacion@gesgocom.es", "Facturación")
.To("cliente@dominio.es", "Ana Pérez")
.Cc("copia@dominio.es")
.Bcc("archivo@gesgocom.es")
.ReplyTo("soporte@gesgocom.es")
.Subject("Pedido 42 preparado")
.Html("<img src=\"cid:logo\"><p>Su pedido está listo.</p>")
.Inline("/recursos/logo.png", "logo")
.Attach("/facturas/2026-118.pdf")
.Priority(EmailPriority.High)
.Header("X-Origen", "ERP")
.Build(), cancellationToken);
- El texto alternativo se genera a partir del HTML si no lo indicas. Mejora la
entregabilidad y se desactiva con
GenerateTextAlternative = false. - Los adjuntos se pueden dar como ruta (
FromFile), memoria (FromBytes) o flujo (FromStreamAsync). El tipo MIME se deduce de la extensión. - Las imágenes incrustadas se referencian con
cid:desde el HTML. - Para escribir a varios destinatarios sin que se vean entre sí, usa
SendIndividuallyAsync, que envía una copia a cada uno de forma secuencial.
Qué devuelve un envío
Ningún proveedor confirma que el correo haya llegado al destinatario, solo que lo
acepta para entregarlo. SendEmailResult refleja esa realidad:
if (resultado.Status == EmailSendStatus.Accepted)
{
logger.LogInformation("Aceptado con Message-Id {MessageId}", resultado.MessageId);
}
| Estado | Significado |
|---|---|
Accepted |
El proveedor se hace cargo del mensaje. |
Rejected |
Lo rechazó; reintentar sin corregir la causa volverá a fallar. |
Unknown |
La conexión se cortó tras transmitirlo. Puede haberse entregado. |
MessageId es la cabecera Message-Id que la librería genera antes de enviar, de
modo que sirve para buscar el mensaje en los registros del servidor incluso cuando el
resultado es indeterminado.
Leer correo
public sealed class BuzonEntrada(IEmailReader email)
{
public async Task ProcesarNuevosAsync(CancellationToken ct)
{
var pagina = await email.GetMessagesAsync(new EmailQuery
{
Folder = WellKnownFolder.Inbox,
UnreadOnly = true,
Since = DateTimeOffset.UtcNow.AddDays(-7),
PageSize = 50
}, ct);
foreach (var resumen in pagina.Items)
{
var detalle = await email.GetMessageAsync(resumen.Id, ct);
foreach (var adjunto in detalle!.Attachments)
{
var contenido = await email.DownloadAttachmentAsync(detalle.Id, adjunto.Id!, ct);
// contenido.Content tiene los bytes del fichero.
}
await email.MarkAsReadAsync(detalle.Id, ct: ct);
}
// pagina.NextPageToken continúa la consulta con los mismos criterios.
}
}
Los identificadores de mensaje son opacos: su formato depende del proveedor y no
debe interpretarse. Al mover un mensaje el identificador puede cambiar, por eso
MoveAsync devuelve el nuevo.
Diferencias entre proveedores
Los tres backends no son equivalentes. Cada lector declara lo que admite en
Capabilities, y lo que no admite lanza NotSupportedException con una explicación,
en lugar de fallar de forma silenciosa:
| Capacidad | IMAP | POP3 | Gmail | Microsoft 365 |
|---|---|---|---|---|
| Carpetas | sí | no | etiquetas | sí |
| Marcar leído | sí | no | sí | sí |
| Mover | sí | no | reetiqueta | sí |
| Borrar | sí | sí | sí | sí |
| Búsqueda en servidor | sí | no | sí | sí |
| Adjuntos individuales | no | no | no | sí |
if (email.Capabilities.HasFlag(EmailCapabilities.Move))
{
await email.MoveAsync(id, carpetaDestino, ct);
}
Puntos concretos que conviene conocer:
- POP3 solo lista y descarga de la bandeja de entrada. No tiene carpetas ni marcas de leído, y los filtros se aplican en el cliente tras descargar las cabeceras, así que consultar un buzón grande es lento. Úsalo solo si no hay IMAP.
- IMAP identifica los mensajes por carpeta y UID. Si el servidor no admite la
extensión
UIDPLUS,MoveAsyncdevuelve una cadena vacía porque no comunica el nuevo identificador: hay que volver a buscar el mensaje. - Gmail usa etiquetas, no carpetas: un mensaje puede estar en varias a la vez.
MoveAsyncretira las etiquetas de ubicación actuales y pone la de destino. «Archivar» equivale a quitar la etiquetaINBOX, por lo queWellKnownFolder.Archiveno se resuelve. - Microsoft 365 pide identificadores inmutables en todas las llamadas; sin ellos el
identificador cambiaría al mover el mensaje.
$searchy$filterno se pueden combinar: si usasBodyContains, el resto de filtros se ignora.
Borrar
Se distinguen dos operaciones porque no tienen las mismas consecuencias ni los mismos permisos:
await email.MoveToTrashAsync(id, ct); // reversible, es la forma normal
await email.DeletePermanentlyAsync(id, ct); // irreversible
En Gmail el borrado permanente exige el ámbito https://mail.google.com/ y hay que
habilitarlo con Gmail:AllowPermanentDelete. En POP3 el borrado se confirma al cerrar
la sesión: si el proceso muere antes, el mensaje sigue en el buzón.
Entornos de desarrollo
{
"GesEmail": {
"TestMode": true,
"RedirectAllTo": "pruebas@gesgocom.es"
}
}
TestModeconstruye y valida el mensaje pero no lo entrega. El resultado llega conWasSimulated = true.RedirectAllTosustituye todos los destinatarios por esa dirección y anota los originales deParayCcen la cabeceraX-GesEmail-Original-Recipients. Los destinatarios en copia oculta no se reproducen: hacerlo revelaría al buzón de pruebas a quién se estaba ocultando el envío.
Errores
Todas las excepciones previstas derivan de GesEmailException y llevan el proveedor,
la operación y, cuando existe, el código del servidor:
| Excepción | Cuándo |
|---|---|
EmailConfigurationException |
Configuración incompleta o mensaje inválido. |
EmailAuthenticationException |
Credenciales o permisos rechazados. |
EmailTransportException |
Fallo de red, TLS, protocolo o servicio. |
EmailMessageNotFoundException |
El identificador ya no resuelve. |
try
{
await email.SendAsync(mensaje, ct);
}
catch (GesEmailException ex)
{
// Registra una sola vez, en el límite donde se decide que el fallo no es recuperable.
logger.LogError(ex,
"GesEmail falló en {Operation} con {Provider}; código {Code}",
ex.Operation, ex.Provider, ex.ErrorCode);
if (ex.IsTransient)
{
// Límite de velocidad o corte temporal: reintentar más tarde tiene sentido.
// ex.RetryAfter trae la espera sugerida cuando el proveedor la indica.
}
}
Un envío no es idempotente. La librería no reintenta ningún envío por su cuenta: si
la conexión se corta después de transmitir el mensaje, el resultado es Unknown y
repetirlo podría duplicar el correo. Comprueba el buzón antes de reintentar.
Seguridad
- Los valores con
\ro\nen asunto, nombres o cabeceras se rechazan: permitirían inyectar destinatarios ocultos en el mensaje. - Las cabeceras que gestiona la librería (
From,To,Bcc,Content-Type…) no se pueden sobrescribir desde la aplicación. Usa nombres con prefijoX-, que además son los únicos que admite Microsoft Graph. AcceptAllCertificatesdesactiva la validación del certificado del servidor. Solo para servidores internos con certificado autofirmado.- Los mensajes que superan
MaxMessageSizeBytesse rechazan antes de contactar con el proveedor. La estimación incluye el sobrecoste de base64, que aumenta el tamaño de los adjuntos alrededor de un tercio.
Logs
GesEmail usa exclusivamente Microsoft.Extensions.Logging y no configura ningún
proveedor: los eventos llegan a los que tenga la aplicación. Las categorías empiezan por
GesEmail, así que se filtran con ese prefijo.
{
"Logging": {
"LogLevel": {
"Default": "Information",
"GesEmail": "Warning"
}
}
}
| EventId | Nivel | Significado |
|---|---|---|
| 1100–1104 | Debug/Warning | Envío por SMTP, incluido el resultado indeterminado. |
| 1300–1302 | Debug | Operaciones IMAP. |
| 1310 | Debug | Operaciones POP3. |
| 1400–1403 | Debug | Operaciones de Gmail. |
| 1500–1504 | Debug | Operaciones de Microsoft Graph. |
| 1600–1601 | Debug | Creación y retirada de clientes en la caché de la factoría. |
| 1610–1611 | Warning | Salvaguardas del registro imponiéndose sobre la configuración. |
Nunca se registran contraseñas, secretos, cuerpos de mensaje ni direcciones de los destinatarios; sí el número de destinatarios, los identificadores y los códigos del proveedor.
Rendimiento y ciclo de vida
Todos los servicios se registran como singleton y son seguros para uso concurrente:
- Los clientes de Gmail y Microsoft Graph se crean una sola vez y conservan la caché de tokens y el grupo de conexiones HTTP. Con la factoría se reutilizan por credenciales, como describe Configuración en base de datos.
- Los clientes de MailKit se crean por operación y se cierran al terminar. No son seguros para uso concurrente y una conexión inactiva acaba cerrándola el servidor.
TimeoutSeconds acota la operación completa: conexión, autenticación y transferencia.
Documentación por proveedor
- docs/Smtp.md — servidores propios, Gmail y Office 365 por SMTP/IMAP.
- docs/Gmail.md — cuenta de servicio, delegación en el dominio y ámbitos.
- docs/Microsoft365.md — registro en Entra ID, permisos y adjuntos grandes.
Licencia
MIT. Consulta LICENSE.
No packages depend on Gesgocom.GesEmail.
.NET 10.0
- Azure.Identity (>= 1.21.0)
- Google.Apis.Gmail.v1 (>= 1.74.0.4162)
- MailKit (>= 4.17.0)
- Microsoft.Extensions.Caching.Memory (>= 10.0.10)
- Microsoft.Extensions.Configuration.Abstractions (>= 10.0.10)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.10)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.10)
- Microsoft.Extensions.Options (>= 10.0.10)
- Microsoft.Extensions.Options.ConfigurationExtensions (>= 10.0.10)
- Microsoft.Graph (>= 6.2.0)
- MimeKit (>= 4.17.0)