From 292f2165044ed3383f1950590440c3e2b249539f Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Mon, 13 Jul 2026 16:20:15 +0200 Subject: [PATCH 1/9] feat(email): 'Send test email' button + route SMTP driver through PHPMailer (#238) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nikola had no way to tell whether SMTP was actually working. Adds a 'Send test email' button in Settings → Email that sends a test message with the current config and reports success or the SPECIFIC error. Also fixes a latent bug: the 'smtp' driver used a hand-rolled SMTP client that never checked server replies and returned true even when the server rejected the message (auth/recipient) — the classic 'SMTP looks fine but no mail arrives'. Both 'smtp' and 'phpmailer' now go through PHPMailer, which verifies the handshake/auth/recipient and surfaces the real error. New Mailer::sendTest() returns {ok, error}; SettingsController::sendTestEmail exposes it as JSON. Verified end-to-end against Mailpit: valid SMTP → success + mail delivered; bad host → success:false with 'Could not connect to SMTP host'. PHPStan L5 clean; 13 strings added to all four locales. --- app/Controllers/SettingsController.php | 43 ++++++ app/Routes/web.php | 6 + app/Support/Mailer.php | 179 +++++++++++-------------- app/Views/settings/index.php | 45 +++++++ locale/de_DE.json | 15 ++- locale/en_US.json | 15 ++- locale/fr_FR.json | 15 ++- locale/it_IT.json | 15 ++- 8 files changed, 232 insertions(+), 101 deletions(-) diff --git a/app/Controllers/SettingsController.php b/app/Controllers/SettingsController.php index 08ccfe71..0530a26f 100644 --- a/app/Controllers/SettingsController.php +++ b/app/Controllers/SettingsController.php @@ -139,6 +139,49 @@ public function updateGeneral(Request $request, Response $response, mysqli $db): return $this->redirect($response, '/admin/settings?tab=general'); } + /** + * Send a test email with the current mail settings and return a JSON result + * with the real outcome — the specific SMTP error on failure — so the admin + * can diagnose an email configuration without guessing (Nikola #238). + */ + public function sendTestEmail(Request $request, Response $response, mysqli $db): Response + { + $data = (array) $request->getParsedBody(); + + // Recipient: an explicit address from the form, else the logged-in admin's. + $to = trim((string) ($data['test_email'] ?? '')); + if ($to === '') { + $adminId = (int) ($_SESSION['user']['id'] ?? 0); + if ($adminId > 0) { + $stmt = $db->prepare('SELECT email FROM utenti WHERE id = ? LIMIT 1'); + $stmt->bind_param('i', $adminId); + $stmt->execute(); + $row = $stmt->get_result()->fetch_assoc(); + $stmt->close(); + $to = (string) ($row['email'] ?? ''); + } + } + + if ($to === '') { + $response->getBody()->write((string) json_encode([ + 'success' => false, + 'message' => __('Nessun destinatario: inserisci un indirizzo email o assicurati che il tuo account admin ne abbia uno.'), + ])); + return $response->withHeader('Content-Type', 'application/json')->withStatus(400); + } + + $result = \App\Support\Mailer::sendTest($to); + $response->getBody()->write((string) json_encode([ + 'success' => $result['ok'], + 'message' => $result['ok'] + ? sprintf(__('Email di prova inviata a %s. Controlla la casella (anche lo spam).'), $to) + : sprintf(__('Invio fallito: %s'), $result['error']), + ])); + return $response + ->withHeader('Content-Type', 'application/json') + ->withStatus($result['ok'] ? 200 : 502); + } + public function updateEmailSettings(Request $request, Response $response, mysqli $db): Response { $data = (array) $request->getParsedBody(); diff --git a/app/Routes/web.php b/app/Routes/web.php index b301e33c..3e3b0095 100644 --- a/app/Routes/web.php +++ b/app/Routes/web.php @@ -571,6 +571,12 @@ return $controller->updateEmailSettings($request, $response, $db); })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + $app->post('/admin/settings/email/test', function ($request, $response) use ($app) { + $db = $app->getContainer()->get('db'); + $controller = new SettingsController(); + return $controller->sendTestEmail($request, $response, $db); + })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + $app->post('/admin/settings/contacts', function ($request, $response) use ($app) { $db = $app->getContainer()->get('db'); $controller = new SettingsController(); diff --git a/app/Support/Mailer.php b/app/Support/Mailer.php index 42bf3816..ea38b8d7 100644 --- a/app/Support/Mailer.php +++ b/app/Support/Mailer.php @@ -44,10 +44,12 @@ public static function send(string $to, string $subject, string $htmlBody, ?stri $fromName = (string)ConfigStore::get('mail.from_name', 'Biblioteca'); $driver = (string)ConfigStore::get('mail.driver', 'mail'); - if ($driver === 'smtp') { - return self::sendSmtp($to, $subject, $htmlBody, $textBody, $fromEmail, $fromName); - } - if ($driver === 'phpmailer') { + // Both 'smtp' and 'phpmailer' go through PHPMailer. It verifies the SMTP + // handshake, AUTH and recipient and fails loudly — unlike the old hand- + // rolled SMTP path, which never checked server replies and could report + // success on a silently-rejected message (the "SMTP looks fine but no mail + // arrives" class of bug). + if ($driver === 'smtp' || $driver === 'phpmailer') { return self::sendPHPMailer($to, $subject, $htmlBody, $textBody, $fromEmail, $fromName); } return self::sendMail($to, $subject, $htmlBody, $fromEmail, $fromName); @@ -72,77 +74,6 @@ private static function sendMail(string $to, string $subject, string $htmlBody, return @mail($to, self::encodeHeader($subject), $body, implode("\r\n", $headers)); } - private static function sendSmtp(string $to, string $subject, string $htmlBody, ?string $textBody, string $fromEmail, string $fromName): bool - { - $host = (string)ConfigStore::get('mail.smtp.host', 'localhost'); - $port = (int)ConfigStore::get('mail.smtp.port', 587); - $enc = (string)ConfigStore::get('mail.smtp.encryption', 'tls'); - $user = (string)ConfigStore::get('mail.smtp.username', ''); - $pass = (string)ConfigStore::get('mail.smtp.password', ''); - - $transport = ($enc === 'ssl') ? 'ssl://' . $host : $host; - $remote = ($enc === 'tls') ? 'tcp://' . $host . ':' . $port : $transport . ':' . $port; - - $errno = 0; $errstr = ''; - $fp = @stream_socket_client($remote, $errno, $errstr, 10, STREAM_CLIENT_CONNECT); - if (!$fp) return false; - stream_set_timeout($fp, 10); - - $send = function(string $cmd) use ($fp) { - fwrite($fp, $cmd . "\r\n"); - return fgets($fp, 512); - }; - - $read = function() use ($fp) { - return fgets($fp, 512); - }; - - $read(); - $send('EHLO localhost'); - if ($enc === 'tls') { - $send('STARTTLS'); - if (!stream_socket_enable_crypto($fp, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) { - fclose($fp); return false; - } - $send('EHLO localhost'); - } - if ($user !== '') { - $send('AUTH LOGIN'); - $send(base64_encode($user)); - $send(base64_encode($pass)); - } - // Validate email addresses to prevent CRLF injection - if (!filter_var($fromEmail, FILTER_VALIDATE_EMAIL) || strpos($fromEmail, "\r") !== false || strpos($fromEmail, "\n") !== false) { - throw new \Exception('Invalid from email address'); - } - $send('MAIL FROM: <' . $fromEmail . '>'); - if (!filter_var($to, FILTER_VALIDATE_EMAIL) || strpos($to, "\r") !== false || strpos($to, "\n") !== false) { - throw new \Exception('Invalid recipient email address'); - } - $send('RCPT TO: <' . $to . '>'); - $send('DATA'); - - // Use more secure boundary generation - $boundary = uniqid('mail_boundary_', true); - $headers = []; - $headers[] = 'From: ' . self::encodeHeader($fromName) . " <{$fromEmail}>"; - $headers[] = 'To: <' . $to . '>'; - $headers[] = 'Subject: ' . self::encodeHeader($subject); - $headers[] = 'MIME-Version: 1.0'; - $headers[] = 'Content-Type: multipart/alternative; boundary="' . $boundary . '"'; - - $text = $textBody ?: strip_tags($htmlBody); - $msg = implode("\r\n", $headers) . "\r\n\r\n"; - $msg .= "--{$boundary}\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n{$text}\r\n"; - $msg .= "--{$boundary}\r\nContent-Type: text/html; charset=utf-8\r\n\r\n{$htmlBody}\r\n"; - $msg .= "--{$boundary}--\r\n."; - - $send($msg); - $send('QUIT'); - fclose($fp); - return true; - } - private static function encodeHeader(string $s): string { if (!preg_match('/^[\x20-\x7E]*$/', $s)) { @@ -151,6 +82,35 @@ private static function encodeHeader(string $s): string return $s; } + /** + * Configure a PHPMailer instance from the stored mail settings (SMTP when a + * host is set, otherwise PHP mail()). Shared by sendPHPMailer() and sendTest(). + */ + private static function configureMailer(\PHPMailer\PHPMailer\PHPMailer $mailer, string $fromEmail, string $fromName): void + { + $mailer->CharSet = 'UTF-8'; + $mailer->setFrom($fromEmail, $fromName); + $mailer->isHTML(true); + + $host = (string) ConfigStore::get('mail.smtp.host', ''); + if ($host !== '') { + $mailer->isSMTP(); + // PHPMailer's default Timeout is 300s: if the SMTP host accepts the TCP + // connection but then stalls, a single send would block the (cron) request + // for 5 minutes. Cap it. + $mailer->Timeout = (int) ConfigStore::get('mail.smtp.timeout', 10); + $mailer->Host = $host; + $mailer->Port = (int) ConfigStore::get('mail.smtp.port', 587); + $enc = (string) ConfigStore::get('mail.smtp.encryption', 'tls'); + if ($enc === 'ssl') { $mailer->SMTPSecure = \PHPMailer\PHPMailer\PHPMailer::ENCRYPTION_SMTPS; } + elseif ($enc === 'tls') { $mailer->SMTPSecure = \PHPMailer\PHPMailer\PHPMailer::ENCRYPTION_STARTTLS; } + else { $mailer->SMTPSecure = ''; $mailer->SMTPAutoTLS = false; } + $user = (string) ConfigStore::get('mail.smtp.username', ''); + $pass = (string) ConfigStore::get('mail.smtp.password', ''); + if ($user !== '') { $mailer->SMTPAuth = true; $mailer->Username = $user; $mailer->Password = $pass; } + } + } + private static function sendPHPMailer(string $to, string $subject, string $htmlBody, ?string $textBody, string $fromEmail, string $fromName): bool { if (!class_exists('PHPMailer\\PHPMailer\\PHPMailer')) { @@ -159,35 +119,60 @@ private static function sendPHPMailer(string $to, string $subject, string $htmlB } try { $mailer = new \PHPMailer\PHPMailer\PHPMailer(true); - $mailer->CharSet = 'UTF-8'; - $mailer->setFrom($fromEmail, $fromName); + self::configureMailer($mailer, $fromEmail, $fromName); $mailer->addAddress($to); $mailer->Subject = $subject; - $mailer->isHTML(true); $mailer->Body = $htmlBody; $mailer->AltBody = $textBody ?: strip_tags($htmlBody); - - // Use SMTP if configured - $host = (string)ConfigStore::get('mail.smtp.host', ''); - if ($host !== '') { - $mailer->isSMTP(); - // PHPMailer's default Timeout is 300s: if the SMTP host accepts the TCP - // connection but then stalls, a single send would block the (cron) request - // for 5 minutes. Cap it — the raw-socket path already uses 10s. - $mailer->Timeout = (int)ConfigStore::get('mail.smtp.timeout', 10); - $mailer->Host = $host; - $mailer->Port = (int)ConfigStore::get('mail.smtp.port', 587); - $enc = (string)ConfigStore::get('mail.smtp.encryption', 'tls'); - if ($enc === 'ssl') { $mailer->SMTPSecure = \PHPMailer\PHPMailer\PHPMailer::ENCRYPTION_SMTPS; } - elseif ($enc === 'tls') { $mailer->SMTPSecure = \PHPMailer\PHPMailer\PHPMailer::ENCRYPTION_STARTTLS; } - else { $mailer->SMTPSecure = ''; $mailer->SMTPAutoTLS = false; } - $user = (string)ConfigStore::get('mail.smtp.username', ''); - $pass = (string)ConfigStore::get('mail.smtp.password', ''); - if ($user !== '') { $mailer->SMTPAuth = true; $mailer->Username = $user; $mailer->Password = $pass; } - } return $mailer->send(); } catch (\Throwable $e) { + SecureLogger::warning('Email send failed: ' . $e->getMessage()); return false; } } + + /** + * Send a diagnostic test email with the CURRENT mail settings and return the + * real outcome — the specific error on failure, not just a boolean — so the + * admin can tell a bad SMTP config from a working one. Verifies the SMTP + * handshake/auth/recipient via PHPMailer. + * + * @return array{ok: bool, error: string} + */ + public static function sendTest(string $to): array + { + $fromEmail = (string) ConfigStore::get('mail.from_email', 'no-reply@localhost'); + $fromName = (string) ConfigStore::get('mail.from_name', 'Biblioteca'); + $driver = (string) ConfigStore::get('mail.driver', 'mail'); + + if (!filter_var($to, FILTER_VALIDATE_EMAIL)) { + return ['ok' => false, 'error' => __('Indirizzo email del destinatario non valido.')]; + } + + $subject = __('Email di prova da Pinakes'); + $html = '

' . __('Questa è un\'email di prova inviata dalle impostazioni di Pinakes. Se la ricevi, la configurazione email funziona.') . '

'; + + // SMTP / PHPMailer path: capture the real handshake/auth/recipient error. + if (($driver === 'smtp' || $driver === 'phpmailer') && class_exists('PHPMailer\\PHPMailer\\PHPMailer')) { + try { + $mailer = new \PHPMailer\PHPMailer\PHPMailer(true); + self::configureMailer($mailer, $fromEmail, $fromName); + $mailer->addAddress($to); + $mailer->Subject = $subject; + $mailer->Body = $html; + $mailer->AltBody = strip_tags($html); + $mailer->send(); + return ['ok' => true, 'error' => '']; + } catch (\Throwable $e) { + return ['ok' => false, 'error' => $e->getMessage()]; + } + } + + // Plain mail() path: no detailed error is available, only success/failure. + $ok = self::send($to, $subject, $html); + return $ok + ? ['ok' => true, 'error' => ''] + : ['ok' => false, 'error' => __('La funzione mail() di PHP ha restituito un errore. Verifica la configurazione del server di posta.')]; + } + } diff --git a/app/Views/settings/index.php b/app/Views/settings/index.php index f9e7b170..616b0bb9 100644 --- a/app/Views/settings/index.php +++ b/app/Views/settings/index.php @@ -320,6 +320,19 @@ class="block w-full rounded-lg border-gray-300 focus:border-gray-500 focus:ring- +
+

+

+
+ + +
+ +
+
+ diff --git a/locale/de_DE.json b/locale/de_DE.json index b81efb85..9ce56011 100644 --- a/locale/de_DE.json +++ b/locale/de_DE.json @@ -6525,5 +6525,18 @@ "Spaziatura": "Abstand", "Margine interno (mm)": "Innenabstand (mm)", "Il contenuto si adatta automaticamente alle dimensioni dell'etichetta. Aggiungi un margine interno extra per rifinire il layout.": "Der Inhalt passt sich automatisch an die Etikettengröße an. Fügen Sie zusätzlichen Innenabstand hinzu, um das Layout zu optimieren.", - "Sembra che tu stia eseguendo l'immagine Docker: i file dell'applicazione appartengono all'immagine e non sono modificabili da questo pulsante. Aggiorna spostando il container alla nuova immagine con «docker compose pull && docker compose up -d» (i tuoi dati nel database e nei volumi storage/uploads restano al sicuro). Il pulsante di aggiornamento è pensato per installazioni classiche/hosting condiviso dove l'utente del web server è proprietario dei file.": "Es sieht so aus, als würden Sie das Docker-Image verwenden: Die Anwendungsdateien gehören zum Image und können über diese Schaltfläche nicht geändert werden. Aktualisieren Sie, indem Sie den Container auf das neue Image umstellen: „docker compose pull && docker compose up -d“ (Ihre Daten in der Datenbank und in den storage/uploads-Volumes bleiben sicher). Die Update-Schaltfläche ist für klassische Installationen/Shared Hosting gedacht, bei denen der Webserver-Benutzer Eigentümer der Dateien ist." + "Sembra che tu stia eseguendo l'immagine Docker: i file dell'applicazione appartengono all'immagine e non sono modificabili da questo pulsante. Aggiorna spostando il container alla nuova immagine con «docker compose pull && docker compose up -d» (i tuoi dati nel database e nei volumi storage/uploads restano al sicuro). Il pulsante di aggiornamento è pensato per installazioni classiche/hosting condiviso dove l'utente del web server è proprietario dei file.": "Es sieht so aus, als würden Sie das Docker-Image verwenden: Die Anwendungsdateien gehören zum Image und können über diese Schaltfläche nicht geändert werden. Aktualisieren Sie, indem Sie den Container auf das neue Image umstellen: „docker compose pull && docker compose up -d“ (Ihre Daten in der Datenbank und in den storage/uploads-Volumes bleiben sicher). Die Update-Schaltfläche ist für klassische Installationen/Shared Hosting gedacht, bei denen der Webserver-Benutzer Eigentümer der Dateien ist.", + "Nessun destinatario: inserisci un indirizzo email o assicurati che il tuo account admin ne abbia uno.": "Kein Empfänger: Geben Sie eine E-Mail-Adresse ein oder stellen Sie sicher, dass Ihr Admin-Konto eine hat.", + "Email di prova inviata a %s. Controlla la casella (anche lo spam).": "Test-E-Mail an %s gesendet. Prüfen Sie den Posteingang (und den Spam-Ordner).", + "Invio fallito: %s": "Senden fehlgeschlagen: %s", + "Indirizzo email del destinatario non valido.": "Ungültige Empfänger-E-Mail-Adresse.", + "Email di prova da Pinakes": "Test-E-Mail von Pinakes", + "Questa è un'email di prova inviata dalle impostazioni di Pinakes. Se la ricevi, la configurazione email funziona.": "Dies ist eine Test-E-Mail aus den Pinakes-Einstellungen. Wenn Sie sie erhalten, funktioniert Ihre E-Mail-Konfiguration.", + "La funzione mail() di PHP ha restituito un errore. Verifica la configurazione del server di posta.": "Die PHP-Funktion mail() hat einen Fehler zurückgegeben. Prüfen Sie die Konfiguration des Mailservers.", + "Prova invio": "Sendetest", + "Invia un'email di prova con le impostazioni attuali e vedi subito se funziona o qual è l'errore. Salva prima le impostazioni.": "Senden Sie eine Test-E-Mail mit den aktuellen Einstellungen und sehen Sie sofort, ob es funktioniert oder was der Fehler ist. Speichern Sie zuerst die Einstellungen.", + "Destinatario (vuoto = la tua email admin)": "Empfänger (leer = Ihre Admin-E-Mail)", + "Invia email di prova": "Test-E-Mail senden", + "Invio in corso…": "Wird gesendet…", + "Richiesta non riuscita. Riprova.": "Anfrage fehlgeschlagen. Bitte erneut versuchen." } diff --git a/locale/en_US.json b/locale/en_US.json index a5cbc807..bfadef0d 100644 --- a/locale/en_US.json +++ b/locale/en_US.json @@ -6525,5 +6525,18 @@ "Spaziatura": "Spacing", "Margine interno (mm)": "Inner padding (mm)", "Il contenuto si adatta automaticamente alle dimensioni dell'etichetta. Aggiungi un margine interno extra per rifinire il layout.": "Content scales automatically to the label size. Add extra inner padding to fine-tune the layout.", - "Sembra che tu stia eseguendo l'immagine Docker: i file dell'applicazione appartengono all'immagine e non sono modificabili da questo pulsante. Aggiorna spostando il container alla nuova immagine con «docker compose pull && docker compose up -d» (i tuoi dati nel database e nei volumi storage/uploads restano al sicuro). Il pulsante di aggiornamento è pensato per installazioni classiche/hosting condiviso dove l'utente del web server è proprietario dei file.": "It looks like you're running the Docker image: the application files belong to the image and can't be modified from this button. Update by moving the container to the new image with “docker compose pull && docker compose up -d” (your data in the database and the storage/uploads volumes stays safe). The update button is meant for classic/shared-hosting installs where the web-server user owns the files." + "Sembra che tu stia eseguendo l'immagine Docker: i file dell'applicazione appartengono all'immagine e non sono modificabili da questo pulsante. Aggiorna spostando il container alla nuova immagine con «docker compose pull && docker compose up -d» (i tuoi dati nel database e nei volumi storage/uploads restano al sicuro). Il pulsante di aggiornamento è pensato per installazioni classiche/hosting condiviso dove l'utente del web server è proprietario dei file.": "It looks like you're running the Docker image: the application files belong to the image and can't be modified from this button. Update by moving the container to the new image with “docker compose pull && docker compose up -d” (your data in the database and the storage/uploads volumes stays safe). The update button is meant for classic/shared-hosting installs where the web-server user owns the files.", + "Nessun destinatario: inserisci un indirizzo email o assicurati che il tuo account admin ne abbia uno.": "No recipient: enter an email address or make sure your admin account has one.", + "Email di prova inviata a %s. Controlla la casella (anche lo spam).": "Test email sent to %s. Check the inbox (and spam).", + "Invio fallito: %s": "Send failed: %s", + "Indirizzo email del destinatario non valido.": "Invalid recipient email address.", + "Email di prova da Pinakes": "Test email from Pinakes", + "Questa è un'email di prova inviata dalle impostazioni di Pinakes. Se la ricevi, la configurazione email funziona.": "This is a test email sent from the Pinakes settings. If you received it, your email configuration works.", + "La funzione mail() di PHP ha restituito un errore. Verifica la configurazione del server di posta.": "PHP's mail() function returned an error. Check your mail-server configuration.", + "Prova invio": "Test send", + "Invia un'email di prova con le impostazioni attuali e vedi subito se funziona o qual è l'errore. Salva prima le impostazioni.": "Send a test email with the current settings and see right away whether it works or what the error is. Save the settings first.", + "Destinatario (vuoto = la tua email admin)": "Recipient (empty = your admin email)", + "Invia email di prova": "Send test email", + "Invio in corso…": "Sending…", + "Richiesta non riuscita. Riprova.": "Request failed. Try again." } diff --git a/locale/fr_FR.json b/locale/fr_FR.json index 028f4f2f..b403e129 100644 --- a/locale/fr_FR.json +++ b/locale/fr_FR.json @@ -6525,5 +6525,18 @@ "Spaziatura": "Espacement", "Margine interno (mm)": "Marge intérieure (mm)", "Il contenuto si adatta automaticamente alle dimensioni dell'etichetta. Aggiungi un margine interno extra per rifinire il layout.": "Le contenu s'adapte automatiquement à la taille de l'étiquette. Ajoutez une marge intérieure supplémentaire pour affiner la mise en page.", - "Sembra che tu stia eseguendo l'immagine Docker: i file dell'applicazione appartengono all'immagine e non sono modificabili da questo pulsante. Aggiorna spostando il container alla nuova immagine con «docker compose pull && docker compose up -d» (i tuoi dati nel database e nei volumi storage/uploads restano al sicuro). Il pulsante di aggiornamento è pensato per installazioni classiche/hosting condiviso dove l'utente del web server è proprietario dei file.": "Il semble que vous utilisiez l'image Docker : les fichiers de l'application appartiennent à l'image et ne peuvent pas être modifiés depuis ce bouton. Mettez à jour en déplaçant le conteneur vers la nouvelle image avec « docker compose pull && docker compose up -d » (vos données dans la base et dans les volumes storage/uploads restent en sécurité). Le bouton de mise à jour est prévu pour les installations classiques/hébergement mutualisé où l'utilisateur du serveur web est propriétaire des fichiers." + "Sembra che tu stia eseguendo l'immagine Docker: i file dell'applicazione appartengono all'immagine e non sono modificabili da questo pulsante. Aggiorna spostando il container alla nuova immagine con «docker compose pull && docker compose up -d» (i tuoi dati nel database e nei volumi storage/uploads restano al sicuro). Il pulsante di aggiornamento è pensato per installazioni classiche/hosting condiviso dove l'utente del web server è proprietario dei file.": "Il semble que vous utilisiez l'image Docker : les fichiers de l'application appartiennent à l'image et ne peuvent pas être modifiés depuis ce bouton. Mettez à jour en déplaçant le conteneur vers la nouvelle image avec « docker compose pull && docker compose up -d » (vos données dans la base et dans les volumes storage/uploads restent en sécurité). Le bouton de mise à jour est prévu pour les installations classiques/hébergement mutualisé où l'utilisateur du serveur web est propriétaire des fichiers.", + "Nessun destinatario: inserisci un indirizzo email o assicurati che il tuo account admin ne abbia uno.": "Aucun destinataire : saisissez une adresse e-mail ou assurez-vous que votre compte admin en possède une.", + "Email di prova inviata a %s. Controlla la casella (anche lo spam).": "E-mail de test envoyé à %s. Vérifiez la boîte de réception (et les spams).", + "Invio fallito: %s": "Échec de l'envoi : %s", + "Indirizzo email del destinatario non valido.": "Adresse e-mail du destinataire non valide.", + "Email di prova da Pinakes": "E-mail de test de Pinakes", + "Questa è un'email di prova inviata dalle impostazioni di Pinakes. Se la ricevi, la configurazione email funziona.": "Ceci est un e-mail de test envoyé depuis les paramètres de Pinakes. Si vous le recevez, votre configuration e-mail fonctionne.", + "La funzione mail() di PHP ha restituito un errore. Verifica la configurazione del server di posta.": "La fonction mail() de PHP a renvoyé une erreur. Vérifiez la configuration du serveur de messagerie.", + "Prova invio": "Test d'envoi", + "Invia un'email di prova con le impostazioni attuali e vedi subito se funziona o qual è l'errore. Salva prima le impostazioni.": "Envoyez un e-mail de test avec les paramètres actuels et voyez immédiatement si cela fonctionne ou quelle est l'erreur. Enregistrez d'abord les paramètres.", + "Destinatario (vuoto = la tua email admin)": "Destinataire (vide = votre e-mail admin)", + "Invia email di prova": "Envoyer un e-mail de test", + "Invio in corso…": "Envoi en cours…", + "Richiesta non riuscita. Riprova.": "La requête a échoué. Réessayez." } diff --git a/locale/it_IT.json b/locale/it_IT.json index 0af14667..d0ba7c9b 100644 --- a/locale/it_IT.json +++ b/locale/it_IT.json @@ -6525,5 +6525,18 @@ "Spaziatura": "Spaziatura", "Margine interno (mm)": "Margine interno (mm)", "Il contenuto si adatta automaticamente alle dimensioni dell'etichetta. Aggiungi un margine interno extra per rifinire il layout.": "Il contenuto si adatta automaticamente alle dimensioni dell'etichetta. Aggiungi un margine interno extra per rifinire il layout.", - "Sembra che tu stia eseguendo l'immagine Docker: i file dell'applicazione appartengono all'immagine e non sono modificabili da questo pulsante. Aggiorna spostando il container alla nuova immagine con «docker compose pull && docker compose up -d» (i tuoi dati nel database e nei volumi storage/uploads restano al sicuro). Il pulsante di aggiornamento è pensato per installazioni classiche/hosting condiviso dove l'utente del web server è proprietario dei file.": "Sembra che tu stia eseguendo l'immagine Docker: i file dell'applicazione appartengono all'immagine e non sono modificabili da questo pulsante. Aggiorna spostando il container alla nuova immagine con «docker compose pull && docker compose up -d» (i tuoi dati nel database e nei volumi storage/uploads restano al sicuro). Il pulsante di aggiornamento è pensato per installazioni classiche/hosting condiviso dove l'utente del web server è proprietario dei file." + "Sembra che tu stia eseguendo l'immagine Docker: i file dell'applicazione appartengono all'immagine e non sono modificabili da questo pulsante. Aggiorna spostando il container alla nuova immagine con «docker compose pull && docker compose up -d» (i tuoi dati nel database e nei volumi storage/uploads restano al sicuro). Il pulsante di aggiornamento è pensato per installazioni classiche/hosting condiviso dove l'utente del web server è proprietario dei file.": "Sembra che tu stia eseguendo l'immagine Docker: i file dell'applicazione appartengono all'immagine e non sono modificabili da questo pulsante. Aggiorna spostando il container alla nuova immagine con «docker compose pull && docker compose up -d» (i tuoi dati nel database e nei volumi storage/uploads restano al sicuro). Il pulsante di aggiornamento è pensato per installazioni classiche/hosting condiviso dove l'utente del web server è proprietario dei file.", + "Nessun destinatario: inserisci un indirizzo email o assicurati che il tuo account admin ne abbia uno.": "Nessun destinatario: inserisci un indirizzo email o assicurati che il tuo account admin ne abbia uno.", + "Email di prova inviata a %s. Controlla la casella (anche lo spam).": "Email di prova inviata a %s. Controlla la casella (anche lo spam).", + "Invio fallito: %s": "Invio fallito: %s", + "Indirizzo email del destinatario non valido.": "Indirizzo email del destinatario non valido.", + "Email di prova da Pinakes": "Email di prova da Pinakes", + "Questa è un'email di prova inviata dalle impostazioni di Pinakes. Se la ricevi, la configurazione email funziona.": "Questa è un'email di prova inviata dalle impostazioni di Pinakes. Se la ricevi, la configurazione email funziona.", + "La funzione mail() di PHP ha restituito un errore. Verifica la configurazione del server di posta.": "La funzione mail() di PHP ha restituito un errore. Verifica la configurazione del server di posta.", + "Prova invio": "Prova invio", + "Invia un'email di prova con le impostazioni attuali e vedi subito se funziona o qual è l'errore. Salva prima le impostazioni.": "Invia un'email di prova con le impostazioni attuali e vedi subito se funziona o qual è l'errore. Salva prima le impostazioni.", + "Destinatario (vuoto = la tua email admin)": "Destinatario (vuoto = la tua email admin)", + "Invia email di prova": "Invia email di prova", + "Invio in corso…": "Invio in corso…", + "Richiesta non riuscita. Riprova.": "Richiesta non riuscita. Riprova." } From 7921d850909cb794e7b05ff012d8de89e288e362 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Mon, 13 Jul 2026 16:56:59 +0200 Subject: [PATCH 2/9] feat(loans): Due date column + overdue highlighting in loans list & dashboard (#238) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nikola asked for loan due dates to be more visible. Two changes: 1. Loans List (/admin/loans) now has a dedicated 'Scadenza' (Due date) column. Previously the due date was only shown squeezed under the loan-start date. The due date turns red (text-red-600 font-semibold) when an active loan (in_corso/in_ritardo) is due today or overdue — the same red-highlight rule the Physical Copies list uses. The server-side endpoint now returns data_scadenza; the DataTable computes the highlight on the local date to match the stored dates. 2. Dashboard 'Prestiti in Corso' list applies the same red date highlight for due-today/overdue loans. The status badge stays faithful to the real loan state (driven by p.stato): a loan due today reads 'In corso' with a red date (act-now cue), while a genuinely late one reads 'In Ritardo'. Previously the badge was hard-coded 'In corso' even for overdue loans. Verified in the browser against seeded loans: a due-today loan and an overdue loan both render the due date in red in both places; badges are accurate. PHPStan L5 clean; no new locale strings (all reused). --- app/Controllers/PrestitiApiController.php | 3 +- app/Views/dashboard/index.php | 29 ++++++++++++++--- app/Views/prestiti/index.php | 39 +++++++++++++++++++---- 3 files changed, 58 insertions(+), 13 deletions(-) diff --git a/app/Controllers/PrestitiApiController.php b/app/Controllers/PrestitiApiController.php index f2892122..6af1b3b8 100644 --- a/app/Controllers/PrestitiApiController.php +++ b/app/Controllers/PrestitiApiController.php @@ -135,7 +135,7 @@ public function list(Request $request, Response $response, mysqli $db): Response $param_types .= 'ii'; $sql_prepared = "SELECT p.id, l.titolo AS libro, CONCAT(u.nome,' ',u.cognome) AS utente, - p.data_prestito, p.data_restituzione, p.attivo, p.stato, + p.data_prestito, p.data_scadenza, p.data_restituzione, p.attivo, p.stato, CONCAT(staff.nome,' ',staff.cognome) AS processed_by $base $where_prepared ORDER BY $orderColumn $orderDir LIMIT ?, ?"; @@ -163,6 +163,7 @@ public function list(Request $request, Response $response, mysqli $db): Response 'libro' => $r['libro'] ?? '', 'utente' => $r['utente'] ?? '', 'data_prestito' => $r['data_prestito'] ?? '', + 'data_scadenza' => $r['data_scadenza'] ?? '', 'data_restituzione' => $r['data_restituzione'] ?? '', 'processed_by' => $r['processed_by'] ?? '', 'attivo' => (int)$r['attivo'], diff --git a/app/Views/dashboard/index.php b/app/Views/dashboard/index.php index 4e4b7f9e..3b0b79e3 100644 --- a/app/Views/dashboard/index.php +++ b/app/Views/dashboard/index.php @@ -650,14 +650,33 @@ class="w-20 h-28 object-cover rounded-lg shadow-sm" - + + - - - - + + + + + + + + + + + diff --git a/app/Views/prestiti/index.php b/app/Views/prestiti/index.php index db19b9d8..ac41649c 100644 --- a/app/Views/prestiti/index.php +++ b/app/Views/prestiti/index.php @@ -323,6 +323,7 @@ class="inline-flex items-center gap-2 whitespace-nowrap px-4 py-2 bg-gray-100 te + @@ -331,7 +332,7 @@ class="inline-flex items-center gap-2 whitespace-nowrap px-4 py-2 bg-gray-100 te - +

@@ -348,12 +349,17 @@ class="inline-flex items-center gap-2 whitespace-nowrap px-4 py-2 bg-gray-100 te
-
- -
-
- -
+ + + + + + + @@ -458,6 +464,25 @@ function escHtml(str) { return `
${dataPrestito}
`; } }, + { + data: 'data_scadenza', + render: function(data, type, row) { + if (!data) { + return `${window.__('N/D')}`; + } + // Same red-highlight rule as the Physical Copies list: an active + // loan (in_corso/in_ritardo) whose due date is today or past. + // Compare on local date to match the server's stored dates. + const now = new Date(); + const today = now.getFullYear() + '-' + + String(now.getMonth() + 1).padStart(2, '0') + '-' + + String(now.getDate()).padStart(2, '0'); + const isOverdue = data <= today && + (row.stato === 'in_corso' || row.stato === 'in_ritardo'); + const cls = isOverdue ? 'text-red-600 font-semibold' : 'text-gray-700'; + return `${formatDateLocale(data)}`; + } + }, { data: 'stato', className: 'text-center', From 04512cdd498f4404164b0824c33387e5d00d7626 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Mon, 13 Jul 2026 21:23:56 +0200 Subject: [PATCH 3/9] fix(loans): notify due-today loans, correct DataTables column map, unify on app timezone (#238) Follow-up refinements to the due-date visibility work: - NotificationService: the expiration-warning query matched data_scadenza exactly at 'today + N days', which silently skipped a loan whose due date is today (Nikola's exact repro). Widen it to BETWEEN today AND today + N so due-today and due-soon loans both get warned; the in-app notification now reads 'scade oggi' when zero days remain. Clamp the configured horizon to >= 0. - PrestitiApiController: the DataTables column map still assumed the old layout (index 3 = stato) after the Due-date column was inserted at index 3, so clicking the 'Scadenza' / 'Stato' headers sorted by the wrong SQL column. Remap 3 -> p.data_scadenza, 4 -> p.stato. - Loans list + dashboard: compute the due-today/overdue highlight against the application's configured date (DateHelper::today(), passed to the DataTable as applicationToday) instead of the browser's or PHP process's local date, and only highlight active loans. Consistent with how loan dates are compared elsewhere and avoids timezone-edge flicker. - Tests: E2E B.9b registers a loan due today and asserts the warning email is delivered and warning_sent flips; a new static unit test guards the query shape, the column map, the timezone source, and locale parity. PHPStan L5 clean; unit test 9/9; browser-verified the red highlight for due-today and overdue loans; locale parity 6541 keys across all four. --- app/Controllers/PrestitiApiController.php | 7 +-- app/Support/NotificationService.php | 19 +++++--- app/Views/dashboard/index.php | 5 +- app/Views/prestiti/index.php | 27 +++++------ locale/de_DE.json | 1 + locale/en_US.json | 1 + locale/fr_FR.json | 1 + locale/it_IT.json | 1 + tests/email-notifications.spec.js | 28 +++++++++++ tests/loan-due-visibility-email.unit.php | 59 +++++++++++++++++++++++ 10 files changed, 122 insertions(+), 27 deletions(-) create mode 100644 tests/loan-due-visibility-email.unit.php diff --git a/app/Controllers/PrestitiApiController.php b/app/Controllers/PrestitiApiController.php index 6af1b3b8..6b3092d5 100644 --- a/app/Controllers/PrestitiApiController.php +++ b/app/Controllers/PrestitiApiController.php @@ -109,13 +109,14 @@ public function list(Request $request, Response $response, mysqli $db): Response $orderDir = 'DESC'; // Map column index to database column - // 0: libro, 1: utente, 2: data_prestito, 3: stato, 4: actions + // 0: libro, 1: utente, 2: data_prestito, 3: data_scadenza, + // 4: stato; columns 5 (PDF) and 6 (actions) are not orderable. $columnMap = [ 0 => 'l.titolo', // Libro 1 => 'utente', // Utente (computed column) 2 => 'p.data_prestito',// Data Prestito - 3 => 'p.stato', // Stato - 4 => 'p.id' // Actions (fallback to id) + 3 => 'p.data_scadenza',// Scadenza + 4 => 'p.stato' // Stato ]; // Parse order parameter from DataTables diff --git a/app/Support/NotificationService.php b/app/Support/NotificationService.php index 2e667c86..2de94750 100644 --- a/app/Support/NotificationService.php +++ b/app/Support/NotificationService.php @@ -366,7 +366,7 @@ public function notifyLoanRequest(int $loanId): bool { } /** - * Invia avvisi di scadenza prestiti (3 giorni prima) + * Invia avvisi per prestiti in scadenza entro la finestra configurata. * Uses atomic mark-then-send pattern to prevent duplicate notifications */ public function sendLoanExpirationWarnings(): int { @@ -374,14 +374,17 @@ public function sendLoanExpirationWarnings(): int { try { // Get configured days before expiry warning (default: 3) - $daysBeforeWarning = (int)ConfigStore::get('advanced.days_before_expiry_warning', 3); + $daysBeforeWarning = max(0, (int)ConfigStore::get('advanced.days_before_expiry_warning', 3)); // "Oggi" nel timezone applicativo come parametro bound (M9): CURDATE() // dipende dalla session timezone del client DB, che differiva tra cron // (UTC forzato) e web (nessuna impostazione). $today = DateHelper::today(); - // Get loans expiring in X days + // Include every still-unnotified loan from today through the configured + // warning horizon. An exact "today + X" match misses short loans created + // inside that horizon (especially loans created with due date today). + // Overdue loans remain handled separately below by the overdue workflow. $stmt = $this->db->prepare(" SELECT p.id, p.data_scadenza, l.titolo as libro_titolo, CONCAT(u.nome, ' ', u.cognome) as utente_nome, u.email as utente_email, @@ -391,10 +394,10 @@ public function sendLoanExpirationWarnings(): int { JOIN utenti u ON p.utente_id = u.id WHERE p.stato = 'in_corso' AND p.attivo = 1 - AND p.data_scadenza = DATE_ADD(?, INTERVAL ? DAY) + AND p.data_scadenza BETWEEN ? AND DATE_ADD(?, INTERVAL ? DAY) AND (p.warning_sent IS NULL OR p.warning_sent = 0) "); - $stmt->bind_param('ssi', $today, $today, $daysBeforeWarning); + $stmt->bind_param('sssi', $today, $today, $today, $daysBeforeWarning); $stmt->execute(); $result = $stmt->get_result(); @@ -433,10 +436,14 @@ public function sendLoanExpirationWarnings(): int { if ($emailSent) { // Create in-app notification for expiring loan + $daysRemaining = (int)$loan['giorni_rimasti']; + $notificationMessage = $daysRemaining === 0 + ? sprintf(__('"%s" prestato a %s scade oggi'), $loan['libro_titolo'], $loan['utente_nome']) + : sprintf(__('"%s" prestato a %s scade fra %d giorni'), $loan['libro_titolo'], $loan['utente_nome'], $daysRemaining); $this->createNotification( 'general', __('Prestito in scadenza'), - sprintf(__('"%s" prestato a %s scade fra %d giorni'), $loan['libro_titolo'], $loan['utente_nome'], (int)$loan['giorni_rimasti']), + $notificationMessage, '/admin/loans', (int)$loan['id'] ); diff --git a/app/Views/dashboard/index.php b/app/Views/dashboard/index.php index 3b0b79e3..3949a1f9 100644 --- a/app/Views/dashboard/index.php +++ b/app/Views/dashboard/index.php @@ -3,6 +3,7 @@ /** @var array $calendarEvents */ use App\Support\ConfigStore; $isCatalogueMode = ConfigStore::isCatalogueMode(); +$applicationToday = \App\Support\DateHelper::today(); ?>
@@ -212,7 +213,7 @@
@@ -656,7 +657,7 @@ class="w-20 h-28 object-cover rounded-lg shadow-sm" // The date turning red is a visibility signal; it does NOT mean the // loan is formally late (a loan due today is still on time). $dueSoonOrOverdue = !empty($p['data_scadenza']) - && strtotime((string)$p['data_scadenza']) <= strtotime(date('Y-m-d')); + && (string)$p['data_scadenza'] <= $applicationToday; // The status badge reflects the REAL loan state (in_ritardo is set // by the maintenance overdue-updater), so a due-today loan still // reads "In corso" while a genuinely late one reads "In Ritardo". diff --git a/app/Views/prestiti/index.php b/app/Views/prestiti/index.php index ac41649c..e7c5b453 100644 --- a/app/Views/prestiti/index.php +++ b/app/Views/prestiti/index.php @@ -28,6 +28,7 @@ function getStatusBadge($status) { return "" . __("Sconosciuto") . ""; } } +$applicationToday = \App\Support\DateHelper::today(); ?> @@ -352,12 +353,13 @@ class="inline-flex items-center gap-2 whitespace-nowrap px-4 py-2 bg-gray-100 te - + @@ -427,6 +429,7 @@ function escHtml(str) { } let currentStatusFilter = ''; + const applicationToday = ; // Initialize DataTable const table = new DataTable('#prestiti-table', { @@ -472,14 +475,11 @@ function escHtml(str) { } // Same red-highlight rule as the Physical Copies list: an active // loan (in_corso/in_ritardo) whose due date is today or past. - // Compare on local date to match the server's stored dates. - const now = new Date(); - const today = now.getFullYear() + '-' + - String(now.getMonth() + 1).padStart(2, '0') + '-' + - String(now.getDate()).padStart(2, '0'); - const isOverdue = data <= today && + // Compare against the application's configured timezone, not + // the browser or PHP process timezone. + const isDueNowOrOverdue = data <= applicationToday && Number(row.attivo) === 1 && (row.stato === 'in_corso' || row.stato === 'in_ritardo'); - const cls = isOverdue ? 'text-red-600 font-semibold' : 'text-gray-700'; + const cls = isDueNowOrOverdue ? 'text-red-600 font-semibold' : 'text-gray-700'; return `${formatDateLocale(data)}`; } }, @@ -531,13 +531,8 @@ className: 'text-right', `; // Show "Conferma Ritiro" button for da_ritirare OR prenotato with today's date - // Use local date (not UTC) to correctly compare with server dates - const now = new Date(); - const today = now.getFullYear() + '-' + - String(now.getMonth() + 1).padStart(2, '0') + '-' + - String(now.getDate()).padStart(2, '0'); const isReadyForPickup = row.stato === 'da_ritirare' || - (row.stato === 'prenotato' && row.data_prestito && row.data_prestito <= today); + (row.stato === 'prenotato' && row.data_prestito && row.data_prestito <= applicationToday); if (isReadyForPickup) { actions += `