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; }; // Saniteer e-mailadressen en headers tegen CRLF en SMTP command injection $toEmail = str_replace(["\r", "\n", "\0"], '', trim($toEmail)); $toName = str_replace(["\r", "\n", "\0"], '', trim($toName)); $subject = str_replace(["\r", "\n", "\0"], '', trim($subject)); if (!filter_var($toEmail, FILTER_VALIDATE_EMAIL)) { $err = "Ongeldig e-mailadres voor verzending: {$toEmail}"; $log("FOUT: {$err}"); return ['success' => false, 'error' => $err, 'logs' => $logs]; } // 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 = str_replace(["\r", "\n", "\0"], '', trim($options['from_email'] ?? (defined('SMTP_FROM_EMAIL') ? SMTP_FROM_EMAIL : 'no-reply@fociz.nl'))); $fromName = str_replace(["\r", "\n", "\0"], '', trim($options['from_name'] ?? (defined('SMTP_FROM_NAME') ? SMTP_FROM_NAME : 'Fociz'))); $replyTo = str_replace(["\r", "\n", "\0"], '', trim($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 conform RFC 5321 §4.1.4 // Voorkom impersonation van het lokale domein (bijv. 'fociz.nl' tegen 'mail.fociz.nl') // Antagonist/Exim weigert 'EHLO fociz.nl' van externe hosts met '550 Bad HELO - Host impersonating domain name' $resolveHeloHost = function() use ($host): string { // 1. Mailserver hostnaam indien beschikbaar en niet een lokaal IP if (!empty($host) && str_contains($host, '.') && !str_starts_with($host, '192.168.') && !str_starts_with($host, '127.')) { return $host; } // 2. SERVER_NAME mits het niet het bare hoofddomein is if (!empty($_SERVER['SERVER_NAME'])) { $sn = preg_replace('/:[0-9]+$/', '', trim($_SERVER['SERVER_NAME'])); if (!in_array(strtolower($sn), ['fociz.nl', 'localhost', '127.0.0.1'])) { return $sn; } } // 3. Systeemhostnaam if (function_exists('gethostname')) { $gh = gethostname(); if (!empty($gh) && strtolower($gh) !== 'fociz.nl') { return $gh; } } return 'mail.fociz.nl'; }; $performHeloHandshake = function(string $preferredHost) use ($sendCommand, $log): string { $candidates = array_unique([$preferredHost, 'mail.fociz.nl', 'localhost', '127.0.0.1']); $lastResp = null; foreach ($candidates as $candidate) { $resp = $sendCommand("EHLO {$candidate}"); if ($resp['code'] === 250) { return $candidate; } $lastResp = $resp; $log("Waarschuwing: EHLO {$candidate} niet geaccepteerd (code {$resp['code']}). Probeert alternatief..."); } // Fallback naar HELO indien EHLO op alle kandidaten niet werkt foreach ($candidates as $candidate) { $resp = $sendCommand("HELO {$candidate}"); if ($resp['code'] === 250) { return $candidate; } $lastResp = $resp; } throw new Exception("EHLO/HELO geweigerd door server voor alle kandidaten. Laatste reactie: " . ($lastResp ? "({$lastResp['code']}) {$lastResp['text']}" : 'onbekend')); }; $preferredHost = $resolveHeloHost(); $clientHost = $performHeloHandshake($preferredHost); // 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) $clientHost = $performHeloHandshake($clientHost); } // 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}
" : "") . "
Werkt de knop niet? Kopieer en plak dan deze link in uw browser:
{$ctaUrl}
|
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('/