[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 <noreply@anthropic.com>
This commit is contained in:
DevEnv nis2-agile 2026-05-31 10:26:39 +02:00
parent aa2db4c6c2
commit 54576119f3
2 changed files with 89 additions and 12 deletions

View File

@ -3,7 +3,8 @@
* NIS2 Agile - Email Service * NIS2 Agile - Email Service
* *
* Gestione notifiche email per compliance NIS2. * 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: * Notifiche supportate:
* - Early warning incidenti (24h, Art. 23 NIS2) * - Early warning incidenti (24h, Art. 23 NIS2)
@ -39,29 +40,105 @@ class EmailService
* @param string $subject Oggetto email * @param string $subject Oggetto email
* @param string $htmlBody Contenuto HTML (inserito nel template) * @param string $htmlBody Contenuto HTML (inserito nel template)
* @param string|null $from Indirizzo mittente (opzionale) * @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 public function send(string $to, string $subject, string $htmlBody, ?string $from = null): bool
{ {
$from = $from ?? $this->defaultFrom; $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); $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); $this->logEmail($to, $subject, $result);
return $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) // NOTIFICHE INCIDENTI (Art. 23 NIS2)
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════

View File

@ -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."}