Gesgocom.GesEmail 1.0.3

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

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 no etiquetas
Marcar leído no
Mover no reetiqueta
Borrar
Búsqueda en servidor no
Adjuntos individuales no no no
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, MoveAsync devuelve 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. MoveAsync retira las etiquetas de ubicación actuales y pone la de destino. «Archivar» equivale a quitar la etiqueta INBOX, por lo que WellKnownFolder.Archive no se resuelve.
  • Microsoft 365 pide identificadores inmutables en todas las llamadas; sin ellos el identificador cambiaría al mover el mensaje. $search y $filter no se pueden combinar: si usas BodyContains, 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"
  }
}
  • TestMode construye y valida el mensaje pero no lo entrega. El resultado llega con WasSimulated = true.
  • RedirectAllTo sustituye todos los destinatarios por esa dirección y anota los originales de Para y Cc en la cabecera X-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 \r o \n en 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 prefijo X-, que además son los únicos que admite Microsoft Graph.
  • AcceptAllCertificates desactiva la validación del certificado del servidor. Solo para servidores internos con certificado autofirmado.
  • Los mensajes que superan MaxMessageSizeBytes se 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

Licencia

MIT. Consulta LICENSE.

No packages depend on Gesgocom.GesEmail.

Primera versión: envío con HTML, texto alternativo, adjuntos e imágenes incrustadas; lectura de carpetas y mensajes; marcar, mover y borrar. Proveedores SMTP/IMAP/POP3, Gmail y Microsoft 365 seleccionables por configuración.

Version Downloads Last updated
1.0.4 22 08/10/2026
1.0.3 22 08/07/2026
1.0.2 7 08/07/2026
1.0.1 17 08/05/2026