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,44 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use Illuminate\Console\Command;
/**
* Conferma a mano l'email di un utente, dalla riga di comando.
*
* Per l'emergenza: la posta non funziona e nessun amministratore confermato
* può sistemarla (per esempio subito dopo l'aggiornamento che ha reso
* obbligatoria la conferma). Chi ha accesso al server è già fidato.
*/
class ConfermaEmailUtente extends Command
{
protected $signature = 'utenti:conferma-email {email : Indirizzo dell\'utente da confermare}';
protected $description = 'Segna come confermata l\'email di un utente (emergenza: posta non funzionante)';
public function handle(): int
{
$user = User::where('email', $this->argument('email'))->first();
if (! $user) {
$this->error('Nessun utente con questo indirizzo.');
return self::FAILURE;
}
if ($user->hasVerifiedEmail()) {
$this->info("{$user->email} è già confermato.");
return self::SUCCESS;
}
$user->markEmailAsVerified();
activity()->performedOn($user)->log('email_confermata_da_riga_di_comando');
$this->info("Email di {$user->email} confermata.");
return self::SUCCESS;
}
}

View File

@@ -95,6 +95,11 @@ class CreateInitialAdmin extends Command
'password' => Hash::make($password),
]);
// Confermato da subito: su un'installazione nuova il server di
// posta non c'è ancora, e chi ha accesso alla riga di comando del
// server non ha bisogno di dimostrare di chi è l'indirizzo. Senza,
// il primo amministratore arriverebbe solo alla pagina della posta.
$user->markEmailAsVerified();
$user->assignRole('amministratore');
return $user;

View File

@@ -0,0 +1,77 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\ConfigurazionePosta;
use Illuminate\Auth\Events\Verified;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
/**
* Conferma dell'indirizzo email degli utenti.
*
* Finché l'indirizzo non è confermato l'account non funziona: il middleware
* `verified` porta qui. Il link della mail funziona anche senza essere
* entrati: lo si apre spesso dal telefono, dove la sessione non c'è, e la
* firma dell'indirizzo (con scadenza) basta a garantire che venga dalla mail.
*/
class VerificaEmailController extends Controller
{
public function avviso(Request $request, ConfigurazionePosta $posta)
{
if ($request->user()->hasVerifiedEmail()) {
return redirect()->route('dashboard');
}
return view('auth.verifica-email', [
'user' => $request->user(),
'postaFunzionante' => $posta->funzionante(),
]);
}
public function invia(Request $request)
{
$user = $request->user();
if ($user->hasVerifiedEmail()) {
return redirect()->route('dashboard');
}
try {
$user->sendEmailVerificationNotification();
} catch (\Throwable $e) {
Log::warning('Email di conferma non inviata.', ['user' => $user->id, 'exception' => $e]);
return back()->with('error', 'L\'email non è partita: il server di posta non risponde o rifiuta l\'invio. Avvisa un amministratore.');
}
return back()->with('success', "Email inviata a {$user->email}. Apri il link che contiene (controlla anche lo spam).");
}
public function conferma(Request $request, int $id, string $hash)
{
$user = User::find($id);
// La firma garantisce id e scadenza; l'hash lega il link all'indirizzo
// di allora, così un link vecchio non conferma un'email cambiata dopo.
if (! $user || ! hash_equals(sha1($user->getEmailForVerification()), $hash)) {
abort(403, 'Link di conferma non valido.');
}
if (! $user->hasVerifiedEmail()) {
$user->markEmailAsVerified();
event(new Verified($user));
activity()->causedBy($user)->log('email_confermata');
}
$messaggio = 'Indirizzo email confermato.';
if ($request->user()?->is($user)) {
return redirect()->route('dashboard')->with('success', $messaggio);
}
return redirect()->route('login')->with('success', $messaggio . ' Ora puoi accedere.');
}
}

View File

@@ -26,6 +26,9 @@ class RequirePasswordChange
'password.change',
'password.change.store',
'logout',
// Il link della mail di conferma: aperto in quel momento andrebbe
// perso, e la persona dovrebbe chiederne un altro.
'verification.verify',
];
public function handle(Request $request, Closure $next): Response

View File

@@ -12,6 +12,9 @@ use Illuminate\Support\Facades\DB;
class Assegna extends Component
{
/** Chiave di sessione delle assegnazioni appena fatte, in attesa della scelta sul link. */
public const DA_INVIARE = 'assegnazioni_da_inviare';
public ?int $territorio_id = null;
public ?int $proclamatore_id = null;
public string $assigned_at = '';
@@ -107,8 +110,27 @@ class Assegna extends Component
])
->log('assigned');
// Si resta qui: in cima compare il riquadro con il link da mandare al
// proclamatore (WhatsApp) e la scelta "inviato / non ancora". La lista
// sta in sessione, così il riquadro resta finché non si sceglie, anche
// ricaricando la pagina o assegnando altri territori nel frattempo.
$daInviare = session(self::DA_INVIARE, []);
$daInviare[] = $assegnazione->id;
session([self::DA_INVIARE => array_values(array_unique($daInviare))]);
session()->flash('success', "Territorio {$territorio->numero} assegnato a {$proclamatore->nome_completo}.");
return $this->redirect(route('territori.show', $territorio), navigate: true);
return $this->redirect(route('assegnazioni.assegna'), navigate: true);
}
/** La scelta nel riquadro: il link è stato mandato, oppure non ancora. */
public function esitoInvio(int $assegnazioneId, bool $inviato): void
{
$this->authorize('territori.assign');
$assegnazione = Assegnazione::find($assegnazioneId);
$assegnazione?->forceFill(['link_sent' => $inviato])->saveQuietly();
session([self::DA_INVIARE => array_values(array_diff(session(self::DA_INVIARE, []), [$assegnazioneId]))]);
}
public function toggleLinkSent(int $assegnazioneId): void
@@ -176,7 +198,19 @@ class Assegna extends Component
// Il link scade alla soglia rientro (giorni_per_smarrito).
$sogliaRientro = max(1, (int) \App\Models\Setting::getValue('giorni_per_smarrito', 120));
// I riquadri in evidenza: solo le assegnazioni ancora aperte (una
// rientrata o cancellata nel frattempo non ha più un link da mandare).
$idDaInviare = session(self::DA_INVIARE, []);
$daInviare = $idDaInviare === [] ? collect() : Assegnazione::aperte()
->whereIn('id', $idDaInviare)
->with(['territorio', 'proclamatore'])
->get();
if ($daInviare->count() !== count($idDaInviare)) {
session([self::DA_INVIARE => $daInviare->pluck('id')->all()]);
}
return view('livewire.assegnazioni.assegna', [
'daInviare' => $daInviare,
'territoriDisponibili' => $territoriDisponibili,
'proclamatoriAttivi' => $proclamatoriAttivi,
'assegnazioniAperte' => $assegnazioniAperte,

View File

@@ -10,6 +10,8 @@ class ProclamatoreCreate extends Component
public string $nome = '';
public string $cognome = '';
public bool $attivo = true;
public string $email = '';
public string $telefono = '';
protected function rules(): array
{
@@ -17,6 +19,17 @@ class ProclamatoreCreate extends Component
'nome' => 'required|string|max:100',
'cognome' => 'required|string|max:100',
'attivo' => 'boolean',
// Facoltativi: si controlla il formato solo se compilati.
'email' => 'nullable|email|max:255',
'telefono' => ['nullable', 'string', 'max:30', 'regex:/^\+?[0-9 ().\/-]{6,25}$/'],
];
}
protected function messages(): array
{
return [
'email.email' => "L'indirizzo email non è valido (puoi anche lasciarlo vuoto).",
'telefono.regex' => 'Il numero può contenere solo cifre, spazi e un "+" iniziale (puoi anche lasciarlo vuoto).',
];
}
@@ -30,6 +43,8 @@ class ProclamatoreCreate extends Component
'nome' => $this->nome,
'cognome' => $this->cognome,
'attivo' => $this->attivo,
'email' => trim($this->email) ?: null,
'telefono' => trim($this->telefono) ?: null,
]);
activity()->causedBy(auth()->user())

View File

@@ -11,6 +11,8 @@ class ProclamatoreEdit extends Component
public string $nome = '';
public string $cognome = '';
public bool $attivo = true;
public string $email = '';
public string $telefono = '';
public function mount(Proclamatore $proclamatore)
{
@@ -18,6 +20,8 @@ class ProclamatoreEdit extends Component
$this->nome = $proclamatore->nome;
$this->cognome = $proclamatore->cognome;
$this->attivo = $proclamatore->attivo;
$this->email = (string) $proclamatore->email;
$this->telefono = (string) $proclamatore->telefono;
}
protected function rules(): array
@@ -26,6 +30,17 @@ class ProclamatoreEdit extends Component
'nome' => 'required|string|max:100',
'cognome' => 'required|string|max:100',
'attivo' => 'boolean',
// Facoltativi: si controlla il formato solo se compilati.
'email' => 'nullable|email|max:255',
'telefono' => ['nullable', 'string', 'max:30', 'regex:/^\+?[0-9 ().\/-]{6,25}$/'],
];
}
protected function messages(): array
{
return [
'email.email' => "L'indirizzo email non è valido (puoi anche lasciarlo vuoto).",
'telefono.regex' => 'Il numero può contenere solo cifre, spazi e un "+" iniziale (puoi anche lasciarlo vuoto).',
];
}
@@ -39,6 +54,8 @@ class ProclamatoreEdit extends Component
'nome' => $this->nome,
'cognome' => $this->cognome,
'attivo' => $this->attivo,
'email' => trim($this->email) ?: null,
'telefono' => trim($this->telefono) ?: null,
]);
session()->flash('success', "Proclamatore {$this->proclamatore->nome_completo} aggiornato.");

View File

@@ -32,10 +32,22 @@ class Profile extends Component
'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')->ignore($user->id)],
]);
$user->update([
$user->fill([
'name' => $validated['name'],
'email' => $validated['email'],
]);
$emailCambiata = $user->isDirty('email');
$user->save();
// L'indirizzo nuovo va confermato (User::booted lo segna da
// confermare): finché non apre il link, l'account resta bloccato.
if ($emailCambiata) {
$user->inviaConfermaEmail()
? session()->flash('success', "Profilo aggiornato. Conferma il nuovo indirizzo: ti abbiamo mandato un'email a {$user->email}.")
: session()->flash('error', 'Profilo aggiornato, ma l\'email di conferma non è partita: richiedila qui sotto.');
return $this->redirect(route('verification.notice'), navigate: false);
}
session()->flash('profile_success', 'Profilo aggiornato con successo.');

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);

View File

@@ -13,13 +13,16 @@ class Proclamatore extends Model
protected $table = 'proclamatori';
protected $fillable = ['nome', 'cognome', 'attivo'];
protected $fillable = ['nome', 'cognome', 'email', 'telefono', 'attivo'];
protected function casts(): array
{
return [
'nome' => 'encrypted',
'cognome' => 'encrypted',
// Facoltativi. Cifrati come nome e cognome: dati personali.
'email' => 'encrypted',
'telefono' => 'encrypted',
'attivo' => 'boolean',
];
}
@@ -27,7 +30,7 @@ class Proclamatore extends Model
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logOnly(['attivo']) // Do NOT log nome/cognome in audit (encrypted, GDPR)
->logOnly(['attivo']) // Do NOT log nome/cognome/email/telefono in audit (encrypted, GDPR)
->logOnlyDirty()
->dontSubmitEmptyLogs();
}
@@ -40,6 +43,46 @@ class Proclamatore extends Model
return trim($this->cognome . ' ' . $this->nome);
}
/**
* Il numero come lo vuole WhatsApp: solo cifre, con il prefisso
* internazionale. Un numero senza prefisso si considera italiano: un
* cellulare scritto "333 1234567" diventa 393331234567. Null se non c'è un
* numero utilizzabile.
*/
public function telefonoInternazionale(): ?string
{
$grezzo = trim((string) $this->telefono);
if ($grezzo === '') {
return null;
}
$cifre = preg_replace('/\D+/', '', $grezzo);
if (str_starts_with($cifre, '00') && ! str_starts_with($grezzo, '+')) {
$cifre = substr($cifre, 2);
} elseif (! str_starts_with($grezzo, '+') && (! str_starts_with($cifre, '39') || strlen($cifre) <= 10)) {
// Senza prefisso: cellulari (3…) e fissi (0…) italiani. Un
// "39…" di 11 cifre o più ha già il prefisso, scritto senza "+".
$cifre = '39' . $cifre;
}
return strlen($cifre) >= 8 && strlen($cifre) <= 15 ? $cifre : null;
}
/**
* Il link che apre una chat WhatsApp con il proclamatore, con un messaggio
* già scritto se c'è. Null se non ha un numero utilizzabile.
*/
public function whatsappUrl(?string $messaggio = null): ?string
{
$numero = $this->telefonoInternazionale();
if ($numero === null) {
return null;
}
return 'https://wa.me/' . $numero . ($messaggio !== null && $messaggio !== '' ? '?text=' . rawurlencode($messaggio) : '');
}
public function assegnazioni()
{
return $this->hasMany(Assegnazione::class, 'proclamatore_id');
@@ -63,6 +106,8 @@ class Proclamatore extends Model
{
$this->nome = 'Anonimo';
$this->cognome = 'Proclamatore #' . $this->id;
$this->email = null;
$this->telefono = null;
$this->attivo = false;
$this->save();
$this->delete(); // soft delete

View File

@@ -20,8 +20,31 @@ class Setting extends Model
'pdf_viewer_show_download',
'audit_retention_days',
'setup_completed',
'mail_host',
'mail_port',
'mail_cifratura',
'mail_username',
'mail_password',
'mail_from_address',
'mail_from_name',
];
/**
* Il server di posta: configurazione di questa installazione, non dati.
* Non si esporta e un import non la tocca (vedi XmlExchange).
*/
public const CAMPI_POSTA = [
'mail_host',
'mail_port',
'mail_cifratura',
'mail_username',
'mail_password',
'mail_from_address',
'mail_from_name',
];
protected $hidden = ['mail_password'];
protected function casts(): array
{
return [
@@ -33,6 +56,8 @@ class Setting extends Model
'home_limit_list' => 'integer',
'assignment_link_ttl_hours' => 'integer',
'audit_retention_days' => 'integer',
'mail_port' => 'integer',
'mail_password' => 'encrypted',
];
}

View File

@@ -2,6 +2,7 @@
namespace App\Models;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
@@ -9,7 +10,12 @@ use Spatie\Permission\Traits\HasRoles;
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Activitylog\LogOptions;
class User extends Authenticatable
/**
* MustVerifyEmail: l'account funziona solo con l'indirizzo confermato
* (middleware `verified`, VerificaEmailController). Chi cambia email — dal
* profilo o perché la cambia l'amministratore — torna da confermare.
*/
class User extends Authenticatable implements MustVerifyEmail
{
use HasFactory, Notifiable, HasRoles, LogsActivity;
@@ -41,4 +47,32 @@ class User extends Authenticatable
->logOnlyDirty()
->dontSubmitEmptyLogs();
}
protected static function booted(): void
{
// Un indirizzo nuovo va confermato di nuovo, chiunque lo cambi.
static::updating(function (self $utente) {
if ($utente->isDirty('email') && ! $utente->isDirty('email_verified_at')) {
$utente->email_verified_at = null;
}
});
}
/**
* Manda l'email di conferma; false se il server di posta non risponde o
* rifiuta. Chi chiama decide cosa dire: l'utente è comunque salvato, e
* l'email si può richiedere di nuovo.
*/
public function inviaConfermaEmail(): bool
{
try {
$this->sendEmailVerificationNotification();
} catch (\Throwable $e) {
report($e);
return false;
}
return true;
}
}

View File

@@ -3,6 +3,10 @@
namespace App\Providers;
use App\Models\Setting;
use App\Services\ConfigurazionePosta;
use Illuminate\Auth\Middleware\EnsureEmailIsVerified;
use Illuminate\Auth\Notifications\VerifyEmail;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Queue;
@@ -24,7 +28,42 @@ class AppServiceProvider extends ServiceProvider
// un'ora: senza questo, ogni job usava le impostazioni lette dal primo,
// anche dopo che l'amministratore le aveva cambiate dal browser o un
// ripristino le aveva sostituite.
Queue::before(fn () => Setting::flushCache());
Queue::before(function () {
Setting::flushCache();
// Anche il server di posta: cambiato dal browser, il worker
// continuava a usare quello del primo invio.
if ($this->app->resolved('mail.manager')) {
app(ConfigurazionePosta::class)->applica();
}
});
// L'email di conferma in italiano. Il link resta quello di Laravel
// (firmato, scade dopo auth.verification.expire minuti) e porta a
// VerificaEmailController::conferma, che funziona anche senza login.
VerifyEmail::toMailUsing(function ($utente, string $link) {
$minuti = (int) config('auth.verification.expire', 60);
return (new MailMessage)
->subject('Conferma il tuo indirizzo email — TerManager2')
->greeting('Ciao ' . $utente->name . ',')
->line('per usare TerManager2 conferma che questo indirizzo email è tuo.')
->action('Conferma l\'indirizzo', $link)
->line("Il link vale {$minuti} minuti. Se è scaduto, entra in TerManager2 e chiedine uno nuovo.")
->line('Se non sai di cosa si tratta, ignora questa email.')
->salutation('TerManager2');
});
// Le richieste di Livewire non ripassano dal gruppo di rotte della
// pagina: senza, una scheda aperta prima del blocco continuerebbe a
// lavorare con un'email non confermata.
Livewire::addPersistentMiddleware([EnsureEmailIsVerified::class]);
// Il server di posta configurato in Impostazioni prende il posto di
// quello del .env, appena Laravel crea il sistema di posta.
$this->app->afterResolving('mail.manager', function ($manager) {
app(ConfigurazionePosta::class)->applica($manager);
});
// Pin generated URLs to APP_URL.
//

View File

@@ -0,0 +1,121 @@
<?php
namespace App\Services;
use App\Models\Setting;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Mail\MailManager;
use Illuminate\Support\Facades\Mail;
use RuntimeException;
/**
* Il server di posta configurato dall'applicazione (Impostazioni → Posta).
*
* Se in Impostazioni c'è un server, vale quello; altrimenti resta quello del
* .env, come prima. La configurazione si applica quando Laravel crea il
* sistema di posta (AppServiceProvider) e prima di ogni job della coda,
* perché il worker vive a lungo e avrebbe tenuto quella del primo invio.
*/
class ConfigurazionePosta
{
public const CIFRATURA_AUTOMATICA = 'auto';
public const CIFRATURA_SSL = 'ssl';
public const CIFRATURA_NESSUNA = 'nessuna';
public const CIFRATURE = [
self::CIFRATURA_AUTOMATICA => 'Automatica (STARTTLS se il server la offre, di solito porta 587)',
self::CIFRATURA_SSL => 'SSL/TLS dall\'inizio (di solito porta 465)',
self::CIFRATURA_NESSUNA => 'Nessuna (solo per server interni)',
];
/** C'è un server di posta configurato dall'applicazione. */
public function configurataNellApp(): bool
{
return trim((string) Setting::instance()->mail_host) !== '';
}
/**
* Le email partono davvero: server dall'app, oppure un mailer del .env che
* non sia "log" o "array" (che le scrivono nel log o le buttano).
*/
public function funzionante(): bool
{
if ($this->configurataNellApp()) {
return true;
}
$mailer = (string) config('mail.default');
return ! in_array($mailer, ['log', 'array'], true)
&& ($mailer !== 'smtp' || trim((string) config('mail.mailers.smtp.host')) !== '');
}
/** Porta la configurazione dell'app nel sistema di posta di Laravel. */
public function applica(?MailManager $manager = null): void
{
try {
$impostazioni = Setting::instance();
} catch (\Throwable) {
// Database non ancora pronto (prima installazione, migrazioni in
// corso): resta il .env.
return;
}
if (trim((string) $impostazioni->mail_host) === '') {
return;
}
try {
$password = $impostazioni->mail_password;
} catch (DecryptException) {
// Cifrata con un'altra APP_KEY: meglio tentare senza password (e
// ricevere dal server un errore che dice "autenticazione") che
// far cadere ogni pagina che tocca la posta.
$password = null;
}
$cifratura = $impostazioni->mail_cifratura ?: self::CIFRATURA_AUTOMATICA;
config([
'mail.default' => 'smtp',
'mail.mailers.smtp' => [
'transport' => 'smtp',
'scheme' => $cifratura === self::CIFRATURA_SSL ? 'smtps' : 'smtp',
'host' => $impostazioni->mail_host,
'port' => $impostazioni->mail_port ?: ($cifratura === self::CIFRATURA_SSL ? 465 : 587),
'username' => $impostazioni->mail_username ?: null,
'password' => $password ?: null,
'timeout' => 20,
'local_domain' => config('mail.mailers.smtp.local_domain'),
// Senza, Symfony prova STARTTLS da solo appena il server lo offre.
'auto_tls' => $cifratura !== self::CIFRATURA_NESSUNA,
],
'mail.from.address' => $impostazioni->mail_from_address ?: config('mail.from.address'),
'mail.from.name' => $impostazioni->mail_from_name ?: ($impostazioni->congregazione_nome ?: config('mail.from.name')),
]);
// Un mailer già creato tiene la configurazione con cui è nato.
($manager ?? app('mail.manager'))->purge('smtp');
}
/**
* Invia l'email di prova. Lancia RuntimeException con un messaggio
* leggibile se il server rifiuta o non risponde.
*/
public function inviaProva(string $destinatario): void
{
$this->applica();
try {
Mail::raw(
"Questa è un'email di prova di TerManager2.\n\nSe la stai leggendo, il server di posta è configurato correttamente: "
. 'gli utenti riceveranno le email per confermare il loro indirizzo.',
fn ($messaggio) => $messaggio->to($destinatario)->subject('TerManager2 — email di prova'),
);
} catch (\Throwable $e) {
throw new RuntimeException('Il server di posta ha rifiutato l\'invio: ' . $e->getMessage(), 0, $e);
}
}
}