649 lines
33 KiB
PHP
649 lines
33 KiB
PHP
<?php
|
|
/**
|
|
* Fociz Pure-PHP SMTP Mailer & Responsive HTML E-mail Engine
|
|
*
|
|
* Volledig zelfstandige, veilige SMTP-client zonder externe afhankelijkheden.
|
|
* Ondersteunt STARTTLS (poort 587), SSL (poort 465), AUTH LOGIN/PLAIN en
|
|
* levert responsive, hoogwaardig gestylede HTML e-mails in de Fociz-huisstijl.
|
|
*/
|
|
|
|
require_once __DIR__ . '/../config/config.php';
|
|
|
|
class FocizMailer {
|
|
|
|
/**
|
|
* Verstuurt een e-mail via native PHP stream sockets over SMTP.
|
|
*
|
|
* @param string $toEmail Ontvanger e-mailadres
|
|
* @param string $toName Ontvanger weergavenaam
|
|
* @param string $subject Onderwerp van het bericht
|
|
* @param string $htmlBody Opgemaakte HTML inhoud
|
|
* @param string|null $plainTextBody Optionele platte tekst (wordt anders afgeleid uit HTML)
|
|
* @param array $options Extra opties (from_email, from_name, reply_to, headers)
|
|
* @return array ['success' => 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 = "
|
|
<!-- Call To Action Knop -->
|
|
<table role='presentation' border='0' cellpadding='0' cellspacing='0' style='margin: 32px 0 24px 0;'>
|
|
<tr>
|
|
<td align='center' style='border-radius: 12px; background: #4f46e5;'>
|
|
<a href='{$ctaUrl}' target='_blank' style='display: inline-block; padding: 14px 32px; font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif; font-size: 15px; font-weight: 700; color: #ffffff; text-decoration: none; border-radius: 12px; background: #4f46e5; letter-spacing: 0.2px;'>
|
|
{$ctaText} →
|
|
</a>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
" . ($ctaSubtext ? "<p style='margin: 0 0 20px 0; font-size: 12px; color: #64748b; line-height: 1.5;'>{$ctaSubtext}</p>" : "") . "
|
|
<p style='margin: 16px 0 0 0; font-size: 11px; color: #94a3b8; word-break: break-all; line-height: 1.5;'>
|
|
Werkt de knop niet? Kopieer en plak dan deze link in uw browser:<br>
|
|
<a href='{$ctaUrl}' style='color: #6366f1; text-decoration: underline;'>{$ctaUrl}</a>
|
|
</p>
|
|
";
|
|
}
|
|
|
|
return "<!DOCTYPE html>
|
|
<html lang='nl'>
|
|
<head>
|
|
<meta charset='UTF-8'>
|
|
<meta name='viewport' content='width=device-width, initial-scale=1.0'>
|
|
<title>{$title}</title>
|
|
<!--[if mso]>
|
|
<style type='text/css'>
|
|
body, table, td, a { font-family: Arial, sans-serif !important; }
|
|
</style>
|
|
<![endif]-->
|
|
<style>
|
|
body { margin: 0; padding: 0; min-width: 100%; background-color: #0c0f17; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; }
|
|
img { border: 0; outline: none; text-decoration: none; }
|
|
table { border-collapse: collapse; }
|
|
td { border-collapse: collapse; }
|
|
@media only screen and (max-width: 600px) {
|
|
.email-container { width: 100% !important; padding: 12px !important; }
|
|
.card-content { padding: 24px 20px !important; }
|
|
.header-banner { padding: 28px 20px !important; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body style='margin: 0; padding: 0; background-color: #0c0f17; -webkit-font-smoothing: antialiased;'>
|
|
<!-- Verborgen Pre-header tekst voor inbox preview -->
|
|
<div style='display: none; max-height: 0; overflow: hidden; font-size: 1px; line-height: 1px; color: #0c0f17; opacity: 0;'>
|
|
{$preheader} ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌
|
|
</div>
|
|
|
|
<table role='presentation' border='0' cellpadding='0' cellspacing='0' width='100%' style='background-color: #0c0f17;'>
|
|
<tr>
|
|
<td align='center' style='padding: 40px 16px 50px 16px;'>
|
|
<!-- Hoofdcontainer -->
|
|
<table role='presentation' border='0' cellpadding='0' cellspacing='0' width='100%' style='max-width: 580px;' class='email-container'>
|
|
|
|
<!-- Luxe Bovenkaart -->
|
|
<tr>
|
|
<td style='background: #111622; border-top-left-radius: 20px; border-top-right-radius: 20px; border: 1px solid #1e293b; border-bottom: none; padding: 36px 36px 32px 36px;' class='header-banner'>
|
|
<table role='presentation' border='0' cellpadding='0' cellspacing='0' width='100%'>
|
|
<tr>
|
|
<td>
|
|
<!-- Logo & Titel -->
|
|
<table role='presentation' border='0' cellpadding='0' cellspacing='0'>
|
|
<tr>
|
|
<td style='width: 44px; height: 44px; background: linear-gradient(135deg, #4f46e5 0%, #312e81 100%); border-radius: 12px; text-align: center; vertical-align: middle; border: 1px solid #6366f1; box-shadow: 0 4px 12px rgba(79, 70, 229, 0.35);'>
|
|
<span style='font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif; font-size: 22px; font-weight: 900; color: #ffffff; line-height: 44px; display: inline-block;'>F</span>
|
|
</td>
|
|
<td style='padding-left: 14px;'>
|
|
<span style='font-size: 22px; font-weight: 900; color: #ffffff; letter-spacing: -0.5px; display: block;'>Fociz</span>
|
|
<span style='font-size: 11px; font-weight: 500; color: #94a3b8; letter-spacing: 0.5px; text-transform: uppercase;'>Focus op wat telt</span>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
</td>
|
|
<td align='right' style='vertical-align: top;'>
|
|
<!-- Badge -->
|
|
<span style='display: inline-block; padding: 5px 12px; font-size: 10px; font-weight: 700; color: #818cf8; background: rgba(99, 102, 241, 0.12); border: 1px solid rgba(99, 102, 241, 0.28); border-radius: 20px; letter-spacing: 0.6px; text-transform: uppercase;'>
|
|
{$badge}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
</td>
|
|
</tr>
|
|
|
|
<!-- Hoofdkaart met Inhoud -->
|
|
<tr>
|
|
<td style='background: #151d2f; border-bottom-left-radius: 20px; border-bottom-right-radius: 20px; border: 1px solid #1e293b; border-top: 1px solid #243048; padding: 36px;' class='card-content'>
|
|
|
|
<h1 style='margin: 0 0 16px 0; font-size: 22px; font-weight: 800; color: #ffffff; letter-spacing: -0.4px; line-height: 1.3;'>
|
|
{$title}
|
|
</h1>
|
|
|
|
<div style='font-size: 14px; line-height: 1.7; color: #cbd5e1;'>
|
|
{$contentHtml}
|
|
</div>
|
|
|
|
{$ctaBlock}
|
|
|
|
<!-- Beveiligings- & Waarschuwingsbox -->
|
|
<table role='presentation' border='0' cellpadding='0' cellspacing='0' width='100%' style='margin-top: 32px; border-top: 1px solid #1e293b; padding-top: 20px;'>
|
|
<tr>
|
|
<td style='background: rgba(15, 23, 42, 0.6); border: 1px solid #243048; border-radius: 12px; padding: 14px 16px;'>
|
|
<table role='presentation' border='0' cellpadding='0' cellspacing='0'>
|
|
<tr>
|
|
<td style='vertical-align: top; padding-right: 10px; font-size: 16px;'>
|
|
🔒
|
|
</td>
|
|
<td style='font-size: 12px; line-height: 1.5; color: #94a3b8;'>
|
|
{$securityNotice}
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
|
|
</td>
|
|
</tr>
|
|
|
|
<!-- Footer -->
|
|
<tr>
|
|
<td align='center' style='padding: 28px 20px 0 20px;'>
|
|
<p style='margin: 0 0 6px 0; font-size: 12px; color: #64748b; font-weight: 500;'>
|
|
© {$currentYear} Fociz • Veilige Project- & Dagplanning
|
|
</p>
|
|
<p style='margin: 0; font-size: 11px; color: #475569;'>
|
|
Dit is een geautomatiseerd systeembericht verzonden naar u door Fociz.
|
|
</p>
|
|
</td>
|
|
</tr>
|
|
|
|
</table>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
</body>
|
|
</html>";
|
|
}
|
|
|
|
/**
|
|
* 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 = "
|
|
<p style='margin: 0 0 14px 0;'>Beste <strong>{$cleanName}</strong>,</p>
|
|
<p style='margin: 0 0 14px 0;'>
|
|
Er is zojuist een verzoek gedaan om het wachtwoord van uw Fociz-account opnieuw in te stellen voor het e-mailadres <strong style='color: #e2e8f0;'>{$email}</strong>.
|
|
</p>
|
|
<p style='margin: 0 0 14px 0;'>
|
|
Klik op onderstaande knop om direct een nieuw wachtwoord te kiezen. Deze link is uit veiligheidsoverwegingen <strong>60 minuten geldig</strong>.
|
|
</p>
|
|
";
|
|
|
|
$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 = "
|
|
<p style='margin: 0 0 14px 0;'>Hallo,</p>
|
|
<p style='margin: 0 0 14px 0;'>
|
|
<strong>{$cleanInviter}</strong> heeft u uitgenodigd om toegang te krijgen tot de project- en dagplanner van <strong>Fociz</strong>.
|
|
</p>
|
|
<div style='background: rgba(99, 102, 241, 0.08); border: 1px solid rgba(99, 102, 241, 0.25); border-radius: 12px; padding: 14px 18px; margin: 18px 0;'>
|
|
<table role='presentation' border='0' cellpadding='0' cellspacing='0' width='100%'>
|
|
<tr>
|
|
<td style='font-size: 13px; color: #94a3b8;'>E-mailadres:</td>
|
|
<td align='right' style='font-size: 13px; font-weight: 700; color: #e2e8f0;'>{$email}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style='font-size: 13px; color: #94a3b8; padding-top: 6px;'>Toegewezen rol:</td>
|
|
<td align='right' style='font-size: 13px; font-weight: 700; color: #818cf8; padding-top: 6px;'>{$roleLabel}</td>
|
|
</tr>
|
|
</table>
|
|
</div>
|
|
<p style='margin: 0 0 14px 0;'>
|
|
Klik op onderstaande knop om uw account te activeren, uw wachtwoord in te stellen en de verplichte tweestapsverificatie (2FA) te koppelen.
|
|
</p>
|
|
";
|
|
|
|
$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 = "
|
|
<p style='margin: 0 0 14px 0;'>Beste <strong>{$cleanName}</strong>,</p>
|
|
<p style='margin: 0 0 14px 0;'>
|
|
Gefeliciteerd! Als u dit bericht leest, is de verbinding met de Fociz SMTP mailserver volledig geslaagd en operationeel.
|
|
</p>
|
|
<div style='background: rgba(16, 185, 129, 0.08); border: 1px solid rgba(16, 185, 129, 0.25); border-radius: 12px; padding: 14px 18px; margin: 18px 0;'>
|
|
<table role='presentation' border='0' cellpadding='0' cellspacing='0' width='100%'>
|
|
<tr>
|
|
<td style='font-size: 13px; color: #94a3b8;'>SMTP Server:</td>
|
|
<td align='right' style='font-size: 13px; font-weight: 700; color: #e2e8f0;'>{$smtpHost}:{$smtpPort}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style='font-size: 13px; color: #94a3b8; padding-top: 6px;'>Verzendadres:</td>
|
|
<td align='right' style='font-size: 13px; font-weight: 700; color: #10b981; padding-top: 6px;'>{$fromEmail}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style='font-size: 13px; color: #94a3b8; padding-top: 6px;'>Tijdstip:</td>
|
|
<td align='right' style='font-size: 13px; font-weight: 700; color: #e2e8f0; padding-top: 6px;'>{$currentTime}</td>
|
|
</tr>
|
|
</table>
|
|
</div>
|
|
<p style='margin: 0;'>
|
|
Wachtwoord-vergeten e-mails, teamuitnodigingen en notificaties kunnen nu veilig en betrouwbaar worden verzonden.
|
|
</p>
|
|
";
|
|
|
|
$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\b[^>]*>(.*?)<\/style>/is', '', $html);
|
|
$text = preg_replace('/<script\b[^>]*>(.*?)<\/script>/is', '', $text);
|
|
$text = strip_tags($text, '<a><p><br><h1><h2><h3><li>');
|
|
$text = preg_replace('/<br\s*\/?>/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));
|
|
}
|
|
}
|
|
|