Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions app/Controllers/PrestitiApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -135,7 +136,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 ?, ?";
Expand Down Expand Up @@ -163,6 +164,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'],
Expand Down
43 changes: 43 additions & 0 deletions app/Controllers/SettingsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
11 changes: 11 additions & 0 deletions app/Models/SettingsRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,17 @@ public function get(string $category, string $key, ?string $default = null): ?st
return (string)$value;
}

/**
* Days-before-expiry warning horizon (Settings → Advanced). Zero is a valid
* value meaning "today only"; negative/corrupt values clamp to 0. Shared by the
* web reminders (NotificationService) and the mobile-api due-soon feed/push so
* the two can never diverge on the setting key, fallback or clamp.
*/
public function daysBeforeExpiryWarning(): int
{
return max(0, (int)($this->get('advanced', 'days_before_expiry_warning', '3') ?? 3));
}

/**
* @return array<string,string>
*/
Expand Down
8 changes: 8 additions & 0 deletions app/Routes/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -571,6 +571,14 @@
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 \App\Middleware\RateLimitMiddleware(5, 60, 'email_test')) // triggers an external SMTP handshake — throttle like the other costly admin routes
->add(new CsrfMiddleware())
->add(new AdminAuthMiddleware());

Comment thread
coderabbitai[bot] marked this conversation as resolved.
$app->post('/admin/settings/contacts', function ($request, $response) use ($app) {
$db = $app->getContainer()->get('db');
$controller = new SettingsController();
Expand Down
190 changes: 91 additions & 99 deletions app/Support/Mailer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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)) {
Expand All @@ -151,43 +82,104 @@ 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')) {
// Fallback to mail()
return self::sendMail($to, $subject, $htmlBody, $fromEmail, $fromName);
}
$result = self::dispatchPHPMailer($to, $subject, $htmlBody, $textBody, $fromEmail, $fromName);
if (!$result['ok']) {
SecureLogger::warning('Email send failed: ' . $result['error']);
}
return $result['ok'];
}

/**
* Build and send one PHPMailer message. Single source of truth for the real
* send path, so the diagnostic sendTest() exercises exactly what production
* uses — if CC/BCC/AltBody/etc. change here they can't silently drift apart.
* Returns the real outcome (the specific error on failure), never throws.
*
* @return array{ok: bool, error: string}
*/
private static function dispatchPHPMailer(string $to, string $subject, string $htmlBody, ?string $textBody, string $fromEmail, string $fromName): array
{
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();
$mailer->send();
return ['ok' => true, 'error' => ''];
} catch (\Throwable $e) {
return false;
return ['ok' => false, 'error' => $e->getMessage()];
}
}

/**
* 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 = '<p>' . __('Questa è un\'email di prova inviata dalle impostazioni di Pinakes. Se la ricevi, la configurazione email funziona.') . '</p>';

// SMTP / PHPMailer path: reuse the exact real send path so the test is a
// faithful diagnostic, and capture its real handshake/auth/recipient error.
if (($driver === 'smtp' || $driver === 'phpmailer') && class_exists('PHPMailer\\PHPMailer\\PHPMailer')) {
return self::dispatchPHPMailer($to, $subject, $html, null, $fromEmail, $fromName);
}

// 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.')];
}

}
Loading
Loading