view = new View(__DIR__ . '/views'); } public function contactForm(): Response { $supportTypes = $this->supportTypes(); return new Response($this->view->render('site/contact.php', [ 'title' => 'Contact', 'error' => (string)($_GET['error'] ?? ''), 'ok' => (string)($_GET['ok'] ?? ''), 'support_types' => $supportTypes, ])); } public function contactSubmit(): Response { $name = trim((string)($_POST['name'] ?? '')); $email = trim((string)($_POST['email'] ?? '')); $subject = trim((string)($_POST['subject'] ?? '')); $message = trim((string)($_POST['message'] ?? '')); $supportType = trim((string)($_POST['support_type'] ?? 'general')); $supportTypes = $this->supportTypes(); $supportTypesMap = []; foreach ($supportTypes as $type) { $supportTypesMap[(string)$type['key']] = $type; } if (!isset($supportTypesMap[$supportType])) { $supportType = (string)($supportTypes[0]['key'] ?? 'general'); } $typeMeta = $supportTypesMap[$supportType] ?? ['label' => 'General', 'fields' => []]; $requiredFields = is_array($typeMeta['fields'] ?? null) ? $typeMeta['fields'] : []; $extraValuesRaw = $_POST['support_extra'] ?? []; $extraValues = []; if (is_array($extraValuesRaw)) { foreach ($extraValuesRaw as $key => $value) { $safeKey = $this->slugifyKey((string)$key); $extraValues[$safeKey] = trim((string)$value); } } if ($name === '' || $email === '' || $subject === '' || $message === '') { return new Response('', 302, ['Location' => '/contact?error=Please+complete+all+fields']); } if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { return new Response('', 302, ['Location' => '/contact?error=Please+enter+a+valid+email']); } $limitKey = sha1(strtolower($email) . '|' . $this->clientIp()); if (RateLimiter::tooMany('support_contact_submit', $limitKey, 5, 600)) { return new Response('', 302, ['Location' => '/contact?error=Too+many+support+requests.+Please+wait+10+minutes']); } foreach ($requiredFields as $requiredField) { if (($extraValues[(string)$requiredField] ?? '') === '') { return new Response('', 302, ['Location' => '/contact?error=' . urlencode('Please complete all required fields for this support type')]); } } $db = Database::get(); if (!$db instanceof PDO) { return new Response('', 302, ['Location' => '/contact?error=Database+unavailable']); } if (!$this->tablesReady($db)) { return new Response('', 302, ['Location' => '/contact?error=Support+module+not+initialized']); } $publicId = $this->newTicketId(); $ip = $this->clientIp(); $typeLabel = (string)($typeMeta['label'] ?? 'General'); $subjectWithType = '[' . $typeLabel . '] ' . $subject; $metaLines = []; $metaLines[] = 'Type: ' . $typeLabel; $fieldLabels = []; foreach ((array)($typeMeta['field_labels'] ?? []) as $row) { if (is_array($row) && isset($row['key'], $row['label'])) { $fieldLabels[(string)$row['key']] = (string)$row['label']; } } foreach ($requiredFields as $fieldKey) { $fieldKey = (string)$fieldKey; $val = trim((string)($extraValues[$fieldKey] ?? '')); if ($val === '') { continue; } $fieldLabel = $fieldLabels[$fieldKey] ?? $fieldKey; $metaLines[] = $fieldLabel . ': ' . $val; } $fullBody = $message; if ($metaLines) { $fullBody .= "\n\n---\n" . implode("\n", $metaLines); } try { $stmt = $db->prepare(" INSERT INTO ac_support_tickets (ticket_no, subject, status, customer_name, customer_email, customer_ip, last_message_at) VALUES (:ticket_no, :subject, 'open', :customer_name, :customer_email, :customer_ip, NOW()) "); $stmt->execute([ ':ticket_no' => $publicId, ':subject' => $subjectWithType, ':customer_name' => $name, ':customer_email' => $email, ':customer_ip' => $ip, ]); $ticketId = (int)$db->lastInsertId(); $msgStmt = $db->prepare(" INSERT INTO ac_support_messages (ticket_id, sender_type, sender_name, sender_email, body_text, source) VALUES (:ticket_id, 'customer', :sender_name, :sender_email, :body_text, 'web') "); $msgStmt->execute([ ':ticket_id' => $ticketId, ':sender_name' => $name, ':sender_email' => $email, ':body_text' => $fullBody, ]); } catch (Throwable $e) { return new Response('', 302, ['Location' => '/contact?error=Could+not+create+ticket']); } return new Response('', 302, ['Location' => '/contact?ok=Ticket+created:+'.urlencode($publicId)]); } public function adminIndex(): Response { if (!Auth::check()) { return new Response('', 302, ['Location' => '/admin/login']); } if (!Auth::hasRole(['admin', 'manager', 'editor'])) { return new Response('', 302, ['Location' => '/admin']); } $db = Database::get(); $tablesReady = false; $tickets = []; if ($db instanceof PDO) { $tablesReady = $this->tablesReady($db); if ($tablesReady) { $q = trim((string)($_GET['q'] ?? '')); $where = ''; $params = []; if ($q !== '') { $where = "WHERE ticket_no LIKE :q OR customer_email LIKE :q OR subject LIKE :q"; $params[':q'] = '%' . $q . '%'; } $stmt = $db->prepare(" SELECT id, ticket_no, subject, status, customer_name, customer_email, customer_ip, last_message_at, created_at FROM ac_support_tickets {$where} ORDER BY last_message_at DESC, id DESC LIMIT 100 "); $stmt->execute($params); $tickets = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; } } return new Response($this->view->render('admin/index.php', [ 'title' => 'Support', 'tables_ready' => $tablesReady, 'tickets' => $tickets, 'q' => (string)($_GET['q'] ?? ''), ])); } public function adminTicket(): Response { if (!Auth::check()) { return new Response('', 302, ['Location' => '/admin/login']); } if (!Auth::hasRole(['admin', 'manager', 'editor'])) { return new Response('', 302, ['Location' => '/admin']); } $id = (int)($_GET['id'] ?? 0); $db = Database::get(); if (!$db instanceof PDO || $id <= 0) { return new Response('', 302, ['Location' => '/admin/support']); } $ticketStmt = $db->prepare("SELECT * FROM ac_support_tickets WHERE id = :id LIMIT 1"); $ticketStmt->execute([':id' => $id]); $ticket = $ticketStmt->fetch(PDO::FETCH_ASSOC) ?: null; if (!$ticket) { return new Response('', 302, ['Location' => '/admin/support']); } $messagesStmt = $db->prepare("SELECT * FROM ac_support_messages WHERE ticket_id = :id ORDER BY id ASC"); $messagesStmt->execute([':id' => $id]); $messages = $messagesStmt->fetchAll(PDO::FETCH_ASSOC) ?: []; return new Response($this->view->render('admin/ticket.php', [ 'title' => 'Ticket ' . (string)($ticket['ticket_no'] ?? ''), 'ticket' => $ticket, 'messages' => $messages, 'saved' => (string)($_GET['saved'] ?? ''), 'error' => (string)($_GET['error'] ?? ''), ])); } public function adminReply(): Response { if (!Auth::check()) { return new Response('', 302, ['Location' => '/admin/login']); } if (!Auth::hasRole(['admin', 'manager', 'editor'])) { return new Response('', 302, ['Location' => '/admin']); } $ticketId = (int)($_POST['ticket_id'] ?? 0); $body = trim((string)($_POST['body'] ?? '')); if ($ticketId <= 0 || $body === '') { return new Response('', 302, ['Location' => '/admin/support']); } $db = Database::get(); if (!$db instanceof PDO) { return new Response('', 302, ['Location' => '/admin/support/ticket?id=' . $ticketId . '&error=Database+unavailable']); } $ticketStmt = $db->prepare("SELECT * FROM ac_support_tickets WHERE id = :id LIMIT 1"); $ticketStmt->execute([':id' => $ticketId]); $ticket = $ticketStmt->fetch(PDO::FETCH_ASSOC) ?: null; if (!$ticket) { return new Response('', 302, ['Location' => '/admin/support']); } $stmt = $db->prepare(" INSERT INTO ac_support_messages (ticket_id, sender_type, sender_name, sender_email, body_text, source) VALUES (:ticket_id, 'admin', :sender_name, :sender_email, :body_text, 'admin') "); $stmt->execute([ ':ticket_id' => $ticketId, ':sender_name' => Auth::name(), ':sender_email' => Settings::get('smtp_from_email', ''), ':body_text' => $body, ]); $db->prepare("UPDATE ac_support_tickets SET last_message_at = NOW(), status = 'pending' WHERE id = :id")->execute([':id' => $ticketId]); $email = (string)($ticket['customer_email'] ?? ''); if ($email !== '') { $subject = '[AC-TICKET-' . (string)($ticket['ticket_no'] ?? $ticketId) . '] ' . (string)($ticket['subject'] ?? 'Support reply'); $safeBody = nl2br(htmlspecialchars($body, ENT_QUOTES, 'UTF-8')); $html = '
Hello ' . htmlspecialchars((string)($ticket['customer_name'] ?? 'there'), ENT_QUOTES, 'UTF-8') . ',
' . '' . $safeBody . '
' . 'Reply to this email to continue ticket ' . htmlspecialchars((string)($ticket['ticket_no'] ?? ''), ENT_QUOTES, 'UTF-8') . '.
'; $smtp = [ 'smtp_host' => Settings::get('smtp_host', ''), 'smtp_port' => Settings::get('smtp_port', '587'), 'smtp_user' => Settings::get('smtp_user', ''), 'smtp_pass' => Settings::get('smtp_pass', ''), 'smtp_encryption' => Settings::get('smtp_encryption', 'tls'), 'smtp_from_email' => Settings::get('smtp_from_email', ''), 'smtp_from_name' => Settings::get('smtp_from_name', 'AudioCore Support'), ]; Mailer::send($email, $subject, $html, $smtp); } return new Response('', 302, ['Location' => '/admin/support/ticket?id=' . $ticketId . '&saved=1']); } public function adminSetStatus(): Response { if (!Auth::check()) { return new Response('', 302, ['Location' => '/admin/login']); } if (!Auth::hasRole(['admin', 'manager', 'editor'])) { return new Response('', 302, ['Location' => '/admin']); } $ticketId = (int)($_POST['ticket_id'] ?? 0); $status = trim((string)($_POST['status'] ?? 'open')); if (!in_array($status, ['open', 'pending', 'closed'], true)) { $status = 'open'; } $db = Database::get(); if ($db instanceof PDO && $ticketId > 0) { $stmt = $db->prepare("UPDATE ac_support_tickets SET status = :status WHERE id = :id"); $stmt->execute([':status' => $status, ':id' => $ticketId]); } return new Response('', 302, ['Location' => '/admin/support/ticket?id=' . $ticketId . '&saved=1']); } public function adminDeleteTicket(): Response { if (!Auth::check()) { return new Response('', 302, ['Location' => '/admin/login']); } if (!Auth::hasRole(['admin', 'manager'])) { return new Response('', 302, ['Location' => '/admin']); } $ticketId = (int)($_POST['ticket_id'] ?? 0); $db = Database::get(); if ($db instanceof PDO && $ticketId > 0) { $stmt = $db->prepare("DELETE FROM ac_support_tickets WHERE id = :id"); $stmt->execute([':id' => $ticketId]); } return new Response('', 302, ['Location' => '/admin/support']); } public function adminSettings(): Response { if (!Auth::check()) { return new Response('', 302, ['Location' => '/admin/login']); } if (!Auth::hasRole(['admin', 'manager'])) { return new Response('', 302, ['Location' => '/admin']); } $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; $host = (string)($_SERVER['HTTP_HOST'] ?? ''); $baseUrl = $host !== '' ? ($scheme . '://' . $host) : ''; $cronKey = $this->getCronKey(); $cronUrl = $baseUrl !== '' ? ($baseUrl . '/support/imap-sync?key=' . urlencode($cronKey)) : '/support/imap-sync?key=' . urlencode($cronKey); $cronCommand = "*/5 * * * * /usr/bin/curl -fsS '" . $cronUrl . "' >/dev/null 2>&1"; return new Response($this->view->render('admin/settings.php', [ 'title' => 'Support Settings', 'saved' => (string)($_GET['saved'] ?? ''), 'regenerated' => (string)($_GET['regenerated'] ?? ''), 'imap_test' => (string)($_GET['imap_test'] ?? ''), 'imap_error' => (string)($_GET['imap_error'] ?? ''), 'sync_result' => (string)($_GET['sync_result'] ?? ''), 'imap_host' => Settings::get('support_imap_host', ''), 'imap_port' => Settings::get('support_imap_port', '993'), 'imap_encryption' => Settings::get('support_imap_encryption', 'ssl'), 'imap_user' => Settings::get('support_imap_user', ''), 'imap_pass' => Settings::get('support_imap_pass', ''), 'imap_folder' => Settings::get('support_imap_folder', 'INBOX'), 'support_from_email' => Settings::get('support_from_email', Settings::get('smtp_from_email', '')), 'support_ticket_prefix' => Settings::get('support_ticket_prefix', 'TCK'), 'support_type_rows' => $this->supportTypes(), 'cron_command' => $cronCommand, 'cron_key' => $cronKey, ])); } public function adminSaveSettings(): Response { if (!Auth::check()) { return new Response('', 302, ['Location' => '/admin/login']); } if (!Auth::hasRole(['admin', 'manager'])) { return new Response('', 302, ['Location' => '/admin']); } Settings::set('support_imap_host', trim((string)($_POST['support_imap_host'] ?? ''))); Settings::set('support_imap_port', trim((string)($_POST['support_imap_port'] ?? '993'))); Settings::set('support_imap_encryption', trim((string)($_POST['support_imap_encryption'] ?? 'ssl'))); Settings::set('support_imap_user', trim((string)($_POST['support_imap_user'] ?? ''))); Settings::set('support_imap_pass', trim((string)($_POST['support_imap_pass'] ?? ''))); Settings::set('support_imap_folder', trim((string)($_POST['support_imap_folder'] ?? 'INBOX'))); Settings::set('support_from_email', trim((string)($_POST['support_from_email'] ?? ''))); $ticketPrefix = $this->slugifyTicketPrefix((string)($_POST['support_ticket_prefix'] ?? 'TCK')); Settings::set('support_ticket_prefix', $ticketPrefix); Settings::set('support_cron_key', trim((string)($_POST['support_cron_key'] ?? $this->getCronKey()))); $titles = $_POST['support_type_title'] ?? []; $optionLabelsMap = $_POST['support_type_option_labels'] ?? []; $optionKeysMap = $_POST['support_type_option_keys'] ?? []; $rows = []; if (is_array($titles)) { foreach ($titles as $idx => $titleRaw) { $title = trim((string)$titleRaw); if ($title === '') { continue; } $key = $this->slugifyKey($title); $selected = []; $fieldLabels = []; $usedKeys = []; $labels = []; $keys = []; if (is_array($optionLabelsMap) && isset($optionLabelsMap[$idx]) && is_array($optionLabelsMap[$idx])) { $labels = $optionLabelsMap[$idx]; } if (is_array($optionKeysMap) && isset($optionKeysMap[$idx]) && is_array($optionKeysMap[$idx])) { $keys = $optionKeysMap[$idx]; } foreach ($labels as $optIndex => $labelRaw) { $safeLabel = trim((string)$labelRaw); if ($safeLabel === '') { continue; } $preferredKey = ''; if (isset($keys[$optIndex])) { $preferredKey = $this->slugifyKey((string)$keys[$optIndex]); } if ($preferredKey === '') { $preferredKey = $this->slugifyKey($safeLabel); } $safeKey = $preferredKey; $suffix = 2; while (isset($usedKeys[$safeKey])) { $safeKey = $preferredKey . '_' . $suffix; $suffix++; } $usedKeys[$safeKey] = true; $selected[] = $safeKey; $fieldLabels[] = ['key' => $safeKey, 'label' => $safeLabel]; } $rows[] = [ 'key' => $key, 'label' => $title, 'fields' => $selected, 'field_labels' => $fieldLabels, ]; } } if (!$rows) { $rows = $this->supportTypesFromConfig($this->defaultSupportTypesConfig()); } Settings::set('support_types_config', json_encode($rows, JSON_UNESCAPED_SLASHES)); return new Response('', 302, ['Location' => '/admin/support/settings?saved=1']); } public function adminRunImapSync(): Response { if (!Auth::check()) { return new Response('', 302, ['Location' => '/admin/login']); } if (!Auth::hasRole(['admin', 'manager'])) { return new Response('', 302, ['Location' => '/admin']); } $result = $this->runImapSync(); $status = $result['ok'] ? 'ok' : ('error:' . $result['error']); return new Response('', 302, ['Location' => '/admin/support/settings?sync_result=' . urlencode($status)]); } public function adminRegenerateCronKey(): Response { if (!Auth::check()) { return new Response('', 302, ['Location' => '/admin/login']); } if (!Auth::hasRole(['admin', 'manager'])) { return new Response('', 302, ['Location' => '/admin']); } $newKey = bin2hex(random_bytes(16)); Settings::set('support_cron_key', $newKey); return new Response('', 302, ['Location' => '/admin/support/settings?regenerated=1']); } public function cronImapSync(): Response { $key = trim((string)($_GET['key'] ?? '')); if ($key === '' || !hash_equals($this->getCronKey(), $key)) { return new Response('Unauthorized', 403, ['Content-Type' => 'text/plain; charset=utf-8']); } $result = $this->runImapSync(); $body = $result['ok'] ? ('OK imported=' . (string)$result['imported'] . ' scanned=' . (string)$result['scanned']) : ('ERROR ' . (string)$result['error']); return new Response($body, $result['ok'] ? 200 : 500, ['Content-Type' => 'text/plain; charset=utf-8']); } public function adminTestImap(): Response { if (!Auth::check()) { return new Response('', 302, ['Location' => '/admin/login']); } if (!Auth::hasRole(['admin', 'manager'])) { return new Response('', 302, ['Location' => '/admin']); } if (!function_exists('imap_open')) { return new Response('', 302, ['Location' => '/admin/support/settings?imap_error=' . urlencode('PHP IMAP extension is not enabled on server')]); } $host = trim((string)($_POST['support_imap_host'] ?? '')); $port = (int)trim((string)($_POST['support_imap_port'] ?? '993')); $enc = strtolower(trim((string)($_POST['support_imap_encryption'] ?? 'ssl'))); $user = trim((string)($_POST['support_imap_user'] ?? '')); $pass = (string)($_POST['support_imap_pass'] ?? ''); $folder = trim((string)($_POST['support_imap_folder'] ?? 'INBOX')); if ($host === '' || $port <= 0 || $user === '' || $pass === '') { return new Response('', 302, ['Location' => '/admin/support/settings?imap_error=' . urlencode('Host, port, username and password are required')]); } if (defined('IMAP_OPENTIMEOUT')) { @imap_timeout(IMAP_OPENTIMEOUT, 12); } if (defined('IMAP_READTIMEOUT')) { @imap_timeout(IMAP_READTIMEOUT, 12); } $imapFlags = '/imap'; if ($enc === 'ssl') { $imapFlags .= '/ssl'; } elseif ($enc === 'tls') { $imapFlags .= '/tls'; } else { $imapFlags .= '/notls'; } $mailbox = '{' . $host . ':' . $port . $imapFlags . '}' . $folder; $mailboxNoValidate = '{' . $host . ':' . $port . $imapFlags . '/novalidate-cert}' . $folder; $conn = @imap_open($mailbox, $user, $pass, OP_READONLY, 1); if (!$conn) { // Many hosts use self-signed/chain-issue certs; retry with novalidate-cert. $conn = @imap_open($mailboxNoValidate, $user, $pass, OP_READONLY, 1); } if (!$conn) { $err = (string)imap_last_error(); if ($err === '') { $err = 'Connection failed'; } return new Response('', 302, ['Location' => '/admin/support/settings?imap_error=' . urlencode($err)]); } @imap_close($conn); return new Response('', 302, ['Location' => '/admin/support/settings?imap_test=ok']); } public function adminInstall(): Response { if (!Auth::check()) { return new Response('', 302, ['Location' => '/admin/login']); } if (!Auth::hasRole(['admin', 'manager'])) { return new Response('', 302, ['Location' => '/admin']); } $db = Database::get(); if ($db instanceof PDO) { $this->createTables($db); } return new Response('', 302, ['Location' => '/admin/support']); } private function tablesReady(PDO $db): bool { try { $ticketCheck = $db->query("SHOW TABLES LIKE 'ac_support_tickets'"); $msgCheck = $db->query("SHOW TABLES LIKE 'ac_support_messages'"); return (bool)($ticketCheck && $ticketCheck->fetch(PDO::FETCH_ASSOC)) && (bool)($msgCheck && $msgCheck->fetch(PDO::FETCH_ASSOC)); } catch (Throwable $e) { return false; } } private function createTables(PDO $db): void { try { $db->exec(" CREATE TABLE IF NOT EXISTS ac_support_tickets ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, ticket_no VARCHAR(32) NOT NULL UNIQUE, subject VARCHAR(255) NOT NULL, status ENUM('open','pending','closed') NOT NULL DEFAULT 'open', customer_name VARCHAR(160) NULL, customer_email VARCHAR(190) NOT NULL, customer_ip VARCHAR(45) NULL, last_message_at DATETIME NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; "); $db->exec(" CREATE TABLE IF NOT EXISTS ac_support_messages ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, ticket_id INT UNSIGNED NOT NULL, sender_type ENUM('customer','admin') NOT NULL, sender_name VARCHAR(160) NULL, sender_email VARCHAR(190) NULL, body_text MEDIUMTEXT NOT NULL, source ENUM('web','email','admin') NOT NULL DEFAULT 'web', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, INDEX idx_ticket_id (ticket_id), CONSTRAINT fk_support_ticket FOREIGN KEY (ticket_id) REFERENCES ac_support_tickets(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; "); $db->exec(" CREATE TABLE IF NOT EXISTS ac_support_inbound_log ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, message_uid VARCHAR(190) NOT NULL UNIQUE, ticket_id INT UNSIGNED NULL, processed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; "); } catch (Throwable $e) { } } /** * @return array{ok:bool,error:string,imported:int,scanned:int} */ private function runImapSync(): array { if (!function_exists('imap_open')) { return ['ok' => false, 'error' => 'IMAP extension missing', 'imported' => 0, 'scanned' => 0]; } $db = Database::get(); if (!$db instanceof PDO || !$this->tablesReady($db)) { return ['ok' => false, 'error' => 'Support tables not ready', 'imported' => 0, 'scanned' => 0]; } $host = Settings::get('support_imap_host', ''); $port = (int)Settings::get('support_imap_port', '993'); $enc = strtolower(Settings::get('support_imap_encryption', 'ssl')); $user = Settings::get('support_imap_user', ''); $pass = Settings::get('support_imap_pass', ''); $folder = Settings::get('support_imap_folder', 'INBOX'); if ($host === '' || $port <= 0 || $user === '' || $pass === '') { return ['ok' => false, 'error' => 'IMAP settings incomplete', 'imported' => 0, 'scanned' => 0]; } $imapFlags = '/imap'; if ($enc === 'ssl') { $imapFlags .= '/ssl'; } elseif ($enc === 'tls') { $imapFlags .= '/tls'; } else { $imapFlags .= '/notls'; } $mailbox = '{' . $host . ':' . $port . $imapFlags . '}' . $folder; $mailboxNoValidate = '{' . $host . ':' . $port . $imapFlags . '/novalidate-cert}' . $folder; $conn = @imap_open($mailbox, $user, $pass, OP_READONLY, 1); if (!$conn) { $conn = @imap_open($mailboxNoValidate, $user, $pass, OP_READONLY, 1); } if (!$conn) { $err = (string)imap_last_error(); return ['ok' => false, 'error' => ($err !== '' ? $err : 'IMAP connection failed'), 'imported' => 0, 'scanned' => 0]; } $msgNos = @imap_search($conn, 'UNSEEN') ?: []; if (!$msgNos) { $msgNos = @imap_search($conn, 'ALL') ?: []; if (count($msgNos) > 100) { $msgNos = array_slice($msgNos, -100); } } $imported = 0; $scanned = 0; $supportFrom = strtolower(Settings::get('support_from_email', Settings::get('smtp_from_email', ''))); foreach ($msgNos as $msgNoRaw) { $msgNo = (int)$msgNoRaw; if ($msgNo <= 0) { continue; } $scanned++; $overview = @imap_fetch_overview($conn, (string)$msgNo, 0); if (!$overview || empty($overview[0])) { continue; } $ov = $overview[0]; $subject = isset($ov->subject) ? imap_utf8((string)$ov->subject) : ''; $fromRaw = isset($ov->from) ? (string)$ov->from : ''; $messageId = isset($ov->message_id) ? trim((string)$ov->message_id) : ''; if ($messageId === '') { $uid = @imap_uid($conn, $msgNo); $messageId = 'uid-' . (string)$uid; } $existsStmt = $db->prepare("SELECT id FROM ac_support_inbound_log WHERE message_uid = :uid LIMIT 1"); $existsStmt->execute([':uid' => $messageId]); if ($existsStmt->fetch(PDO::FETCH_ASSOC)) { continue; } $token = $this->extractTicketToken($subject); if ($token === '') { $this->logInbound($db, $messageId, null); continue; } $ticketStmt = $db->prepare("SELECT id FROM ac_support_tickets WHERE ticket_no = :ticket_no LIMIT 1"); $ticketStmt->execute([':ticket_no' => $token]); $ticket = $ticketStmt->fetch(PDO::FETCH_ASSOC); if (!$ticket) { $this->logInbound($db, $messageId, null); continue; } $ticketId = (int)$ticket['id']; $fromEmail = $this->extractEmailFromHeader($fromRaw); if ($fromEmail !== '' && $supportFrom !== '' && strtolower($fromEmail) === $supportFrom) { $this->logInbound($db, $messageId, $ticketId); continue; } $body = $this->fetchMessageBody($conn, $msgNo); if ($body === '') { $this->logInbound($db, $messageId, $ticketId); continue; } $insertMsg = $db->prepare(" INSERT INTO ac_support_messages (ticket_id, sender_type, sender_name, sender_email, body_text, source) VALUES (:ticket_id, 'customer', :sender_name, :sender_email, :body_text, 'email') "); $insertMsg->execute([ ':ticket_id' => $ticketId, ':sender_name' => '', ':sender_email' => $fromEmail !== '' ? $fromEmail : $fromRaw, ':body_text' => $body, ]); $db->prepare("UPDATE ac_support_tickets SET status = 'open', last_message_at = NOW() WHERE id = :id")->execute([':id' => $ticketId]); $this->logInbound($db, $messageId, $ticketId); $imported++; } @imap_close($conn); return ['ok' => true, 'error' => '', 'imported' => $imported, 'scanned' => $scanned]; } private function logInbound(PDO $db, string $uid, ?int $ticketId): void { $stmt = $db->prepare("INSERT INTO ac_support_inbound_log (message_uid, ticket_id) VALUES (:uid, :ticket_id)"); $stmt->execute([ ':uid' => $uid, ':ticket_id' => $ticketId, ]); } private function extractTicketToken(string $subject): string { if (preg_match('/\\[AC-TICKET-([A-Z0-9\\-]+)\\]/i', $subject, $m)) { return strtoupper(trim((string)$m[1])); } return ''; } private function extractEmailFromHeader(string $from): string { if (preg_match('/<([^>]+)>/', $from, $m)) { return trim((string)$m[1]); } return filter_var(trim($from), FILTER_VALIDATE_EMAIL) ? trim($from) : ''; } private function fetchMessageBody($conn, int $msgNo): string { $structure = @imap_fetchstructure($conn, $msgNo); if (!$structure) { $raw = (string)@imap_body($conn, $msgNo); return $this->sanitizeEmailBody($raw); } $body = ''; if (isset($structure->parts) && is_array($structure->parts)) { foreach ($structure->parts as $i => $part) { $partNo = (string)($i + 1); $isText = ((int)($part->type ?? -1) === 0); $subtype = strtolower((string)($part->subtype ?? '')); if ($isText && $subtype === 'plain') { $body = (string)@imap_fetchbody($conn, $msgNo, $partNo); $body = $this->decodeImapBodyByEncoding($body, (int)($part->encoding ?? 0)); break; } } if ($body === '') { foreach ($structure->parts as $i => $part) { $partNo = (string)($i + 1); $isText = ((int)($part->type ?? -1) === 0); if ($isText) { $body = (string)@imap_fetchbody($conn, $msgNo, $partNo); $body = $this->decodeImapBodyByEncoding($body, (int)($part->encoding ?? 0)); break; } } } } if ($body === '') { $raw = (string)@imap_body($conn, $msgNo); $body = $this->decodeImapBodyByEncoding($raw, (int)($structure->encoding ?? 0)); } return $this->sanitizeEmailBody($body); } private function decodeImapBodyByEncoding(string $body, int $encoding): string { return match ($encoding) { 3 => base64_decode($body, true) ?: $body, 4 => quoted_printable_decode($body), default => $body, }; } private function sanitizeEmailBody(string $body): string { $body = str_replace("\r\n", "\n", $body); $body = preg_replace('/\nOn .*wrote:\n.*/s', '', $body) ?? $body; $body = trim($body); if (strlen($body) > 8000) { $body = substr($body, 0, 8000); } return $body; } private function getCronKey(): string { $key = trim(Settings::get('support_cron_key', '')); if ($key === '') { $key = bin2hex(random_bytes(16)); Settings::set('support_cron_key', $key); } return $key; } private function newTicketId(): string { $prefix = $this->slugifyTicketPrefix(Settings::get('support_ticket_prefix', 'TCK')); return $prefix . '-' . date('Ymd') . '-' . strtoupper(substr(bin2hex(random_bytes(3)), 0, 6)); } private function slugifyTicketPrefix(string $value): string { $value = strtoupper(trim($value)); $value = preg_replace('/[^A-Z0-9]+/', '-', $value) ?? $value; $value = trim($value, '-'); if ($value === '') { return 'TCK'; } return substr($value, 0, 16); } private function defaultSupportTypesConfig(): string { return json_encode([ ['key' => 'general', 'label' => 'General', 'fields' => [], 'field_labels' => []], ['key' => 'order_issue', 'label' => 'Order Issue', 'fields' => ['order_no'], 'field_labels' => [['key' => 'order_no', 'label' => 'Order Number']]], ['key' => 'billing', 'label' => 'Billing', 'fields' => ['billing_ref'], 'field_labels' => [['key' => 'billing_ref', 'label' => 'Billing Reference']]], ['key' => 'technical', 'label' => 'Technical', 'fields' => ['page_url', 'browser_info'], 'field_labels' => [['key' => 'page_url', 'label' => 'Page URL'], ['key' => 'browser_info', 'label' => 'Browser / Device']]], ['key' => 'dmca', 'label' => 'DMCA', 'fields' => ['infringing_url', 'rights_owner', 'proof_url'], 'field_labels' => [['key' => 'infringing_url', 'label' => 'Infringing URL'], ['key' => 'rights_owner', 'label' => 'Rights Owner'], ['key' => 'proof_url', 'label' => 'Proof URL']]], ['key' => 'other', 'label' => 'Other', 'fields' => [], 'field_labels' => []], ], JSON_UNESCAPED_SLASHES) ?: '[]'; } /** * @return array