feat: implement communications module with SMTP settings, email logging, and frontend UI
This commit is contained in:
@@ -11,35 +11,35 @@ Sarà allineato alla visione del modulo "Comunicazioni" (Gestione invio mail, ch
|
|||||||
## Piano di Lavoro
|
## Piano di Lavoro
|
||||||
|
|
||||||
### 1. Documentazione
|
### 1. Documentazione
|
||||||
- [ ] Aggiornamento piano di lavoro (questo file).
|
- [x] Aggiornamento piano di lavoro (questo file).
|
||||||
- [ ] Aggiornamento `ZENTRAL.md`.
|
- [x] Aggiornamento `ZENTRAL.md`.
|
||||||
|
|
||||||
### 2. Backend (.NET)
|
### 2. Backend (.NET)
|
||||||
#### Domain Layer (`Zentral.Domain`)
|
#### Domain Layer (`Zentral.Domain`)
|
||||||
- [ ] **Interfaccia `IEmailSender`**: Contratto standard per l'invio.
|
- [x] **Interfaccia `IEmailSender`**: Contratto standard per l'invio.
|
||||||
- [ ] **Entities (Namespace `Communications`)**:
|
- [x] **Entities (Namespace `Communications`)**:
|
||||||
- `EmailLog`: Storico invii (`Id`, `Data`, `Mittente`, `Destinatario`, `Oggetto`, `Stato`, `Errore`).
|
- `EmailLog`: Storico invii (`Id`, `Data`, `Mittente`, `Destinatario`, `Oggetto`, `Stato`, `Errore`).
|
||||||
- `EmailTemplate` (Opzionale Fase 1): Per standardizzare il layout delle mail.
|
- `EmailTemplate` (Opzionale Fase 1): Per standardizzare il layout delle mail.
|
||||||
|
|
||||||
#### Infrastructure Layer (`Zentral.Infrastructure`)
|
#### Infrastructure Layer (`Zentral.Infrastructure`)
|
||||||
- [ ] **Implementazione `SmtpEmailSender`**:
|
- [x] **Implementazione `SmtpEmailSender`**:
|
||||||
- Logica di invio tramite MailKit.
|
- Logica di invio tramite MailKit.
|
||||||
- Integrazione con `Configurazione` per leggere le credenziali SMTP a runtime.
|
- Integrazione con `Configurazione` per leggere le credenziali SMTP a runtime.
|
||||||
- Salvataggio automatico del log in `EmailLog`.
|
- Salvataggio automatico del log in `EmailLog`.
|
||||||
|
|
||||||
#### API Layer (`Zentral.API`)
|
#### API Layer (`Zentral.API`)
|
||||||
- [ ] **Controller `CommunicationsController`**:
|
- [x] **Controller `CommunicationsController`**:
|
||||||
- Endpoint per test invio.
|
- Endpoint per test invio.
|
||||||
- Endpoint per consultazione Logs.
|
- Endpoint per consultazione Logs.
|
||||||
- Endpoint per salvataggio Configurazione SMTP.
|
- Endpoint per salvataggio Configurazione SMTP.
|
||||||
|
|
||||||
### 3. Frontend (React)
|
### 3. Frontend (React)
|
||||||
#### Modulo `communications` (`src/apps/communications`)
|
#### Modulo `communications` (`src/apps/communications`)
|
||||||
- [ ] **Setup App**: Creazione struttura standard modulo.
|
- [x] **Setup App**: Creazione struttura standard modulo.
|
||||||
- [ ] **Settings Page**:
|
- [x] **Settings Page**:
|
||||||
- Form per configurazione SMTP (Host, Port, User, Pass, SSL).
|
- Form per configurazione SMTP (Host, Port, User, Pass, SSL).
|
||||||
- Pulsante "Test Connessione".
|
- Pulsante "Test Connessione".
|
||||||
- [ ] **Logs Page**:
|
- [x] **Logs Page**:
|
||||||
- Tabella visualizzazione storico email inviate con stato (Successo/Errore).
|
- Tabella visualizzazione storico email inviate con stato (Successo/Errore).
|
||||||
|
|
||||||
## Integrazione
|
## Integrazione
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Zentral.API.Apps.Communications.Dtos;
|
||||||
|
using Zentral.Domain.Entities;
|
||||||
|
using Zentral.Domain.Interfaces;
|
||||||
|
using Zentral.Infrastructure.Data;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace Zentral.API.Apps.Communications.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/communications")]
|
||||||
|
public class CommunicationsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly ZentralDbContext _context;
|
||||||
|
private readonly IEmailSender _emailSender;
|
||||||
|
|
||||||
|
public CommunicationsController(ZentralDbContext context, IEmailSender emailSender)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
_emailSender = emailSender;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("config")]
|
||||||
|
public async Task<ActionResult<SmtpConfigDto>> GetConfig()
|
||||||
|
{
|
||||||
|
var configs = await _context.Configurazioni
|
||||||
|
.Where(c => c.Chiave.StartsWith("SMTP_"))
|
||||||
|
.ToDictionaryAsync(c => c.Chiave, c => c.Valore);
|
||||||
|
|
||||||
|
var dto = new SmtpConfigDto
|
||||||
|
{
|
||||||
|
Host = GetValue(configs, "SMTP_HOST"),
|
||||||
|
Port = int.Parse(GetValue(configs, "SMTP_PORT", "587")),
|
||||||
|
User = GetValue(configs, "SMTP_USER"),
|
||||||
|
Password = GetValue(configs, "SMTP_PASS"),
|
||||||
|
EnableSsl = bool.Parse(GetValue(configs, "SMTP_SSL", "false")),
|
||||||
|
FromEmail = GetValue(configs, "SMTP_FROM_EMAIL"),
|
||||||
|
FromName = GetValue(configs, "SMTP_FROM_NAME")
|
||||||
|
};
|
||||||
|
|
||||||
|
return Ok(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("config")]
|
||||||
|
public async Task<ActionResult> SaveConfig(SmtpConfigDto dto)
|
||||||
|
{
|
||||||
|
await SetConfig("SMTP_HOST", dto.Host);
|
||||||
|
await SetConfig("SMTP_PORT", dto.Port.ToString());
|
||||||
|
await SetConfig("SMTP_USER", dto.User);
|
||||||
|
await SetConfig("SMTP_PASS", dto.Password);
|
||||||
|
await SetConfig("SMTP_SSL", dto.EnableSsl.ToString().ToLower());
|
||||||
|
await SetConfig("SMTP_FROM_EMAIL", dto.FromEmail);
|
||||||
|
await SetConfig("SMTP_FROM_NAME", dto.FromName);
|
||||||
|
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("send-test")]
|
||||||
|
public async Task<ActionResult> SendTestEmail(TestEmailDto dto)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _emailSender.SendEmailAsync(dto.To, dto.Subject, dto.Body);
|
||||||
|
return Ok(new { message = "Email send process initiated. Check logs for status." });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return BadRequest(new { message = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("logs")]
|
||||||
|
public async Task<ActionResult<List<EmailLogDto>>> GetLogs([FromQuery] int limit = 50)
|
||||||
|
{
|
||||||
|
var logs = await _context.EmailLogs
|
||||||
|
.OrderByDescending(l => l.SentDate)
|
||||||
|
.Take(limit)
|
||||||
|
.Select(l => new EmailLogDto
|
||||||
|
{
|
||||||
|
Id = l.Id,
|
||||||
|
SentDate = l.SentDate,
|
||||||
|
Sender = l.Sender,
|
||||||
|
Recipient = l.Recipient,
|
||||||
|
Subject = l.Subject,
|
||||||
|
Status = l.Status,
|
||||||
|
ErrorMessage = l.ErrorMessage
|
||||||
|
})
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return Ok(logs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetValue(Dictionary<string, string?> dict, string key, string def = "")
|
||||||
|
{
|
||||||
|
return dict.ContainsKey(key) && dict[key] != null ? dict[key]! : def;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SetConfig(string key, string? value)
|
||||||
|
{
|
||||||
|
var config = await _context.Configurazioni.FirstOrDefaultAsync(c => c.Chiave == key);
|
||||||
|
if (config == null)
|
||||||
|
{
|
||||||
|
config = new Configurazione { Chiave = key, CreatedAt = DateTime.UtcNow, CreatedBy = User.FindFirstValue(ClaimTypes.Name) ?? "System" };
|
||||||
|
_context.Configurazioni.Add(config);
|
||||||
|
}
|
||||||
|
config.Valore = value;
|
||||||
|
config.UpdatedAt = DateTime.UtcNow;
|
||||||
|
config.UpdatedBy = User.FindFirstValue(ClaimTypes.Name) ?? "System";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace Zentral.API.Apps.Communications.Dtos;
|
||||||
|
|
||||||
|
public class EmailLogDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public DateTime SentDate { get; set; }
|
||||||
|
public string Sender { get; set; } = string.Empty;
|
||||||
|
public string Recipient { get; set; } = string.Empty;
|
||||||
|
public string Subject { get; set; } = string.Empty;
|
||||||
|
public string Status { get; set; } = string.Empty;
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace Zentral.API.Apps.Communications.Dtos;
|
||||||
|
|
||||||
|
public class SmtpConfigDto
|
||||||
|
{
|
||||||
|
public string Host { get; set; } = string.Empty;
|
||||||
|
public int Port { get; set; } = 587;
|
||||||
|
public string User { get; set; } = string.Empty;
|
||||||
|
public string Password { get; set; } = string.Empty;
|
||||||
|
public bool EnableSsl { get; set; } = false;
|
||||||
|
public string FromEmail { get; set; } = string.Empty;
|
||||||
|
public string FromName { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace Zentral.API.Apps.Communications.Dtos;
|
||||||
|
|
||||||
|
public class TestEmailDto
|
||||||
|
{
|
||||||
|
public string To { get; set; } = string.Empty;
|
||||||
|
public string Subject { get; set; } = "Test Email from Zentral";
|
||||||
|
public string Body { get; set; } = "This is a test email sent from Zentral Communications Module.";
|
||||||
|
}
|
||||||
@@ -6,7 +6,10 @@ using Zentral.API.Apps.Warehouse.Services;
|
|||||||
using Zentral.API.Apps.Purchases.Services;
|
using Zentral.API.Apps.Purchases.Services;
|
||||||
using Zentral.API.Apps.Sales.Services;
|
using Zentral.API.Apps.Sales.Services;
|
||||||
using Zentral.API.Apps.Production.Services;
|
using Zentral.API.Apps.Production.Services;
|
||||||
|
using Zentral.API.Apps.Production.Services;
|
||||||
using Zentral.Infrastructure.Data;
|
using Zentral.Infrastructure.Data;
|
||||||
|
using Zentral.Infrastructure.Services;
|
||||||
|
using Zentral.Domain.Interfaces;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
@@ -28,6 +31,9 @@ builder.Services.AddScoped<AutoCodeService>();
|
|||||||
builder.Services.AddScoped<CustomFieldService>();
|
builder.Services.AddScoped<CustomFieldService>();
|
||||||
builder.Services.AddSingleton<DataNotificationService>();
|
builder.Services.AddSingleton<DataNotificationService>();
|
||||||
|
|
||||||
|
// Communications Module Services
|
||||||
|
builder.Services.AddTransient<IEmailSender, SmtpEmailSender>();
|
||||||
|
|
||||||
// Warehouse Module Services
|
// Warehouse Module Services
|
||||||
builder.Services.AddScoped<IWarehouseService, WarehouseService>();
|
builder.Services.AddScoped<IWarehouseService, WarehouseService>();
|
||||||
|
|
||||||
|
|||||||
@@ -521,6 +521,20 @@ public class AppService
|
|||||||
RoutePath = "/report-designer",
|
RoutePath = "/report-designer",
|
||||||
IsAvailable = true,
|
IsAvailable = true,
|
||||||
CreatedAt = DateTime.UtcNow
|
CreatedAt = DateTime.UtcNow
|
||||||
|
},
|
||||||
|
new App
|
||||||
|
{
|
||||||
|
Code = "communications",
|
||||||
|
Name = "Comunicazioni",
|
||||||
|
Description = "Gestione invio mail, chat interna e condivisione risorse",
|
||||||
|
Icon = "Email",
|
||||||
|
BasePrice = 1000m,
|
||||||
|
MonthlyMultiplier = 1.2m,
|
||||||
|
SortOrder = 90,
|
||||||
|
IsCore = false,
|
||||||
|
RoutePath = "/communications",
|
||||||
|
IsAvailable = true,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using System;
|
||||||
|
using Zentral.Domain;
|
||||||
|
|
||||||
|
namespace Zentral.Domain.Entities.Communications;
|
||||||
|
|
||||||
|
public class EmailLog : BaseEntity
|
||||||
|
{
|
||||||
|
public DateTime SentDate { get; set; }
|
||||||
|
public string Sender { get; set; } = string.Empty;
|
||||||
|
public string Recipient { get; set; } = string.Empty;
|
||||||
|
public string Subject { get; set; } = string.Empty;
|
||||||
|
public string Status { get; set; } = string.Empty; // "Success", "Failed"
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
}
|
||||||
7
src/backend/Zentral.Domain/Interfaces/IEmailSender.cs
Normal file
7
src/backend/Zentral.Domain/Interfaces/IEmailSender.cs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
namespace Zentral.Domain.Interfaces;
|
||||||
|
|
||||||
|
public interface IEmailSender
|
||||||
|
{
|
||||||
|
Task SendEmailAsync(string to, string subject, string body, bool isHtml = true);
|
||||||
|
Task SendEmailAsync(string to, string subject, string body, List<string> attachments, bool isHtml = true);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ using Zentral.Domain.Entities.Purchases;
|
|||||||
using Zentral.Domain.Entities.Sales;
|
using Zentral.Domain.Entities.Sales;
|
||||||
using Zentral.Domain.Entities.Production;
|
using Zentral.Domain.Entities.Production;
|
||||||
using Zentral.Domain.Entities.HR;
|
using Zentral.Domain.Entities.HR;
|
||||||
|
using Zentral.Domain.Entities.Communications;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace Zentral.Infrastructure.Data;
|
namespace Zentral.Infrastructure.Data;
|
||||||
@@ -94,6 +95,9 @@ public class ZentralDbContext : DbContext
|
|||||||
public DbSet<Assenza> Assenze => Set<Assenza>();
|
public DbSet<Assenza> Assenze => Set<Assenza>();
|
||||||
public DbSet<Pagamento> Pagamenti => Set<Pagamento>();
|
public DbSet<Pagamento> Pagamenti => Set<Pagamento>();
|
||||||
public DbSet<Rimborso> Rimborsi => Set<Rimborso>();
|
public DbSet<Rimborso> Rimborsi => Set<Rimborso>();
|
||||||
|
|
||||||
|
// Communications module entities
|
||||||
|
public DbSet<EmailLog> EmailLogs => Set<EmailLog>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
@@ -989,5 +993,16 @@ public class ZentralDbContext : DbContext
|
|||||||
.HasForeignKey(e => e.ArticleId)
|
.HasForeignKey(e => e.ArticleId)
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ===============================================
|
||||||
|
// COMMUNICATIONS MODULE ENTITIES
|
||||||
|
// ===============================================
|
||||||
|
modelBuilder.Entity<EmailLog>(entity =>
|
||||||
|
{
|
||||||
|
entity.ToTable("EmailLogs");
|
||||||
|
entity.HasIndex(e => e.SentDate);
|
||||||
|
entity.HasIndex(e => e.Status);
|
||||||
|
entity.HasIndex(e => e.Recipient);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
133
src/backend/Zentral.Infrastructure/Services/SmtpEmailSender.cs
Normal file
133
src/backend/Zentral.Infrastructure/Services/SmtpEmailSender.cs
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using MimeKit;
|
||||||
|
using MailKit.Net.Smtp;
|
||||||
|
using MailKit.Security;
|
||||||
|
using Zentral.Domain.Entities.Communications;
|
||||||
|
using Zentral.Domain.Interfaces;
|
||||||
|
using Zentral.Infrastructure.Data;
|
||||||
|
|
||||||
|
namespace Zentral.Infrastructure.Services;
|
||||||
|
|
||||||
|
public class SmtpEmailSender : IEmailSender
|
||||||
|
{
|
||||||
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
|
|
||||||
|
public SmtpEmailSender(IServiceScopeFactory scopeFactory)
|
||||||
|
{
|
||||||
|
_scopeFactory = scopeFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SendEmailAsync(string to, string subject, string body, bool isHtml = true)
|
||||||
|
{
|
||||||
|
await SendEmailAsync(to, subject, body, new List<string>(), isHtml);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SendEmailAsync(string to, string subject, string body, List<string> attachments, bool isHtml = true)
|
||||||
|
{
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var context = scope.ServiceProvider.GetRequiredService<ZentralDbContext>();
|
||||||
|
|
||||||
|
// 1. Get Configuration
|
||||||
|
var configs = await context.Configurazioni
|
||||||
|
.Where(c => c.Chiave.StartsWith("SMTP_"))
|
||||||
|
.ToDictionaryAsync(c => c.Chiave, c => c.Valore);
|
||||||
|
|
||||||
|
var host = GetConfig(configs, "SMTP_HOST");
|
||||||
|
var portStr = GetConfig(configs, "SMTP_PORT", "587");
|
||||||
|
var user = GetConfig(configs, "SMTP_USER");
|
||||||
|
var pass = GetConfig(configs, "SMTP_PASS");
|
||||||
|
var sslStr = GetConfig(configs, "SMTP_SSL", "false"); // StartTls is usually implied by port 587 but simpler handling here
|
||||||
|
var fromEmail = GetConfig(configs, "SMTP_FROM_EMAIL", user);
|
||||||
|
var fromName = GetConfig(configs, "SMTP_FROM_NAME", "Zentral");
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(host))
|
||||||
|
{
|
||||||
|
await LogResultAsync(context, fromEmail, to, subject, "Failed", "SMTP Host not configured");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int.TryParse(portStr, out int port);
|
||||||
|
bool.TryParse(sslStr, out bool useSsl);
|
||||||
|
|
||||||
|
// 2. Prepare Message
|
||||||
|
var message = new MimeMessage();
|
||||||
|
message.From.Add(new MailboxAddress(fromName, fromEmail));
|
||||||
|
message.To.Add(MailboxAddress.Parse(to));
|
||||||
|
message.Subject = subject;
|
||||||
|
|
||||||
|
var builder = new BodyBuilder();
|
||||||
|
if (isHtml)
|
||||||
|
builder.HtmlBody = body;
|
||||||
|
else
|
||||||
|
builder.TextBody = body;
|
||||||
|
|
||||||
|
foreach (var attachment in attachments)
|
||||||
|
{
|
||||||
|
if (System.IO.File.Exists(attachment))
|
||||||
|
{
|
||||||
|
builder.Attachments.Add(attachment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message.Body = builder.ToMessageBody();
|
||||||
|
|
||||||
|
// 3. Send
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var client = new SmtpClient();
|
||||||
|
// Use SecureSocketOptions.Auto for flexibility or StartTls based on config
|
||||||
|
// For now, simple logic:
|
||||||
|
if (port == 465)
|
||||||
|
await client.ConnectAsync(host, port, SecureSocketOptions.SslOnConnect);
|
||||||
|
else if (port == 587)
|
||||||
|
await client.ConnectAsync(host, port, SecureSocketOptions.StartTls);
|
||||||
|
else
|
||||||
|
await client.ConnectAsync(host, port, SecureSocketOptions.Auto);
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(user) && !string.IsNullOrEmpty(pass))
|
||||||
|
{
|
||||||
|
await client.AuthenticateAsync(user, pass);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.SendAsync(message);
|
||||||
|
await client.DisconnectAsync(true);
|
||||||
|
|
||||||
|
await LogResultAsync(context, fromEmail, to, subject, "Success", null);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await LogResultAsync(context, fromEmail, to, subject, "Failed", ex.Message);
|
||||||
|
// We do not rethrow the exception to avoid breaking the application flow,
|
||||||
|
// but in some cases it might be useful to return a result.
|
||||||
|
// For now, logging to DB is the requirement.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetConfig(Dictionary<string, string?> configs, string key, string defaultValue = "")
|
||||||
|
{
|
||||||
|
return configs.ContainsKey(key) && !string.IsNullOrEmpty(configs[key]) ? configs[key]! : defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LogResultAsync(ZentralDbContext context, string from, string to, string subject, string status, string? error)
|
||||||
|
{
|
||||||
|
var log = new EmailLog
|
||||||
|
{
|
||||||
|
SentDate = DateTime.UtcNow,
|
||||||
|
Sender = from,
|
||||||
|
Recipient = to,
|
||||||
|
Subject = subject,
|
||||||
|
Status = status,
|
||||||
|
ErrorMessage = error,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
CreatedBy = "System"
|
||||||
|
};
|
||||||
|
|
||||||
|
context.EmailLogs.Add(log);
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.0" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.0" />
|
||||||
|
<PackageReference Include="MailKit" Version="4.3.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import SalesRoutes from "./apps/sales/routes";
|
|||||||
import ProductionRoutes from "./apps/production/routes";
|
import ProductionRoutes from "./apps/production/routes";
|
||||||
import EventsRoutes from "./apps/events/routes";
|
import EventsRoutes from "./apps/events/routes";
|
||||||
import HRRoutes from "./apps/hr/routes";
|
import HRRoutes from "./apps/hr/routes";
|
||||||
|
import CommunicationsRoutes from "./apps/communications/routes";
|
||||||
import { AppGuard } from "./components/AppGuard";
|
import { AppGuard } from "./components/AppGuard";
|
||||||
import { useRealTimeUpdates } from "./hooks/useRealTimeUpdates";
|
import { useRealTimeUpdates } from "./hooks/useRealTimeUpdates";
|
||||||
import { CollaborationProvider } from "./contexts/CollaborationContext";
|
import { CollaborationProvider } from "./contexts/CollaborationContext";
|
||||||
@@ -135,6 +136,15 @@ function App() {
|
|||||||
</AppGuard>
|
</AppGuard>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{/* Communications Module */}
|
||||||
|
<Route
|
||||||
|
path="communications/*"
|
||||||
|
element={
|
||||||
|
<AppGuard appCode="communications">
|
||||||
|
<CommunicationsRoutes />
|
||||||
|
</AppGuard>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</TabProvider>
|
</TabProvider>
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||||
|
import { Box, Paper, Tab, Tabs } from "@mui/material";
|
||||||
|
|
||||||
|
export default function CommunicationsLayout() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const getActiveTab = () => {
|
||||||
|
const path = location.pathname;
|
||||||
|
if (path.includes("/communications/logs")) return "/communications/logs";
|
||||||
|
return "/communications/settings";
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (_event: React.SyntheticEvent, newValue: string) => {
|
||||||
|
navigate(newValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||||
|
<Paper sx={{ mb: 2 }}>
|
||||||
|
<Tabs
|
||||||
|
value={getActiveTab()}
|
||||||
|
onChange={handleChange}
|
||||||
|
indicatorColor="primary"
|
||||||
|
textColor="primary"
|
||||||
|
>
|
||||||
|
<Tab label="Configurazione" value="/communications/settings" />
|
||||||
|
<Tab label="Logs" value="/communications/logs" />
|
||||||
|
</Tabs>
|
||||||
|
</Paper>
|
||||||
|
<Box sx={{ flex: 1, overflow: "auto" }}>
|
||||||
|
<Outlet />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
src/frontend/src/apps/communications/pages/LogsPage.tsx
Normal file
69
src/frontend/src/apps/communications/pages/LogsPage.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { DataGrid, GridColDef } from '@mui/x-data-grid';
|
||||||
|
import { Box, Typography } from '@mui/material';
|
||||||
|
import { History } from '@mui/icons-material';
|
||||||
|
import { communicationsService } from '../services/communicationsService';
|
||||||
|
import { EmailLog } from '../types';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
export default function LogsPage() {
|
||||||
|
const [logs, setLogs] = useState<EmailLog[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadLogs();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadLogs = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await communicationsService.getLogs(100);
|
||||||
|
setLogs(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: GridColDef[] = [
|
||||||
|
{ field: 'id', headerName: 'ID', width: 70 },
|
||||||
|
{
|
||||||
|
field: 'sentDate', headerName: 'Data', width: 180,
|
||||||
|
valueFormatter: (params) => dayjs(params.value).format('DD/MM/YYYY HH:mm')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'status', headerName: 'Stato', width: 120,
|
||||||
|
renderCell: (params) => (
|
||||||
|
<span style={{
|
||||||
|
color: params.value === 'Success' ? 'green' : 'red',
|
||||||
|
fontWeight: 'bold'
|
||||||
|
}}>
|
||||||
|
{params.value}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ field: 'sender', headerName: 'Mittente', width: 200 },
|
||||||
|
{ field: 'recipient', headerName: 'Destinatario', width: 200 },
|
||||||
|
{ field: 'subject', headerName: 'Oggetto', flex: 1 },
|
||||||
|
{ field: 'errorMessage', headerName: 'Errore', width: 200 },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box p={3} sx={{ height: '80vh', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<Box display="flex" justifyContent="space-between" mb={2}>
|
||||||
|
<Typography variant="h4"><History /> Email Logs</Typography>
|
||||||
|
</Box>
|
||||||
|
<DataGrid
|
||||||
|
rows={logs}
|
||||||
|
columns={columns}
|
||||||
|
loading={loading}
|
||||||
|
initialState={{
|
||||||
|
pagination: { paginationModel: { pageSize: 25 } },
|
||||||
|
}}
|
||||||
|
pageSizeOptions={[25, 50, 100]}
|
||||||
|
disableRowSelectionOnClick
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
204
src/frontend/src/apps/communications/pages/SettingsPage.tsx
Normal file
204
src/frontend/src/apps/communications/pages/SettingsPage.tsx
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useForm, Controller } from 'react-hook-form';
|
||||||
|
import {
|
||||||
|
Box, Paper, Typography, TextField, Button, Grid,
|
||||||
|
Switch, FormControlLabel, Divider, Alert, Snackbar
|
||||||
|
} from '@mui/material';
|
||||||
|
import { Save, Send, Email } from '@mui/icons-material';
|
||||||
|
import { communicationsService } from '../services/communicationsService';
|
||||||
|
import { SmtpConfig, TestEmail } from '../types';
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
const { control, handleSubmit, reset } = useForm<SmtpConfig>();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [testMode, setTestMode] = useState(false);
|
||||||
|
const [testData, setTestData] = useState<TestEmail>({ to: '', subject: 'Test Email', body: 'Test content' });
|
||||||
|
const [notification, setNotification] = useState<{ type: 'success' | 'error', message: string } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadConfig();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadConfig = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const config = await communicationsService.getConfig();
|
||||||
|
reset(config);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
setNotification({ type: 'error', message: 'Failed to load configuration' });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async (data: SmtpConfig) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
await communicationsService.saveConfig(data);
|
||||||
|
setNotification({ type: 'success', message: 'Configuration saved successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
setNotification({ type: 'error', message: 'Failed to save configuration' });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendTest = async () => {
|
||||||
|
if (!testData.to) {
|
||||||
|
setNotification({ type: 'error', message: 'Recipient email is required for test' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
await communicationsService.sendTestEmail(testData);
|
||||||
|
setNotification({ type: 'success', message: 'Test email queued successfully' });
|
||||||
|
setTestMode(false);
|
||||||
|
} catch (error: any) {
|
||||||
|
setNotification({ type: 'error', message: error.response?.data?.message || 'Failed to send test email' });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box p={3}>
|
||||||
|
<Typography variant="h4" gutterBottom display="flex" alignItems="center" gap={2}>
|
||||||
|
<Email fontSize="large" color="primary" /> Configurazione SMTP
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Paper sx={{ p: 3, mb: 3 }}>
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
<Grid item xs={12} md={8}>
|
||||||
|
<Controller
|
||||||
|
name="host"
|
||||||
|
control={control}
|
||||||
|
defaultValue=""
|
||||||
|
render={({ field }) => <TextField {...field} label="SMTP Host" fullWidth required />}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} md={4}>
|
||||||
|
<Controller
|
||||||
|
name="port"
|
||||||
|
control={control}
|
||||||
|
defaultValue={587}
|
||||||
|
render={({ field }) => <TextField {...field} label="Port" type="number" fullWidth required />}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<Controller
|
||||||
|
name="user"
|
||||||
|
control={control}
|
||||||
|
defaultValue=""
|
||||||
|
render={({ field }) => <TextField {...field} label="Username" fullWidth />}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<Controller
|
||||||
|
name="password"
|
||||||
|
control={control}
|
||||||
|
defaultValue=""
|
||||||
|
render={({ field }) => <TextField {...field} label="Password" type="password" fullWidth />}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={12} md={4}>
|
||||||
|
<Controller
|
||||||
|
name="enableSsl"
|
||||||
|
control={control}
|
||||||
|
defaultValue={false}
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Switch checked={value} onChange={onChange} />}
|
||||||
|
label="Enable SSL/TLS"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Divider sx={{ my: 2 }} />
|
||||||
|
<Typography variant="h6">Mittente Default</Typography>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<Controller
|
||||||
|
name="fromEmail"
|
||||||
|
control={control}
|
||||||
|
defaultValue=""
|
||||||
|
render={({ field }) => <TextField {...field} label="From Email" fullWidth required />}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<Controller
|
||||||
|
name="fromName"
|
||||||
|
control={control}
|
||||||
|
defaultValue=""
|
||||||
|
render={({ field }) => <TextField {...field} label="From Name" fullWidth />}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={12} display="flex" justifyContent="space-between" alignItems="center">
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<Send />}
|
||||||
|
onClick={() => setTestMode(!testMode)}
|
||||||
|
>
|
||||||
|
Test Connessione
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<Save />}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Salva Configurazione
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</form>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{testMode && (
|
||||||
|
<Paper sx={{ p: 3, bgcolor: '#f5f5f5' }}>
|
||||||
|
<Typography variant="h6" gutterBottom>Test Email</Typography>
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<TextField
|
||||||
|
label="Destinatario"
|
||||||
|
fullWidth
|
||||||
|
value={testData.to}
|
||||||
|
onChange={(e) => setTestData({ ...testData, to: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<TextField
|
||||||
|
label="Oggetto"
|
||||||
|
fullWidth
|
||||||
|
value={testData.subject}
|
||||||
|
onChange={(e) => setTestData({ ...testData, subject: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Button variant="contained" color="secondary" onClick={sendTest} disabled={loading}>
|
||||||
|
Invia Test
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Snackbar
|
||||||
|
open={!!notification}
|
||||||
|
autoHideDuration={6000}
|
||||||
|
onClose={() => setNotification(null)}
|
||||||
|
>
|
||||||
|
<Alert severity={notification?.type || 'info'} onClose={() => setNotification(null)}>
|
||||||
|
{notification?.message}
|
||||||
|
</Alert>
|
||||||
|
</Snackbar>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
src/frontend/src/apps/communications/routes.tsx
Normal file
16
src/frontend/src/apps/communications/routes.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Routes, Route, Navigate } from "react-router-dom";
|
||||||
|
import SettingsPage from "./pages/SettingsPage";
|
||||||
|
import LogsPage from "./pages/LogsPage";
|
||||||
|
import CommunicationsLayout from "./components/CommunicationsLayout";
|
||||||
|
|
||||||
|
export default function CommunicationsRoutes() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route element={<CommunicationsLayout />}>
|
||||||
|
<Route index element={<Navigate to="settings" replace />} />
|
||||||
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
|
<Route path="logs" element={<LogsPage />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import api from '../../../services/api';
|
||||||
|
import { SmtpConfig, TestEmail, EmailLog } from '../types';
|
||||||
|
|
||||||
|
export const communicationsService = {
|
||||||
|
getConfig: async () => {
|
||||||
|
const response = await api.get<SmtpConfig>('/communications/config');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
saveConfig: async (config: SmtpConfig) => {
|
||||||
|
await api.post('/communications/config', config);
|
||||||
|
},
|
||||||
|
|
||||||
|
sendTestEmail: async (data: TestEmail) => {
|
||||||
|
await api.post('/communications/send-test', data);
|
||||||
|
},
|
||||||
|
|
||||||
|
getLogs: async (limit: number = 50) => {
|
||||||
|
const response = await api.get<EmailLog[]>('/communications/logs', { params: { limit } });
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
};
|
||||||
25
src/frontend/src/apps/communications/types/index.ts
Normal file
25
src/frontend/src/apps/communications/types/index.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
export interface SmtpConfig {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
user: string;
|
||||||
|
password?: string;
|
||||||
|
enableSsl: boolean;
|
||||||
|
fromEmail: string;
|
||||||
|
fromName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestEmail {
|
||||||
|
to: string;
|
||||||
|
subject: string;
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmailLog {
|
||||||
|
id: number;
|
||||||
|
sentDate: string;
|
||||||
|
sender: string;
|
||||||
|
recipient: string;
|
||||||
|
subject: string;
|
||||||
|
status: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user