[FEAT] Epic C / C5 — chiusura open-items: email gated + fonti + provenienza 92/87

- #7 (go-live ready): send() aggancia l'invio email del questionario/firma-lettura via
  EmailService, GATED dal kill-switch EMAIL_SENDING_ENABLED (oggi=false → ZERO invii;
  pronto al go-live). Magic-link assoluto via APP_URL; invio solo ai target con email
  valida; i link restano sempre disponibili per la condivisione manuale. Smoke prod:
  email_sent=0, email_enabled=false (nessun invio).
- #2 (prevale Simon): nis2_sources.php — aggiunta NOTA DI PROVENIENZA sul 92 vs 87
  (prodotto usa 92 = file referente; ACN ufficiale 87; divergenza nota/accettata).
  Numero NON modificato per decisione confermata.

Note operative (host, fuori repo):
- #8: EMAIL_MS_URL spostato sul path interno http://172.21.0.1:8081/api/emails
  (verificato 400 con X-Internal-Key = funzionante; pre edge-strip). Rollback: URL pubblico.
- #9: pre-equip PHP-CA del DB CONFERMATO (CA presente, app connessa in TLS 1.3); enforce
  ALTER USER nis2_user REQUIRE SSL resta azione DB coordinata con VIGILE (utente @%).

v1.21.3. Additivo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
DevEnv nis2-agile
2026-06-16 23:32:27 +02:00
co-authored by Claude Opus 4.8
parent 6992bdcf14
commit 69873e21be
4 changed files with 41 additions and 8 deletions
@@ -22,6 +22,7 @@
*/
require_once __DIR__ . '/BaseController.php';
require_once APP_PATH . '/services/EmailService.php';
class StakeholderActivityController extends BaseController
{
@@ -424,7 +425,13 @@ class StakeholderActivityController extends BaseController
? date('Y-m-d H:i:s', strtotime($a['due_date'] . ' +30 days'))
: date('Y-m-d H:i:s', strtotime('+90 days'));
$emailOn = defined('EMAIL_SENDING_ENABLED') && EMAIL_SENDING_ENABLED;
$base = defined('APP_URL') ? rtrim(APP_URL, '/') : 'https://nis2.agile.software';
$emailer = $emailOn ? new EmailService() : null; // istanziato solo se l'invio è abilitato
$isReadAck = ($a['type'] === 'read_ack');
$links = [];
$emailSent = 0;
foreach ($targets as $g) {
$token = bin2hex(random_bytes(24)); // 48 hex
Database::update('stk_activity_targets', [
@@ -433,6 +440,14 @@ class StakeholderActivityController extends BaseController
'sent_at' => date('Y-m-d H:i:s'),
'token_expires_at' => $expires,
], 'id = ?', [(int) $g['id']]);
$absLink = $base . '/stk-portal.html?t=' . $token;
// Invio email gated dal kill-switch: EmailService::send rispetta EMAIL_SENDING_ENABLED
// (con kill-switch OFF non parte nulla; pronto per il go-live). Solo se c'è un'email.
if ($emailer && !empty($g['contact_email']) && filter_var($g['contact_email'], FILTER_VALIDATE_EMAIL)) {
if ($emailer->send($g['contact_email'], $this->mailSubject($isReadAck, $a['title']), $this->mailBody($isReadAck, $a['title'], $g['stakeholder_name'], $absLink))) {
$emailSent++;
}
}
$links[] = [
'target_id' => (int) $g['id'],
'stakeholder_name' => $g['stakeholder_name'],
@@ -442,16 +457,34 @@ class StakeholderActivityController extends BaseController
}
Database::update('stk_activities', ['status' => 'sent'], 'id = ? AND organization_id = ?', [$id, $orgId]);
$emailOn = defined('EMAIL_SENDING_ENABLED') && EMAIL_SENDING_ENABLED;
$this->logAudit('stk_activity_sent', 'stk_activity', $id, ['targets' => count($links), 'email_enabled' => $emailOn]);
$this->logAudit('stk_activity_sent', 'stk_activity', $id, ['targets' => count($links), 'email_enabled' => $emailOn, 'email_sent' => $emailSent]);
$this->jsonSuccess([
'sent' => count($links),
'email_sent' => false,
'email_sent' => $emailSent,
'email_enabled' => $emailOn,
'links' => $links,
'note' => $emailOn
? 'Invio email non ancora collegato: condividi i magic-link con gli stakeholder.'
? ($emailSent . ' email inviate ai contatti disponibili; condividi i magic-link con gli stakeholder senza email.')
: 'Email disattivate (kill-switch): copia e condividi manualmente i magic-link qui sotto.',
], 'Attività inviata (magic-link generati)');
], 'Attività inviata');
}
/** Oggetto email invito (gated): questionario o presa visione. */
private function mailSubject(bool $isReadAck, string $title): string
{
return ($isReadAck ? 'Presa visione richiesta: ' : 'Questionario NIS2: ') . $title;
}
/** Corpo HTML email con il magic-link (EmailService applica il wrap/branding). */
private function mailBody(bool $isReadAck, string $title, string $name, string $link): string
{
$esc = static fn($s) => htmlspecialchars((string) $s, ENT_QUOTES, 'UTF-8');
$azione = $isReadAck ? 'prendere visione del documento e confermarne la lettura' : 'compilare il questionario';
return '<p>Gentile ' . $esc($name) . ',</p>'
. '<p>nell\'ambito degli adempimenti NIS2 della nostra organizzazione, ti chiediamo di ' . $azione . ': <strong>' . $esc($title) . '</strong>.</p>'
. '<p><a href="' . $esc($link) . '" style="display:inline-block;padding:10px 18px;background:#0066CC;color:#fff;border-radius:8px;text-decoration:none;">Apri</a></p>'
. '<p style="font-size:.85rem;color:#666;">Oppure copia questo link: ' . $esc($link) . '</p>'
. '<p style="font-size:.8rem;color:#999;">Il link è personale: non inoltrarlo.</p>';
}
// ─────────────────────────────────────────────────────────────────────────