Files
MokoSuiteBackup/source/packages/com_mokosuitebackup/src/Engine/NotificationSender.php
T
Jonathan Miller 391047d8e5 feat: email/ntfy notifications for restore operations (#60)
Send notifications when site restores and snapshot create/restore
complete. Uses sendRestoreNotification() with type-specific subjects.
All calls wrapped in try-catch to never break the actual operation.

Closes #60
2026-06-22 09:27:16 -05:00

569 lines
17 KiB
PHP

<?php
/**
* @package MokoSuiteBackup
* @subpackage com_mokosuitebackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @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 [];
}
}
}