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 = << EARLY WARNING - Preallarme 24 ore
Ai sensi dell'Art. 23, par. 4, lett. a) della Direttiva NIS2

Si comunica che l'organizzazione {$this->esc($organization['name'])} ha rilevato un incidente significativo che richiede la notifica obbligatoria al CSIRT nazionale (ACN).

Codice Incidente {$this->esc($incident['incident_code'] ?? '-')}
Titolo {$this->esc($incident['title'] ?? '-')}
Classificazione {$this->esc($incident['classification'] ?? '-')}
Gravità {$this->esc($severityLabel)}
Rilevato il {$this->esc($detectedAt)}
Scadenza Early Warning {$this->esc($deadline)}
Azione richiesta: 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.

Gestisci Incidente

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 = << NOTIFICA INCIDENTE - Termine 72 ore
Ai sensi dell'Art. 23, par. 4, lett. b) della Direttiva NIS2

Si comunica la necessità di inviare la notifica formale dell'incidente significativo al CSIRT nazionale (ACN) per l'organizzazione {$this->esc($organization['name'])}.

Codice Incidente {$this->esc($incident['incident_code'] ?? '-')}
Titolo {$this->esc($incident['title'] ?? '-')}
Gravità {$this->esc($severityLabel)}
Rilevato il {$this->esc($detectedAt)}
Servizi Interessati {$this->esc($incident['affected_services'] ?? 'Non specificati')}
Scadenza Notifica {$this->esc($deadline)}
Azione richiesta: Aggiornare la notifica al CSIRT nazionale (ACN) entro 72 ore dalla rilevazione, fornendo una valutazione iniziale dell'incidente, compresa la sua gravità e il suo impatto, nonché gli indicatori di compromissione, ove disponibili.

Gestisci Incidente

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 = << REPORT FINALE - Termine 30 giorni
Ai sensi dell'Art. 23, par. 4, lett. d) della Direttiva NIS2

È necessario predisporre e trasmettere il report finale dell'incidente al CSIRT nazionale (ACN) per l'organizzazione {$this->esc($organization['name'])}.

Codice Incidente {$this->esc($incident['incident_code'] ?? '-')}
Titolo {$this->esc($incident['title'] ?? '-')}
Rilevato il {$this->esc($detectedAt)}
Causa Radice {$this->esc($incident['root_cause'] ?? 'Da determinare')}
Azioni di Rimedio {$this->esc($incident['remediation_actions'] ?? 'Da documentare')}
Scadenza Report Finale {$this->esc($deadline)}
Azione richiesta: 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.

Prepara Report Finale

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 = << Nuovo corso di formazione assegnato
Formazione cybersecurity ai sensi dell'Art. 20 della Direttiva NIS2

Gentile {$this->esc($user['full_name'])},

Le è stato assegnato un nuovo corso di formazione sulla sicurezza informatica nell'ambito del programma di conformità NIS2 della Sua organizzazione.

Corso {$this->esc($course['title'] ?? '-')}
Descrizione {$this->esc($course['description'] ?? '-')}
Durata Stimata {$duration} minuti
Tipo {$this->esc($mandatoryText)}
Scadenza {$this->esc($dueDate)}

Inizia il Corso

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 è stata superata', $daysLeft === 0 => 'La scadenza è oggi', $daysLeft === 1 => 'Manca 1 giorno alla scadenza', default => "Mancano {$daysLeft} giorni alla scadenza", }; $html = << Promemoria Formazione
{$daysText}

Gentile {$this->esc($user['full_name'])},

Le ricordiamo che il corso di formazione {$this->esc($course['title'])} deve essere completato entro il {$this->esc($dueDate)}.

Il completamento della formazione sulla sicurezza informatica è un requisito previsto dall'Art. 20 della Direttiva NIS2 e dal programma di compliance della Sua organizzazione.

Completa il Corso

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 = << Avviso Scadenza - {$this->esc($typeLabel)}
{$daysText}

Si segnala la seguente scadenza relativa al programma di conformità NIS2:

Tipo {$this->esc($typeLabel)}
Descrizione {$this->esc($title)}
Scadenza {$this->esc($formattedDate)}

Vai alla Dashboard

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 = <<
🔒

Reimposta la tua password

Gentile {$fullName},

Abbiamo ricevuto una richiesta di reimpostazione password per il tuo account su {$this->esc($this->appName)}. Clicca sul pulsante qui sotto per impostare una nuova password.

Reimposta password

Il link è valido per {$ttlMin} minuti e può essere usato una sola volta. Se non hai richiesto tu il reset, ignora questa email: la tua password attuale resta valida.

Se il pulsante non funziona, copia questo link nel browser:
{$this->esc($resetUrl)}

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 = <<
🔒

Benvenuto in {$this->esc($this->appName)}!

Gentile {$this->esc($user['full_name'])},

La Sua registrazione su {$this->esc($this->appName)} è stata completata con successo.

{$this->esc($this->appName)} è la piattaforma per la gestione della conformità alla Direttiva NIS2 (EU 2022/2555) e al D.Lgs. 138/2024. Con questa piattaforma potrà:

Accedi alla Piattaforma

Se non ha effettuato questa registrazione, può ignorare questa email.

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 = << Invito a collaborare
{$this->esc($organization['name'])}

Gentile {$this->esc($user['full_name'])},

È stata invitata/o a unirsi all'organizzazione {$this->esc($organization['name'])} sulla piattaforma {$this->esc($this->appName)} per la gestione della conformità NIS2.

Organizzazione {$this->esc($organization['name'])}
Ruolo Assegnato {$this->esc($roleLabel)}

Accetta Invito

Se non riconosce questa richiesta, può ignorare questa email.

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 = << ✓ Segnalazione Risolta
Il problema segnalato è stato risolto

Una segnalazione nella tua organizzazione è stata risolta:

Segnalazione {$this->esc($reportTitle)}
Risoluzione {$this->esc($resolution)}

Vai alla Dashboard

HTML; $subject = "✓ 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 << {$this->esc($subject)}

{$this->esc($this->appName)}

Piattaforma di Conformità NIS2

{$bodyHtml}

Questa è una notifica automatica inviata da {$this->esc($this->appName)}.
Piattaforma di gestione della conformità alla Direttiva NIS2 (EU 2022/2555).

{$this->esc($this->appUrl)}


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à al Regolamento (UE) 2016/679 (GDPR).

© {$year} {$this->esc($this->appName)} - Tutti i diritti riservati

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)), }; } }