Unifi-Voucher-Tool/includes/Mailer.php
Friederich Loheide e28527ed91 E-Mails und Druckvorlage im neuen Design
E-Mail:
- Nachrichten werden in ein markentreues HTML-Gerüst gelegt (Tabellen +
  Inline-Styles, damit Outlook & Co. es zuverlässig darstellen); die
  Kopfleiste nutzt den eingestellten Markenverlauf
- neuer Platzhalter {VOUCHER_CARD} rendert den Code als hervorgehobene
  Karte; bestehende Vorlagen mit {VOUCHER_CODE} funktionieren unverändert
  weiter, sie werden lediglich eingerahmt
- Zeilenumbrüche der Vorlage werden vor dem Einsetzen der Platzhalter
  umgewandelt, Markup aus dem WYSIWYG-Editor bleibt unangetastet

Druck:
- neue Standardvorlage als Ticket mit QR-Code, Code, Gültigkeit und
  Anleitung; zentral in Ui::defaultPrintTemplate()
- neuer Platzhalter {QR_CODE}; der QR-Code wird lokal im Browser erzeugt
  (qrcodejs aus assets/vendor), auch bei Bulk-Erstellung für jede Karte
- die Druckvorlage ist am Bildschirm ausgeblendet und erscheint nur im
  Ausdruck

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 06:42:45 +00:00

339 lines
No EOL
14 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
class Mailer {
private $db;
private $smtpEnabled;
private $smtpHost;
private $smtpPort;
private $smtpUsername;
private $smtpPassword;
private $smtpEncryption;
private $fromEmail;
private $fromName;
public function __construct() {
$this->db = Database::getInstance();
$this->loadSettings();
}
private function loadSettings() {
$this->smtpEnabled = $this->db->getSetting('smtp_enabled', '0') === '1';
$this->smtpHost = $this->db->getSetting('smtp_host', '');
$this->smtpPort = (int)$this->db->getSetting('smtp_port', '587');
$this->smtpUsername = $this->db->getSetting('smtp_username', '');
$this->smtpPassword = $this->db->getSetting('smtp_password', '');
$this->smtpEncryption = $this->db->getSetting('smtp_encryption', 'tls');
$this->fromEmail = $this->db->getSetting('smtp_from_email', 'noreply@' . $_SERVER['HTTP_HOST']);
$this->fromName = $this->db->getSetting('smtp_from_name', $this->db->getSetting('app_title', 'UniFi Voucher System'));
}
public function sendRaw($to, $subject, $plainBody) {
return $this->send($to, $subject, $plainBody, false);
}
public function send($to, $subject, $body, $isHtml = false) {
// Bis zu 2 Versuche bei vorübergehenden Zustellfehlern (Retry).
$attempts = 2;
for ($i = 1; $i <= $attempts; $i++) {
if (!$this->smtpEnabled || empty($this->smtpHost)) {
$ok = $this->sendWithPhpMail($to, $subject, $body);
} else {
$ok = $this->sendWithSmtp($to, $subject, $body, $isHtml);
}
if ($ok) {
return true;
}
if ($i < $attempts) {
usleep(500000); // 0,5s vor erneutem Versuch
}
}
error_log("Mailer: Zustellung an {$to} nach {$attempts} Versuchen fehlgeschlagen.");
return false;
}
private function sendWithPhpMail($to, $subject, $body) {
$headers = "From: {$this->fromName} <{$this->fromEmail}>\r\n";
$headers .= "Reply-To: {$this->fromEmail}\r\n";
$headers .= "Content-Type: text/plain; charset=UTF-8\r\n";
return mail($to, $subject, $body, $headers);
}
private function sendWithSmtp($to, $subject, $body, $isHtml = false) {
try {
// Verbindung aufbauen
$socket = $this->connectToSmtp();
// EHLO
$this->smtpCommand($socket, "EHLO " . $_SERVER['HTTP_HOST']);
// STARTTLS wenn nötig
if ($this->smtpEncryption === 'tls') {
$this->smtpCommand($socket, "STARTTLS");
stream_socket_enable_crypto($socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT);
$this->smtpCommand($socket, "EHLO " . $_SERVER['HTTP_HOST']);
}
// AUTH LOGIN
$this->smtpCommand($socket, "AUTH LOGIN");
$this->smtpCommand($socket, base64_encode($this->smtpUsername));
$this->smtpCommand($socket, base64_encode($this->smtpPassword));
// MAIL FROM
$this->smtpCommand($socket, "MAIL FROM:<{$this->fromEmail}>");
// RCPT TO
$this->smtpCommand($socket, "RCPT TO:<{$to}>");
// DATA
$this->smtpCommand($socket, "DATA");
// Headers
$message = "From: {$this->fromName} <{$this->fromEmail}>\r\n";
$message .= "To: {$to}\r\n";
$message .= "Subject: =?UTF-8?B?" . base64_encode($subject) . "?=\r\n";
$message .= "MIME-Version: 1.0\r\n";
if ($isHtml) {
$message .= "Content-Type: text/html; charset=UTF-8\r\n";
} else {
$message .= "Content-Type: text/plain; charset=UTF-8\r\n";
}
$message .= "\r\n";
// Body - bei Plain Text Zeilenumbrüche konvertieren
if (!$isHtml) {
$body = nl2br($body, false); // Für Plain Text
$body = str_replace('<br>', "\r\n", $body);
}
$message .= $body;
$message .= "\r\n.\r\n";
fwrite($socket, $message);
$response = fgets($socket);
// QUIT
$this->smtpCommand($socket, "QUIT");
fclose($socket);
return strpos($response, '250') === 0;
} catch (Exception $e) {
error_log("SMTP Error: " . $e->getMessage());
return false;
}
}
private function connectToSmtp() {
$context = stream_context_create([
'ssl' => [
'verify_peer' => false,
'verify_peer_name' => false,
'allow_self_signed' => true
]
]);
if ($this->smtpEncryption === 'ssl') {
$host = 'ssl://' . $this->smtpHost;
} else {
$host = $this->smtpHost;
}
$socket = stream_socket_client(
$host . ':' . $this->smtpPort,
$errno,
$errstr,
30,
STREAM_CLIENT_CONNECT,
$context
);
if (!$socket) {
throw new Exception("SMTP Connection failed: $errstr ($errno)");
}
// Willkommensnachricht lesen
fgets($socket);
return $socket;
}
private function smtpCommand($socket, $command) {
fwrite($socket, $command . "\r\n");
$response = fgets($socket);
// Prüfen auf Fehler (4xx oder 5xx)
if (preg_match('/^[45]/', $response)) {
throw new Exception("SMTP Error: $response");
}
return $response;
}
// Vordefinierte E-Mail-Templates
/**
* Legt den Nachrichtentext in ein schlichtes, markentreues HTML-Gerüst.
* Bewusst Tabellen + Inline-Styles: nur so rendern Outlook & Co. zuverlässig.
*/
private function brandedHtml(string $title, string $contentHtml, string $footerNote = ''): string
{
$accent = $this->db->getSetting('brand_gradient_from', '') ?: '#5b5bd6';
$accent2 = $this->db->getSetting('brand_gradient_to', '') ?: '#8b5cf6';
if (!preg_match('/^#[0-9a-fA-F]{6}$/', $accent)) { $accent = '#5b5bd6'; }
if (!preg_match('/^#[0-9a-fA-F]{6}$/', $accent2)) { $accent2 = '#8b5cf6'; }
$safeTitle = htmlspecialchars($title, ENT_QUOTES, 'UTF-8');
$year = date('Y');
$footer = $footerNote !== '' ? '<div style="margin-top:8px;">' . htmlspecialchars($footerNote, ENT_QUOTES, 'UTF-8') . '</div>' : '';
return '<!DOCTYPE html><html><head><meta charset="UTF-8">'
. '<meta name="viewport" content="width=device-width, initial-scale=1.0">'
. '<title>' . $safeTitle . '</title></head>'
. '<body style="margin:0;padding:0;background:#f6f7f9;">'
. '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f6f7f9;padding:28px 12px;">'
. '<tr><td align="center">'
. '<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;width:100%;background:#ffffff;border:1px solid #e5e8ee;border-radius:14px;overflow:hidden;font-family:-apple-system,BlinkMacSystemFont,\'Segoe UI\',Roboto,Helvetica,Arial,sans-serif;">'
. '<tr><td style="background:' . $accent . ';background-image:linear-gradient(135deg,' . $accent . ' 0%,' . $accent2 . ' 100%);padding:22px 26px;">'
. '<div style="color:#ffffff;font-size:16px;font-weight:600;letter-spacing:-0.01em;">' . $safeTitle . '</div>'
. '</td></tr>'
. '<tr><td style="padding:26px;color:#101625;font-size:15px;line-height:1.6;">' . $contentHtml . '</td></tr>'
. '<tr><td style="padding:16px 26px;background:#f8f9fb;border-top:1px solid #e5e8ee;color:#6b7280;font-size:12px;">'
. '&copy; ' . $year . ' ' . $safeTitle . $footer
. '</td></tr>'
. '</table></td></tr></table></body></html>';
}
/**
* Voucher-Code als hervorgehobene Karte für die E-Mail.
*/
private function voucherCardHtml(string $code, string $siteName, $maxUses): string
{
return '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" '
. 'style="margin:18px 0;background:#f8f9fb;border:1px solid #e5e8ee;border-radius:12px;">'
. '<tr><td align="center" style="padding:22px;">'
. '<div style="font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:#6b7280;">'
. htmlspecialchars($siteName, ENT_QUOTES, 'UTF-8') . '</div>'
. '<div style="margin:10px 0;font-family:Consolas,Menlo,monospace;font-size:28px;font-weight:700;letter-spacing:.12em;color:#101625;">'
. htmlspecialchars($code, ENT_QUOTES, 'UTF-8') . '</div>'
. '<div style="font-size:13px;color:#525c6e;">'
. htmlspecialchars((string)$maxUses, ENT_QUOTES, 'UTF-8') . ' '
. htmlspecialchars(function_exists('__') ? __('label_devices') : 'Geräte', ENT_QUOTES, 'UTF-8') . '</div>'
. '</td></tr></table>';
}
public function sendVoucherEmail($to, $voucherCode, $siteName, $maxUses) {
$appTitle = $this->db->getSetting('app_title', 'UniFi Voucher System');
$instructionHeader = $this->db->getSetting('instruction_header', '');
$instructionText = $this->db->getSetting('instruction_text', '');
// System-URL aus Einstellungen oder automatisch erkennen
$systemUrl = $this->db->getSetting('system_url', '');
if (empty($systemUrl)) {
$protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'];
$scriptPath = dirname($_SERVER['SCRIPT_NAME']);
$scriptPath = $scriptPath === '/' ? '' : $scriptPath;
$systemUrl = $protocol . '://' . $host . $scriptPath;
}
// Template aus Datenbank laden
$subjectTemplate = $this->db->getSetting('email_voucher_subject', '{APP_TITLE} - Ihr WLAN-Zugang');
$bodyTemplate = $this->db->getSetting('email_voucher_body', "Hallo,\n\nhier ist Ihr WLAN-Zugangscode:\n{VOUCHER_CARD}\n<strong>Maximale Geräte:</strong> {MAX_USES}<br>\n<strong>Standort:</strong> {SITE_NAME}\n\n{INSTRUCTIONS}\n\nViele Grüße\n{APP_TITLE}");
// Anleitung formatieren
$instructions = '';
if ($instructionText) {
$instructions = $instructionHeader . "\n" . $instructionText;
}
// Platzhalter ersetzen
$placeholders = [
'{VOUCHER_CARD}' => $this->voucherCardHtml($voucherCode, (string)$siteName, $maxUses),
'{VOUCHER_CODE}' => $voucherCode,
'{SITE_NAME}' => $siteName,
'{MAX_USES}' => $maxUses,
'{APP_TITLE}' => $appTitle,
'{INSTRUCTIONS}' => $instructions,
'{SYSTEM_URL}' => $systemUrl
];
$subject = str_replace(array_keys($placeholders), array_values($placeholders), $subjectTemplate);
// Umbrueche der Vorlage vor dem Einsetzen der Platzhalter umwandeln,
// sonst wuerde das Markup der Voucher-Karte die Erkennung stoeren.
$isHtml = strip_tags($bodyTemplate) !== $bodyTemplate || strpos($bodyTemplate, '{VOUCHER_CARD}') !== false;
$template = $isHtml ? $this->textToHtml($bodyTemplate) : $bodyTemplate;
$body = str_replace(array_keys($placeholders), array_values($placeholders), $template);
if ($isHtml) {
$body = $this->brandedHtml($appTitle, $body);
}
return $this->send($to, $subject, $body, $isHtml);
}
public function sendTestEmail($to) {
$appTitle = $this->db->getSetting('app_title', 'UniFi Voucher System');
$subject = '[Test] E-Mail-Konfiguration ' . $appTitle;
$body = "Dies ist eine Test-E-Mail von {$appTitle}.\n\nDie SMTP-Konfiguration ist korrekt eingerichtet.";
return $this->send($to, $subject, $body, false);
}
public function sendUserNotification($to, $userName, $changes) {
$appTitle = $this->db->getSetting('app_title', 'UniFi Voucher System');
// System-URL aus Einstellungen oder automatisch erkennen
$systemUrl = $this->db->getSetting('system_url', '');
if (empty($systemUrl)) {
$protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'];
$scriptPath = dirname($_SERVER['SCRIPT_NAME']);
$scriptPath = $scriptPath === '/' ? '' : $scriptPath;
$systemUrl = $protocol . '://' . $host . $scriptPath;
}
// Template aus Datenbank laden
$subjectTemplate = $this->db->getSetting('email_user_notification_subject', '{APP_TITLE} - Ihre Berechtigungen wurden geändert');
$bodyTemplate = $this->db->getSetting('email_user_notification_body', "Hallo {USER_NAME},\n\nEin Administrator hat Ihre Berechtigungen im {APP_TITLE} geändert:\n\n{CHANGES}\n\nSie können sich unter folgender Adresse anmelden:\n{SYSTEM_URL}\n\nMit freundlichen Grüßen\n{APP_TITLE}");
// Änderungen formatieren
$changesText = '';
foreach ($changes as $change) {
$changesText .= "$change\n";
}
// Platzhalter ersetzen
$placeholders = [
'{USER_NAME}' => $userName,
'{CHANGES}' => $changesText,
'{APP_TITLE}' => $appTitle,
'{SYSTEM_URL}' => $systemUrl
];
$subject = str_replace(array_keys($placeholders), array_values($placeholders), $subjectTemplate);
$isHtml = strip_tags($bodyTemplate) !== $bodyTemplate;
$template = $isHtml ? $this->textToHtml($bodyTemplate) : $bodyTemplate;
$body = str_replace(array_keys($placeholders), array_values($placeholders), $template);
if ($isHtml) {
$body = $this->brandedHtml($appTitle, $body);
}
return $this->send($to, $subject, $body, $isHtml);
}
/**
* Zeilenumbrüche aus dem Vorlagentext in HTML übernehmen, ohne bereits
* vorhandenes Markup (z. B. aus dem WYSIWYG-Editor) zu zerstören.
*/
private function textToHtml(string $body): string
{
if (preg_match('#<(p|div|ul|ol|h[1-6])[\s>]#i', $body)) {
return $body;
}
return nl2br($body, false);
}
}