Files
AudioCore/plugins/support/SupportController.php
AudioCore Bot 9deabe1ec9 Release v1.5.1
2026-04-01 14:12:17 +00:00

950 lines
40 KiB
PHP

<?php
declare(strict_types=1);
namespace Plugins\Support;
use Core\Http\Response;
use Core\Services\Auth;
use Core\Services\Database;
use Core\Services\Mailer;
use Core\Services\RateLimiter;
use Core\Services\Settings;
use Core\Views\View;
use PDO;
use Throwable;
class SupportController
{
private View $view;
public function __construct()
{
$this->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 = '<p>Hello ' . htmlspecialchars((string)($ticket['customer_name'] ?? 'there'), ENT_QUOTES, 'UTF-8') . ',</p>'
. '<p>' . $safeBody . '</p>'
. '<p>Reply to this email to continue ticket <strong>' . htmlspecialchars((string)($ticket['ticket_no'] ?? ''), ENT_QUOTES, 'UTF-8') . '</strong>.</p>';
$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<int, array{key:string,label:string,fields:array<int,string>}>
*/
private function supportTypes(): array
{
$raw = Settings::get('support_types_config', $this->defaultSupportTypesConfig());
return $this->supportTypesFromConfig($raw);
}
/**
* @return array<int, array{key:string,label:string,fields:array<int,string>}>
*/
private function supportTypesFromConfig(string $raw): array
{
$types = [];
$decodedJson = json_decode($raw, true);
if (is_array($decodedJson)) {
foreach ($decodedJson as $row) {
if (!is_array($row)) {
continue;
}
$key = $this->slugifyKey((string)($row['key'] ?? $row['label'] ?? ''));
$label = trim((string)($row['label'] ?? ''));
$rawFields = is_array($row['fields'] ?? null) ? $row['fields'] : [];
$rawFieldLabels = is_array($row['field_labels'] ?? null) ? $row['field_labels'] : [];
if ($key === '' || $label === '') {
continue;
}
$fields = [];
foreach ($rawFields as $field) {
$field = trim((string)$field);
if ($field !== '') {
$fields[] = $field;
}
}
$fieldLabels = [];
foreach ($rawFieldLabels as $fr) {
if (!is_array($fr)) {
continue;
}
$fk = $this->slugifyKey((string)($fr['key'] ?? ''));
$fl = trim((string)($fr['label'] ?? ''));
if ($fk !== '' && $fl !== '') {
$fieldLabels[] = ['key' => $fk, 'label' => $fl];
}
}
$types[] = [
'key' => $key,
'label' => $label,
'fields' => array_values(array_unique($fields)),
'field_labels' => $fieldLabels,
];
}
}
if ($types) {
return $types;
}
foreach (preg_split('/\r?\n/', $raw) as $line) {
$line = trim((string)$line);
if ($line === '' || str_starts_with($line, '#')) {
continue;
}
$parts = array_map('trim', explode('|', $line));
$key = preg_replace('/[^a-z0-9_]/', '', strtolower((string)($parts[0] ?? '')));
$label = (string)($parts[1] ?? '');
$fieldRaw = (string)($parts[2] ?? '');
if ($key === '' || $label === '') {
continue;
}
$fields = [];
foreach (array_map('trim', explode(',', $fieldRaw)) as $field) {
if ($field !== '') {
$fields[] = $field;
}
}
$types[] = ['key' => $key, 'label' => $label, 'fields' => $fields];
}
if (!$types) {
return [
['key' => 'general', 'label' => 'General', 'fields' => []],
];
}
return $types;
}
private function slugifyKey(string $value): string
{
$value = strtolower(trim($value));
$value = preg_replace('/[^a-z0-9]+/', '_', $value) ?? $value;
$value = trim($value, '_');
return $value !== '' ? substr($value, 0, 48) : 'type_' . substr(bin2hex(random_bytes(2)), 0, 4);
}
private function clientIp(): string
{
$ip = (string)($_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? '');
if (strpos($ip, ',') !== false) {
$parts = explode(',', $ip);
$ip = trim((string)$parts[0]);
}
return substr($ip, 0, 45);
}
}