diff --git a/changedetectionio/blueprint/notification_dashboard/__init__.py b/changedetectionio/blueprint/notification_dashboard/__init__.py index 1f7217478..d792a3616 100644 --- a/changedetectionio/blueprint/notification_dashboard/__init__.py +++ b/changedetectionio/blueprint/notification_dashboard/__init__.py @@ -67,7 +67,7 @@ def construct_blueprint(): message = "✓ Notification sent successfully and removed from queue." flash(message, 'notice') else: - message = "Failed to send notification. It remains scheduled for automatic retry." + message = "Failed to send notification. It has been re-queued for automatic retry." flash(message, 'error') return redirect(url_for('notification_dashboard.dashboard')) @@ -108,7 +108,7 @@ def construct_blueprint(): @notification_dashboard.route("/clear-all", methods=['POST']) @login_optionally_required def clear_all_notifications(): - """Clear ALL notifications (pending, retrying, and failed)""" + """Clear ALL notifications (delivered, pending, retrying, and failed)""" from changedetectionio.notification.task_queue import clear_all_notifications result = clear_all_notifications() @@ -116,8 +116,8 @@ def construct_blueprint(): if 'error' in result: flash(f"Error clearing notifications: {result['error']}", 'error') else: - total_cleared = result.get('queue', 0) + result.get('schedule', 0) + result.get('results', 0) - flash(f"Cleared {total_cleared} notification(s) from queue.", 'notice') + total_cleared = result.get('queue', 0) + result.get('schedule', 0) + result.get('results', 0) + result.get('delivered', 0) + flash(f"Cleared {total_cleared} notification(s) (delivered, queued, retrying, and failed).", 'notice') return redirect(url_for('notification_dashboard.dashboard')) diff --git a/changedetectionio/notification/task_queue/__init__.py b/changedetectionio/notification/task_queue/__init__.py index 82decc4ea..e8640541b 100644 --- a/changedetectionio/notification/task_queue/__init__.py +++ b/changedetectionio/notification/task_queue/__init__.py @@ -1067,14 +1067,17 @@ def retry_notification_now(task_id): return True except Exception as e: - # Notification failed - but we already revoked it, so it won't retry automatically - # The failure will be logged and visible in the dashboard + # Notification failed - re-queue it so it doesn't disappear and can retry automatically logger.warning(f"Failed to send notification for task {task_id}: {e}") - logger.info(f"Task {task_id} already removed from queue (user requested immediate execution)") + logger.info(f"Re-queueing notification for automatic retry after manual send failed") - # Clean up metadata and result - _delete_result(task_id) - _delete_task_metadata(task_id) + # Re-queue the notification for automatic retry with exponential backoff + # This ensures it doesn't disappear from the dashboard and will retry later + try: + result = send_notification_task(notification_data) + logger.info(f"Re-queued notification after failed manual send") + except Exception as queue_error: + logger.error(f"Failed to re-queue notification: {queue_error}") return False @@ -1429,9 +1432,14 @@ def send_notification_task(n_object: NotificationContextData): """ from changedetectionio.notification.handler import process_notification from changedetectionio.flask_app import datastore, notification_debug_log, app + from changedetectionio.notification_service import NotificationContextData from datetime import datetime import json + # Wrap dict in NotificationContextData if needed (for retried tasks from Huey) + if not isinstance(n_object, NotificationContextData): + n_object = NotificationContextData(n_object) + # Load watch watch = datastore.data['watching'].get(n_object.get('uuid')) if not watch: diff --git a/changedetectionio/notification/task_queue/file_storage.py b/changedetectionio/notification/task_queue/file_storage.py index 7a352d71e..8011210ff 100644 --- a/changedetectionio/notification/task_queue/file_storage.py +++ b/changedetectionio/notification/task_queue/file_storage.py @@ -132,7 +132,8 @@ class FileStorageTaskManager(HueyTaskManager): 'schedule': 0, 'results': 0, 'retry_attempts': 0, - 'task_metadata': 0 + 'task_metadata': 0, + 'delivered': 0 } if not self.storage_path: @@ -181,6 +182,14 @@ class FileStorageTaskManager(HueyTaskManager): os.remove(os.path.join(metadata_dir, f)) cleared['task_metadata'] += 1 + # Clear delivered (success) notifications + success_dir = os.path.join(self.storage_path, 'success') + if os.path.exists(success_dir): + for f in os.listdir(success_dir): + if f.startswith('success-') and f.endswith('.json'): + os.remove(os.path.join(success_dir, f)) + cleared['delivered'] += 1 + return cleared def store_task_metadata(self, task_id, metadata): diff --git a/changedetectionio/tests/test_notifications_huey.py b/changedetectionio/tests/test_notifications_huey.py index 9409af13d..61c845c7e 100644 --- a/changedetectionio/tests/test_notifications_huey.py +++ b/changedetectionio/tests/test_notifications_huey.py @@ -769,37 +769,39 @@ def test_send_now_button(client, live_server, measure_memory_usage, datastore_pa # Click "Send Now" button (GET request) res = client.get(url_for("notification_dashboard.send_now", task_id=task_id), follow_redirects=True) - # Should redirect back to notification dashboard with message - # The notification will still fail (bad SMTP server), but should be executed immediately - # and removed from the retry schedule + # Should redirect back to notification dashboard with error message + # The notification will fail (bad SMTP server) and be re-queued for automatic retry + assert b'Failed to send notification' in res.data, "Should show error message" + assert b're-queued for automatic retry' in res.data, "Should indicate notification was re-queued" - # Wait for the task to be removed from schedule (give revoke time to propagate) + # Wait for the notification to be re-queued (old task revoked, new task created) + # The re-queued notification will have a different task_id max_wait = 5 start_time = time.time() - task_removed = False + notification_requeued = False while time.time() - start_time < max_wait: pending_after = get_pending_notifications(limit=100) all_retrying = [n for n in pending_after if n.get('status') == 'retrying'] - retrying_after = [n for n in all_retrying if n.get('task_id') == task_id] - logging.info(f"[{time.time() - start_time:.1f}s] Total retrying: {len(all_retrying)}, Matching task_id {task_id[:8]}: {len(retrying_after)}") + # Check if there's still a retrying notification for this watch + # (it will have a different task_id after re-queueing) + retrying_for_watch = [n for n in all_retrying if n.get('watch_uuid') == uuid] - if len(retrying_after) == 0: - task_removed = True - logging.info(f"✓ Task {task_id} removed from retry schedule after {time.time() - start_time:.1f}s") + logging.info(f"[{time.time() - start_time:.1f}s] Total retrying: {len(all_retrying)}, For this watch: {len(retrying_for_watch)}") + + if len(retrying_for_watch) > 0: + notification_requeued = True + new_task_id = retrying_for_watch[0].get('task_id') + logging.info(f"✓ Notification re-queued with new task_id {new_task_id[:8]}... after {time.time() - start_time:.1f}s") break - # Log details of the found notification - if retrying_after: - logging.info(f" Found retrying notification: {retrying_after[0].get('retry_at')}") - time.sleep(0.5) - # The task should be gone from schedule (either succeeded or moved to dead letter) - assert task_removed, f"Notification should be removed from retry schedule after 'Send Now', but still found after {max_wait}s" + # The notification should be re-queued (not disappeared) + assert notification_requeued, f"Notification should be re-queued after failed 'Send Now', but not found after {max_wait}s" - logging.info("✓ Send Now button successfully executed notification immediately") + logging.info("✓ Send Now button correctly re-queued failed notification") client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True)