feat(helpdesk): IMAP email-to-ticket polling + auto-close (#136)
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 13m29s
Universal: Auto Version Bump / Version Bump (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Has been cancelled
Platform: moko-platform CI / CI Summary (pull_request) Has been cancelled
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Has been cancelled
Universal: PR Check / Branch Policy (pull_request) Has been cancelled
Universal: PR Check / Validate PR (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Access control (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Site Health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 13m29s
Universal: Auto Version Bump / Version Bump (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Has been cancelled
Platform: moko-platform CI / CI Summary (pull_request) Has been cancelled
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Has been cancelled
Universal: PR Check / Branch Policy (pull_request) Has been cancelled
Universal: PR Check / Validate PR (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Access control (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Site Health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
IMAP polling scheduled task: - Connects via php-imap, reads UNSEEN messages - Creates tickets from new emails, matches sender to Joomla user - Detects replies via [#123] in subject line - Marks processed emails as seen, optionally moves to folder - IMAP config fields in component options Auto-close scheduled task: - Closes resolved tickets after configurable days - Uses autoclose_days from component params Both registered as Joomla scheduled task types in plg_task_mokosuite_tickets alongside existing automation task.
This commit is contained in:
@@ -81,6 +81,34 @@
|
||||
description="Maximum upload size per file in megabytes." />
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="email_to_ticket" label="Email-to-Ticket (IMAP)" description="Create tickets from incoming emails via IMAP polling.">
|
||||
<field name="imap_host" type="text" default=""
|
||||
label="IMAP Server"
|
||||
description="IMAP hostname (e.g. imap.gmail.com)"
|
||||
hint="imap.gmail.com" />
|
||||
<field name="imap_port" type="number" default="993"
|
||||
label="Port"
|
||||
description="IMAP port (993 for SSL, 143 for plain)" />
|
||||
<field name="imap_ssl" type="radio" default="1"
|
||||
label="Use SSL"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="imap_user" type="text" default=""
|
||||
label="Username"
|
||||
description="IMAP login username or email address." />
|
||||
<field name="imap_password" type="password" default=""
|
||||
label="Password"
|
||||
description="IMAP password or app-specific password." />
|
||||
<field name="imap_folder" type="text" default="INBOX"
|
||||
label="Inbox Folder"
|
||||
description="IMAP folder to poll for new messages." />
|
||||
<field name="imap_processed_folder" type="text" default="INBOX.Processed"
|
||||
label="Processed Folder"
|
||||
description="Move processed emails to this folder. Leave empty to just mark as read." />
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="permissions" label="COM_MOKOSUITE_ACL_TITLE"
|
||||
description="COM_MOKOSUITE_ACL_DESC">
|
||||
<field name="rules" type="rules"
|
||||
|
||||
@@ -10,12 +10,16 @@ namespace Moko\Plugin\Task\MokoSuiteTickets\Extension;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Log\Log;
|
||||
use Joomla\CMS\Plugin\CMSPlugin;
|
||||
use Joomla\Component\Scheduler\Administrator\Event\ExecuteTaskEvent;
|
||||
use Joomla\Component\Scheduler\Administrator\Task\Status;
|
||||
use Joomla\Component\Scheduler\Administrator\Traits\TaskPluginTrait;
|
||||
use Joomla\Event\SubscriberInterface;
|
||||
use Moko\Component\MokoSuite\Administrator\Model\TicketsModel;
|
||||
use Moko\Component\MokoSuite\Administrator\Service\AttachmentService;
|
||||
use Moko\Component\MokoSuite\Administrator\Service\NotificationService;
|
||||
|
||||
class TicketAutomation extends CMSPlugin implements SubscriberInterface
|
||||
{
|
||||
@@ -26,6 +30,14 @@ class TicketAutomation extends CMSPlugin implements SubscriberInterface
|
||||
'langConstPrefix' => '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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user