diff --git a/source/packages/com_mokosuite/admin/config.xml b/source/packages/com_mokosuite/admin/config.xml index 66539008..637a9b7e 100644 --- a/source/packages/com_mokosuite/admin/config.xml +++ b/source/packages/com_mokosuite/admin/config.xml @@ -81,6 +81,34 @@ description="Maximum upload size per file in megabytes." /> +
+ + + + + + + + + + +
+
'PLG_TASK_MOKOSUITE_TICKETS_AUTOMATION', 'method' => 'runAutomation', ], + 'mokosuite.ticket.imap_poll' => [ + 'langConstPrefix' => 'PLG_TASK_MOKOSUITE_TICKETS_IMAP_POLL', + 'method' => 'runImapPoll', + ], + 'mokosuite.ticket.autoclose' => [ + 'langConstPrefix' => 'PLG_TASK_MOKOSUITE_TICKETS_AUTOCLOSE', + 'method' => 'runAutoClose', + ], ]; protected $autoloadLanguage = true; @@ -62,4 +74,239 @@ class TicketAutomation extends CMSPlugin implements SubscriberInterface return Status::KNOCKOUT; } } + + /** + * Poll IMAP inbox and create tickets from unread emails (#136). + */ + private function runImapPoll(ExecuteTaskEvent $event): int + { + $config = $this->getComponentConfig(); + $host = $config['imap_host'] ?? ''; + $port = (int) ($config['imap_port'] ?? 993); + $user = $config['imap_user'] ?? ''; + $pass = $config['imap_password'] ?? ''; + $ssl = ($config['imap_ssl'] ?? '1') === '1'; + $folder = $config['imap_folder'] ?? 'INBOX'; + $processed = $config['imap_processed_folder'] ?? 'INBOX.Processed'; + $defaultCat = (int) ($config['default_category'] ?? 0) ?: null; + + if (empty($host) || empty($user) || empty($pass)) + { + $this->logTask('IMAP not configured — skipping', 'warning'); + return Status::OK; + } + + if (!function_exists('imap_open')) + { + $this->logTask('php-imap extension not available', 'error'); + return Status::KNOCKOUT; + } + + $mailbox = '{' . $host . ':' . $port . '/imap' . ($ssl ? '/ssl' : '') . '/novalidate-cert}' . $folder; + $mbox = @imap_open($mailbox, $user, $pass); + + if (!$mbox) + { + $this->logTask('IMAP connection failed: ' . imap_last_error(), 'error'); + return Status::KNOCKOUT; + } + + $db = Factory::getDbo(); + $created = 0; + $replied = 0; + + $emails = imap_search($mbox, 'UNSEEN'); + + if ($emails === false) + { + imap_close($mbox); + $this->logTask('No new emails'); + return Status::OK; + } + + foreach ($emails as $msgNum) + { + try + { + $header = imap_headerinfo($mbox, $msgNum); + $subject = isset($header->subject) ? imap_utf8($header->subject) : '(no subject)'; + $fromAddr = $header->from[0]->mailbox . '@' . $header->from[0]->host; + $body = $this->getImapBody($mbox, $msgNum); + + // Match sender to Joomla user + $userId = $this->findUserByEmail($fromAddr); + + // Check if this is a reply (subject contains [#123]) + $ticketId = 0; + if (preg_match('/\[#(\d+)\]/', $subject, $m)) + { + $ticketId = (int) $m[1]; + } + + if ($ticketId > 0) + { + // Add as reply to existing ticket + $reply = (object) [ + 'ticket_id' => $ticketId, + 'user_id' => $userId, + 'body' => $body, + 'is_internal' => 0, + 'created' => Factory::getDate()->toSql(), + ]; + $db->insertObject('#__mokosuite_ticket_replies', $reply, 'id'); + $replied++; + + // Notify + $db->setQuery($db->getQuery(true)->select('*')->from('#__mokosuite_tickets')->where('id = ' . $ticketId)); + $ticket = $db->loadObject(); + if ($ticket) { + NotificationService::notify('ticket_replied', $ticket, ['reply_body' => $body]); + } + } + else + { + // Create new ticket + $ticket = (object) [ + 'subject' => $subject, + 'body' => $body, + 'status' => 'open', + 'priority' => 'normal', + 'category_id' => $defaultCat, + 'created_by' => $userId, + 'created' => Factory::getDate()->toSql(), + ]; + $db->insertObject('#__mokosuite_tickets', $ticket, 'id'); + $created++; + + NotificationService::notify('ticket_created', $ticket); + } + + // Mark as seen / move to processed folder + imap_setflag_full($mbox, (string) $msgNum, '\\Seen'); + + if ($processed && $processed !== $folder) + { + @imap_mail_move($mbox, (string) $msgNum, $processed); + } + } + catch (\Throwable $e) + { + Log::add('IMAP message processing error: ' . $e->getMessage(), Log::WARNING, 'mokosuite'); + } + } + + imap_expunge($mbox); + imap_close($mbox); + + $this->logTask("IMAP poll: {$created} tickets created, {$replied} replies added"); + return Status::OK; + } + + /** + * Auto-close resolved tickets after configured days. + */ + private function runAutoClose(ExecuteTaskEvent $event): int + { + $config = $this->getComponentConfig(); + $days = (int) ($config['autoclose_days'] ?? 7); + + if ($days <= 0) + { + $this->logTask('Auto-close disabled (days = 0)'); + return Status::OK; + } + + $db = Factory::getDbo(); + $cutoff = Factory::getDate('-' . $days . ' days')->toSql(); + + $db->setQuery( + "UPDATE {$db->quoteName('#__mokosuite_tickets')}" + . " SET status = 'closed', closed = {$db->quote(Factory::getDate()->toSql())}" + . " WHERE status = 'resolved'" + . " AND resolved IS NOT NULL" + . " AND resolved < {$db->quote($cutoff)}" + ); + $db->execute(); + $closed = $db->getAffectedRows(); + + $this->logTask("Auto-close: {$closed} tickets closed (resolved > {$days} days ago)"); + return Status::OK; + } + + // ── Helpers ────────────────────────────────────────────────── + + private function getComponentConfig(): array + { + try + { + $db = Factory::getDbo(); + $db->setQuery( + $db->getQuery(true) + ->select('params') + ->from('#__extensions') + ->where('element = ' . $db->quote('com_mokosuite')) + ->where('type = ' . $db->quote('component')) + ); + return json_decode($db->loadResult() ?? '{}', true) ?: []; + } + catch (\Throwable $e) + { + return []; + } + } + + private function findUserByEmail(string $email): int + { + $db = Factory::getDbo(); + $db->setQuery( + $db->getQuery(true) + ->select('id') + ->from('#__users') + ->where('email = ' . $db->quote($email)) + ->setLimit(1) + ); + return (int) $db->loadResult(); + } + + private function getImapBody($mbox, int $msgNum): string + { + $structure = imap_fetchstructure($mbox, $msgNum); + + // Simple single-part message + if (empty($structure->parts)) + { + $body = imap_fetchbody($mbox, $msgNum, '1'); + if ($structure->encoding === 3) $body = base64_decode($body); + if ($structure->encoding === 4) $body = quoted_printable_decode($body); + return trim(strip_tags($body)); + } + + // Multipart — find text/plain or text/html + $textBody = ''; + + foreach ($structure->parts as $i => $part) + { + $partNum = (string) ($i + 1); + + if ($part->type === 0) // text + { + $content = imap_fetchbody($mbox, $msgNum, $partNum); + if ($part->encoding === 3) $content = base64_decode($content); + if ($part->encoding === 4) $content = quoted_printable_decode($content); + + $subtype = strtolower($part->subtype ?? ''); + + if ($subtype === 'plain' && empty($textBody)) + { + $textBody = $content; + } + elseif ($subtype === 'html' && empty($textBody)) + { + $textBody = strip_tags($content); + } + } + } + + return trim($textBody); + } }