Posta configurabile dall'app, contatti dei proclamatori, conferma email

- Server di posta in Impostazioni (password cifrata, email di prova): se
  compilato prende il posto del .env; non si esporta e l'import XML non lo
  cancella. MAIL_SCHEME=tls nel .env.example non era valido per Laravel 11.
- Proclamatori con email e telefono facoltativi, cifrati, nell'XML e
  cancellati dall'anonimizzazione; il numero apre WhatsApp.
- Dopo l'assegnazione un riquadro in evidenza con il link del PDF,
  "Invia su WhatsApp" con il messaggio pronto e la scelta "inviato /
  non ancora", che resta finché non si sceglie.
- Conferma dell'email obbligatoria per tutti gli utenti, anche esistenti:
  email in italiano, link valido senza login, nuova conferma a ogni cambio
  di indirizzo, "Reinvia conferma" in Utenti. Senza conferma restano
  raggiungibili profilo, cambio password e, per l'admin, il server di
  posta; comando utenti:conferma-email per l'emergenza.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 17:12:31 +02:00
parent e7148c7b32
commit fc8623f7db
35 changed files with 1737 additions and 14 deletions

View File

@@ -0,0 +1,132 @@
<?php
namespace App\Livewire\Settings;
use App\Livewire\Concerns\Avvisa;
use App\Models\Setting;
use App\Services\ConfigurazionePosta;
use Illuminate\Validation\Rule;
use Livewire\Component;
/**
* Server di posta (solo amministratore).
*
* Serve alle email di conferma degli utenti. Raggiungibile anche da un
* amministratore che non ha ancora confermato la sua email (vedi
* routes/web.php): se la posta non funziona, è l'unica pagina da cui
* sistemarla senza passare dal server.
*/
class PostaElettronica extends Component
{
use Avvisa;
public string $host = '';
public string $porta = '';
public string $cifratura = ConfigurazionePosta::CIFRATURA_AUTOMATICA;
public string $utente = '';
/** Vuota = si tiene quella salvata. Non torna mai al browser. */
public string $password = '';
public string $mittente = '';
public string $nomeMittente = '';
public function mount(): void
{
$impostazioni = Setting::instance();
$this->host = (string) $impostazioni->mail_host;
$this->porta = (string) ($impostazioni->mail_port ?? '');
$this->cifratura = $impostazioni->mail_cifratura ?: ConfigurazionePosta::CIFRATURA_AUTOMATICA;
$this->utente = (string) $impostazioni->mail_username;
$this->mittente = (string) $impostazioni->mail_from_address;
$this->nomeMittente = (string) $impostazioni->mail_from_name;
}
public function salva(): void
{
$this->host = trim($this->host);
$this->mittente = trim($this->mittente);
$this->validate([
'host' => ['required', 'string', 'max:255', 'regex:/^[A-Za-z0-9.\-]+$/'],
'porta' => ['nullable', 'integer', 'min:1', 'max:65535'],
'cifratura' => ['required', Rule::in(array_keys(ConfigurazionePosta::CIFRATURE))],
'utente' => ['nullable', 'string', 'max:255'],
'password' => ['nullable', 'string', 'max:500'],
'mittente' => ['required', 'email', 'max:255'],
'nomeMittente' => ['nullable', 'string', 'max:255'],
], [
'host.required' => 'Indica il server SMTP (es. smtp.gmail.com).',
'host.regex' => 'Solo il nome del server, senza "smtp://" né porta.',
'mittente.required' => 'Indica l\'indirizzo da cui partono le email.',
'mittente.email' => 'L\'indirizzo del mittente non è valido.',
]);
$valori = [
'mail_host' => $this->host,
'mail_port' => $this->porta !== '' ? (int) $this->porta : null,
'mail_cifratura' => $this->cifratura,
'mail_username' => trim($this->utente) ?: null,
'mail_from_address' => $this->mittente,
'mail_from_name' => trim($this->nomeMittente) ?: null,
];
if ($this->password !== '') {
$valori['mail_password'] = $this->password;
}
Setting::instance()->update($valori);
$this->password = '';
activity()->causedBy(auth()->user())->log('posta_configurata');
$this->avvisa('Server di posta salvato. Prova l\'invio con "Invia email di prova".');
}
public function inviaProva(ConfigurazionePosta $posta): void
{
if (! $posta->configurataNellApp()) {
$this->avvisaErrore('Salva prima il server di posta.');
return;
}
try {
$posta->inviaProva(auth()->user()->email);
} catch (\RuntimeException $e) {
$this->avvisaErrore($e->getMessage());
return;
}
$this->avvisa('Email di prova inviata a ' . auth()->user()->email . '. Controlla anche lo spam.');
}
/** Torna al server del .env. */
public function rimuovi(): void
{
Setting::instance()->update(array_fill_keys(Setting::CAMPI_POSTA, null));
$this->mount();
$this->password = '';
activity()->causedBy(auth()->user())->log('posta_rimossa');
$this->avvisa('Configurazione rimossa: valgono di nuovo le impostazioni del server (.env).');
}
public function render(ConfigurazionePosta $posta)
{
$impostazioni = Setting::instance();
return view('livewire.settings.posta-elettronica', [
'configurataNellApp' => $posta->configurataNellApp(),
'funzionante' => $posta->funzionante(),
'passwordSalvata' => $impostazioni->getRawOriginal('mail_password') !== null,
'cifrature' => ConfigurazionePosta::CIFRATURE,
'nonConfermato' => ! auth()->user()->hasVerifiedEmail(),
'title' => 'Server di posta',
]);
}
}

View File

@@ -85,7 +85,30 @@ class UsersIndex extends Component
if (! empty($this->availableRoles)) {
$this->selectedRole = $this->availableRoles[0];
}
$this->avvisa('Utente creato con successo.');
// L'account funziona solo dopo la conferma dell'indirizzo.
if ($user->inviaConfermaEmail()) {
$this->avvisa("Utente creato. Gli è stata inviata un'email a {$user->email}: l'account funziona dopo che conferma l'indirizzo.");
} else {
$this->avvisaErrore("Utente creato, ma l'email di conferma non è partita: controlla il server di posta e usa \"Reinvia conferma\".");
}
}
public function reinviaConferma(int $userId): void
{
$user = User::query()->findOrFail($userId);
if ($user->hasVerifiedEmail()) {
$this->avvisa("{$user->email} è già confermato.");
return;
}
if ($user->inviaConfermaEmail()) {
$this->avvisa("Email di conferma inviata a {$user->email}.");
} else {
$this->avvisaErrore('L\'email non è partita: controlla il server di posta.');
}
}
public function startEdit(int $userId): void
@@ -145,10 +168,24 @@ class UsersIndex extends Component
$user->password = $validated['editPassword'];
}
$emailCambiata = $user->isDirty('email');
$user->save();
$user->syncRoles([$validated['editSelectedRole']]);
$this->cancelEdit();
// Indirizzo nuovo: torna da confermare (User::booted) e gli si manda
// il link, altrimenti il suo account smetterebbe di funzionare senza
// che sappia perché.
if ($emailCambiata) {
$inviata = $user->inviaConfermaEmail();
$inviata
? $this->avvisa("Utente aggiornato. Il nuovo indirizzo va confermato: email inviata a {$user->email}.")
: $this->avvisaErrore('Utente aggiornato, ma l\'email di conferma del nuovo indirizzo non è partita: controlla il server di posta.');
return;
}
$this->avvisa('Utente aggiornato con successo.');
}

View File

@@ -475,6 +475,13 @@ class XmlExchange extends Component
Tipologia::query()->delete();
Zona::query()->delete();
AnnoTeocratico::query()->delete();
// Il server di posta è configurazione di questa installazione, non
// un dato da trasferire: non si esporta (la password è cifrata con
// la sua APP_KEY) e un import non deve cancellarlo — senza posta
// nessuno riceverebbe più le email di conferma.
$posta = Setting::query()->first()?->only(Setting::CAMPI_POSTA) ?? [];
Setting::query()->delete();
// The mass delete above fires no model events, so Setting's
@@ -502,6 +509,10 @@ class XmlExchange extends Component
]);
}
if ($posta !== []) {
Setting::instance()->update($posta);
}
// Users are UPSERTED by email, never wiped. Deleting the users
// table during a restore risks locking out the administrator
// performing the import (e.g. a backup that predates their account,
@@ -618,6 +629,9 @@ class XmlExchange extends Component
'nome' => (string) $proclamatoreNode->nome,
'cognome' => (string) $proclamatoreNode->cognome,
'attivo' => ((int) ($proclamatoreNode->attivo ?? 1)) === 1,
// Facoltativi, e assenti nei file di prima dell'ottobre 2026.
'email' => trim((string) ($proclamatoreNode->email ?? '')) ?: null,
'telefono' => trim((string) ($proclamatoreNode->telefono ?? '')) ?: null,
]);
$this->applyRowMeta('proclamatori', $p->id, $proclamatoreNode, true);
$stats['proclamatori_importati']++;
@@ -814,7 +828,7 @@ class XmlExchange extends Component
])->all(),
'zones' => $this->righeEsportate(Zona::query()->orderBy('id'), ['id', 'nome', 'attivo', 'created_at', 'updated_at']),
'tipologie' => $this->righeEsportate(Tipologia::query()->orderBy('id'), ['id', 'nome', 'attivo', 'created_at', 'updated_at']),
'proclamatori' => $this->righeEsportate(Proclamatore::query()->withTrashed()->orderBy('id'), ['id', 'nome', 'cognome', 'attivo', 'deleted_at', 'created_at', 'updated_at']),
'proclamatori' => $this->righeEsportate(Proclamatore::query()->withTrashed()->orderBy('id'), ['id', 'nome', 'cognome', 'email', 'telefono', 'attivo', 'deleted_at', 'created_at', 'updated_at']),
'territori' => $this->righeEsportate(Territorio::query()->withTrashed()->orderBy('id'), ['id', 'numero', 'zona_id', 'tipologia_id', 'confini', 'note', 'attivo', 'prioritario', 'pdf_path', 'thumbnail_path', 'deleted_at', 'created_at', 'updated_at']),
'anni_teocratici' => $this->righeEsportate(AnnoTeocratico::query()->orderBy('id'), ['id', 'label', 'start_date', 'end_date', 'created_at', 'updated_at']),
'campagne' => $this->righeEsportate(Campagna::query()->orderBy('id'), ['id', 'descrizione', 'start_date', 'end_date', 'created_at', 'updated_at']),
@@ -1249,6 +1263,8 @@ class XmlExchange extends Component
$this->addLegacyIdAttribute($node, $proclamatore);
$this->addXmlText($node, 'nome', (string) ($proclamatore['nome'] ?? ''));
$this->addXmlText($node, 'cognome', (string) ($proclamatore['cognome'] ?? ''));
$this->addXmlText($node, 'email', (string) ($proclamatore['email'] ?? ''));
$this->addXmlText($node, 'telefono', (string) ($proclamatore['telefono'] ?? ''));
$node->addChild('attivo', (string) ((int) ($proclamatore['attivo'] ?? 1)));
$node->addChild('deleted_at', (string) ($proclamatore['deleted_at'] ?? ''));
$this->addTimestampNodes($node, $proclamatore);