* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. * @license GNU General Public License version 3 or later; see LICENSE * * Sends email notifications on backup success or failure. * Uses Joomla's built-in mail system (Factory::getMailer()). */ namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine; defined('_JEXEC') or die; use Joomla\CMS\Factory; use Joomla\CMS\Language\Text; use Joomla\CMS\Uri\Uri; class NotificationSender { /** * Send a backup notification email. * * @param object $profile Profile object with notification settings * @param object $record Backup record object with results * @param bool $success Whether the backup succeeded * @param string $logText Backup log text * * @return bool True if email was sent */ public static function send(object $profile, object $record, bool $success, string $logText = ''): bool { $emailSent = self::sendEmail($profile, $record, $success, $logText); $ntfySent = self::sendNtfy($profile, $record, $success); return $emailSent || $ntfySent; } private static function sendEmail(object $profile, object $record, bool $success, string $logText = ''): bool { $notifyEmail = trim($profile->notify_email ?? ''); $notifyUserGroups = $profile->notify_user_groups ?? ''; // Resolve user group members to email addresses $groupEmails = self::resolveUserGroupEmails($notifyUserGroups); if (empty($notifyEmail) && empty($groupEmails)) { return false; } // Check notification preferences if ($success && empty($profile->notify_on_success)) { return false; } if (!$success && empty($profile->notify_on_failure)) { return false; } try { $mailer = Factory::getMailer(); $config = Factory::getApplication()->getConfig(); $siteName = $config->get('sitename', 'Joomla Site'); $siteUrl = Uri::root(); // Parse recipient list (comma-separated) + user group emails $recipients = array_map('trim', explode(',', $notifyEmail)); $recipients = array_merge($recipients, $groupEmails); $recipients = array_unique(array_filter($recipients, fn($e) => filter_var($e, FILTER_VALIDATE_EMAIL))); if (empty($recipients)) { return false; } foreach ($recipients as $recipient) { $mailer->addRecipient($recipient); } // Build subject $statusLabel = $success ? 'SUCCESS' : 'FAILED'; $mailer->setSubject("[MokoSuiteBackup] {$statusLabel}: {$record->description} — {$siteName}"); // Build body $duration = ''; if (!empty($record->backupstart) && !empty($record->backupend) && $record->backupend !== '0000-00-00 00:00:00') { $start = strtotime($record->backupstart); $end = strtotime($record->backupend); $seconds = max(0, $end - $start); $duration = $seconds < 60 ? $seconds . ' seconds' : round($seconds / 60, 1) . ' minutes'; } $sizeHuman = $record->total_size > 0 ? number_format($record->total_size / 1048576, 2) . ' MB' : 'N/A'; $body = "MokoSuiteBackup Notification\n" . "============================\n\n" . "Site: {$siteName}\n" . "URL: {$siteUrl}\n" . "Status: {$statusLabel}\n" . "Profile: {$profile->title}\n" . "Description: {$record->description}\n" . "Type: {$record->backup_type}\n" . "Origin: {$record->origin}\n" . "Archive: {$record->archivename}\n" . "Size: {$sizeHuman}\n" . "Duration: {$duration}\n" . "Started: {$record->backupstart}\n" . "Ended: {$record->backupend}\n"; if (!empty($record->remote_filename)) { $body .= "Remote: {$record->remote_filename}\n"; } if ($record->files_count > 0 || $record->tables_count > 0) { $body .= "Files: {$record->files_count}\n" . "Tables: {$record->tables_count}\n"; } // Add log excerpt on failure (last 30 lines) if (!$success && !empty($logText)) { $logLines = explode("\n", $logText); $excerpt = array_slice($logLines, -30); $body .= "\n--- Log (last 30 lines) ---\n" . implode("\n", $excerpt) . "\n"; } $body .= "\n--\n" . "MokoSuiteBackup — https://mokoconsulting.tech\n"; $mailer->setBody($body); $mailer->isHtml(false); return $mailer->Send(); } catch (\Throwable $e) { // Don't let notification failure break the backup flow error_log('MokoSuiteBackup notification error: ' . $e->getMessage()); return false; } } /** * Send a push notification via ntfy. */ private static function sendNtfy(object $profile, object $record, bool $success): bool { $topic = trim($profile->ntfy_topic ?? ''); $server = trim($profile->ntfy_server ?? 'https://ntfy.sh'); $token = trim($profile->ntfy_token ?? ''); if ($topic === '') { return false; } // Respect the same success/failure preferences as email if ($success && empty($profile->notify_on_success)) { return false; } if (!$success && empty($profile->notify_on_failure)) { return false; } if (!function_exists('curl_init')) { error_log('MokoSuiteBackup: ntfy notifications require ext-curl'); return false; } try { $config = Factory::getApplication()->getConfig(); $siteName = $config->get('sitename', 'Joomla Site'); $statusLabel = $success ? 'SUCCESS' : 'FAILED'; $statusEmoji = $success ? "\xE2\x9C\x85" : "\xE2\x9D\x8C"; $sizeHuman = $record->total_size > 0 ? number_format($record->total_size / 1048576, 2) . ' MB' : 'N/A'; $title = "{$statusEmoji} Backup {$statusLabel}: {$siteName}"; $body = "Profile: {$profile->title}\n" . "Type: {$record->backup_type}\n" . "Archive: {$record->archivename}\n" . "Size: {$sizeHuman}"; $url = rtrim($server, '/') . '/' . rawurlencode($topic); $headers = [ 'Title: ' . $title, 'Priority: ' . ($success ? '3' : '5'), 'Tags: ' . ($success ? 'white_check_mark' : 'rotating_light'), ]; if ($token !== '') { $headers[] = 'Authorization: Bearer ' . $token; } $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_HTTPHEADER => $headers, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 10, CURLOPT_CONNECTTIMEOUT => 5, ]); $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $error = curl_error($ch); curl_close($ch); if ($error !== '') { error_log('MokoSuiteBackup: ntfy error: ' . $error); return false; } if ($httpCode < 200 || $httpCode >= 300) { error_log('MokoSuiteBackup: ntfy returned HTTP ' . $httpCode . ': ' . substr((string) $response, 0, 200)); return false; } return true; } catch (\Throwable $e) { error_log('MokoSuiteBackup: ntfy notification error: ' . $e->getMessage()); return false; } } /** * Send a restore/snapshot notification via email and ntfy. * * @param object $profile Profile object with notification settings * @param string $type One of: site_restore, snapshot_create, snapshot_restore * @param array $details Context: record_id, content_types, row_count, mode, user, etc. * @param string $log Operation log text * * @return bool True if at least one notification was sent */ public static function sendRestoreNotification(object $profile, string $type, array $details, string $log = ''): bool { $emailSent = self::sendRestoreEmail($profile, $type, $details, $log); $ntfySent = self::sendRestoreNtfy($profile, $type, $details); return $emailSent || $ntfySent; } /** * Load the default profile (ID 1) for notification settings. * * @return object|null Profile object or null if not found */ public static function getDefaultProfile(): ?object { try { $db = Factory::getDbo(); $query = $db->getQuery(true) ->select('*') ->from($db->quoteName('#__mokosuitebackup_profiles')) ->where($db->quoteName('id') . ' = 1'); $db->setQuery($query); return $db->loadObject() ?: null; } catch (\Throwable $e) { error_log('MokoSuiteBackup: Cannot load default profile: ' . $e->getMessage()); return null; } } /** * Build subject and body for a restore/snapshot notification email. */ private static function buildRestoreMessage(string $type, array $details, string $siteName, string $siteUrl): array { $user = $details['user'] ?? 'Unknown'; switch ($type) { case 'site_restore': $subject = "[MokoSuiteBackup] RESTORE: Site restored — {$siteName}"; $options = []; if (!empty($details['restore_files'])) { $options[] = 'Files'; } if (!empty($details['restore_db'])) { $options[] = 'Database'; } if (!empty($details['preserve_config'])) { $options[] = 'Config preserved'; } $body = "MokoSuiteBackup — Site Restore Notification\n" . "=============================================\n\n" . "Site: {$siteName}\n" . "URL: {$siteUrl}\n" . "Action: Full site restore\n" . "Record ID: " . ($details['record_id'] ?? 'N/A') . "\n" . "Options: " . (empty($options) ? 'N/A' : implode(', ', $options)) . "\n" . "Triggered by: {$user}\n"; break; case 'snapshot_create': $types = $details['content_types'] ?? []; $typesStr = !empty($types) ? implode(', ', $types) : 'N/A'; $subject = "[MokoSuiteBackup] SNAPSHOT: Content snapshot created — {$siteName}"; $body = "MokoSuiteBackup — Snapshot Created\n" . "===================================\n\n" . "Site: {$siteName}\n" . "URL: {$siteUrl}\n" . "Action: Snapshot created\n" . "Content types: {$typesStr}\n" . "Articles: " . ($details['articles_count'] ?? 0) . "\n" . "Categories: " . ($details['categories_count'] ?? 0) . "\n" . "Modules: " . ($details['modules_count'] ?? 0) . "\n" . "Triggered by: {$user}\n"; break; case 'snapshot_restore': $types = $details['content_types'] ?? []; $typesStr = !empty($types) ? implode(', ', $types) : 'N/A'; $subject = "[MokoSuiteBackup] RESTORE: Snapshot restored — {$siteName}"; $body = "MokoSuiteBackup — Snapshot Restore Notification\n" . "================================================\n\n" . "Site: {$siteName}\n" . "URL: {$siteUrl}\n" . "Action: Snapshot restore\n" . "Mode: " . ($details['mode'] ?? 'N/A') . "\n" . "Content types: {$typesStr}\n" . "Rows restored: " . ($details['row_count'] ?? 0) . "\n" . "Triggered by: {$user}\n"; break; default: $subject = "[MokoSuiteBackup] NOTIFICATION: {$type} — {$siteName}"; $body = "MokoSuiteBackup Notification\n" . "============================\n\n" . "Site: {$siteName}\n" . "URL: {$siteUrl}\n" . "Type: {$type}\n" . "Details: " . json_encode($details) . "\n"; break; } $body .= "\n--\n" . "MokoSuiteBackup — https://mokoconsulting.tech\n"; return ['subject' => $subject, 'body' => $body]; } /** * Send a restore/snapshot notification email. */ private static function sendRestoreEmail(object $profile, string $type, array $details, string $log = ''): bool { $notifyEmail = trim($profile->notify_email ?? ''); $notifyUserGroups = $profile->notify_user_groups ?? ''; $groupEmails = self::resolveUserGroupEmails($notifyUserGroups); if (empty($notifyEmail) && empty($groupEmails)) { return false; } // Restore notifications are always "success" events — use notify_on_success preference if (empty($profile->notify_on_success)) { return false; } try { $mailer = Factory::getMailer(); $config = Factory::getApplication()->getConfig(); $siteName = $config->get('sitename', 'Joomla Site'); $siteUrl = Uri::root(); $recipients = array_map('trim', explode(',', $notifyEmail)); $recipients = array_merge($recipients, $groupEmails); $recipients = array_unique(array_filter($recipients, fn($e) => filter_var($e, FILTER_VALIDATE_EMAIL))); if (empty($recipients)) { return false; } foreach ($recipients as $recipient) { $mailer->addRecipient($recipient); } $message = self::buildRestoreMessage($type, $details, $siteName, $siteUrl); $mailer->setSubject($message['subject']); $body = $message['body']; // Append log excerpt if provided (last 30 lines) if (!empty($log)) { $logLines = explode("\n", $log); $excerpt = array_slice($logLines, -30); $body .= "\n--- Log (last 30 lines) ---\n" . implode("\n", $excerpt) . "\n"; } $mailer->setBody($body); $mailer->isHtml(false); return $mailer->Send(); } catch (\Throwable $e) { error_log('MokoSuiteBackup restore notification error: ' . $e->getMessage()); return false; } } /** * Send a restore/snapshot push notification via ntfy. */ private static function sendRestoreNtfy(object $profile, string $type, array $details): bool { $topic = trim($profile->ntfy_topic ?? ''); $server = trim($profile->ntfy_server ?? 'https://ntfy.sh'); $token = trim($profile->ntfy_token ?? ''); if ($topic === '') { return false; } // Restore notifications are always "success" events — use notify_on_success preference if (empty($profile->notify_on_success)) { return false; } if (!function_exists('curl_init')) { error_log('MokoSuiteBackup: ntfy notifications require ext-curl'); return false; } try { $config = Factory::getApplication()->getConfig(); $siteName = $config->get('sitename', 'Joomla Site'); switch ($type) { case 'site_restore': $emoji = "\xF0\x9F\x94\x84"; // 🔄 $title = "{$emoji} Site Restored: {$siteName}"; $body = "Record ID: " . ($details['record_id'] ?? 'N/A') . "\n" . "Triggered by: " . ($details['user'] ?? 'Unknown'); break; case 'snapshot_create': $emoji = "\xF0\x9F\x93\xB8"; // 📸 $types = $details['content_types'] ?? []; $title = "{$emoji} Snapshot Created: {$siteName}"; $body = "Types: " . implode(', ', $types) . "\n" . "Articles: " . ($details['articles_count'] ?? 0) . "\n" . "Categories: " . ($details['categories_count'] ?? 0) . "\n" . "Modules: " . ($details['modules_count'] ?? 0); break; case 'snapshot_restore': $emoji = "\xF0\x9F\x94\x84"; // 🔄 $types = $details['content_types'] ?? []; $title = "{$emoji} Snapshot Restored: {$siteName}"; $body = "Mode: " . ($details['mode'] ?? 'N/A') . "\n" . "Types: " . implode(', ', $types) . "\n" . "Rows: " . ($details['row_count'] ?? 0); break; default: $title = "MokoSuiteBackup: {$type} — {$siteName}"; $body = json_encode($details); break; } $url = rtrim($server, '/') . '/' . rawurlencode($topic); $headers = [ 'Title: ' . $title, 'Priority: 3', 'Tags: arrows_counterclockwise', ]; if ($token !== '') { $headers[] = 'Authorization: Bearer ' . $token; } $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_HTTPHEADER => $headers, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 10, CURLOPT_CONNECTTIMEOUT => 5, ]); $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $error = curl_error($ch); curl_close($ch); if ($error !== '') { error_log('MokoSuiteBackup: ntfy error: ' . $error); return false; } if ($httpCode < 200 || $httpCode >= 300) { error_log('MokoSuiteBackup: ntfy returned HTTP ' . $httpCode . ': ' . substr((string) $response, 0, 200)); return false; } return true; } catch (\Throwable $e) { error_log('MokoSuiteBackup: ntfy restore notification error: ' . $e->getMessage()); return false; } } /** * Resolve user group IDs to email addresses of group members. * * @param string|array $groups Comma-separated group IDs or array * * @return array Email addresses */ private static function resolveUserGroupEmails(string|array $groups): array { if (empty($groups)) { return []; } if (\is_string($groups)) { $groups = array_filter(array_map('intval', explode(',', $groups))); } if (empty($groups)) { return []; } try { $db = Factory::getDbo(); $query = $db->getQuery(true) ->select('DISTINCT ' . $db->quoteName('u.email')) ->from($db->quoteName('#__users', 'u')) ->join('INNER', $db->quoteName('#__user_usergroup_map', 'ugm') . ' ON ugm.user_id = u.id') ->where($db->quoteName('u.block') . ' = 0') ->whereIn($db->quoteName('ugm.group_id'), $groups); $db->setQuery($query); return $db->loadColumn() ?: []; } catch (\Throwable $e) { error_log('MokoSuiteBackup: Could not resolve user group emails: ' . $e->getMessage()); return []; } } }