feat: email notifications on backup success/failure (#14)
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled

- NotificationSender: sends email via Joomla's mailer on backup
  complete or fail, with site info, duration, size, and log excerpt
- Per-profile settings: notify_email (comma-separated), notify_on_success,
  notify_on_failure (default: on)
- Notifications tab added to profile editor
- Wired into BackupEngine for both success and failure paths
- Failure emails include last 30 lines of backup log for debugging

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan Miller
2026-06-02 16:19:31 -05:00
parent c0ee2608f1
commit 1f4471e088
6 changed files with 205 additions and 4 deletions
@@ -158,6 +158,39 @@
</field>
</fieldset>
<fieldset name="notifications" label="COM_MOKOBACKUP_FIELDSET_NOTIFICATIONS">
<field
name="notify_email"
type="text"
label="COM_MOKOBACKUP_FIELD_NOTIFY_EMAIL"
description="COM_MOKOBACKUP_FIELD_NOTIFY_EMAIL_DESC"
maxlength="512"
hint="admin@example.com, backup@example.com"
/>
<field
name="notify_on_success"
type="radio"
label="COM_MOKOBACKUP_FIELD_NOTIFY_SUCCESS"
description="COM_MOKOBACKUP_FIELD_NOTIFY_SUCCESS_DESC"
default="0"
class="btn-group"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field
name="notify_on_failure"
type="radio"
label="COM_MOKOBACKUP_FIELD_NOTIFY_FAILURE"
description="COM_MOKOBACKUP_FIELD_NOTIFY_FAILURE_DESC"
default="1"
class="btn-group"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
</fieldset>
<fieldset name="ftp" label="COM_MOKOBACKUP_FIELDSET_FTP">
<field
name="ftp_host"
@@ -152,6 +152,16 @@ COM_MOKOBACKUP_BACKUP_PROFILE="Backup Profile"
COM_MOKOBACKUP_TOOLBAR_RESTORE="Restore"
COM_MOKOBACKUP_RESTORE_CONFIRM="WARNING: Restoring will overwrite your current site files and/or database. Are you sure you want to continue?"
; Notifications
COM_MOKOBACKUP_TAB_NOTIFICATIONS="Notifications"
COM_MOKOBACKUP_FIELDSET_NOTIFICATIONS="Email Notifications"
COM_MOKOBACKUP_FIELD_NOTIFY_EMAIL="Notification Email(s)"
COM_MOKOBACKUP_FIELD_NOTIFY_EMAIL_DESC="Comma-separated list of email addresses to notify. Leave empty to disable notifications."
COM_MOKOBACKUP_FIELD_NOTIFY_SUCCESS="Notify on Success"
COM_MOKOBACKUP_FIELD_NOTIFY_SUCCESS_DESC="Send an email when a backup completes successfully."
COM_MOKOBACKUP_FIELD_NOTIFY_FAILURE="Notify on Failure"
COM_MOKOBACKUP_FIELD_NOTIFY_FAILURE_DESC="Send an email when a backup fails. Includes log excerpt for debugging."
; Akeeba Import
COM_MOKOBACKUP_TOOLBAR_IMPORT_AKEEBA="Import from Akeeba"
COM_MOKOBACKUP_AKEEBA_NOT_FOUND="Akeeba Backup tables not found. Is Akeeba Backup Pro installed?"
@@ -24,6 +24,9 @@ CREATE TABLE IF NOT EXISTS `#__mokobackup_profiles` (
`gdrive_folder_id` VARCHAR(255) NOT NULL DEFAULT '',
`remote_keep_local` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Keep local copy after upload',
`include_kickstart` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Include standalone restore.php in archive',
`notify_email` VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'Comma-separated notification emails',
`notify_on_success` TINYINT(1) NOT NULL DEFAULT 0,
`notify_on_failure` TINYINT(1) NOT NULL DEFAULT 1,
`published` TINYINT(1) NOT NULL DEFAULT 1,
`ordering` INT(11) NOT NULL DEFAULT 0,
`created` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
@@ -210,6 +210,9 @@ class BackupEngine
$db->updateObject('#__mokobackup_records', $update, 'id');
// Send success notification
NotificationSender::send($profile, $update, true, implode("\n", $this->log));
return [
'success' => true,
'message' => 'Backup complete: ' . $archiveName . ' (' . $sizeHuman . ')',
@@ -219,14 +222,22 @@ class BackupEngine
$this->log('FATAL: ' . $e->getMessage());
$update = (object) [
'id' => $recordId,
'status' => 'fail',
'backupend' => date('Y-m-d H:i:s'),
'log' => implode("\n", $this->log),
'id' => $recordId,
'status' => 'fail',
'description' => $description ?: '',
'backup_type' => $profile->backup_type ?? 'full',
'origin' => $origin,
'archivename' => $archiveName,
'backupstart' => $now ?? date('Y-m-d H:i:s'),
'backupend' => date('Y-m-d H:i:s'),
'log' => implode("\n", $this->log),
];
$db->updateObject('#__mokobackup_records', $update, 'id');
// Send failure notification
NotificationSender::send($profile, $update, false, implode("\n", $this->log));
return ['success' => false, 'message' => 'Backup failed: ' . $e->getMessage(), 'record_id' => $recordId];
}
}
@@ -0,0 +1,136 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokobackup
* @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\MokoBackup\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
{
$notifyEmail = trim($profile->notify_email ?? '');
if (empty($notifyEmail)) {
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)
$recipients = array_map('trim', explode(',', $notifyEmail));
$recipients = 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("[MokoBackup] {$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 = "MokoJoomBackup 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"
. "MokoJoomBackup — 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('MokoBackup notification error: ' . $e->getMessage());
return false;
}
}
}
@@ -50,6 +50,14 @@ HTMLHelper::_('behavior.keepalive');
</div>
<?php echo HTMLHelper::_('uitab.endTab'); ?>
<?php echo HTMLHelper::_('uitab.addTab', 'profileTab', 'notifications', Text::_('COM_MOKOBACKUP_TAB_NOTIFICATIONS')); ?>
<div class="row">
<div class="col-lg-9">
<?php echo $this->form->renderFieldset('notifications'); ?>
</div>
</div>
<?php echo HTMLHelper::_('uitab.endTab'); ?>
<?php echo HTMLHelper::_('uitab.addTab', 'profileTab', 'remote', Text::_('COM_MOKOBACKUP_TAB_REMOTE')); ?>
<div class="row">
<div class="col-lg-9">