feat: introduce Resend email provider and add admin email configuration page.

This commit is contained in:
2025-12-12 12:43:29 +01:00
parent ad5a880219
commit 54cf1ff276
16 changed files with 5119 additions and 56 deletions

View File

@@ -0,0 +1,29 @@
# Implementazione Configurazione Email in Amministrazione
## Obiettivo
Rendere disponibile la configurazione dell'invio email del modulo Comunicazioni nella sezione Amministrazione dell'interfaccia grafica.
## Stato Attuale
- Il backend ha già gli endpoint per la configurazione SMTP (`api/communications/config`).
- Esiste già una pagina `SettingsPage` nel modulo Comunicazioni (`src/frontend/src/apps/communications/pages/SettingsPage.tsx`) che gestisce il form di configurazione.
- Il modulo Comunicazioni non è attualmente visibile nel menu principale se non attivo/acquistato, ma la configurazione email è un setting globale che dovrebbe essere accessibile.
## Piano di Lavoro
1. **Aggiornamento Route**: Aggiungere una route `/admin/email-config` in `App.tsx` che punta alla pagina di configurazione esistente (o un wrapper).
2. **Aggiornamento Menu**: Aggiungere la voce "Configurazione Email" nel menu "Amministrazione" in `Sidebar.tsx`.
3. **Traduzioni**: Aggiungere le chiavi di traduzione per la nuova voce di menu in `it/translation.json` e `en/translation.json`.
4. **Test**: Avviare l'applicazione e verificare che la pagina sia accessibile e funzionante.
## Dettagli Tecnici
- Riutilizzare `src/frontend/src/apps/communications/pages/SettingsPage.tsx`.
- La route sarà protetta se necessario, ma accessibile come parte dell'amministrazione.
## Stato Finale
- [x] Aggiunta route `/admin/email-config` in `App.tsx`.
- [x] Aggiunta voce menu "Configurazione Email" in `Sidebar.tsx`.
- [x] Aggiunte traduzioni IT ed EN.
- [x] Installato .NET 9.0 SDK via script locale (`~/.dotnet`).
- [x] Installato `dotnet-ef` tool.
- [x] Creata migrazione `UpdateCommunicationsModule` e aggiornato il database.
- [x] Backend avviato su porta 5000.
- [x] Frontend avviato su porta 5173.

View File

@@ -0,0 +1,37 @@
# Integrazione Supporto Resend per Invio Email
## Obiettivo
Abilitare l'invio di email tramite servizi terzi (Resend) oltre al già presente SMTP, con configurazione via interfaccia grafica.
## Stato Attuale
- Backend: `SmtpEmailSender` gestisce solo SMTP.
- Frontend: `SettingsPage` gestisce solo campi SMTP.
- DTO: `SmtpConfigDto` limitato a SMTP.
## Piano di Lavoro
1. **Backend DTO**: Aggiornare `SmtpConfigDto` con campi `Provider` e `ResendApiKey`.
2. **Backend Controller**: Aggiornare `CommunicationsController` per leggere/salvare le nuove configurazioni (`EMAIL_PROVIDER`, `RESEND_API_KEY`).
3. **Backend Service**: Modificare `SmtpEmailSender` (o rinominarlo in `UnifiedEmailSender`) per supportare la logica condizionale (SMTP vs Resend). Implementare l'invio tramite HTTP Client per Resend.
4. **Frontend Service**: Aggiornare le definizioni di tipo TypeScript.
5. **Frontend UI**: Modificare `SettingsPage` per aggiungere un selettore di provider (SMTP/Resend) e mostrare i campi pertinenti dinamicamente.
6. **Traduzioni**: Aggiungere le nuove etichette.
## Dettagli Tecnici
- **API Resend**: Richiesta POST a `https://api.resend.com/emails` con Bearer Token.
- **Provider Enum**: "smtp", "resend".
- **Defaut**: SMTP per retrocompatibilità.
## Avanzamento
- [x] Backend DTO Update (`SmtpConfigDto`)
- [x] Backend Controller Update (`CommunicationsController`)
- [x] Backend Service Logic (`SmtpEmailSender` now handles Resend via HTTP)
- [x] Frontend Types Update
- [x] Frontend UI Update (`SettingsPage.tsx` with Provider selector)
- [x] Dependencies (Added `Microsoft.Extensions.Http` to Infrastructure)
## Note Finali
- L'integrazione supporta ora la selezione dinamica tra SMTP e Resend.
- La configurazione viene salvata su database (`EMAIL_PROVIDER`, `RESEND_API_KEY`).
- Il backend utilizza `IHttpClientFactory` per le chiamate API verso Resend.
- UI aggiornata per mostrare campi condizionali.

View File

@@ -25,7 +25,7 @@ public class CommunicationsController : ControllerBase
public async Task<ActionResult<SmtpConfigDto>> GetConfig()
{
var configs = await _context.Configurazioni
.Where(c => c.Chiave.StartsWith("SMTP_"))
.Where(c => c.Chiave.StartsWith("SMTP_") || c.Chiave == "EMAIL_PROVIDER" || c.Chiave == "RESEND_API_KEY")
.ToDictionaryAsync(c => c.Chiave, c => c.Valore);
var dto = new SmtpConfigDto
@@ -36,7 +36,9 @@ public class CommunicationsController : ControllerBase
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")
FromName = GetValue(configs, "SMTP_FROM_NAME"),
Provider = GetValue(configs, "EMAIL_PROVIDER", "smtp"),
ResendApiKey = GetValue(configs, "RESEND_API_KEY")
};
return Ok(dto);
@@ -53,6 +55,9 @@ public class CommunicationsController : ControllerBase
await SetConfig("SMTP_FROM_EMAIL", dto.FromEmail);
await SetConfig("SMTP_FROM_NAME", dto.FromName);
await SetConfig("EMAIL_PROVIDER", dto.Provider);
await SetConfig("RESEND_API_KEY", dto.ResendApiKey);
await _context.SaveChangesAsync();
return Ok();
}

View File

@@ -9,4 +9,8 @@ public class SmtpConfigDto
public bool EnableSsl { get; set; } = false;
public string FromEmail { get; set; } = string.Empty;
public string FromName { get; set; } = string.Empty;
// New fields for Resend support
public string Provider { get; set; } = "smtp"; // "smtp" or "resend"
public string ResendApiKey { get; set; } = string.Empty;
}

View File

@@ -22,6 +22,7 @@ builder.Services.AddDbContext<ZentralDbContext>(options =>
options.UseSqlite(connectionString));
// Services
builder.Services.AddHttpClient();
builder.Services.AddScoped<EventoCostiService>();
builder.Services.AddScoped<DemoDataService>();
builder.Services.AddScoped<ReportGeneratorService>();

View File

@@ -0,0 +1,60 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Zentral.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class UpdateCommunicationsModule : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "EmailLogs",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
SentDate = table.Column<DateTime>(type: "TEXT", nullable: false),
Sender = table.Column<string>(type: "TEXT", nullable: false),
Recipient = table.Column<string>(type: "TEXT", nullable: false),
Subject = table.Column<string>(type: "TEXT", nullable: false),
Status = table.Column<string>(type: "TEXT", nullable: false),
ErrorMessage = table.Column<string>(type: "TEXT", nullable: true),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
CreatedBy = table.Column<string>(type: "TEXT", nullable: true),
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
UpdatedBy = table.Column<string>(type: "TEXT", nullable: true),
CustomFieldsJson = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_EmailLogs", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_EmailLogs_Recipient",
table: "EmailLogs",
column: "Recipient");
migrationBuilder.CreateIndex(
name: "IX_EmailLogs_SentDate",
table: "EmailLogs",
column: "SentDate");
migrationBuilder.CreateIndex(
name: "IX_EmailLogs_Status",
table: "EmailLogs",
column: "Status");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "EmailLogs");
}
}
}

View File

@@ -418,6 +418,60 @@ namespace Zentral.Infrastructure.Migrations
b.ToTable("CodiciCategoria");
});
modelBuilder.Entity("Zentral.Domain.Entities.Communications.EmailLog", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime?>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CreatedBy")
.HasColumnType("TEXT");
b.Property<string>("CustomFieldsJson")
.HasColumnType("TEXT");
b.Property<string>("ErrorMessage")
.HasColumnType("TEXT");
b.Property<string>("Recipient")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Sender")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("SentDate")
.HasColumnType("TEXT");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Subject")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("UpdatedBy")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Recipient");
b.HasIndex("SentDate");
b.HasIndex("Status");
b.ToTable("EmailLogs", (string)null);
});
modelBuilder.Entity("Zentral.Domain.Entities.Configurazione", b =>
{
b.Property<int>("Id")

View File

@@ -7,6 +7,9 @@ using Microsoft.Extensions.DependencyInjection;
using MimeKit;
using MailKit.Net.Smtp;
using MailKit.Security;
using System.Net.Http.Json;
using System.Text.Json;
using System.Net.Http;
using Zentral.Domain.Entities.Communications;
using Zentral.Domain.Interfaces;
using Zentral.Infrastructure.Data;
@@ -16,10 +19,12 @@ namespace Zentral.Infrastructure.Services;
public class SmtpEmailSender : IEmailSender
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly IHttpClientFactory _httpClientFactory;
public SmtpEmailSender(IServiceScopeFactory scopeFactory)
public SmtpEmailSender(IServiceScopeFactory scopeFactory, IHttpClientFactory httpClientFactory)
{
_scopeFactory = scopeFactory;
_httpClientFactory = httpClientFactory;
}
public async Task SendEmailAsync(string to, string subject, string body, bool isHtml = true)
@@ -34,14 +39,80 @@ public class SmtpEmailSender : IEmailSender
// 1. Get Configuration
var configs = await context.Configurazioni
.Where(c => c.Chiave.StartsWith("SMTP_"))
.Where(c => c.Chiave.StartsWith("SMTP_") || c.Chiave == "EMAIL_PROVIDER" || c.Chiave == "RESEND_API_KEY")
.ToDictionaryAsync(c => c.Chiave, c => c.Valore);
var provider = GetConfig(configs, "EMAIL_PROVIDER", "smtp");
if (provider.ToLower() == "resend")
{
await SendViaResendAsync(context, to, subject, body, attachments, isHtml, configs);
}
else
{
await SendViaSmtpAsync(context, to, subject, body, attachments, isHtml, configs);
}
}
private async Task SendViaResendAsync(ZentralDbContext context, string to, string subject, string body, List<string> attachments, bool isHtml, Dictionary<string, string?> configs)
{
var apiKey = GetConfig(configs, "RESEND_API_KEY");
var fromEmail = GetConfig(configs, "SMTP_FROM_EMAIL"); // Resend often requires a verified domain, but we reuse the field
var fromName = GetConfig(configs, "SMTP_FROM_NAME", "Zentral");
if (string.IsNullOrEmpty(apiKey))
{
await LogResultAsync(context, fromEmail, to, subject, "Failed", "Resend API Key not configured");
return;
}
try
{
var client = _httpClientFactory.CreateClient();
client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", apiKey);
var request = new
{
from = $"{fromName} <{fromEmail}>",
to = new[] { to },
subject = subject,
html = isHtml ? body : null,
text = !isHtml ? body : null,
attachments = attachments.Select(a => {
var bytes = System.IO.File.ReadAllBytes(a);
return new
{
filename = System.IO.Path.GetFileName(a),
content = Convert.ToBase64String(bytes)
};
}).ToArray()
};
var response = await client.PostAsJsonAsync("https://api.resend.com/emails", request);
if (response.IsSuccessStatusCode)
{
await LogResultAsync(context, fromEmail, to, subject, "Success", "Via Resend");
}
else
{
var errorContent = await response.Content.ReadAsStringAsync();
await LogResultAsync(context, fromEmail, to, subject, "Failed", $"Resend Error: {errorContent}");
}
}
catch (Exception ex)
{
await LogResultAsync(context, fromEmail, to, subject, "Failed", ex.Message);
}
}
private async Task SendViaSmtpAsync(ZentralDbContext context, string to, string subject, string body, List<string> attachments, bool isHtml, Dictionary<string, string?> configs)
{
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 sslStr = GetConfig(configs, "SMTP_SSL", "false");
var fromEmail = GetConfig(configs, "SMTP_FROM_EMAIL", user);
var fromName = GetConfig(configs, "SMTP_FROM_NAME", "Zentral");
@@ -80,8 +151,6 @@ public class SmtpEmailSender : IEmailSender
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)
@@ -102,9 +171,6 @@ public class SmtpEmailSender : IEmailSender
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.
}
}

View File

@@ -11,6 +11,7 @@
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.0" />
<PackageReference Include="MailKit" Version="4.3.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.1" />
</ItemGroup>
<PropertyGroup>

View File

@@ -62,6 +62,7 @@
"cycles": "Cycles",
"mrp": "MRP",
"administration": "Administration",
"emailConfig": "Email Configuration",
"movements": "Movements",
"stock": "Stock",
"inventory": "Inventory"

View File

@@ -58,6 +58,7 @@
"cycles": "Cicli",
"mrp": "MRP",
"administration": "Amministrazione",
"emailConfig": "Configurazione Email",
"movements": "Movimenti",
"stock": "Giacenze",
"inventory": "Inventario"

View File

@@ -25,6 +25,7 @@ import { useRealTimeUpdates } from "./hooks/useRealTimeUpdates";
import { CollaborationProvider } from "./contexts/CollaborationContext";
import { AppProvider } from "./contexts/AppContext";
import { TabProvider } from "./contexts/TabContext";
import EmailConfigPage from "./apps/communications/pages/SettingsPage";
const queryClient = new QueryClient({
defaultOptions: {
@@ -82,6 +83,10 @@ function App() {
path="admin/custom-fields"
element={<CustomFieldsAdminPage />}
/>
<Route
path="admin/email-config"
element={<EmailConfigPage />}
/>
{/* Warehouse Module */}
<Route
path="warehouse/*"

View File

@@ -2,14 +2,16 @@ 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
Switch, FormControlLabel, Divider, Alert, Snackbar,
FormControl, InputLabel, Select, MenuItem
} 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 { control, handleSubmit, reset, watch } = useForm<SmtpConfig>();
const provider = watch('provider') || 'smtp';
const [loading, setLoading] = useState(false);
const [testMode, setTestMode] = useState(false);
const [testData, setTestData] = useState<TestEmail>({ to: '', subject: 'Test Email', body: 'Test content' });
@@ -64,59 +66,94 @@ export default function SettingsPage() {
return (
<Box p={3}>
<Typography variant="h4" gutterBottom display="flex" alignItems="center" gap={2}>
<Email fontSize="large" color="primary" /> Configurazione SMTP
<Email fontSize="large" color="primary" /> Configurazione Email
</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 />}
/>
<FormControl fullWidth>
<InputLabel>Provider</InputLabel>
<Controller
name="provider"
control={control}
defaultValue="smtp"
render={({ field }) => (
<Select {...field} label="Provider">
<MenuItem value="smtp">SMTP</MenuItem>
<MenuItem value="resend">Resend</MenuItem>
</Select>
)}
/>
</FormControl>
</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"
{provider === 'smtp' && (
<>
<Grid item xs={12} md={8}>
<Controller
name="host"
control={control}
defaultValue=""
render={({ field }) => <TextField {...field} label="SMTP Host" fullWidth required />}
/>
)}
/>
</Grid>
</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>
</>
)}
{provider === 'resend' && (
<Grid item xs={12}>
<Controller
name="resendApiKey"
control={control}
defaultValue=""
render={({ field }) => <TextField {...field} label="Resend API Key" type="password" fullWidth required />}
/>
<Typography variant="caption" color="textSecondary" sx={{ mt: 1, display: 'block' }}>
Ottieni la tua API Key su <a href="https://resend.com/api-keys" target="_blank" rel="noopener noreferrer">resend.com</a>
</Typography>
</Grid>
)}
<Grid item xs={12}>
<Divider sx={{ my: 2 }} />

View File

@@ -6,6 +6,8 @@ export interface SmtpConfig {
enableSsl: boolean;
fromEmail: string;
fromName: string;
provider?: 'smtp' | 'resend';
resendApiKey?: string;
}
export interface TestEmail {

View File

@@ -40,6 +40,7 @@ import {
Receipt as ReceiptIcon,
ChevronLeft,
ChevronRight,
Email as EmailIcon,
} from '@mui/icons-material';
import { useLocation } from 'react-router-dom';
import { useLanguage } from '../contexts/LanguageContext';
@@ -193,6 +194,7 @@ export default function Sidebar({ onClose, isCollapsed = false, onToggleCollapse
{ id: 'autocodes', label: t('menu.autoCodes'), icon: <AutoCodeIcon />, path: '/admin/auto-codes', translationKey: 'menu.autoCodes' },
{ id: 'customfields', label: t('menu.customFields'), icon: <AutoCodeIcon />, path: '/admin/custom-fields', translationKey: 'menu.customFields' },
{ id: 'reports', label: t('menu.reports'), icon: <PrintIcon />, path: '/report-designer', appCode: 'report-designer', translationKey: 'menu.reports' },
{ id: 'email-config', label: t('menu.emailConfig'), icon: <EmailIcon />, path: '/admin/email-config', translationKey: 'menu.emailConfig' },
],
},
];