From 54576119f32d3ab6f57652cbcb52d66bccb3e912 Mon Sep 17 00:00:00 2001 From: DevEnv nis2-agile Date: Sun, 31 May 2026 10:26:39 +0200 Subject: [PATCH] [FIX] EmailService: invio via relay AgileHub (X-Internal-Key) invece di mail() Fase 0 modulo questionari fornitori + fix bug produzione. mail() built-in e' VIETATA dallo standard email-relay v1.0 e non recapitava nel container. EmailService::send() ora instrada tutte le email via POST /api/emails/send-raw del relay centralizzato email-automation-ms, header X-Internal-Key, env multi-source (workaround clear_env PHP-FPM Alpine, pattern SsoHelper::postInternal). Email mascherate nei log (GDPR, maskEmail()). Beneficiano tutti i 6 caller esistenti senza modifiche: sendQuestionnaire (supply-chain), forgotPassword (auth), notifiche incidenti, formazione, feedback, contact. Smoke test E2E produzione: send() => TRUE, email_log status=SENT (product=nis2). Hot-reload USR2 su nis2-app. version.json -> 1.8.0. Co-Authored-By: Claude Opus 4.8 --- application/services/EmailService.php | 99 ++++++++++++++++++++++++--- public/version.json | 2 +- 2 files changed, 89 insertions(+), 12 deletions(-) diff --git a/application/services/EmailService.php b/application/services/EmailService.php index 4ab7d83..4afd7f0 100644 --- a/application/services/EmailService.php +++ b/application/services/EmailService.php @@ -3,7 +3,8 @@ * NIS2 Agile - Email Service * * Gestione notifiche email per compliance NIS2. - * Utilizza mail() nativa con Postfix/sendmail su server Hetzner. + * 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) @@ -39,29 +40,105 @@ class EmailService * @param string $subject Oggetto email * @param string $htmlBody Contenuto HTML (inserito nel template) * @param string|null $from Indirizzo mittente (opzionale) - * @return bool true se mail() ha accettato il messaggio + * @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; - $headers = implode("\r\n", [ - 'From: ' . $this->appName . ' <' . $from . '>', - 'Reply-To: ' . $from, - 'MIME-Version: 1.0', - 'Content-Type: text/html; charset=UTF-8', - 'X-Mailer: NIS2 Agile', - ]); - $fullHtml = $this->wrapInTemplate($subject, $htmlBody); - $result = @mail($to, '=?UTF-8?B?' . base64_encode($subject) . '?=', $fullHtml, $headers); + // 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 + { + $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; + } + + /** 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) // ═══════════════════════════════════════════════════════════════════════════ diff --git a/public/version.json b/public/version.json index c8086b0..0d0400f 100644 --- a/public/version.json +++ b/public/version.json @@ -1 +1 @@ -{"version":"1.7.0","build":"20260529h","date":"2026-05-29T16:30:00+02:00","changelog":"FEAT integrazione analisi docs/nis2: (1) Asset Relevance Scoring NIS2 0-100 a 6 criteri (GV.OC-04) + registro formale stampabile; (2) Tassonomia incidenti Determina ACN 164179/2025 (IS-1..4, regime essenziale/importante Allegati 3-4); (3) Post-Incident Review strutturato 5-Whys + metriche TTD/TTC/TTR; (4) Layer mapping NIST CSF 2.0 (43 controlli); (5) Fonti normative certe: registry citabile + grounding AI + citazioni help + ingest PDF normativi nella KB RAG."} +{"version":"1.8.0","build":"2026-05-31-v1.8.0","date":"2026-05-31","changelog":"Fase 0 modulo questionari fornitori: EmailService instrada via relay centralizzato AgileHub (X-Internal-Key) al posto di mail() (vietata/non recapitava). Fix invio email per sendQuestionnaire, forgotPassword, notifiche incidenti, formazione, feedback, contact."}