From 79bc17912ac97785f47bdc40de46a43348dd779c Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 2 Jun 2026 22:52:21 -0500 Subject: [PATCH] feat: helpdesk email notifications (#135) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NotificationService with dual recipient support: - Joomla user IDs (looks up email from #__users) - Raw email addresses (comma-separated) - Admin emails + admin user IDs from component params - Per-event smart routing (creator, assignee, admins) Events: - ticket_created → admin emails + assigned user - ticket_replied → creator + assigned user (skip internal notes) - status_changed → creator (includes old/new status) - ticket_assigned → newly assigned user Email format: plain text with ticket details + view link Automation engine: added send_email action type for rules to send emails to specific addresses. Config stored in com_mokowaas params.notifications: admin_emails: comma-separated email addresses admin_user_ids: comma-separated Joomla user IDs Authored-by: Moko Consulting Co-Authored-By: Claude Opus 4.6 (1M context) --- .../admin/src/Model/TicketsModel.php | 45 ++- .../admin/src/Service/NotificationService.php | 334 ++++++++++++++++++ 2 files changed, 376 insertions(+), 3 deletions(-) create mode 100644 src/packages/com_mokowaas/admin/src/Service/NotificationService.php diff --git a/src/packages/com_mokowaas/admin/src/Model/TicketsModel.php b/src/packages/com_mokowaas/admin/src/Model/TicketsModel.php index 657b7d71..9b7a8169 100644 --- a/src/packages/com_mokowaas/admin/src/Model/TicketsModel.php +++ b/src/packages/com_mokowaas/admin/src/Model/TicketsModel.php @@ -12,6 +12,7 @@ defined('_JEXEC') or die; use Joomla\CMS\Factory; use Joomla\CMS\MVC\Model\BaseDatabaseModel; +use Moko\Component\MokoWaaS\Administrator\Service\NotificationService; class TicketsModel extends BaseDatabaseModel { @@ -173,8 +174,9 @@ class TicketsModel extends BaseDatabaseModel $db->insertObject('#__mokowaas_tickets', $ticket, 'id'); - // Run automation + // Run automation + notifications $this->runAutomation('ticket_created', (int) $ticket->id); + NotificationService::notify('ticket_created', $this->getTicket((int) $ticket->id)); return ['success' => true, 'message' => 'Ticket #' . $ticket->id . ' created.', 'id' => (int) $ticket->id]; } @@ -215,9 +217,14 @@ class TicketsModel extends BaseDatabaseModel ->where($db->quoteName('sla_responded') . ' = 0') )->execute(); - // Run automation + // Run automation + notifications (skip internal notes) $this->runAutomation('ticket_replied', $ticketId); + if (!$isInternal) + { + NotificationService::notify('ticket_replied', $this->getTicket($ticketId), ['reply_body' => $body]); + } + return ['success' => true, 'message' => 'Reply added.']; } catch (\Throwable $e) @@ -243,6 +250,15 @@ class TicketsModel extends BaseDatabaseModel $db = $this->getDatabase(); $now = Factory::getDate()->toSql(); + // Capture old status for notification + $db->setQuery( + $db->getQuery(true) + ->select($db->quoteName('status')) + ->from($db->quoteName('#__mokowaas_tickets')) + ->where($db->quoteName('id') . ' = ' . $ticketId) + ); + $oldStatus = $db->loadResult() ?? ''; + $sets = [ $db->quoteName('status') . ' = ' . $db->quote($status), $db->quoteName('modified') . ' = ' . $db->quote($now), @@ -265,8 +281,9 @@ class TicketsModel extends BaseDatabaseModel ->where($db->quoteName('id') . ' = ' . $ticketId) )->execute(); - // Run automation + // Run automation + notifications $this->runAutomation('status_changed', $ticketId); + NotificationService::notify('status_changed', $this->getTicket($ticketId), ['old_status' => $oldStatus]); return ['success' => true, 'message' => 'Status updated to ' . $status . '.']; } @@ -569,6 +586,28 @@ class TicketsModel extends BaseDatabaseModel ]; $db->insertObject('#__mokowaas_ticket_replies', $reply, 'id'); break; + + case 'send_email': + // value = email address or comma-separated list + $emails = array_filter(array_map('trim', explode(',', $value))); + + foreach ($emails as $email) + { + try + { + $mailer = Factory::getMailer(); + $mailer->addRecipient($email); + $mailer->setSubject('[Ticket #' . $ticketId . '] Automation Alert'); + $mailer->setBody('Automation rule triggered for ticket #' . $ticketId . ': ' . ($ticket->subject ?? '')); + $mailer->isHtml(false); + $mailer->Send(); + } + catch (\Throwable $e) + { + \Joomla\CMS\Log\Log::add('Automation email failed: ' . $e->getMessage(), \Joomla\CMS\Log\Log::WARNING, 'mokowaas'); + } + } + break; } } } diff --git a/src/packages/com_mokowaas/admin/src/Service/NotificationService.php b/src/packages/com_mokowaas/admin/src/Service/NotificationService.php new file mode 100644 index 00000000..57e00105 --- /dev/null +++ b/src/packages/com_mokowaas/admin/src/Service/NotificationService.php @@ -0,0 +1,334 @@ +isHtml(false); + $mailer->setSubject($subject); + $mailer->setBody($body); + + foreach ($recipients as $email) + { + $email = trim($email); + + if (empty($email) || !filter_var($email, FILTER_VALIDATE_EMAIL)) + { + continue; + } + + try + { + $mailer->clearAddresses(); + $mailer->addRecipient($email); + $mailer->Send(); + } + catch (\Throwable $e) + { + Log::add('Notification send failed to ' . $email . ': ' . $e->getMessage(), Log::WARNING, 'mokowaas'); + } + } + } + catch (\Throwable $e) + { + Log::add('Notification error: ' . $e->getMessage(), Log::WARNING, 'mokowaas'); + } + } + + /** + * Determine recipients based on event type and ticket data. + */ + private static function getRecipients(string $event, object $ticket): array + { + $emails = []; + + // Get notification config from component params + $config = self::getNotificationConfig(); + + // Always notify configured admin emails + $adminEmails = array_filter(array_map('trim', explode(',', $config['admin_emails'] ?? ''))); + $emails = array_merge($emails, $adminEmails); + + // Always notify configured admin user IDs + $adminUserIds = array_filter(array_map('intval', explode(',', $config['admin_user_ids'] ?? ''))); + + foreach ($adminUserIds as $uid) + { + $email = self::getUserEmail($uid); + + if ($email) + { + $emails[] = $email; + } + } + + switch ($event) + { + case 'ticket_created': + // Notify assigned user if any + if (!empty($ticket->assigned_to)) + { + $email = self::getUserEmail((int) $ticket->assigned_to); + + if ($email) + { + $emails[] = $email; + } + } + break; + + case 'ticket_replied': + // Notify ticket creator (customer gets notified of staff reply) + if (!empty($ticket->created_by)) + { + $email = self::getUserEmail((int) $ticket->created_by); + + if ($email) + { + $emails[] = $email; + } + } + + // Notify assigned user + if (!empty($ticket->assigned_to)) + { + $email = self::getUserEmail((int) $ticket->assigned_to); + + if ($email) + { + $emails[] = $email; + } + } + break; + + case 'status_changed': + // Notify ticket creator + if (!empty($ticket->created_by)) + { + $email = self::getUserEmail((int) $ticket->created_by); + + if ($email) + { + $emails[] = $email; + } + } + break; + + case 'ticket_assigned': + // Notify newly assigned user + if (!empty($ticket->assigned_to)) + { + $email = self::getUserEmail((int) $ticket->assigned_to); + + if ($email) + { + $emails[] = $email; + } + } + break; + } + + return array_unique($emails); + } + + /** + * Build email subject line. + */ + private static function buildSubject(string $event, object $ticket): string + { + $siteName = Factory::getConfig()->get('sitename', 'Support'); + $prefix = '[' . $siteName . ' #' . $ticket->id . '] '; + + switch ($event) + { + case 'ticket_created': + return $prefix . 'New Ticket: ' . ($ticket->subject ?? ''); + + case 'ticket_replied': + return $prefix . 'Reply: ' . ($ticket->subject ?? ''); + + case 'status_changed': + return $prefix . 'Status Changed: ' . ($ticket->subject ?? ''); + + case 'ticket_assigned': + return $prefix . 'Assigned: ' . ($ticket->subject ?? ''); + + default: + return $prefix . ($ticket->subject ?? ''); + } + } + + /** + * Build email body. + */ + private static function buildBody(string $event, object $ticket, array $extra): string + { + $siteName = Factory::getConfig()->get('sitename', 'Support'); + $siteUrl = rtrim(Uri::root(), '/'); + $ticketUrl = $siteUrl . '/index.php?option=com_mokowaas&view=ticket&id=' . $ticket->id; + + $lines = []; + $lines[] = $siteName . ' Support'; + $lines[] = str_repeat('-', 40); + $lines[] = ''; + + switch ($event) + { + case 'ticket_created': + $lines[] = 'A new support ticket has been created.'; + $lines[] = ''; + $lines[] = 'Subject: ' . ($ticket->subject ?? ''); + $lines[] = 'Priority: ' . ucfirst($ticket->priority ?? 'normal'); + $lines[] = 'Category: ' . ($ticket->category_title ?? 'General'); + $lines[] = ''; + + if (!empty($ticket->body)) + { + $lines[] = 'Description:'; + $lines[] = strip_tags($ticket->body); + $lines[] = ''; + } + break; + + case 'ticket_replied': + $lines[] = 'A new reply has been added to your ticket.'; + $lines[] = ''; + $lines[] = 'Subject: ' . ($ticket->subject ?? ''); + $lines[] = 'Status: ' . ucwords(str_replace('_', ' ', $ticket->status ?? '')); + $lines[] = ''; + + if (!empty($extra['reply_body'])) + { + $lines[] = 'Reply:'; + $lines[] = strip_tags($extra['reply_body']); + $lines[] = ''; + } + break; + + case 'status_changed': + $lines[] = 'Your ticket status has been updated.'; + $lines[] = ''; + $lines[] = 'Subject: ' . ($ticket->subject ?? ''); + $lines[] = 'New Status: ' . ucwords(str_replace('_', ' ', $ticket->status ?? '')); + + if (!empty($extra['old_status'])) + { + $lines[] = 'Old Status: ' . ucwords(str_replace('_', ' ', $extra['old_status'])); + } + + $lines[] = ''; + break; + + case 'ticket_assigned': + $lines[] = 'A ticket has been assigned to you.'; + $lines[] = ''; + $lines[] = 'Subject: ' . ($ticket->subject ?? ''); + $lines[] = 'Priority: ' . ucfirst($ticket->priority ?? 'normal'); + $lines[] = ''; + break; + } + + $lines[] = 'View ticket: ' . $ticketUrl; + $lines[] = ''; + $lines[] = '-- '; + $lines[] = $siteName . ' | Powered by MokoWaaS'; + + return implode("\n", $lines); + } + + /** + * Get email address for a Joomla user ID. + */ + private static function getUserEmail(int $userId): ?string + { + if ($userId <= 0) + { + return null; + } + + try + { + $db = Factory::getDbo(); + $db->setQuery( + $db->getQuery(true) + ->select($db->quoteName('email')) + ->from($db->quoteName('#__users')) + ->where($db->quoteName('id') . ' = ' . $userId) + ); + + return $db->loadResult() ?: null; + } + catch (\Throwable $e) + { + return null; + } + } + + /** + * Get notification configuration from component params. + */ + private static function getNotificationConfig(): array + { + try + { + $db = Factory::getDbo(); + $db->setQuery( + $db->getQuery(true) + ->select($db->quoteName('params')) + ->from($db->quoteName('#__extensions')) + ->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas')) + ->where($db->quoteName('type') . ' = ' . $db->quote('component')) + ); + + $params = json_decode($db->loadResult() ?? '{}', true); + + return $params['notifications'] ?? []; + } + catch (\Throwable $e) + { + return []; + } + } +}