feat: helpdesk email notifications (#135)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage com_mokowaas
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoWaaS\Administrator\Service;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Log\Log;
|
||||
use Joomla\CMS\Uri\Uri;
|
||||
|
||||
/**
|
||||
* Helpdesk email notification service.
|
||||
*
|
||||
* Sends emails for ticket events to Joomla users (by ID) and/or
|
||||
* raw email addresses. Uses Joomla's configured mailer.
|
||||
*
|
||||
* @since 02.32.00
|
||||
*/
|
||||
class NotificationService
|
||||
{
|
||||
/**
|
||||
* Send a ticket notification email.
|
||||
*
|
||||
* @param string $event Event name (ticket_created, ticket_replied, status_changed, ticket_assigned)
|
||||
* @param object $ticket Ticket object with id, subject, status, priority, created_by, assigned_to
|
||||
* @param array $extra Extra context (reply body, old status, etc.)
|
||||
*/
|
||||
public static function notify(string $event, object $ticket, array $extra = []): void
|
||||
{
|
||||
try
|
||||
{
|
||||
$recipients = self::getRecipients($event, $ticket);
|
||||
|
||||
if (empty($recipients))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$subject = self::buildSubject($event, $ticket);
|
||||
$body = self::buildBody($event, $ticket, $extra);
|
||||
|
||||
$mailer = Factory::getMailer();
|
||||
$mailer->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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user