From a7aad57e0b9c40b8376ae73d1beab1b71ddf7632 Mon Sep 17 00:00:00 2001 From: Niek Rabelink Date: Thu, 10 Sep 2026 09:57:53 +0200 Subject: [PATCH] Add password reset functionality and SMTP mailer class - Implemented reset-password.php for users to reset their passwords securely. - Added validation for reset tokens and password strength checks. - Created mailer.php to handle sending emails via SMTP, including password reset and invitation emails. - Integrated responsive HTML email templates for better user experience. --- admin.php | 86 ++++++ auth/forgot-password.php | 207 ++++++++++++++ auth/login.php | 5 +- auth/reset-password.php | 280 ++++++++++++++++++ config/.env.example | 10 + config/config.php | 12 + config/database.php | 1 + config/schema.sql | 11 + includes/mailer.php | 597 +++++++++++++++++++++++++++++++++++++++ users.php | 44 ++- 10 files changed, 1248 insertions(+), 5 deletions(-) create mode 100644 auth/forgot-password.php create mode 100644 auth/reset-password.php create mode 100644 includes/mailer.php diff --git a/admin.php b/admin.php index f456b07..d96e99e 100644 --- a/admin.php +++ b/admin.php @@ -136,6 +136,25 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $message = "AI Co-Pilot instellingen zijn succesvol opgeslagen."; $messageType = 'success'; } + + // 5. Test e-mail versturen via SMTP (Release v1.6) + elseif ($action === 'send_test_email') { + require_once __DIR__ . '/includes/mailer.php'; + $testEmail = trim($_POST['test_email'] ?? ''); + if (empty($testEmail) || !filter_var($testEmail, FILTER_VALIDATE_EMAIL)) { + $message = "Vul een geldig e-mailadres in om een test e-mail te sturen."; + $messageType = 'error'; + } else { + $mailResult = FocizMailer::sendTestEmail($testEmail, $currentUser['name']); + if ($mailResult['success']) { + $message = "Test e-mail succesvol verzonden via mail.fociz.nl naar " . htmlspecialchars($testEmail) . "!"; + $messageType = 'success'; + } else { + $message = "Fout bij verzenden test e-mail via mail.fociz.nl: " . htmlspecialchars($mailResult['error'] ?? 'Onbekende fout'); + $messageType = 'error'; + } + } + } } // Haal actuele configuratie op @@ -646,6 +665,73 @@ require_once __DIR__ . '/includes/header.php'; + + +
+
+
+
+ +
+
+

SMTP Mail Server & Notificaties

+

Uitgaande e-mailserver voor wachtwoord-vergeten, uitnodigingen en notificaties

+
+
+ + mail.fociz.nl:587 • Actief + +
+ + +
+
+ SMTP Host & Poort: + : (STARTTLS) +
+
+ SMTP Gebruiker: + +
+
+ Zichtbare Afzender (From): + <> +
+
+ Antwoordadres (Reply-To): + +
+
+ + +
+

+ + Directe SMTP Verbindingstest +

+

+ Verstuur een realtime testmail met het Fociz HTML-sjabloon om de verbinding, STARTTLS-handshake en aflevering direct te verifiëren. +

+ +
+ +
+ +
+ +
+
+
diff --git a/auth/forgot-password.php b/auth/forgot-password.php new file mode 100644 index 0000000..335dbd9 --- /dev/null +++ b/auth/forgot-password.php @@ -0,0 +1,207 @@ +prepare("SELECT id, name, email FROM users WHERE email = ?"); + $stmt->execute([$email]); + $user = $stmt->fetch(); + + if ($user) { + // Verwijder eventuele eerdere openstaande reset tokens voor dit e-mailadres + $stmtClean = $db->prepare("DELETE FROM password_resets WHERE email = ? AND used_at IS NULL"); + $stmtClean->execute([$email]); + + // Genereer veilig uniek token + $token = bin2hex(random_bytes(32)); + $expiresAt = date('Y-m-d H:i:s', strtotime('+1 hour')); + + $stmtInsert = $db->prepare(" + INSERT INTO password_resets (email, token, expires_at) + VALUES (?, ?, ?) + "); + $stmtInsert->execute([$email, $token, $expiresAt]); + + // Bouw herstel-URL + $baseUrl = rtrim(APP_URL, '/'); + $resetUrl = "{$baseUrl}/auth/reset-password.php?token={$token}"; + + // Verstuur herstelmail via de nieuwe FocizMailer + $mailResult = FocizMailer::sendPasswordResetEmail($user['email'], $user['name'], $resetUrl); + if (!$mailResult['success']) { + error_log("Fout bij verzenden wachtwoordherstel mail naar {$email}: " . ($mailResult['error'] ?? 'Onbekend')); + } + } + + // Anti-enumeratie: Toon altijd succes ongeacht of het e-mailadres bestaat + $submitted = true; + + } catch (Throwable $e) { + error_log("Wachtwoord vergeten fout: " . $e->getMessage()); + $error = "Er is een technische fout opgetreden. Probeer het over enkele ogenblikken opnieuw."; + } + } +} + +$pageTitle = "Fociz — Wachtwoord Vergeten"; +?> + + + + + + <?= htmlspecialchars($pageTitle) ?> + + + + + + + + + + + + + +
+ + +
+
+ +
+

Wachtwoord Vergeten?

+

+ Geen probleem. Vul uw e-mailadres in en we sturen u een veilige herstellink. +

+
+ + +
+ + +
+ + + +
+
+
+ +
+ Herstellink verzonden! + + Als het opgegeven e-mailadres bij ons bekend is, ontvangt u binnen enkele ogenblikken een e-mail met instructies om uw wachtwoord opnieuw in te stellen. + +
+
+
+ +
+

💡 Tip: Heeft u na enkele minuten nog niets ontvangen?

+
    +
  • Controleer uw map met Ongewenste e-mail of Spam.
  • +
  • Controleer of u het juiste e-mailadres heeft ingevuld.
  • +
+
+ + + + Terug naar Inloggen + +
+ +
+
+ +
+ +
+
+ + +
+ + + + +
+ Beveiligd met end-to-end authenticatie • Fociz 2026 +
+
+ + + + + diff --git a/auth/login.php b/auth/login.php index eb8282b..5882b87 100644 --- a/auth/login.php +++ b/auth/login.php @@ -215,7 +215,10 @@ $pageTitle = "Fociz — Inloggen";
- +
+ + Wachtwoord vergeten? +
diff --git a/auth/reset-password.php b/auth/reset-password.php new file mode 100644 index 0000000..89fa560 --- /dev/null +++ b/auth/reset-password.php @@ -0,0 +1,280 @@ +prepare(" + SELECT pr.*, u.name, u.email as user_email + FROM password_resets pr + JOIN users u ON pr.email = u.email + WHERE pr.token = ? AND pr.used_at IS NULL AND pr.expires_at > NOW() + "); + $stmt->execute([$token]); + $resetRecord = $stmt->fetch(); +} + +$error = null; +$success = false; + +if ($_SERVER['REQUEST_METHOD'] === 'POST' && $resetRecord) { + $password = $_POST['password'] ?? ''; + $confirmPassword = $_POST['confirm_password'] ?? ''; + + if (empty($password)) { + $error = "Vul een nieuw wachtwoord in."; + } elseif (strlen($password) < 8) { + $error = "Het wachtwoord moet minimaal 8 tekens lang zijn."; + } elseif ($password !== $confirmPassword) { + $error = "De wachtwoorden komen niet overeen."; + } else { + try { + $db->beginTransaction(); + + $newHash = password_hash($password, PASSWORD_DEFAULT); + + // 1. Update wachtwoord van de gebruiker en reset remember tokens + $stmtUser = $db->prepare(" + UPDATE users + SET password_hash = ?, remember_token = NULL, remember_token_expires_at = NULL + WHERE email = ? + "); + $stmtUser->execute([$newHash, $resetRecord['user_email']]); + + // 2. Markeer reset token als verbruikt + $stmtToken = $db->prepare("UPDATE password_resets SET used_at = NOW() WHERE token = ?"); + $stmtToken->execute([$token]); + + $db->commit(); + $success = true; + + } catch (Throwable $e) { + if ($db->inTransaction()) $db->rollBack(); + error_log("Fout bij resetten wachtwoord: " . $e->getMessage()); + $error = "Er is een fout opgetreden bij het opslaan van uw nieuwe wachtwoord."; + } + } +} + +$pageTitle = "Fociz — Wachtwoord Opnieuw Instellen"; +?> + + + + + + <?= htmlspecialchars($pageTitle) ?> + + + + + + + + + + + + + +
+ + + +
+
+ +
+

Herstellink Ongeldig

+

+ Deze wachtwoordherstellink is ongeldig, verlopen (langer dan 60 minuten geleden aangevraagd) of al eerder gebruikt. +

+ +
+ + + +
+
+ +
+

Wachtwoord Gewijzigd!

+

+ Uw nieuwe wachtwoord is succesvol opgeslagen. U kunt nu direct inloggen in Fociz. +

+ + Inloggen met Nieuw Wachtwoord + + +
+ + + +
+
+ +
+

Nieuw Wachtwoord

+

+ Kies een nieuw veilig wachtwoord voor +

+
+ + +
+ + +
+ + +
+ + +
+ +
+ +
+ +
+
+
+
+ +
+
+ +
+ +
+ +
+
+ + +
+ + + + +
+ Beveiligd met end-to-end authenticatie • Fociz 2026 +
+
+ + + + + diff --git a/config/.env.example b/config/.env.example index e87ef4a..b930d6c 100644 --- a/config/.env.example +++ b/config/.env.example @@ -25,4 +25,14 @@ JIRA_DOMAIN="" JIRA_EMAIL="" JIRA_API_TOKEN="" +# SMTP Mail Server +SMTP_HOST="mail.fociz.nl" +SMTP_PORT="587" +SMTP_USER="info@fociz.nl" +SMTP_PASS="" +SMTP_SECURE="tls" +SMTP_FROM_EMAIL="no-reply@fociz.nl" +SMTP_FROM_NAME="Fociz" +SMTP_REPLY_TO="info@fociz.nl" + diff --git a/config/config.php b/config/config.php index b3ca0c8..127691e 100644 --- a/config/config.php +++ b/config/config.php @@ -128,6 +128,18 @@ define('APP_URL', fociz_detect_app_url()); // Tijdzone date_default_timezone_set('Europe/Amsterdam'); +// ========================================================================= +// 3b. SMTP MAIL SERVER CONFIGURATIE +// ========================================================================= +define('SMTP_HOST', fociz_env('SMTP_HOST', 'mail.fociz.nl')); +define('SMTP_PORT', (int)fociz_env('SMTP_PORT', 587)); +define('SMTP_USER', fociz_env('SMTP_USER', 'info@fociz.nl')); +define('SMTP_PASS', fociz_env('SMTP_PASS', '6swpQSBg5uWFmjar7F6a')); +define('SMTP_SECURE', fociz_env('SMTP_SECURE', 'tls')); // 'tls' (STARTTLS port 587) of 'ssl' (port 465) +define('SMTP_FROM_EMAIL', fociz_env('SMTP_FROM_EMAIL', 'no-reply@fociz.nl')); +define('SMTP_FROM_NAME', fociz_env('SMTP_FROM_NAME', 'Fociz')); +define('SMTP_REPLY_TO', fociz_env('SMTP_REPLY_TO', 'info@fociz.nl')); + // ========================================================================= // 4. VEILIGE FOUTAFHANDELING & PEN TEST BESCHERMING // ========================================================================= diff --git a/config/database.php b/config/database.php index 74462a2..77d4cd4 100644 --- a/config/database.php +++ b/config/database.php @@ -148,6 +148,7 @@ function ensure_tables_exist(PDO $pdo): void { "ALTER TABLE users ADD COLUMN avatar_url VARCHAR(255) NULL AFTER theme", "ALTER TABLE users ADD COLUMN backup_codes TEXT NULL AFTER two_factor_enabled", "ALTER TABLE board_card_schedules ADD UNIQUE KEY uk_sched_card (card_id)", + "CREATE TABLE IF NOT EXISTS `password_resets` (`id` INT AUTO_INCREMENT PRIMARY KEY, `email` VARCHAR(191) NOT NULL, `token` VARCHAR(64) NOT NULL UNIQUE, `expires_at` DATETIME NOT NULL, `used_at` DATETIME NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX (`email`), INDEX (`token`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", ]; foreach ($colUpgrades as $sql) { try { diff --git a/config/schema.sql b/config/schema.sql index 992abbe..d49f3c2 100644 --- a/config/schema.sql +++ b/config/schema.sql @@ -31,6 +31,17 @@ CREATE TABLE IF NOT EXISTS `invites` ( CONSTRAINT `fk_invites_user` FOREIGN KEY (`created_by_id`) REFERENCES `users` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE TABLE IF NOT EXISTS `password_resets` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `email` VARCHAR(191) NOT NULL, + `token` VARCHAR(64) NOT NULL UNIQUE, + `expires_at` DATETIME NOT NULL, + `used_at` DATETIME NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX (`email`), + INDEX (`token`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + CREATE TABLE IF NOT EXISTS `daily_plans` ( `id` INT AUTO_INCREMENT PRIMARY KEY, `user_id` INT NOT NULL, diff --git a/includes/mailer.php b/includes/mailer.php new file mode 100644 index 0000000..b5fd43c --- /dev/null +++ b/includes/mailer.php @@ -0,0 +1,597 @@ + bool, 'error' => ?string, 'logs' => array] + */ + public static function send( + string $toEmail, + string $toName, + string $subject, + string $htmlBody, + ?string $plainTextBody = null, + array $options = [] + ): array { + $logs = []; + $log = function(string $msg) use (&$logs) { + $logs[] = date('H:i:s') . ' ' . $msg; + }; + + // 1. Configuratieparameters ophalen + $host = $options['host'] ?? (defined('SMTP_HOST') ? SMTP_HOST : 'mail.fociz.nl'); + $port = (int)($options['port'] ?? (defined('SMTP_PORT') ? SMTP_PORT : 587)); + $username = $options['username'] ?? (defined('SMTP_USER') ? SMTP_USER : 'info@fociz.nl'); + $password = $options['password'] ?? (defined('SMTP_PASS') ? SMTP_PASS : ''); + $secure = strtolower($options['secure'] ?? (defined('SMTP_SECURE') ? SMTP_SECURE : 'tls')); + $fromEmail = $options['from_email'] ?? (defined('SMTP_FROM_EMAIL') ? SMTP_FROM_EMAIL : 'no-reply@fociz.nl'); + $fromName = $options['from_name'] ?? (defined('SMTP_FROM_NAME') ? SMTP_FROM_NAME : 'Fociz'); + $replyTo = $options['reply_to'] ?? (defined('SMTP_REPLY_TO') ? SMTP_REPLY_TO : 'info@fociz.nl'); + $timeout = (int)($options['timeout'] ?? 15); + + // Technische afzender voor de SMTP envelope (moet doorgaans overeenkomen met het geauthenticeerde account) + $envelopeFrom = !empty($username) && filter_var($username, FILTER_VALIDATE_EMAIL) ? $username : $fromEmail; + + if (empty($plainTextBody)) { + $plainTextBody = self::htmlToPlainText($htmlBody); + } + + $log("Verbinding initialiseren naar {$host}:{$port} (beveiliging: {$secure})..."); + + // 2. Socket verbinden + $socketContext = stream_context_create([ + 'ssl' => [ + 'verify_peer' => false, + 'verify_peer_name' => false, + 'allow_self_signed' => true, + ] + ]); + + $remoteTarget = ($secure === 'ssl' || $port === 465) ? "ssl://{$host}:{$port}" : "tcp://{$host}:{$port}"; + $errno = 0; + $errstr = ''; + + $socket = @stream_socket_client( + $remoteTarget, + $errno, + $errstr, + $timeout, + STREAM_CLIENT_CONNECT, + $socketContext + ); + + if (!$socket) { + $err = "Kan geen socketverbinding openen naar {$host}:{$port} [{$errno}]: {$errstr}"; + $log("FOUT: {$err}"); + error_log("FocizMailer: " . $err); + return ['success' => false, 'error' => $err, 'logs' => $logs]; + } + + stream_set_timeout($socket, $timeout); + + // Helperfuncties voor SMTP commando's + $readResponse = function() use ($socket, $log): array { + $data = ''; + $code = 0; + while (!feof($socket)) { + $line = fgets($socket, 1024); + if ($line === false) break; + $data .= $line; + if (preg_match('/^([0-9]{3})([ -])(.*)$/', $line, $matches)) { + $code = (int)$matches[1]; + if ($matches[2] === ' ') { + // Laatste regel van het antwoord + break; + } + } + } + $log("<< " . trim(preg_replace('/\r?\n/', ' | ', $data))); + return ['code' => $code, 'text' => $data]; + }; + + $sendCommand = function(string $cmd, string $maskLog = '') use ($socket, $log, $readResponse): array { + $logMsg = !empty($maskLog) ? $maskLog : $cmd; + $log(">> " . trim($logMsg)); + fwrite($socket, $cmd . "\r\n"); + return $readResponse(); + }; + + try { + // 3. Welkomstbanner lezen (220) + $resp = $readResponse(); + if ($resp['code'] !== 220) { + throw new Exception("Ongeldige server banner (verwacht 220, kreeg {$resp['code']}): {$resp['text']}"); + } + + // 4. EHLO Handshake + $clientHost = !empty($_SERVER['SERVER_NAME']) ? $_SERVER['SERVER_NAME'] : 'fociz.nl'; + $resp = $sendCommand("EHLO {$clientHost}"); + if ($resp['code'] !== 250) { + $resp = $sendCommand("HELO {$clientHost}"); + if ($resp['code'] !== 250) { + throw new Exception("EHLO/HELO geweigerd door server: {$resp['text']}"); + } + } + + // 5. STARTTLS negotiatie indien vereist (poort 587 of 'tls') + if ($secure === 'tls' || ($port === 587 && $secure !== 'none')) { + $log("STARTTLS handshake starten..."); + $resp = $sendCommand("STARTTLS"); + if ($resp['code'] !== 220) { + throw new Exception("STARTTLS geweigerd door server (code {$resp['code']}): {$resp['text']}"); + } + + // Crypto activeren op de actieve stream + $cryptoMethod = STREAM_CRYPTO_METHOD_TLS_CLIENT; + if (defined('STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT')) { + $cryptoMethod |= STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT; + } + if (defined('STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT')) { + $cryptoMethod |= STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT; + } + + $cryptoSuccess = @stream_socket_enable_crypto($socket, true, $cryptoMethod); + if (!$cryptoSuccess) { + throw new Exception("TLS encryptiehandshake kon niet tot stand worden gebracht."); + } + $log("TLS encryptie succesvol geactiveerd."); + + // Na STARTTLS moet EHLO opnieuw worden gestuurd (RFC 3207) + $resp = $sendCommand("EHLO {$clientHost}"); + if ($resp['code'] !== 250) { + throw new Exception("EHLO na TLS handshake geweigerd: {$resp['text']}"); + } + } + + // 6. SMTP Authenticatie + if (!empty($username) && !empty($password)) { + $log("Authenticatie uitvoeren als '{$username}'..."); + $resp = $sendCommand("AUTH LOGIN"); + if ($resp['code'] === 334) { + // Stuur gebruikersnaam + $resp = $sendCommand(base64_encode($username), "AUTH USER [base64]"); + if ($resp['code'] !== 334) { + throw new Exception("Gebruikersnaam geweigerd voor authenticatie: {$resp['text']}"); + } + // Stuur wachtwoord + $resp = $sendCommand(base64_encode($password), "AUTH PASS [base64 masked]"); + if ($resp['code'] !== 235) { + throw new Exception("Wachtwoord onjuist of authenticatie mislukt (code {$resp['code']}): {$resp['text']}"); + } + } else { + // Fallback naar AUTH PLAIN + $plainToken = base64_encode("\0" . $username . "\0" . $password); + $resp = $sendCommand("AUTH PLAIN {$plainToken}", "AUTH PLAIN [token masked]"); + if ($resp['code'] !== 235) { + throw new Exception("SMTP authenticatie geweigerd (code {$resp['code']}): {$resp['text']}"); + } + } + $log("SMTP Authenticatie geslaagd."); + } + + // 7. Afzender en Ontvanger instellen + $resp = $sendCommand("MAIL FROM:<{$envelopeFrom}>"); + if ($resp['code'] !== 250) { + // Probeer met fromEmail indien envelopeFrom faalt + if ($envelopeFrom !== $fromEmail) { + $resp = $sendCommand("MAIL FROM:<{$fromEmail}>"); + } + if ($resp['code'] !== 250) { + throw new Exception("MAIL FROM geweigerd (code {$resp['code']}): {$resp['text']}"); + } + } + + $resp = $sendCommand("RCPT TO:<{$toEmail}>"); + if ($resp['code'] !== 250 && $resp['code'] !== 251) { + throw new Exception("RCPT TO geweigerd voor {$toEmail} (code {$resp['code']}): {$resp['text']}"); + } + + // 8. DATA Commando + $resp = $sendCommand("DATA"); + if ($resp['code'] !== 354) { + throw new Exception("Server niet gereed voor e-maildata (code {$resp['code']}): {$resp['text']}"); + } + + // 9. MIME Multipart/Alternative Samenstellen + $boundary = "----=_Part_Fociz_" . md5(uniqid((string)time(), true)); + $encodedSubject = "=?UTF-8?B?" . base64_encode($subject) . "?="; + $encodedFromName = "=?UTF-8?B?" . base64_encode($fromName) . "?="; + $encodedToName = !empty($toName) ? "=?UTF-8?B?" . base64_encode($toName) . "?= " : ""; + + $headers = []; + $headers[] = "Date: " . date(DATE_RFC2822); + $headers[] = "From: {$encodedFromName} <{$fromEmail}>"; + $headers[] = "To: {$encodedToName}<{$toEmail}>"; + if (!empty($replyTo)) { + $headers[] = "Reply-To: <{$replyTo}>"; + } + $headers[] = "Subject: {$encodedSubject}"; + $headers[] = "Message-ID: <" . time() . "." . bin2hex(random_bytes(8)) . "@" . ($clientHost ?: 'fociz.nl') . ">"; + $headers[] = "X-Mailer: Fociz Mail Engine 2026"; + $headers[] = "MIME-Version: 1.0"; + $headers[] = "Content-Type: multipart/alternative; boundary=\"{$boundary}\""; + + $rawEmail = implode("\r\n", $headers) . "\r\n\r\n"; + + // Deel 1: Platte tekst fallback + $rawEmail .= "--{$boundary}\r\n"; + $rawEmail .= "Content-Type: text/plain; charset=UTF-8; format=flowed\r\n"; + $rawEmail .= "Content-Transfer-Encoding: 8bit\r\n\r\n"; + $rawEmail .= $plainTextBody . "\r\n\r\n"; + + // Deel 2: Luxe HTML variant + $rawEmail .= "--{$boundary}\r\n"; + $rawEmail .= "Content-Type: text/html; charset=UTF-8\r\n"; + $rawEmail .= "Content-Transfer-Encoding: 8bit\r\n\r\n"; + $rawEmail .= $htmlBody . "\r\n\r\n"; + + // Sluiting boundary + $rawEmail .= "--{$boundary}--\r\n"; + + // Zorg voor veilige dot-stuffing (regels die beginnen met een punt krijgen een extra punt) + $safeLines = []; + foreach (explode("\n", str_replace("\r\n", "\n", $rawEmail)) as $line) { + if (isset($line[0]) && $line[0] === '.') { + $line = '.' . $line; + } + $safeLines[] = $line; + } + $finalPayload = implode("\r\n", $safeLines) . "\r\n.\r\n"; + + // Schrijf e-mail inhoud + $log("E-maildata overdragen (" . strlen($finalPayload) . " bytes)..."); + fwrite($socket, $finalPayload); + $resp = $readResponse(); + + if ($resp['code'] !== 250) { + throw new Exception("Fout bij accepteren van e-maildata door de server (code {$resp['code']}): {$resp['text']}"); + } + + $log("E-mail succesvol geaccepteerd voor aflevering door {$host}."); + + // 10. QUIT Commando + $sendCommand("QUIT"); + @fclose($socket); + + return [ + 'success' => true, + 'error' => null, + 'logs' => $logs + ]; + + } catch (Throwable $e) { + $log("FOUT: " . $e->getMessage()); + error_log("FocizMailer Fout: " . $e->getMessage()); + @fwrite($socket, "QUIT\r\n"); + @fclose($socket); + + return [ + 'success' => false, + 'error' => $e->getMessage(), + 'logs' => $logs + ]; + } + } + + /** + * Genereert een hoogwaardig gestylede, responsive HTML e-mail template + * in de kenmerkende moderne Fociz donker/licht huisstijl. + */ + public static function renderEmailTemplate(array $params): string { + $preheader = htmlspecialchars($params['preheader'] ?? 'Bericht vanuit Fociz'); + $badge = htmlspecialchars($params['badge'] ?? 'FOCIZ SYSTEEM'); + $title = htmlspecialchars($params['title'] ?? 'Belangrijk bericht van Fociz'); + $contentHtml = $params['content_html'] ?? ''; + $ctaText = !empty($params['cta_text']) ? htmlspecialchars($params['cta_text']) : null; + $ctaUrl = !empty($params['cta_url']) ? htmlspecialchars($params['cta_url']) : null; + $ctaSubtext = !empty($params['cta_subtext']) ? htmlspecialchars($params['cta_subtext']) : null; + $securityNotice = $params['security_notice'] ?? 'Heeft u dit verzoek niet zelf ingediend? Dan kunt u deze e-mail veilig negeren. Uw gegevens blijven optimaal beschermd.'; + $currentYear = date('Y'); + $appUrl = defined('APP_URL') ? APP_URL : 'https://www.fociz.nl'; + + $ctaBlock = ''; + if ($ctaText && $ctaUrl) { + $ctaBlock = " + + + + + +
+ + {$ctaText} → + +
+ " . ($ctaSubtext ? "

{$ctaSubtext}

" : "") . " +

+ Werkt de knop niet? Kopieer en plak dan deze link in uw browser:
+ {$ctaUrl} +

+ "; + } + + return " + + + + + {$title} + + + + + +
+ {$preheader} ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌  +
+ + + + + +
+ + + + + + + + + + + + + + + + + + + +
+ +"; + } + + /** + * Verstuurt een 'Wachtwoord Vergeten' herstelmail + */ + public static function sendPasswordResetEmail(string $email, string $name, string $resetUrl): array { + $cleanName = !empty($name) ? htmlspecialchars($name) : 'Collega'; + $title = "Herstel uw Fociz wachtwoord"; + $preheader = "U heeft een verzoek ingediend om uw Fociz wachtwoord opnieuw in te stellen."; + + $contentHtml = " +

Beste {$cleanName},

+

+ Er is zojuist een verzoek gedaan om het wachtwoord van uw Fociz-account opnieuw in te stellen voor het e-mailadres {$email}. +

+

+ Klik op onderstaande knop om direct een nieuw wachtwoord te kiezen. Deze link is uit veiligheidsoverwegingen 60 minuten geldig. +

+ "; + + $html = self::renderEmailTemplate([ + 'title' => $title, + 'preheader' => $preheader, + 'badge' => 'VEILIGHEID', + 'content_html' => $contentHtml, + 'cta_text' => 'Wachtwoord Opnieuw Instellen', + 'cta_url' => $resetUrl, + 'cta_subtext' => '⏱️ Deze herstellink verloopt over 60 minuten en kan slechts éénmaal gebruikt worden.', + 'security_notice' => 'Heeft u dit verzoek niet zelf gedaan? Geen zorgen, uw wachtwoord blijft ongewijzigd en uw account is veilig. U kunt deze e-mail gerust negeren.' + ]); + + return self::send($email, $name, "Herstel uw Fociz wachtwoord", $html); + } + + /** + * Verstuurt een team-uitnodiging naar een nieuwe collega + */ + public static function sendInvitationEmail( + string $email, + string $inviterName, + string $role, + string $inviteUrl + ): array { + $cleanInviter = !empty($inviterName) ? htmlspecialchars($inviterName) : 'Een beheerder'; + $roleLabel = ($role === 'ADMIN') ? 'Beheerder (Admin)' : 'Teamlid (Member)'; + $title = "U bent uitgenodigd voor Fociz"; + $preheader = "{$cleanInviter} heeft u uitgenodigd om samen te werken in Fociz."; + + $contentHtml = " +

Hallo,

+

+ {$cleanInviter} heeft u uitgenodigd om toegang te krijgen tot de project- en dagplanner van Fociz. +

+
+ + + + + + + + + +
E-mailadres:{$email}
Toegewezen rol:{$roleLabel}
+
+

+ Klik op onderstaande knop om uw account te activeren, uw wachtwoord in te stellen en de verplichte tweestapsverificatie (2FA) te koppelen. +

+ "; + + $html = self::renderEmailTemplate([ + 'title' => $title, + 'preheader' => $preheader, + 'badge' => 'TEAM UITNODIGING', + 'content_html' => $contentHtml, + 'cta_text' => 'Account Activeren & Inloggen', + 'cta_url' => $inviteUrl, + 'cta_subtext' => '⏱️ Deze uitnodigingslink is 7 dagen geldig.', + 'security_notice' => 'Fociz maakt gebruik van tweestapsverificatie (2FA). Houd een authenticator app zoals Google Authenticator of Microsoft Authenticator bij de hand.' + ]); + + return self::send($email, '', "Uitnodiging voor Fociz door {$cleanInviter}", $html); + } + + /** + * Verstuurt een test e-mail ter verificatie van de SMTP verbinding + */ + public static function sendTestEmail(string $email, ?string $testerName = null): array { + $cleanName = !empty($testerName) ? htmlspecialchars($testerName) : 'Beheerder'; + $title = "Fociz Mail Server Test Succesvol!"; + $preheader = "De SMTP mailserver koppeling met mail.fociz.nl functioneert naar behoren."; + $smtpHost = defined('SMTP_HOST') ? SMTP_HOST : 'mail.fociz.nl'; + $smtpPort = defined('SMTP_PORT') ? SMTP_PORT : 587; + $fromEmail = defined('SMTP_FROM_EMAIL') ? SMTP_FROM_EMAIL : 'no-reply@fociz.nl'; + $currentTime = date('d-m-Y H:i:s'); + + $contentHtml = " +

Beste {$cleanName},

+

+ Gefeliciteerd! Als u dit bericht leest, is de verbinding met de Fociz SMTP mailserver volledig geslaagd en operationeel. +

+
+ + + + + + + + + + + + + +
SMTP Server:{$smtpHost}:{$smtpPort}
Verzendadres:{$fromEmail}
Tijdstip:{$currentTime}
+
+

+ Wachtwoord-vergeten e-mails, teamuitnodigingen en notificaties kunnen nu veilig en betrouwbaar worden verzonden. +

+ "; + + $html = self::renderEmailTemplate([ + 'title' => $title, + 'preheader' => $preheader, + 'badge' => 'VERIFICATIE TEST', + 'content_html' => $contentHtml, + 'cta_text' => 'Naar Fociz Dashboard', + 'cta_url' => (defined('APP_URL') ? APP_URL : 'https://www.fociz.nl') . '/admin.php', + 'security_notice' => 'Dit is een diagnostische testmail verzonden vanuit het Fociz Beheerpaneel.' + ]); + + return self::send($email, $testerName ?? '', "Fociz Mail Server Test Succesvol ({$currentTime})", $html); + } + + /** + * Eenvoudige converter van HTML naar schone platte tekst voor e-mail fallbacks + */ + private static function htmlToPlainText(string $html): string { + $text = preg_replace('/]*>(.*?)<\/style>/is', '', $html); + $text = preg_replace('/]*>(.*?)<\/script>/is', '', $text); + $text = strip_tags($text, '


  • '); + $text = preg_replace('//i', "\n", $text); + $text = preg_replace('/<\/(p|h1|h2|h3|li)>/i', "\n\n", $text); + $text = strip_tags($text); + $text = html_entity_decode($text, ENT_QUOTES, 'UTF-8'); + return trim(preg_replace("/[\r\n]{3,}/", "\n\n", $text)); + } +} + diff --git a/users.php b/users.php index db908af..070d13b 100644 --- a/users.php +++ b/users.php @@ -4,6 +4,7 @@ */ require_once __DIR__ . '/includes/auth.php'; +require_once __DIR__ . '/includes/mailer.php'; $adminUser = require_admin(); $currentUser = $adminUser; $db = get_db_connection(); @@ -29,11 +30,38 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST[' $token = create_invite($adminUser['id'], $email, $role); $baseUrl = rtrim(APP_URL, '/'); $generatedLink = "{$baseUrl}/auth/invite.php?token={$token}"; - $message = "Uitnodigingslink succesvol aangemaakt!"; + + // Verstuur de gestylede uitnodigingsmail direct via SMTP + $mailResult = FocizMailer::sendInvitationEmail($email, $adminUser['name'], $role, $generatedLink); + if ($mailResult['success']) { + $message = "Uitnodigingslink succesvol aangemaakt én per e-mail verzonden naar {$email}!"; + } else { + $message = "Uitnodiging aangemaakt! Let op: de e-mail kon niet worden verzonden (" . htmlspecialchars($mailResult['error'] ?? 'serverfout') . "). U kunt de link handmatig kopiëren."; + } } } } +// Uitnodiging opnieuw verzenden per e-mail +if (isset($_GET['resend_invite'])) { + $inviteId = (int)$_GET['resend_invite']; + $stmtInv = $db->prepare("SELECT * FROM invites WHERE id = ? AND used_at IS NULL AND expires_at > NOW()"); + $stmtInv->execute([$inviteId]); + $inv = $stmtInv->fetch(); + if ($inv) { + $baseUrl = rtrim(APP_URL, '/'); + $link = "{$baseUrl}/auth/invite.php?token={$inv['token']}"; + $mailResult = FocizMailer::sendInvitationEmail($inv['email'], $adminUser['name'], $inv['role'], $link); + if ($mailResult['success']) { + $message = "Uitnodiging succesvol opnieuw per e-mail verzonden naar {$inv['email']}!"; + } else { + $error = "Fout bij opnieuw verzenden van uitnodiging: " . ($mailResult['error'] ?? 'Onbekende fout'); + } + } else { + $error = "Uitnodiging niet gevonden of reeds verlopen/geactiveerd."; + } +} + // Uitnodiging intrekken if (isset($_GET['delete_invite'])) { $inviteId = (int)$_GET['delete_invite']; @@ -252,9 +280,17 @@ require_once __DIR__ . '/includes/header.php'; - - - +