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, 'mokosuiteclient'); } } // Push notification via ntfy self::pushNtfy($event, $ticket, $subject); } catch (\Throwable $e) { Log::add('Notification error: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient'); } } /** * 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_mokosuiteclient&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 MokoSuiteClient'; 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_mokosuiteclient')) ->where($db->quoteName('type') . ' = ' . $db->quote('component')) ); $params = json_decode($db->loadResult() ?? '{}', true); return $params['notifications'] ?? []; } catch (\Throwable $e) { return []; } } // ================================================================== // Ntfy Push Notifications (#205) // ================================================================== /** * Send a push notification via ntfy for ticket events. */ private static function pushNtfy(string $event, object $ticket, string $title): void { $config = self::getNotificationConfig(); $ntfyEnabled = $config['ntfy_enabled'] ?? '0'; if (!$ntfyEnabled) { return; } $ntfyServer = rtrim($config['ntfy_server'] ?? 'https://ntfy.mokoconsulting.tech', '/'); $ntfyTopic = $config['ntfy_topic'] ?? 'mokosuiteclient-tickets'; $ntfyToken = $config['ntfy_token'] ?? ''; $tagMap = [ 'ticket_created' => 'ticket,new', 'ticket_replied' => 'speech_balloon', 'status_changed' => 'arrows_counterclockwise', 'ticket_assigned' => 'bust_in_silhouette', ]; $priorityMap = [ 'ticket_created' => '4', 'ticket_replied' => '3', 'status_changed' => '3', 'ticket_assigned' => '3', ]; $siteUrl = rtrim(Uri::root(), '/'); $ticketUrl = $siteUrl . '/administrator/index.php?option=com_mokosuiteclient&view=ticket&id=' . ($ticket->id ?? 0); $message = self::buildNtfyMessage($event, $ticket); $headers = [ 'Title: ' . $title, 'Priority: ' . ($priorityMap[$event] ?? '3'), 'Tags: ' . ($tagMap[$event] ?? 'ticket'), 'Click: ' . $ticketUrl, ]; if ($ntfyToken !== '') { $headers[] = 'Authorization: Bearer ' . $ntfyToken; } $url = $ntfyServer . '/' . $ntfyTopic; try { $ch = curl_init($url); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, $message); curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_TIMEOUT, 5); curl_exec($ch); $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($httpCode < 200 || $httpCode >= 300) { Log::add("Ntfy push failed (HTTP {$httpCode}) for event {$event}", Log::WARNING, 'mokosuiteclient'); } } catch (\Throwable $e) { Log::add('Ntfy push error: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient'); } } /** * Build a short ntfy message body for ticket events. */ private static function buildNtfyMessage(string $event, object $ticket): string { $subject = $ticket->subject ?? 'Ticket #' . ($ticket->id ?? '?'); switch ($event) { case 'ticket_created': $priority = ucfirst($ticket->priority ?? 'normal'); return "New ticket: {$subject}\nPriority: {$priority}"; case 'ticket_replied': return "Reply on: {$subject}"; case 'status_changed': $status = ucwords(str_replace('_', ' ', $ticket->status ?? '')); return "Status → {$status}: {$subject}"; case 'ticket_assigned': return "Assigned to you: {$subject}"; default: return $subject; } } /** * Send a push notification via ntfy for security events. */ public static function pushNtfySecurity(string $event, string $title, string $body): void { $config = self::getNotificationConfig(); $ntfyEnabled = $config['ntfy_enabled'] ?? '0'; if (!$ntfyEnabled) { return; } $ntfyServer = rtrim($config['ntfy_server'] ?? 'https://ntfy.mokoconsulting.tech', '/'); $ntfyTopic = $config['ntfy_security_topic'] ?? $config['ntfy_topic'] ?? 'mokosuiteclient-security'; $ntfyToken = $config['ntfy_token'] ?? ''; $headers = [ 'Title: [Security] ' . $title, 'Priority: 5', 'Tags: warning,shield', ]; if ($ntfyToken !== '') { $headers[] = 'Authorization: Bearer ' . $ntfyToken; } $url = $ntfyServer . '/' . $ntfyTopic; try { $ch = curl_init($url); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, $body); curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_TIMEOUT, 5); curl_exec($ch); curl_close($ch); } catch (\Throwable $e) { Log::add('Ntfy security push error: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient'); } } // ================================================================== // Security Event Notifications (#131) // ================================================================== /** * Send a security alert to admin emails. */ public static function securityAlert(string $event, string $subject, string $body): void { try { $config = self::getNotificationConfig(); $enabled = $config['security_alerts'] ?? '1'; if (!$enabled) { return; } $adminEmails = array_filter(array_map('trim', explode(',', $config['admin_emails'] ?? ''))); $adminUserIds = array_filter(array_map('intval', explode(',', $config['admin_user_ids'] ?? ''))); $recipients = $adminEmails; foreach ($adminUserIds as $uid) { $email = self::getUserEmail($uid); if ($email) { $recipients[] = $email; } } $recipients = array_unique($recipients); if (empty($recipients)) { return; } $siteName = Factory::getConfig()->get('sitename', 'Site'); $fullSubject = '[' . $siteName . ' Security] ' . $subject; $lines = [ $siteName . ' Security Alert', str_repeat('-', 40), '', 'Event: ' . $event, 'Time: ' . gmdate('Y-m-d H:i:s') . ' UTC', '', $body, '', '-- ', $siteName . ' | MokoSuiteClient Security', ]; $mailer = Factory::getMailer(); $mailer->isHtml(false); $mailer->setSubject($fullSubject); $mailer->setBody(implode("\n", $lines)); foreach ($recipients as $email) { try { $mailer->clearAddresses(); $mailer->addRecipient(trim($email)); $mailer->Send(); } catch (\Throwable $e) { Log::add('Security alert send failed: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient'); } } // Also push via ntfy self::pushNtfySecurity($event, $subject, $body); } catch (\Throwable $e) { Log::add('Security alert error: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient'); } } }