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
{
$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)
// ═══════════════════════════════════════════════════════════════════════════
/**
* 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)} |
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)} |
È 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)} |
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)} |
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.
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)}Si segnala la seguente scadenza relativa al programma di conformità NIS2:
| Tipo | {$this->esc($typeLabel)} |
| Descrizione | {$this->esc($title)} |
| Scadenza | {$this->esc($formattedDate)} |
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.
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)}
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à:
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 collaborareGentile {$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)} |
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 RisoltaUna segnalazione nella tua organizzazione è stata risolta:
| Segnalazione | {$this->esc($reportTitle)} |
| Risoluzione | {$this->esc($resolution)} |
|