| Tila | +Aihe | +Lähettäjä | +Viestejä | +Osoitettu | +Päivitetty | +
|---|
diff --git a/api.php b/api.php index 1cfc3d7..c47d710 100644 --- a/api.php +++ b/api.php @@ -18,6 +18,7 @@ define('LEADS_FILE', DATA_DIR . '/leads.json'); define('TOKENS_FILE', DATA_DIR . '/reset_tokens.json'); define('RATE_FILE', DATA_DIR . '/login_attempts.json'); define('CONFIG_FILE', DATA_DIR . '/config.json'); +define('TICKETS_FILE', DATA_DIR . '/tickets.json'); define('SITE_URL', 'https://intra.cuitunet.fi'); // Sähköpostiasetukset @@ -26,7 +27,7 @@ define('MAIL_FROM_NAME', 'CuituNet Intra'); // Varmista data-kansio ja tiedostot if (!file_exists(DATA_DIR)) mkdir(DATA_DIR, 0755, true); -foreach ([DATA_FILE, USERS_FILE, CHANGELOG_FILE, ARCHIVE_FILE, LEADS_FILE, TOKENS_FILE, RATE_FILE] as $f) { +foreach ([DATA_FILE, USERS_FILE, CHANGELOG_FILE, ARCHIVE_FILE, LEADS_FILE, TOKENS_FILE, RATE_FILE, TICKETS_FILE] as $f) { if (!file_exists($f)) file_put_contents($f, '[]'); } @@ -116,6 +117,232 @@ function sendMail(string $to, string $subject, string $htmlBody): bool { return mail($to, $subject, $htmlBody, $headers, '-f ' . MAIL_FROM); } +// ==================== IMAP CLIENT ==================== + +class ImapClient { + private $connection = null; + + public function connect(array $config): bool { + $host = $config['imap_host'] ?? ''; + $port = intval($config['imap_port'] ?? 993); + $user = $config['imap_user'] ?? ''; + $pass = $config['imap_password'] ?? ''; + $encryption = $config['imap_encryption'] ?? 'ssl'; + + if (empty($host) || empty($user) || empty($pass)) { + return false; + } + + $mailbox = '{' . $host . ':' . $port . '/imap/' . $encryption . '}INBOX'; + + // Suppress warnings, handle errors manually + $this->connection = @imap_open($mailbox, $user, $pass, 0, 1); + + if (!$this->connection) { + return false; + } + return true; + } + + public function fetchMessages(int $limit = 50): array { + if (!$this->connection) return []; + + $messages = []; + $check = imap_check($this->connection); + if (!$check || $check->Nmsgs === 0) return []; + + $start = max(1, $check->Nmsgs - $limit + 1); + $end = $check->Nmsgs; + + for ($i = $end; $i >= $start; $i--) { + $header = @imap_headerinfo($this->connection, $i); + if (!$header) continue; + + $overview = @imap_fetch_overview($this->connection, strval($i), 0); + + // Decode subject + $subject = ''; + if (isset($header->subject)) { + $decoded = imap_mime_header_decode($header->subject); + foreach ($decoded as $part) { + $subject .= $part->text; + } + } + + // From + $fromEmail = ''; + $fromName = ''; + if (isset($header->from[0])) { + $fromEmail = $header->from[0]->mailbox . '@' . ($header->from[0]->host ?? ''); + if (isset($header->from[0]->personal)) { + $decoded = imap_mime_header_decode($header->from[0]->personal); + foreach ($decoded as $part) { + $fromName .= $part->text; + } + } + } + + // Message-ID + $messageId = isset($header->message_id) ? trim($header->message_id) : ''; + + // In-Reply-To + $inReplyTo = ''; + if (isset($header->in_reply_to)) { + $inReplyTo = trim($header->in_reply_to); + } + + // References + $references = ''; + if (isset($header->references)) { + $references = trim($header->references); + } + + // Date + $date = isset($header->date) ? date('Y-m-d H:i:s', strtotime($header->date)) : date('Y-m-d H:i:s'); + + // Body — prefer plain text + $body = $this->getBody($i); + + $messages[] = [ + 'subject' => $subject, + 'from_email' => $fromEmail, + 'from_name' => $fromName, + 'message_id' => $messageId, + 'in_reply_to' => $inReplyTo, + 'references' => $references, + 'date' => $date, + 'body' => $body, + ]; + } + + return $messages; + } + + private function getBody(int $msgNum): string { + if (!$this->connection) return ''; + + $structure = @imap_fetchstructure($this->connection, $msgNum); + if (!$structure) return ''; + + // Simple message (no parts) + if (empty($structure->parts)) { + $body = imap_fetchbody($this->connection, $msgNum, '1'); + return $this->decodeBody($body, $structure->encoding ?? 0); + } + + // Multipart — look for text/plain first, then text/html + $plainBody = ''; + $htmlBody = ''; + + foreach ($structure->parts as $partNum => $part) { + $partIndex = strval($partNum + 1); + + if ($part->type === 0) { // TEXT + $subtype = strtolower($part->subtype ?? ''); + $body = imap_fetchbody($this->connection, $msgNum, $partIndex); + $decoded = $this->decodeBody($body, $part->encoding ?? 0); + + // Handle charset + $charset = $this->getCharset($part); + if ($charset && strtolower($charset) !== 'utf-8') { + $converted = @iconv($charset, 'UTF-8//IGNORE', $decoded); + if ($converted !== false) $decoded = $converted; + } + + if ($subtype === 'plain') { + $plainBody = $decoded; + } elseif ($subtype === 'html') { + $htmlBody = $decoded; + } + } + } + + if ($plainBody) return trim($plainBody); + if ($htmlBody) return trim(strip_tags($htmlBody)); + return ''; + } + + private function decodeBody(string $body, int $encoding): string { + switch ($encoding) { + case 3: return base64_decode($body); // BASE64 + case 4: return quoted_printable_decode($body); // QUOTED-PRINTABLE + default: return $body; + } + } + + private function getCharset($part): string { + if (!isset($part->parameters)) return ''; + foreach ($part->parameters as $param) { + if (strtolower($param->attribute) === 'charset') { + return $param->value; + } + } + return ''; + } + + public function disconnect(): void { + if ($this->connection) { + @imap_close($this->connection); + $this->connection = null; + } + } +} + +// ==================== TICKETS ==================== + +function loadTickets(): array { + return json_decode(file_get_contents(TICKETS_FILE), true) ?: []; +} + +function saveTickets(array $tickets): void { + file_put_contents(TICKETS_FILE, json_encode($tickets, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); +} + +function findTicketByMessageId(array $tickets, string $messageId): ?int { + foreach ($tickets as $i => $t) { + if ($t['message_id'] === $messageId) return $i; + foreach ($t['messages'] ?? [] as $m) { + if (($m['message_id'] ?? '') === $messageId) return $i; + } + } + return null; +} + +function findTicketByReferences(array $tickets, string $inReplyTo, string $references): ?int { + // Check In-Reply-To header + if ($inReplyTo) { + $idx = findTicketByMessageId($tickets, $inReplyTo); + if ($idx !== null) return $idx; + } + // Check References header + if ($references) { + $refs = preg_split('/\s+/', $references); + foreach ($refs as $ref) { + $ref = trim($ref); + if (!$ref) continue; + $idx = findTicketByMessageId($tickets, $ref); + if ($idx !== null) return $idx; + } + } + return null; +} + +function sendTicketMail(string $to, string $subject, string $body, string $inReplyTo = '', string $references = ''): bool { + $config = loadConfig(); + $fromEmail = $config['imap_user'] ?? MAIL_FROM; + $fromName = 'CuituNet Asiakaspalvelu'; + + $headers = "MIME-Version: 1.0\r\n"; + $headers .= "Content-Type: text/plain; charset=UTF-8\r\n"; + $headers .= "From: {$fromName} <{$fromEmail}>\r\n"; + $headers .= "Reply-To: {$fromEmail}\r\n"; + if ($inReplyTo) { + $headers .= "In-Reply-To: {$inReplyTo}\r\n"; + $headers .= "References: " . ($references ? $references . ' ' : '') . $inReplyTo . "\r\n"; + } + return mail($to, $subject, $body, $headers, '-f ' . $fromEmail); +} + // ==================== USERS ==================== function initUsers(): void { @@ -339,6 +566,12 @@ switch ($action) { $origins = array_filter(array_map('trim', explode("\n", $input['cors_origins']))); $config['cors_origins'] = array_values($origins); } + // IMAP-asetukset + if (isset($input['imap_host'])) $config['imap_host'] = trim($input['imap_host']); + if (isset($input['imap_port'])) $config['imap_port'] = intval($input['imap_port']); + if (isset($input['imap_user'])) $config['imap_user'] = trim($input['imap_user']); + if (isset($input['imap_password'])) $config['imap_password'] = $input['imap_password']; + if (isset($input['imap_encryption'])) $config['imap_encryption'] = trim($input['imap_encryption']); saveConfig($config); addLog('config_update', '', '', 'Päivitti asetukset'); echo json_encode($config); @@ -996,6 +1229,321 @@ switch ($action) { echo json_encode(['success' => true]); break; + // ---------- TICKETS ---------- + case 'tickets': + requireAuth(); + $tickets = loadTickets(); + // Palauta ilman viestisisältöjä (lista-näkymä) + $list = array_map(function($t) { + $msgCount = count($t['messages'] ?? []); + $lastMsg = $msgCount > 0 ? $t['messages'][$msgCount - 1] : null; + return [ + 'id' => $t['id'], + 'subject' => $t['subject'], + 'from_email' => $t['from_email'], + 'from_name' => $t['from_name'], + 'status' => $t['status'], + 'assigned_to' => $t['assigned_to'] ?? '', + 'created' => $t['created'], + 'updated' => $t['updated'], + 'message_count' => $msgCount, + 'last_message_type' => $lastMsg ? ($lastMsg['type'] ?? '') : '', + 'last_message_time' => $lastMsg ? ($lastMsg['timestamp'] ?? '') : '', + ]; + }, $tickets); + echo json_encode($list); + break; + + case 'ticket_detail': + requireAuth(); + $id = $_GET['id'] ?? ''; + $tickets = loadTickets(); + $ticket = null; + foreach ($tickets as $t) { + if ($t['id'] === $id) { $ticket = $t; break; } + } + if (!$ticket) { + http_response_code(404); + echo json_encode(['error' => 'Tikettiä ei löydy']); + break; + } + echo json_encode($ticket); + break; + + case 'ticket_fetch': + requireAuth(); + if ($method !== 'POST') break; + $config = loadConfig(); + if (empty($config['imap_host']) || empty($config['imap_user']) || empty($config['imap_password'])) { + http_response_code(400); + echo json_encode(['error' => 'IMAP-asetukset puuttuvat. Aseta ne API-välilehdellä.']); + break; + } + + $imap = new ImapClient(); + if (!$imap->connect($config)) { + $errors = imap_errors(); + http_response_code(500); + echo json_encode(['error' => 'IMAP-yhteys epäonnistui' . ($errors ? ': ' . implode(', ', $errors) : '')]); + break; + } + + $emails = $imap->fetchMessages(100); + $imap->disconnect(); + + $tickets = loadTickets(); + $newCount = 0; + $threadedCount = 0; + + // Collect all existing message IDs for duplicate detection + $existingMsgIds = []; + foreach ($tickets as $t) { + if ($t['message_id']) $existingMsgIds[$t['message_id']] = true; + foreach ($t['messages'] ?? [] as $m) { + if (!empty($m['message_id'])) $existingMsgIds[$m['message_id']] = true; + } + } + + foreach ($emails as $email) { + // Skip duplicates + if (!empty($email['message_id']) && isset($existingMsgIds[$email['message_id']])) { + continue; + } + + $msg = [ + 'id' => generateId(), + 'type' => 'email_in', + 'from' => $email['from_email'], + 'from_name' => $email['from_name'], + 'body' => $email['body'], + 'timestamp' => $email['date'], + 'message_id' => $email['message_id'], + ]; + + // Try to thread into existing ticket + $ticketIdx = findTicketByReferences($tickets, $email['in_reply_to'], $email['references']); + + if ($ticketIdx !== null) { + $tickets[$ticketIdx]['messages'][] = $msg; + $tickets[$ticketIdx]['updated'] = $email['date']; + // If ticket was resolved/closed, reopen it + if (in_array($tickets[$ticketIdx]['status'], ['ratkaistu', 'suljettu'])) { + $tickets[$ticketIdx]['status'] = 'kasittelyssa'; + } + $threadedCount++; + } else { + // New ticket + $ticket = [ + 'id' => generateId(), + 'subject' => $email['subject'] ?: '(Ei aihetta)', + 'from_email' => $email['from_email'], + 'from_name' => $email['from_name'], + 'status' => 'uusi', + 'assigned_to' => '', + 'created' => $email['date'], + 'updated' => $email['date'], + 'message_id' => $email['message_id'], + 'messages' => [$msg], + ]; + $tickets[] = $ticket; + $newCount++; + } + + if ($email['message_id']) $existingMsgIds[$email['message_id']] = true; + } + + // Sort tickets by updated date (newest first) + usort($tickets, function($a, $b) { + return strcmp($b['updated'], $a['updated']); + }); + + saveTickets($tickets); + addLog('ticket_fetch', '', '', "Haettu sähköpostit: {$newCount} uutta tikettiä, {$threadedCount} ketjutettu"); + echo json_encode(['success' => true, 'new_tickets' => $newCount, 'threaded' => $threadedCount, 'total' => count($tickets)]); + break; + + case 'ticket_reply': + requireAuth(); + if ($method !== 'POST') break; + $input = json_decode(file_get_contents('php://input'), true); + $id = $input['id'] ?? ''; + $body = trim($input['body'] ?? ''); + if (empty($body)) { + http_response_code(400); + echo json_encode(['error' => 'Viesti ei voi olla tyhjä']); + break; + } + $tickets = loadTickets(); + $found = false; + foreach ($tickets as &$t) { + if ($t['id'] === $id) { + // Find last message_id for threading + $lastMsgId = $t['message_id'] ?? ''; + $allRefs = $lastMsgId; + foreach ($t['messages'] as $m) { + if (!empty($m['message_id'])) { + $lastMsgId = $m['message_id']; + $allRefs .= ' ' . $m['message_id']; + } + } + + // Send email + $subject = 'Re: ' . $t['subject']; + $sent = sendTicketMail($t['from_email'], $subject, $body, $lastMsgId, trim($allRefs)); + + if (!$sent) { + http_response_code(500); + echo json_encode(['error' => 'Sähköpostin lähetys epäonnistui']); + break 2; + } + + // Add reply to ticket + $reply = [ + 'id' => generateId(), + 'type' => 'reply_out', + 'from' => currentUser(), + 'from_name' => $_SESSION['nimi'] ?? currentUser(), + 'body' => $body, + 'timestamp' => date('Y-m-d H:i:s'), + 'message_id' => '', + ]; + $t['messages'][] = $reply; + $t['updated'] = date('Y-m-d H:i:s'); + if ($t['status'] === 'uusi') $t['status'] = 'kasittelyssa'; + + $found = true; + addLog('ticket_reply', $t['id'], $t['subject'], 'Vastasi tikettiin'); + echo json_encode($t); + break; + } + } + unset($t); + if (!$found) { + http_response_code(404); + echo json_encode(['error' => 'Tikettiä ei löydy']); + break; + } + saveTickets($tickets); + break; + + case 'ticket_status': + requireAuth(); + if ($method !== 'POST') break; + $input = json_decode(file_get_contents('php://input'), true); + $id = $input['id'] ?? ''; + $status = $input['status'] ?? ''; + $validStatuses = ['uusi', 'kasittelyssa', 'odottaa', 'ratkaistu', 'suljettu']; + if (!in_array($status, $validStatuses)) { + http_response_code(400); + echo json_encode(['error' => 'Virheellinen tila']); + break; + } + $tickets = loadTickets(); + $found = false; + foreach ($tickets as &$t) { + if ($t['id'] === $id) { + $oldStatus = $t['status']; + $t['status'] = $status; + $t['updated'] = date('Y-m-d H:i:s'); + $found = true; + addLog('ticket_status', $t['id'], $t['subject'], "Tila: {$oldStatus} → {$status}"); + echo json_encode($t); + break; + } + } + unset($t); + if (!$found) { + http_response_code(404); + echo json_encode(['error' => 'Tikettiä ei löydy']); + break; + } + saveTickets($tickets); + break; + + case 'ticket_assign': + requireAuth(); + if ($method !== 'POST') break; + $input = json_decode(file_get_contents('php://input'), true); + $id = $input['id'] ?? ''; + $assignTo = trim($input['assigned_to'] ?? ''); + $tickets = loadTickets(); + $found = false; + foreach ($tickets as &$t) { + if ($t['id'] === $id) { + $t['assigned_to'] = $assignTo; + $t['updated'] = date('Y-m-d H:i:s'); + $found = true; + addLog('ticket_assign', $t['id'], $t['subject'], "Osoitettu: {$assignTo}"); + echo json_encode($t); + break; + } + } + unset($t); + if (!$found) { + http_response_code(404); + echo json_encode(['error' => 'Tikettiä ei löydy']); + break; + } + saveTickets($tickets); + break; + + case 'ticket_note': + requireAuth(); + if ($method !== 'POST') break; + $input = json_decode(file_get_contents('php://input'), true); + $id = $input['id'] ?? ''; + $body = trim($input['body'] ?? ''); + if (empty($body)) { + http_response_code(400); + echo json_encode(['error' => 'Muistiinpano ei voi olla tyhjä']); + break; + } + $tickets = loadTickets(); + $found = false; + foreach ($tickets as &$t) { + if ($t['id'] === $id) { + $note = [ + 'id' => generateId(), + 'type' => 'note', + 'from' => currentUser(), + 'from_name' => $_SESSION['nimi'] ?? currentUser(), + 'body' => $body, + 'timestamp' => date('Y-m-d H:i:s'), + 'message_id' => '', + ]; + $t['messages'][] = $note; + $t['updated'] = date('Y-m-d H:i:s'); + $found = true; + addLog('ticket_note', $t['id'], $t['subject'], 'Lisäsi muistiinpanon'); + echo json_encode($t); + break; + } + } + unset($t); + if (!$found) { + http_response_code(404); + echo json_encode(['error' => 'Tikettiä ei löydy']); + break; + } + saveTickets($tickets); + break; + + case 'ticket_delete': + requireAuth(); + if ($method !== 'POST') break; + $input = json_decode(file_get_contents('php://input'), true); + $id = $input['id'] ?? ''; + $tickets = loadTickets(); + $deleted = null; + foreach ($tickets as $t) { + if ($t['id'] === $id) { $deleted = $t; break; } + } + $tickets = array_values(array_filter($tickets, fn($t) => $t['id'] !== $id)); + saveTickets($tickets); + if ($deleted) addLog('ticket_delete', $id, $deleted['subject'] ?? '', 'Poisti tiketin'); + echo json_encode(['success' => true]); + break; + default: http_response_code(404); echo json_encode(['error' => 'Tuntematon toiminto']); diff --git a/index.html b/index.html index 8da3e8e..744e2df 100644 --- a/index.html +++ b/index.html @@ -76,6 +76,7 @@ + @@ -235,6 +236,75 @@ + +
| Tila | +Aihe | +Lähettäjä | +Viestejä | +Osoitettu | +Päivitetty | +
|---|
Asiakaspalvelu-sähköpostin IMAP-asetukset. Käytetään tikettien hakuun.
+