nis2-agile/application/services/EmailService.php
DevEnv nis2-agile fe77ee8679 [SEC] EmailService: kill-switch invio email (EMAIL_SENDING_ENABLED)
Ambiente con soli dati demo: NESSUNA email deve partire. Aggiunto guard
all'inizio di sendViaRelay() e sendViaTemplate() (gli UNICI due punti che fanno
HTTP al relay -> copre tutti i canali: incidenti, training, inviti, reminder,
welcome, password reset, feedback, OTP portale fornitori).

EMAIL_SENDING_ENABLED in config: default false in sviluppo, true in produzione,
override via .env. Quando false l'invio viene loggato e scartato (return false),
nessuna chiamata di rete.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 18:46:55 +02:00

963 lines
49 KiB
PHP

<?php
/**
* NIS2 Agile - Email Service
*
* Gestione notifiche email per compliance NIS2.
* Invio via relay centralizzato AgileHub (email-automation-ms) con X-Internal-Key.
* Standard: docs/STANDARD_EMAIL_RELAY.md (mail() built-in VIETATA).
*
* Notifiche supportate:
* - Early warning incidenti (24h, Art. 23 NIS2)
* - Notifica incidenti (72h)
* - Report finale incidenti (30 giorni)
* - Assegnazione e promemoria formazione
* - Avvisi scadenze generiche
* - Benvenuto registrazione
* - Invito organizzazione
*/
class EmailService
{
private string $defaultFrom;
private string $appName;
private string $appUrl;
public function __construct()
{
$this->appName = APP_NAME;
$this->appUrl = APP_URL;
$this->defaultFrom = 'noreply@' . parse_url($this->appUrl, PHP_URL_HOST);
}
// ═══════════════════════════════════════════════════════════════════════════
// INVIO EMAIL
// ═══════════════════════════════════════════════════════════════════════════
/**
* Invia una email HTML con template professionale
*
* @param string $to Indirizzo destinatario
* @param string $subject Oggetto email
* @param string $htmlBody Contenuto HTML (inserito nel template)
* @param string|null $from Indirizzo mittente (opzionale)
* @return bool true se il relay ha accettato il messaggio
*/
public function send(string $to, string $subject, string $htmlBody, ?string $from = null): bool
{
$from = $from ?? $this->defaultFrom;
$fullHtml = $this->wrapInTemplate($subject, $htmlBody);
// Standard email-relay v1.0 (AgileHub): mail() built-in e VIETATO e non
// recapita nel container. L'unico canale valido e il relay centralizzato
// (email-automation-ms) via X-Internal-Key. Vedi docs/STANDARD_EMAIL_RELAY.md.
$result = $this->sendViaRelay($to, $subject, $fullHtml, $from);
$this->logEmail($to, $subject, $result);
return $result;
}
/**
* Invia via relay centralizzato AgileHub (POST /api/emails/send-raw).
* cURL nativo, header X-Internal-Key. Pattern adattato da SsoHelper::postInternal.
*
* @return bool true se il relay ha accettato il messaggio (HTTP 2xx + success)
*/
private function sendViaRelay(string $to, string $subject, string $fullHtml, string $from): bool
{
// KILL-SWITCH: in ambiente con soli dati demo nessuna email deve partire.
if (defined('EMAIL_SENDING_ENABLED') && !EMAIL_SENDING_ENABLED) {
error_log('[EmailService] invio DISABILITATO (EMAIL_SENDING_ENABLED=false): scartato "' . $subject . '" per ' . self::maskEmail($to));
return false;
}
$base = rtrim(self::env('EMAIL_MS_URL', 'https://agilehub.agile.software/api/emails'), '/');
$key = self::env('INTERNAL_EMAIL_KEY', '');
if ($key === '') {
error_log('[EmailService] INTERNAL_EMAIL_KEY non configurata: invio email saltato per ' . self::maskEmail($to));
return false;
}
$payload = json_encode([
'to' => $to,
'subject' => $subject,
'html' => $fullHtml,
'product' => 'nis2',
'reply_to' => $from,
'priority' => 'transactional',
]);
$ch = curl_init($base . '/send-raw');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-Internal-Key: ' . $key,
],
]);
$body = curl_exec($ch);
$errno = curl_errno($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($errno !== 0) {
error_log('[EmailService] relay unreachable (' . curl_strerror($errno) . ') per ' . self::maskEmail($to));
return false;
}
if ($httpCode < 200 || $httpCode >= 300) {
error_log('[EmailService] relay HTTP ' . $httpCode . ' per ' . self::maskEmail($to) . ': ' . substr((string) $body, 0, 200));
return false;
}
$decoded = json_decode((string) $body, true);
if (is_array($decoded) && array_key_exists('success', $decoded) && !$decoded['success']) {
error_log('[EmailService] relay success=false per ' . self::maskEmail($to) . ': ' . substr((string) $body, 0, 200));
return false;
}
return true;
}
/**
* Invia via TEMPLATE del relay AgileHub (POST /api/emails/send), NON send-raw.
* A differenza di send()/sendViaRelay() NON chiama logEmail(): pensato per
* messaggi che non devono lasciare traccia del contenuto a DB (es. OTP del
* portale fornitore). Il corpo e' renderizzato lato relay da template+data.
*
* @param string $to destinatario
* @param string $template slug template registrato sul relay (es. "supplier_otp")
* @param array $vars variabili di sostituzione del template
* @param string|null $brandName nome committente per branding (opzionale)
* @return bool true se il relay ha accettato (HTTP 2xx + success)
*/
public function sendViaTemplate(string $to, string $template, array $vars, ?string $brandName = null): bool
{
// KILL-SWITCH: in ambiente con soli dati demo nessuna email deve partire.
if (defined('EMAIL_SENDING_ENABLED') && !EMAIL_SENDING_ENABLED) {
error_log('[EmailService] invio DISABILITATO (EMAIL_SENDING_ENABLED=false): template "' . $template . '" scartato per ' . self::maskEmail($to));
return false;
}
$base = rtrim(self::env('EMAIL_MS_URL', 'https://agilehub.agile.software/api/emails'), '/');
$key = self::env('INTERNAL_EMAIL_KEY', '');
if ($key === '') {
error_log('[EmailService] INTERNAL_EMAIL_KEY non configurata: template "' . $template . '" saltato per ' . self::maskEmail($to));
return false;
}
$payload = json_encode([
'to' => $to,
'template' => $template,
'data' => $vars, // campo canonico standard email-relay AgileHub (Handlebars)
'vars' => $vars, // alias difensivo (alcune versioni del relay leggono "vars")
'product' => 'nis2',
'brand_name' => $brandName,
'priority' => 'transactional',
], JSON_UNESCAPED_UNICODE);
$ch = curl_init($base . '/send'); // endpoint TEMPLATE (NON /send-raw)
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-Internal-Key: ' . $key,
],
]);
$body = curl_exec($ch);
$errno = curl_errno($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($errno !== 0) {
error_log('[EmailService] relay template unreachable (' . curl_strerror($errno) . ') per ' . self::maskEmail($to));
return false;
}
if ($httpCode < 200 || $httpCode >= 300) {
error_log('[EmailService] relay template HTTP ' . $httpCode . ' per ' . self::maskEmail($to) . ': ' . substr((string) $body, 0, 200));
return false;
}
$decoded = json_decode((string) $body, true);
if (is_array($decoded) && array_key_exists('success', $decoded) && !$decoded['success']) {
error_log('[EmailService] relay template success=false per ' . self::maskEmail($to) . ': ' . substr((string) $body, 0, 200));
return false;
}
// NESSUN logEmail(): il contenuto (OTP) non deve finire in email_log.
return true;
}
/** Maschera l'email per i log (GDPR): m***@dominio.it. */
private static function maskEmail(string $email): string
{
$at = strpos($email, '@');
if ($at === false || $at === 0) return '***';
return $email[0] . '***' . substr($email, $at);
}
/**
* Multi-source env lookup (workaround PHP-FPM Alpine: getenv() puo' essere
* false nei worker FPM con clear_env=yes). Stesso pattern di SsoHelper/VectorService.
*/
private static function env(string $key, string $default): string
{
$v = getenv($key);
if ($v !== false && $v !== '') return $v;
if (!empty($_SERVER[$key])) return (string) $_SERVER[$key];
if (!empty($_ENV[$key])) return (string) $_ENV[$key];
return $default;
}
// ═══════════════════════════════════════════════════════════════════════════
// NOTIFICHE INCIDENTI (Art. 23 NIS2)
// ═══════════════════════════════════════════════════════════════════════════
/**
* Notifica early warning 24h (Art. 23 par. 4 lett. a)
*
* @param array $incident Dati incidente (title, incident_code, severity, detected_at, classification)
* @param array $organization Dati organizzazione (name, sector)
* @param array $recipients Lista di indirizzi email destinatari
*/
public function sendIncidentEarlyWarning(array $incident, array $organization, array $recipients): void
{
$severityLabel = $this->translateSeverity($incident['severity'] ?? 'medium');
$detectedAt = $this->formatDateTime($incident['detected_at'] ?? '');
$deadline = $this->formatDateTime($incident['early_warning_due'] ?? '');
$html = <<<HTML
<div style="background-color: #fef2f2; border-left: 4px solid #dc2626; padding: 16px; margin-bottom: 24px; border-radius: 4px;">
<strong style="color: #dc2626; font-size: 16px;">EARLY WARNING - Preallarme 24 ore</strong><br>
<span style="color: #991b1b;">Ai sensi dell'Art. 23, par. 4, lett. a) della Direttiva NIS2</span>
</div>
<p>Si comunica che l'organizzazione <strong>{$this->esc($organization['name'])}</strong> ha rilevato un incidente significativo che richiede la notifica obbligatoria al CSIRT nazionale (ACN).</p>
<table style="width: 100%; border-collapse: collapse; margin: 20px 0;">
<tr style="background-color: #f8fafc;">
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; font-weight: 600; width: 200px;">Codice Incidente</td>
<td style="padding: 10px 14px; border: 1px solid #e2e8f0;">{$this->esc($incident['incident_code'] ?? '-')}</td>
</tr>
<tr>
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; font-weight: 600;">Titolo</td>
<td style="padding: 10px 14px; border: 1px solid #e2e8f0;">{$this->esc($incident['title'] ?? '-')}</td>
</tr>
<tr style="background-color: #f8fafc;">
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; font-weight: 600;">Classificazione</td>
<td style="padding: 10px 14px; border: 1px solid #e2e8f0;">{$this->esc($incident['classification'] ?? '-')}</td>
</tr>
<tr>
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; font-weight: 600;">Gravit&agrave;</td>
<td style="padding: 10px 14px; border: 1px solid #e2e8f0;">{$this->esc($severityLabel)}</td>
</tr>
<tr style="background-color: #f8fafc;">
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; font-weight: 600;">Rilevato il</td>
<td style="padding: 10px 14px; border: 1px solid #e2e8f0;">{$this->esc($detectedAt)}</td>
</tr>
<tr>
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; font-weight: 600;">Scadenza Early Warning</td>
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; color: #dc2626; font-weight: 600;">{$this->esc($deadline)}</td>
</tr>
</table>
<div style="background-color: #fffbeb; border-left: 4px solid #f59e0b; padding: 14px; margin: 20px 0; border-radius: 4px;">
<strong>Azione richiesta:</strong> Inviare il preallarme al CSIRT nazionale (ACN) entro 24 ore dalla rilevazione dell'incidente, indicando se l'incidente sia sospettato di essere causato da atti illegittimi o malevoli e se possa avere un impatto transfrontaliero.
</div>
<p style="margin-top: 20px;">
<a href="{$this->esc($this->appUrl)}/dashboard/incidents" style="display: inline-block; background-color: #1e40af; color: #ffffff; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600;">Gestisci Incidente</a>
</p>
HTML;
$incidentCode = $incident['incident_code'] ?? '';
$subject = "[URGENTE] Early Warning Incidente {$incidentCode} - {$organization['name']}";
foreach ($recipients as $email) {
$this->send($email, $subject, $html);
}
}
/**
* Notifica incidente 72h (Art. 23 par. 4 lett. b)
*
* @param array $incident Dati incidente
* @param array $organization Dati organizzazione
* @param array $recipients Lista indirizzi email
*/
public function sendIncidentNotification(array $incident, array $organization, array $recipients): void
{
$severityLabel = $this->translateSeverity($incident['severity'] ?? 'medium');
$detectedAt = $this->formatDateTime($incident['detected_at'] ?? '');
$deadline = $this->formatDateTime($incident['notification_due'] ?? '');
$html = <<<HTML
<div style="background-color: #fff7ed; border-left: 4px solid #ea580c; padding: 16px; margin-bottom: 24px; border-radius: 4px;">
<strong style="color: #ea580c; font-size: 16px;">NOTIFICA INCIDENTE - Termine 72 ore</strong><br>
<span style="color: #9a3412;">Ai sensi dell'Art. 23, par. 4, lett. b) della Direttiva NIS2</span>
</div>
<p>Si comunica la necessit&agrave; di inviare la notifica formale dell'incidente significativo al CSIRT nazionale (ACN) per l'organizzazione <strong>{$this->esc($organization['name'])}</strong>.</p>
<table style="width: 100%; border-collapse: collapse; margin: 20px 0;">
<tr style="background-color: #f8fafc;">
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; font-weight: 600; width: 200px;">Codice Incidente</td>
<td style="padding: 10px 14px; border: 1px solid #e2e8f0;">{$this->esc($incident['incident_code'] ?? '-')}</td>
</tr>
<tr>
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; font-weight: 600;">Titolo</td>
<td style="padding: 10px 14px; border: 1px solid #e2e8f0;">{$this->esc($incident['title'] ?? '-')}</td>
</tr>
<tr style="background-color: #f8fafc;">
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; font-weight: 600;">Gravit&agrave;</td>
<td style="padding: 10px 14px; border: 1px solid #e2e8f0;">{$this->esc($severityLabel)}</td>
</tr>
<tr>
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; font-weight: 600;">Rilevato il</td>
<td style="padding: 10px 14px; border: 1px solid #e2e8f0;">{$this->esc($detectedAt)}</td>
</tr>
<tr style="background-color: #f8fafc;">
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; font-weight: 600;">Servizi Interessati</td>
<td style="padding: 10px 14px; border: 1px solid #e2e8f0;">{$this->esc($incident['affected_services'] ?? 'Non specificati')}</td>
</tr>
<tr>
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; font-weight: 600;">Scadenza Notifica</td>
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; color: #ea580c; font-weight: 600;">{$this->esc($deadline)}</td>
</tr>
</table>
<div style="background-color: #fffbeb; border-left: 4px solid #f59e0b; padding: 14px; margin: 20px 0; border-radius: 4px;">
<strong>Azione richiesta:</strong> Aggiornare la notifica al CSIRT nazionale (ACN) entro 72 ore dalla rilevazione, fornendo una valutazione iniziale dell'incidente, compresa la sua gravit&agrave; e il suo impatto, nonch&eacute; gli indicatori di compromissione, ove disponibili.
</div>
<p style="margin-top: 20px;">
<a href="{$this->esc($this->appUrl)}/dashboard/incidents" style="display: inline-block; background-color: #1e40af; color: #ffffff; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600;">Gestisci Incidente</a>
</p>
HTML;
$incidentCode = $incident['incident_code'] ?? '';
$subject = "[NIS2] Notifica Incidente 72h {$incidentCode} - {$organization['name']}";
foreach ($recipients as $email) {
$this->send($email, $subject, $html);
}
}
/**
* Notifica report finale 30 giorni (Art. 23 par. 4 lett. d)
*
* @param array $incident Dati incidente
* @param array $organization Dati organizzazione
* @param array $recipients Lista indirizzi email
*/
public function sendIncidentFinalReport(array $incident, array $organization, array $recipients): void
{
$detectedAt = $this->formatDateTime($incident['detected_at'] ?? '');
$deadline = $this->formatDateTime($incident['final_report_due'] ?? '');
$html = <<<HTML
<div style="background-color: #eff6ff; border-left: 4px solid #1e40af; padding: 16px; margin-bottom: 24px; border-radius: 4px;">
<strong style="color: #1e40af; font-size: 16px;">REPORT FINALE - Termine 30 giorni</strong><br>
<span style="color: #1e3a8a;">Ai sensi dell'Art. 23, par. 4, lett. d) della Direttiva NIS2</span>
</div>
<p>&Egrave; necessario predisporre e trasmettere il report finale dell'incidente al CSIRT nazionale (ACN) per l'organizzazione <strong>{$this->esc($organization['name'])}</strong>.</p>
<table style="width: 100%; border-collapse: collapse; margin: 20px 0;">
<tr style="background-color: #f8fafc;">
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; font-weight: 600; width: 200px;">Codice Incidente</td>
<td style="padding: 10px 14px; border: 1px solid #e2e8f0;">{$this->esc($incident['incident_code'] ?? '-')}</td>
</tr>
<tr>
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; font-weight: 600;">Titolo</td>
<td style="padding: 10px 14px; border: 1px solid #e2e8f0;">{$this->esc($incident['title'] ?? '-')}</td>
</tr>
<tr style="background-color: #f8fafc;">
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; font-weight: 600;">Rilevato il</td>
<td style="padding: 10px 14px; border: 1px solid #e2e8f0;">{$this->esc($detectedAt)}</td>
</tr>
<tr>
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; font-weight: 600;">Causa Radice</td>
<td style="padding: 10px 14px; border: 1px solid #e2e8f0;">{$this->esc($incident['root_cause'] ?? 'Da determinare')}</td>
</tr>
<tr style="background-color: #f8fafc;">
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; font-weight: 600;">Azioni di Rimedio</td>
<td style="padding: 10px 14px; border: 1px solid #e2e8f0;">{$this->esc($incident['remediation_actions'] ?? 'Da documentare')}</td>
</tr>
<tr>
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; font-weight: 600;">Scadenza Report Finale</td>
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; color: #1e40af; font-weight: 600;">{$this->esc($deadline)}</td>
</tr>
</table>
<div style="background-color: #fffbeb; border-left: 4px solid #f59e0b; padding: 14px; margin: 20px 0; border-radius: 4px;">
<strong>Azione richiesta:</strong> Predisporre il report finale entro 30 giorni dalla notifica dell'incidente, contenente: descrizione dettagliata dell'incidente, tipo di minaccia o causa radice, misure di attenuazione applicate e in corso, eventuale impatto transfrontaliero.
</div>
<p style="margin-top: 20px;">
<a href="{$this->esc($this->appUrl)}/dashboard/incidents" style="display: inline-block; background-color: #1e40af; color: #ffffff; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600;">Prepara Report Finale</a>
</p>
HTML;
$incidentCode = $incident['incident_code'] ?? '';
$subject = "[NIS2] Report Finale Incidente {$incidentCode} - {$organization['name']}";
foreach ($recipients as $email) {
$this->send($email, $subject, $html);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// NOTIFICHE FORMAZIONE (Art. 20 NIS2)
// ═══════════════════════════════════════════════════════════════════════════
/**
* Notifica assegnazione corso di formazione
*
* @param array $assignment Dati assegnazione (due_date)
* @param array $user Dati utente (full_name, email)
* @param array $course Dati corso (title, description, duration_minutes, is_mandatory)
*/
public function sendTrainingAssignment(array $assignment, array $user, array $course): void
{
$dueDate = $this->formatDate($assignment['due_date'] ?? '');
$duration = (int) ($course['duration_minutes'] ?? 0);
$mandatoryText = !empty($course['is_mandatory']) ? 'Obbligatorio' : 'Facoltativo';
$html = <<<HTML
<div style="background-color: #f0fdf4; border-left: 4px solid #16a34a; padding: 16px; margin-bottom: 24px; border-radius: 4px;">
<strong style="color: #16a34a; font-size: 16px;">Nuovo corso di formazione assegnato</strong><br>
<span style="color: #166534;">Formazione cybersecurity ai sensi dell'Art. 20 della Direttiva NIS2</span>
</div>
<p>Gentile <strong>{$this->esc($user['full_name'])}</strong>,</p>
<p>Le &egrave; stato assegnato un nuovo corso di formazione sulla sicurezza informatica nell'ambito del programma di conformit&agrave; NIS2 della Sua organizzazione.</p>
<table style="width: 100%; border-collapse: collapse; margin: 20px 0;">
<tr style="background-color: #f8fafc;">
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; font-weight: 600; width: 200px;">Corso</td>
<td style="padding: 10px 14px; border: 1px solid #e2e8f0;">{$this->esc($course['title'] ?? '-')}</td>
</tr>
<tr>
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; font-weight: 600;">Descrizione</td>
<td style="padding: 10px 14px; border: 1px solid #e2e8f0;">{$this->esc($course['description'] ?? '-')}</td>
</tr>
<tr style="background-color: #f8fafc;">
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; font-weight: 600;">Durata Stimata</td>
<td style="padding: 10px 14px; border: 1px solid #e2e8f0;">{$duration} minuti</td>
</tr>
<tr>
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; font-weight: 600;">Tipo</td>
<td style="padding: 10px 14px; border: 1px solid #e2e8f0;">{$this->esc($mandatoryText)}</td>
</tr>
<tr style="background-color: #f8fafc;">
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; font-weight: 600;">Scadenza</td>
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; font-weight: 600;">{$this->esc($dueDate)}</td>
</tr>
</table>
<p style="margin-top: 20px;">
<a href="{$this->esc($this->appUrl)}/dashboard/training" style="display: inline-block; background-color: #16a34a; color: #ffffff; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600;">Inizia il Corso</a>
</p>
HTML;
$subject = "Formazione assegnata: {$course['title']}";
$this->send($user['email'], $subject, $html);
}
/**
* Promemoria scadenza formazione
*
* @param array $assignment Dati assegnazione (due_date)
* @param array $user Dati utente (full_name, email)
* @param array $course Dati corso (title, is_mandatory)
*/
public function sendTrainingReminder(array $assignment, array $user, array $course): void
{
$dueDate = $this->formatDate($assignment['due_date'] ?? '');
$daysLeft = $this->daysUntil($assignment['due_date'] ?? '');
$urgencyColor = match (true) {
$daysLeft <= 1 => '#dc2626',
$daysLeft <= 3 => '#ea580c',
$daysLeft <= 7 => '#f59e0b',
default => '#1e40af',
};
$daysText = match (true) {
$daysLeft < 0 => 'La scadenza &egrave; stata superata',
$daysLeft === 0 => 'La scadenza &egrave; oggi',
$daysLeft === 1 => 'Manca 1 giorno alla scadenza',
default => "Mancano {$daysLeft} giorni alla scadenza",
};
$html = <<<HTML
<div style="background-color: #fffbeb; border-left: 4px solid {$urgencyColor}; padding: 16px; margin-bottom: 24px; border-radius: 4px;">
<strong style="color: {$urgencyColor}; font-size: 16px;">Promemoria Formazione</strong><br>
<span style="color: #92400e;">{$daysText}</span>
</div>
<p>Gentile <strong>{$this->esc($user['full_name'])}</strong>,</p>
<p>Le ricordiamo che il corso di formazione <strong>{$this->esc($course['title'])}</strong> deve essere completato entro il <strong>{$this->esc($dueDate)}</strong>.</p>
<p>Il completamento della formazione sulla sicurezza informatica &egrave; un requisito previsto dall'Art. 20 della Direttiva NIS2 e dal programma di compliance della Sua organizzazione.</p>
<p style="margin-top: 20px;">
<a href="{$this->esc($this->appUrl)}/dashboard/training" style="display: inline-block; background-color: {$urgencyColor}; color: #ffffff; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600;">Completa il Corso</a>
</p>
HTML;
$subject = "Promemoria: {$course['title']} - scadenza {$dueDate}";
$this->send($user['email'], $subject, $html);
}
// ═══════════════════════════════════════════════════════════════════════════
// AVVISI SCADENZE
// ═══════════════════════════════════════════════════════════════════════════
/**
* Avviso generico di scadenza
*
* @param string $type Tipo scadenza (assessment, policy_review, training, incident_report, audit, ecc.)
* @param string $title Titolo descrittivo della scadenza
* @param string $dueDate Data di scadenza (Y-m-d o Y-m-d H:i:s)
* @param array $recipients Lista indirizzi email
*/
public function sendDeadlineAlert(string $type, string $title, string $dueDate, array $recipients): void
{
$formattedDate = $this->formatDate($dueDate);
$daysLeft = $this->daysUntil($dueDate);
$typeLabel = $this->translateDeadlineType($type);
$urgencyColor = match (true) {
$daysLeft <= 1 => '#dc2626',
$daysLeft <= 3 => '#ea580c',
$daysLeft <= 7 => '#f59e0b',
default => '#1e40af',
};
$daysText = match (true) {
$daysLeft < 0 => 'Scadenza superata',
$daysLeft === 0 => 'Scade oggi',
$daysLeft === 1 => 'Scade domani',
default => "Scade tra {$daysLeft} giorni",
};
$html = <<<HTML
<div style="background-color: #f8fafc; border-left: 4px solid {$urgencyColor}; padding: 16px; margin-bottom: 24px; border-radius: 4px;">
<strong style="color: {$urgencyColor}; font-size: 16px;">Avviso Scadenza - {$this->esc($typeLabel)}</strong><br>
<span style="color: #64748b;">{$daysText}</span>
</div>
<p>Si segnala la seguente scadenza relativa al programma di conformit&agrave; NIS2:</p>
<table style="width: 100%; border-collapse: collapse; margin: 20px 0;">
<tr style="background-color: #f8fafc;">
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; font-weight: 600; width: 200px;">Tipo</td>
<td style="padding: 10px 14px; border: 1px solid #e2e8f0;">{$this->esc($typeLabel)}</td>
</tr>
<tr>
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; font-weight: 600;">Descrizione</td>
<td style="padding: 10px 14px; border: 1px solid #e2e8f0;">{$this->esc($title)}</td>
</tr>
<tr style="background-color: #f8fafc;">
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; font-weight: 600;">Scadenza</td>
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; color: {$urgencyColor}; font-weight: 600;">{$this->esc($formattedDate)}</td>
</tr>
</table>
<p style="margin-top: 20px;">
<a href="{$this->esc($this->appUrl)}/dashboard" style="display: inline-block; background-color: #1e40af; color: #ffffff; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600;">Vai alla Dashboard</a>
</p>
HTML;
$subject = "[Scadenza] {$typeLabel}: {$title} - {$formattedDate}";
foreach ($recipients as $email) {
$this->send($email, $subject, $html);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// BENVENUTO E INVITI
// ═══════════════════════════════════════════════════════════════════════════
/**
* Email reset password — link con token TTL 30 min (Fase 3 / G08).
*
* @param array $user Dati utente (id, email, full_name)
* @param string $token Token in chiaro (versione DB è SHA-256)
* @param int $ttlSeconds Durata validità link
*/
public function sendPasswordReset(array $user, string $token, int $ttlSeconds): bool
{
$resetUrl = $this->appUrl . '/reset-password.html?token=' . urlencode($token);
$ttlMin = (int) round($ttlSeconds / 60);
$fullName = $this->esc($user['full_name'] ?? $user['email']);
$html = <<<HTML
<div style="text-align: center; margin-bottom: 30px;">
<div style="font-size: 48px; margin-bottom: 10px;">&#128274;</div>
<h2 style="color: #1e40af; margin: 0;">Reimposta la tua password</h2>
</div>
<p>Gentile <strong>{$fullName}</strong>,</p>
<p>Abbiamo ricevuto una richiesta di reimpostazione password per il tuo account su <strong>{$this->esc($this->appName)}</strong>. Clicca sul pulsante qui sotto per impostare una nuova password.</p>
<p style="text-align:center; margin: 32px 0;">
<a href="{$this->esc($resetUrl)}" style="display: inline-block; background-color: #1e40af; color: #ffffff; padding: 14px 28px; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 16px;">Reimposta password</a>
</p>
<p style="font-size: 13px; color: #6b7280;">Il link è valido per <strong>{$ttlMin} minuti</strong> e può essere usato una sola volta. Se non hai richiesto tu il reset, ignora questa email: la tua password attuale resta valida.</p>
<p style="font-size: 13px; color: #6b7280; word-break: break-all;">Se il pulsante non funziona, copia questo link nel browser:<br>{$this->esc($resetUrl)}</p>
HTML;
return $this->send($user['email'], 'Reimposta la tua password — ' . $this->appName, $html);
}
/**
* Email di benvenuto dopo la registrazione
*
* @param array $user Dati utente (full_name, email)
*/
public function sendWelcome(array $user): void
{
$html = <<<HTML
<div style="text-align: center; margin-bottom: 30px;">
<div style="font-size: 48px; margin-bottom: 10px;">&#128274;</div>
<h2 style="color: #1e40af; margin: 0;">Benvenuto in {$this->esc($this->appName)}!</h2>
</div>
<p>Gentile <strong>{$this->esc($user['full_name'])}</strong>,</p>
<p>La Sua registrazione su <strong>{$this->esc($this->appName)}</strong> &egrave; stata completata con successo.</p>
<p>{$this->esc($this->appName)} &egrave; la piattaforma per la gestione della conformit&agrave; alla Direttiva NIS2 (EU 2022/2555) e al D.Lgs. 138/2024. Con questa piattaforma potr&agrave;:</p>
<ul style="line-height: 1.8; color: #374151;">
<li>Effettuare la <strong>Gap Analysis</strong> rispetto ai requisiti NIS2</li>
<li>Gestire il <strong>Risk Assessment</strong> con matrice di rischio</li>
<li>Gestire gli <strong>incidenti di sicurezza</strong> con i flussi di notifica Art. 23</li>
<li>Generare <strong>policy di sicurezza</strong> personalizzate</li>
<li>Monitorare la <strong>supply chain</strong> e i fornitori critici</li>
<li>Gestire la <strong>formazione</strong> del personale (Art. 20)</li>
<li>Preparare <strong>audit e documentazione</strong> per le autorit&agrave;</li>
</ul>
<p style="margin-top: 24px;">
<a href="{$this->esc($this->appUrl)}/dashboard" style="display: inline-block; background-color: #1e40af; color: #ffffff; padding: 14px 28px; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 16px;">Accedi alla Piattaforma</a>
</p>
<p style="margin-top: 24px; color: #6b7280; font-size: 14px;">Se non ha effettuato questa registrazione, pu&ograve; ignorare questa email.</p>
HTML;
$subject = "Benvenuto in {$this->appName}";
$this->send($user['email'], $subject, $html);
}
/**
* Invito a unirsi a un'organizzazione
*
* @param array $user Dati utente invitato (full_name, email)
* @param array $organization Dati organizzazione (name)
* @param string $role Ruolo assegnato
*/
public function sendMemberInvite(array $user, array $organization, string $role): void
{
$roleLabel = $this->translateRole($role);
$html = <<<HTML
<div style="background-color: #eff6ff; border-left: 4px solid #1e40af; padding: 16px; margin-bottom: 24px; border-radius: 4px;">
<strong style="color: #1e40af; font-size: 16px;">Invito a collaborare</strong><br>
<span style="color: #1e3a8a;">{$this->esc($organization['name'])}</span>
</div>
<p>Gentile <strong>{$this->esc($user['full_name'])}</strong>,</p>
<p>&Egrave; stata invitata/o a unirsi all'organizzazione <strong>{$this->esc($organization['name'])}</strong> sulla piattaforma <strong>{$this->esc($this->appName)}</strong> per la gestione della conformit&agrave; NIS2.</p>
<table style="width: 100%; border-collapse: collapse; margin: 20px 0;">
<tr style="background-color: #f8fafc;">
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; font-weight: 600; width: 200px;">Organizzazione</td>
<td style="padding: 10px 14px; border: 1px solid #e2e8f0;">{$this->esc($organization['name'])}</td>
</tr>
<tr>
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; font-weight: 600;">Ruolo Assegnato</td>
<td style="padding: 10px 14px; border: 1px solid #e2e8f0;">{$this->esc($roleLabel)}</td>
</tr>
</table>
<p style="margin-top: 20px;">
<a href="{$this->esc($this->appUrl)}/dashboard" style="display: inline-block; background-color: #1e40af; color: #ffffff; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600;">Accetta Invito</a>
</p>
<p style="margin-top: 20px; color: #6b7280; font-size: 14px;">Se non riconosce questa richiesta, pu&ograve; ignorare questa email.</p>
HTML;
$subject = "Invito: unisciti a {$organization['name']} su {$this->appName}";
$this->send($user['email'], $subject, $html);
}
// ═══════════════════════════════════════════════════════════════════════════
// FEEDBACK & SEGNALAZIONI
// ═══════════════════════════════════════════════════════════════════════════
/**
* Broadcast risoluzione segnalazione a un membro dell'org
*
* @param string $to Email destinatario
* @param string $reportTitle Titolo breve della segnalazione
* @param string $resolution Testo risoluzione (nota admin o risposta AI)
*/
public function sendFeedbackResolved(string $to, string $reportTitle, string $resolution): bool
{
$html = <<<HTML
<div style="background-color: #f0fdf4; border-left: 4px solid #16a34a; padding: 16px; margin-bottom: 24px; border-radius: 4px;">
<strong style="color: #16a34a; font-size: 16px;">&#10003; Segnalazione Risolta</strong><br>
<span style="color: #166534;">Il problema segnalato &egrave; stato risolto</span>
</div>
<p>Una segnalazione nella tua organizzazione &egrave; stata risolta:</p>
<table style="width: 100%; border-collapse: collapse; margin: 20px 0;">
<tr style="background-color: #f8fafc;">
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; font-weight: 600; width: 160px;">Segnalazione</td>
<td style="padding: 10px 14px; border: 1px solid #e2e8f0;">{$this->esc($reportTitle)}</td>
</tr>
<tr>
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; font-weight: 600;">Risoluzione</td>
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; line-height: 1.6;">{$this->esc($resolution)}</td>
</tr>
</table>
<p style="margin-top: 20px;">
<a href="{$this->esc($this->appUrl)}/dashboard" style="display: inline-block; background-color: #ef4444; color: #ffffff; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600;">Vai alla Dashboard</a>
</p>
HTML;
$subject = "&#10003; Risolto: {$reportTitle}";
return $this->send($to, $subject, $html);
}
// ═══════════════════════════════════════════════════════════════════════════
// TEMPLATE HTML
// ═══════════════════════════════════════════════════════════════════════════
/**
* Avvolge il contenuto HTML nel template email professionale
*/
private function wrapInTemplate(string $subject, string $bodyHtml): string
{
$year = date('Y');
return <<<HTML
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{$this->esc($subject)}</title>
</head>
<body style="margin: 0; padding: 0; background-color: #f1f5f9; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; -webkit-font-smoothing: antialiased;">
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="background-color: #f1f5f9;">
<tr>
<td align="center" style="padding: 30px 15px;">
<table role="presentation" cellpadding="0" cellspacing="0" width="600" style="max-width: 600px; width: 100%;">
<!-- Header -->
<tr>
<td style="background-color: #1e40af; padding: 28px 32px; border-radius: 8px 8px 0 0; text-align: center;">
<h1 style="margin: 0; color: #ffffff; font-size: 22px; font-weight: 700; letter-spacing: 0.5px;">{$this->esc($this->appName)}</h1>
<p style="margin: 6px 0 0; color: #bfdbfe; font-size: 13px; font-weight: 400;">Piattaforma di Conformit&agrave; NIS2</p>
</td>
</tr>
<!-- Content -->
<tr>
<td style="background-color: #ffffff; padding: 32px; border-left: 1px solid #e2e8f0; border-right: 1px solid #e2e8f0; color: #1e293b; font-size: 15px; line-height: 1.6;">
{$bodyHtml}
</td>
</tr>
<!-- Footer -->
<tr>
<td style="background-color: #f8fafc; padding: 24px 32px; border-radius: 0 0 8px 8px; border: 1px solid #e2e8f0; border-top: none;">
<p style="margin: 0 0 12px; color: #64748b; font-size: 13px; line-height: 1.5; text-align: center;">
Questa &egrave; una notifica automatica inviata da <strong>{$this->esc($this->appName)}</strong>.<br>
Piattaforma di gestione della conformit&agrave; alla Direttiva NIS2 (EU 2022/2555).
</p>
<p style="margin: 0 0 12px; text-align: center;">
<a href="{$this->esc($this->appUrl)}" style="color: #1e40af; text-decoration: none; font-size: 13px;">{$this->esc($this->appUrl)}</a>
</p>
<hr style="border: none; border-top: 1px solid #e2e8f0; margin: 16px 0;">
<p style="margin: 0; color: #94a3b8; font-size: 11px; line-height: 1.5; text-align: center;">
Questa email e i suoi allegati sono riservati e destinati esclusivamente ai destinatari indicati.
Se ha ricevuto questa comunicazione per errore, La preghiamo di contattare il mittente e cancellare il messaggio.
La informiamo che il trattamento dei dati personali avviene in conformit&agrave; al Regolamento (UE) 2016/679 (GDPR).
</p>
<p style="margin: 12px 0 0; color: #94a3b8; font-size: 11px; text-align: center;">
&copy; {$year} {$this->esc($this->appName)} - Tutti i diritti riservati
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
HTML;
}
// ═══════════════════════════════════════════════════════════════════════════
// LOGGING
// ═══════════════════════════════════════════════════════════════════════════
/**
* Registra l'invio email nel database per audit
*/
private function logEmail(string $to, string $subject, bool $success): void
{
try {
Database::insert('email_log', [
'recipient' => $to,
'subject' => mb_substr($subject, 0, 255),
'status' => $success ? 'sent' : 'failed',
]);
} catch (\Throwable $e) {
error_log('[EmailService] Errore log email: ' . $e->getMessage());
}
}
// ═══════════════════════════════════════════════════════════════════════════
// UTILITA'
// ═══════════════════════════════════════════════════════════════════════════
/**
* Escape HTML per prevenire XSS nei contenuti dinamici
*/
private function esc(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
/**
* Formatta data e ora in formato italiano
*/
private function formatDateTime(string $datetime): string
{
if (empty($datetime)) {
return '-';
}
$ts = strtotime($datetime);
if ($ts === false) {
return $datetime;
}
return date('d/m/Y H:i', $ts);
}
/**
* Formatta solo la data in formato italiano
*/
private function formatDate(string $date): string
{
if (empty($date)) {
return '-';
}
$ts = strtotime($date);
if ($ts === false) {
return $date;
}
return date('d/m/Y', $ts);
}
/**
* Calcola i giorni rimanenti fino a una data
*/
private function daysUntil(string $date): int
{
if (empty($date)) {
return 0;
}
$target = strtotime(date('Y-m-d', strtotime($date)));
$today = strtotime(date('Y-m-d'));
if ($target === false) {
return 0;
}
return (int) (($target - $today) / 86400);
}
/**
* Traduce il livello di gravita' in italiano
*/
private function translateSeverity(string $severity): string
{
return match ($severity) {
'low' => 'Bassa',
'medium' => 'Media',
'high' => 'Alta',
'critical' => 'Critica',
default => ucfirst($severity),
};
}
/**
* Traduce il tipo di scadenza in italiano
*/
private function translateDeadlineType(string $type): string
{
return match ($type) {
'assessment' => 'Gap Analysis',
'policy_review' => 'Revisione Policy',
'training' => 'Formazione',
'incident_report' => 'Report Incidente',
'audit' => 'Audit',
'risk_review' => 'Revisione Rischi',
'supply_chain' => 'Valutazione Supply Chain',
default => ucfirst(str_replace('_', ' ', $type)),
};
}
/**
* Traduce il ruolo organizzativo in italiano
*/
private function translateRole(string $role): string
{
return match ($role) {
'org_admin' => 'Amministratore Organizzazione',
'compliance_manager' => 'Compliance Manager',
'risk_manager' => 'Risk Manager',
'it_manager' => 'IT Manager',
'employee' => 'Collaboratore',
'auditor' => 'Auditor',
'viewer' => 'Osservatore',
default => ucfirst(str_replace('_', ' ', $role)),
};
}
}