Feat: Implementa auditoría de envíos de email y mejora la UX

Se introduce un sistema completo de logging para todas las comunicaciones por correo electrónico y se realizan mejoras significativas en la experiencia del usuario, tanto en la retroalimentación del sistema como en la estética de los emails enviados al cliente.

###  Nuevas Características

- **Auditoría y Log de Envíos de Email:**
    - Se ha creado una nueva tabla `com_EmailLogs` en la base de datos para registrar cada intento de envío de correo.
    - El `EmailService` ahora centraliza toda la lógica de logging, registrando automáticamente la fecha, destinatario, asunto, estado (`Enviado` o `Fallido`), y mensajes de error detallados.
    - Se implementó un nuevo `EmailLogService` y `EmailLogRepository` para gestionar estos registros.

- **Historial de Envíos en la Interfaz de Usuario:**
    - Se añade un nuevo ícono de "Historial" (<span style="color: #607d8b;">&#x1F4E7;</span>) junto a cada factura en la página de "Consulta de Facturas".
    - Al hacer clic, se abre un modal que muestra una tabla detallada con todos los intentos de envío para esa factura, incluyendo el estado y el motivo del error (si lo hubo).
    - Esto proporciona una trazabilidad completa y una herramienta de diagnóstico para el usuario final.

### 🔄 Refactorización y Mejoras

- **Mensajes de Éxito Dinámicos:**
    - Se ha mejorado la retroalimentación al enviar una factura por PDF. El sistema ahora muestra un mensaje de éxito específico, como "El email... se ha enviado correctamente a suscriptor@email.com", en lugar de un mensaje técnico genérico.
    - Se ajustó la cadena de llamadas (`Controller` -> `Service`) para que el email del destinatario esté disponible para la respuesta de la API.

- **Diseño Unificado de Emails:**
    - Se ha rediseñado el template HTML para el "Aviso de Cuenta Mensual" para que coincida con la estética del email de "Envío de Factura PDF".
    - Ambos correos ahora presentan un diseño profesional y consistente, con cabecera, logo y pie de página, reforzando la imagen de marca.

- **Manejo de Errores de Email Mejorado:**
    - El `EmailService` ahora captura excepciones específicas de la librería `MailKit` (ej. `SmtpCommandException`).
    - Esto permite registrar en el log errores mucho más precisos y útiles, como rechazos de destinatarios por parte del servidor (`User unknown`), fallos de autenticación, etc., que ahora son visibles en el `Tooltip` del historial.
This commit is contained in:
2025-08-09 21:12:11 -03:00
parent 5a806eda38
commit 7dc0940001
17 changed files with 647 additions and 197 deletions

View File

@@ -0,0 +1,53 @@
using GestionIntegral.Api.Data.Repositories.Comunicaciones;
using GestionIntegral.Api.Data.Repositories.Usuarios;
using GestionIntegral.Api.Dtos.Comunicaciones;
namespace GestionIntegral.Api.Services.Comunicaciones
{
public class EmailLogService : IEmailLogService
{
private readonly IEmailLogRepository _emailLogRepository;
private readonly IUsuarioRepository _usuarioRepository;
public EmailLogService(IEmailLogRepository emailLogRepository, IUsuarioRepository usuarioRepository)
{
_emailLogRepository = emailLogRepository;
_usuarioRepository = usuarioRepository;
}
public async Task<IEnumerable<EmailLogDto>> ObtenerHistorialPorReferencia(string referenciaId)
{
var logs = await _emailLogRepository.GetByReferenceAsync(referenciaId);
if (!logs.Any())
{
return Enumerable.Empty<EmailLogDto>();
}
// Optimización N+1: Obtener todos los usuarios necesarios en una sola consulta
var idsUsuarios = logs
.Where(l => l.IdUsuarioDisparo.HasValue)
.Select(l => l.IdUsuarioDisparo!.Value)
.Distinct();
var usuariosDict = new Dictionary<int, string>();
if (idsUsuarios.Any())
{
var usuarios = await _usuarioRepository.GetByIdsAsync(idsUsuarios);
usuariosDict = usuarios.ToDictionary(u => u.Id, u => $"{u.Nombre} {u.Apellido}");
}
// Mapear a DTO
return logs.Select(log => new EmailLogDto
{
FechaEnvio = log.FechaEnvio,
Estado = log.Estado,
Asunto = log.Asunto,
DestinatarioEmail = log.DestinatarioEmail,
Error = log.Error,
NombreUsuarioDisparo = log.IdUsuarioDisparo.HasValue
? usuariosDict.GetValueOrDefault(log.IdUsuarioDisparo.Value, "Usuario Desconocido")
: "Sistema"
});
}
}
}

View File

@@ -1,3 +1,4 @@
using GestionIntegral.Api.Data.Repositories.Comunicaciones;
using GestionIntegral.Api.Models.Comunicaciones;
using MailKit.Net.Smtp;
using MailKit.Security;
@@ -10,14 +11,22 @@ namespace GestionIntegral.Api.Services.Comunicaciones
{
private readonly MailSettings _mailSettings;
private readonly ILogger<EmailService> _logger;
private readonly IEmailLogRepository _emailLogRepository;
public EmailService(IOptions<MailSettings> mailSettings, ILogger<EmailService> logger)
public EmailService(
IOptions<MailSettings> mailSettings,
ILogger<EmailService> logger,
IEmailLogRepository emailLogRepository) // Inyectar el nuevo repositorio
{
_mailSettings = mailSettings.Value;
_logger = logger;
_emailLogRepository = emailLogRepository;
}
public async Task EnviarEmailAsync(string destinatarioEmail, string destinatarioNombre, string asunto, string cuerpoHtml, byte[]? attachment = null, string? attachmentName = null)
public async Task EnviarEmailAsync(
string destinatarioEmail, string destinatarioNombre, string asunto, string cuerpoHtml,
byte[]? attachment = null, string? attachmentName = null,
string? origen = null, string? referenciaId = null, int? idUsuarioDisparo = null)
{
var email = new MimeMessage();
email.Sender = new MailboxAddress(_mailSettings.SenderName, _mailSettings.SenderEmail);
@@ -32,26 +41,14 @@ namespace GestionIntegral.Api.Services.Comunicaciones
}
email.Body = builder.ToMessageBody();
using var smtp = new SmtpClient();
try
{
await smtp.ConnectAsync(_mailSettings.SmtpHost, _mailSettings.SmtpPort, SecureSocketOptions.StartTls);
await smtp.AuthenticateAsync(_mailSettings.SmtpUser, _mailSettings.SmtpPass);
await smtp.SendAsync(email);
_logger.LogInformation("Email enviado exitosamente a {Destinatario}", destinatarioEmail);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error al enviar email a {Destinatario}", destinatarioEmail);
throw; // Relanzar para que el servicio que lo llamó sepa que falló
}
finally
{
await smtp.DisconnectAsync(true);
}
// Llamar al método centralizado de envío y logging
await SendAndLogEmailAsync(email, origen, referenciaId, idUsuarioDisparo);
}
public async Task EnviarEmailConsolidadoAsync(string destinatarioEmail, string destinatarioNombre, string asunto, string cuerpoHtml, List<(byte[] content, string name)> adjuntos)
public async Task EnviarEmailConsolidadoAsync(
string destinatarioEmail, string destinatarioNombre, string asunto, string cuerpoHtml,
List<(byte[] content, string name)> adjuntos,
string? origen = null, string? referenciaId = null, int? idUsuarioDisparo = null)
{
var email = new MimeMessage();
email.Sender = new MailboxAddress(_mailSettings.SenderName, _mailSettings.SenderEmail);
@@ -60,7 +57,6 @@ namespace GestionIntegral.Api.Services.Comunicaciones
email.Subject = asunto;
var builder = new BodyBuilder { HtmlBody = cuerpoHtml };
if (adjuntos != null)
{
foreach (var adjunto in adjuntos)
@@ -68,25 +64,80 @@ namespace GestionIntegral.Api.Services.Comunicaciones
builder.Attachments.Add(adjunto.name, adjunto.content, ContentType.Parse("application/pdf"));
}
}
email.Body = builder.ToMessageBody();
// Llamar al método centralizado de envío y logging
await SendAndLogEmailAsync(email, origen, referenciaId, idUsuarioDisparo);
}
/// <summary>
/// Método privado que centraliza el envío de correo y el registro de logs.
/// </summary>
private async Task SendAndLogEmailAsync(MimeMessage emailMessage, string? origen, string? referenciaId, int? idUsuarioDisparo)
{
var destinatario = emailMessage.To.Mailboxes.FirstOrDefault()?.Address ?? "desconocido";
var log = new EmailLog
{
FechaEnvio = DateTime.Now,
DestinatarioEmail = destinatario,
Asunto = emailMessage.Subject,
Origen = origen,
ReferenciaId = referenciaId,
IdUsuarioDisparo = idUsuarioDisparo
};
using var smtp = new SmtpClient();
try
{
await smtp.ConnectAsync(_mailSettings.SmtpHost, _mailSettings.SmtpPort, SecureSocketOptions.StartTls);
await smtp.AuthenticateAsync(_mailSettings.SmtpUser, _mailSettings.SmtpPass);
await smtp.SendAsync(email);
_logger.LogInformation("Email consolidado enviado exitosamente a {Destinatario}", destinatarioEmail);
await smtp.SendAsync(emailMessage);
log.Estado = "Enviado";
_logger.LogInformation("Email enviado exitosamente a {Destinatario}. Asunto: {Asunto}", destinatario, emailMessage.Subject);
}
catch (Exception ex)
// Capturamos excepciones específicas de MailKit para obtener errores más detallados.
catch (SmtpCommandException scEx)
{
_logger.LogError(ex, "Error al enviar email consolidado a {Destinatario}", destinatarioEmail);
// Este error ocurre cuando el servidor SMTP rechaza un comando.
// Es el caso más común para direcciones de email inválidas que son rechazadas inmediatamente.
_logger.LogError(scEx, "Error de comando SMTP al enviar a {Destinatario}. StatusCode: {StatusCode}", destinatario, scEx.StatusCode);
log.Estado = "Fallido";
log.Error = $"Error del servidor: ({scEx.StatusCode}) {scEx.Message}";
throw;
}
catch (AuthenticationException authEx)
{
// Error específico de autenticación.
_logger.LogError(authEx, "Error de autenticación con el servidor SMTP.");
log.Estado = "Fallido";
log.Error = "Error de autenticación. Revise las credenciales de correo.";
throw;
}
catch (Exception ex) // Captura genérica para cualquier otro problema (conexión, etc.)
{
_logger.LogError(ex, "Error general al enviar email a {Destinatario}. Asunto: {Asunto}", destinatario, emailMessage.Subject);
log.Estado = "Fallido";
log.Error = ex.Message;
throw;
}
finally
{
await smtp.DisconnectAsync(true);
if (smtp.IsConnected)
{
await smtp.DisconnectAsync(true);
}
// Guardar el log en la base de datos, sin importar el resultado del envío
try
{
await _emailLogRepository.CreateAsync(log);
}
catch (Exception logEx)
{
_logger.LogError(logEx, "FALLO CRÍTICO: No se pudo guardar el log del email para {Destinatario}", destinatario);
}
}
}
}

View File

@@ -0,0 +1,9 @@
using GestionIntegral.Api.Dtos.Comunicaciones;
namespace GestionIntegral.Api.Services.Comunicaciones
{
public interface IEmailLogService
{
Task<IEnumerable<EmailLogDto>> ObtenerHistorialPorReferencia(string referenciaId);
}
}

View File

@@ -2,7 +2,50 @@ namespace GestionIntegral.Api.Services.Comunicaciones
{
public interface IEmailService
{
Task EnviarEmailAsync(string destinatarioEmail, string destinatarioNombre, string asunto, string cuerpoHtml, byte[]? attachment = null, string? attachmentName = null);
Task EnviarEmailConsolidadoAsync(string destinatarioEmail, string destinatarioNombre, string asunto, string cuerpoHtml, List<(byte[] content, string name)> adjuntos);
/// <summary>
/// Envía un correo electrónico a un único destinatario, con la posibilidad de adjuntar un archivo.
/// Este método también registra automáticamente el resultado del envío en la base de datos.
/// </summary>
/// <param name="destinatarioEmail">La dirección de correo del destinatario.</param>
/// <param name="destinatarioNombre">El nombre del destinatario.</param>
/// <param name="asunto">El asunto del correo.</param>
/// <param name="cuerpoHtml">El contenido del correo en formato HTML.</param>
/// <param name="attachment">Los bytes del archivo a adjuntar (opcional).</param>
/// <param name="attachmentName">El nombre del archivo adjunto (requerido si se provee attachment).</param>
/// <param name="origen">Identificador del proceso que dispara el email (ej. "EnvioManualPDF"). Para logging.</param>
/// <param name="referenciaId">ID de la entidad relacionada (ej. "Factura-59"). Para logging.</param>
/// <param name="idUsuarioDisparo">ID del usuario que inició la acción (si aplica). Para logging.</param>
Task EnviarEmailAsync(
string destinatarioEmail,
string destinatarioNombre,
string asunto,
string cuerpoHtml,
byte[]? attachment = null,
string? attachmentName = null,
string? origen = null,
string? referenciaId = null,
int? idUsuarioDisparo = null);
/// <summary>
/// Envía un correo electrónico a un único destinatario, con la posibilidad de adjuntar múltiples archivos.
/// Este método también registra automáticamente el resultado del envío en la base de datos.
/// </summary>
/// <param name="destinatarioEmail">La dirección de correo del destinatario.</param>
/// <param name="destinatarioNombre">El nombre del destinatario.</param>
/// <param name="asunto">El asunto del correo.</param>
/// <param name="cuerpoHtml">El contenido del correo en formato HTML.</param>
/// <param name="adjuntos">Una lista de tuplas que contienen los bytes y el nombre de cada archivo a adjuntar.</param>
/// <param name="origen">Identificador del proceso que dispara el email (ej. "FacturacionMensual"). Para logging.</param>
/// <param name="referenciaId">ID de la entidad relacionada (ej. "Suscriptor-3"). Para logging.</param>
/// <param name="idUsuarioDisparo">ID del usuario que inició la acción (si aplica). Para logging.</param>
Task EnviarEmailConsolidadoAsync(
string destinatarioEmail,
string destinatarioNombre,
string asunto,
string cuerpoHtml,
List<(byte[] content, string name)> adjuntos,
string? origen = null,
string? referenciaId = null,
int? idUsuarioDisparo = null);
}
}