Add Asiakaspalvelu email ticketing system

IMAP client for fetching emails from asiakaspalvelu@cuitunet.fi,
Freshdesk-style ticket management with status tracking, message
threading, reply/note functionality, and IMAP settings in API tab.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 08:52:00 +02:00
parent cc3a6c465d
commit 42e3648e3d
4 changed files with 1068 additions and 1 deletions

550
api.php
View File

@@ -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']);