From 6168955c6bd9846b5ae29de28aab6bf96118f0b8 Mon Sep 17 00:00:00 2001 From: Seth Morrow Date: Sun, 13 Sep 2026 13:35:55 +0000 Subject: [PATCH 1/6] Fix duplicate/missed notifications, add test suite, extend GUI Reliability fixes in e2nb_core: - Seen-item caches evicted arbitrary entries once past their size limit because they were stored as unordered sets, so recently seen RSS items and POP3 messages could be forgotten and notified again. They are now insertion-ordered and evict oldest-first. - RSS item ages were computed by comparing feedparser's UTC timestamps against local time, skewing every age by the machine's UTC offset. On a UTC+10 host a five-minute-old item looked ten hours old and was dropped as stale. Dates are now converted to local time. - Log monitoring tracked file position by re-encoding decoded text, which drifts on undecodable bytes and CRLF endings and then re-reads or skips lines. Reading is now done in binary with exact byte offsets, and a partially written final line is held until it is complete. - Routing rules that matched the same notification overwrote each other, so only the last rule's channels received it. Matching routing rules are now unioned, with suppression applied last. - Monitor events (RSS, web, HTTP, database, log) were dispatched as source_type="email" at default severity, so no source- or severity-specific routing rule ever matched them. Added dispatch_event(), which preserves both. - One channel raising could abort the whole fan-out; each channel is now isolated, as are result callbacks, rule evaluation, and templating. - Template rendering substituted variables one at a time, so text pulled in by an earlier variable could itself be substituted. It is now single-pass. - config.ini is written atomically and restricted to owner-only permissions; a failed write can no longer truncate a working configuration holding credentials. - Missing config sections are filled in on load, so a hand-edited config no longer makes saving fail with a KeyError. - MonitorState tolerates corrupt, non-object, and legacy state files, and a failed state save no longer propagates into the monitor loop. GUI: - Added Database and Log Files pages; both sources were supported by the core and the daemon but unreachable from the GUI, and the GUI monitor loop ignored them entirely. - Each source now runs on its own check interval instead of the global one. - The sidebar scrolls with a visible scrollbar; ~240px of navigation was unreachable at the default window size. Scrollbars are now usable rather than one pixel wide, and appear only when content overflows. - Form help text no longer clips; protocol, TLS and SSL controls are themed instead of raw Tk widgets. - Unsaved changes are shown in the status bar and prompted for on exit. Testing: - New pytest suite (198 tests) covering config handling, validators, email parsing and filters, state persistence and eviction, templates, routing rules, dispatcher fan-out with mocked senders, log monitoring, and a GUI smoke suite that is skipped when no display is available. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011UHBtwQGR4gt7w4zrsDvCe --- e2nb-headless.py | 56 +- e2nb.py | 861 +++++++++++++++++++++----- e2nb_core.py | 1052 +++++++++++++++++++------------- tests/conftest.py | 42 ++ tests/test_config.py | 102 ++++ tests/test_dispatcher.py | 286 +++++++++ tests/test_email_processing.py | 123 ++++ tests/test_gui.py | 216 +++++++ tests/test_log_monitor.py | 253 ++++++++ tests/test_monitors.py | 158 +++++ tests/test_rules.py | 154 +++++ tests/test_state.py | 174 ++++++ tests/test_templates.py | 120 ++++ tests/test_validation.py | 133 ++++ 14 files changed, 3111 insertions(+), 619 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/test_config.py create mode 100644 tests/test_dispatcher.py create mode 100644 tests/test_email_processing.py create mode 100644 tests/test_gui.py create mode 100644 tests/test_log_monitor.py create mode 100644 tests/test_monitors.py create mode 100644 tests/test_rules.py create mode 100644 tests/test_state.py create mode 100644 tests/test_templates.py create mode 100644 tests/test_validation.py diff --git a/e2nb-headless.py b/e2nb-headless.py index 5f6f22c..453a285 100644 --- a/e2nb-headless.py +++ b/e2nb-headless.py @@ -20,7 +20,7 @@ import sys import threading import time -from typing import Optional +from typing import List, Optional # Import shared core module from e2nb_core import ( @@ -29,7 +29,6 @@ setup_logging, connect_to_imap, connect_to_pop3, - fetch_unread_emails, fetch_unread_emails_by_uid, fetch_pop3_emails, delete_pop3_message, @@ -46,16 +45,13 @@ EmailConfig, SmtpReceiverConfig, SmtpReceiver, - TeamsConfig, - PushoverConfig, - NtfyConfig, NotificationRulesConfig, RssFeedConfig, WebMonitorConfig, HttpEndpointConfig, DatabaseMonitorConfig, LogMonitorConfig, - MessageTemplateConfig, + MonitorEvent, MonitorState, check_rss_feeds, check_web_pages, @@ -66,12 +62,9 @@ AIOSMTPD_AVAILABLE, SLACK_AVAILABLE, DEFAULT_CHECK_INTERVAL, - DEFAULT_IMAP_PORT, - DEFAULT_POP3_PORT, CONFIG_FILE_PATH, LOG_FILE_PATH, safe_int, - logger, ) @@ -478,7 +471,6 @@ def _log_enabled_sources(): # Get configuration values (thread-safe access) with self._config_lock: config = self.config - dispatcher = self.dispatcher # Build EmailConfig for full TLS/OAuth2 support email_config = EmailConfig.from_config(config) @@ -647,8 +639,7 @@ def _log_enabled_sources(): if self.stop_event.is_set(): break logging.info(f"[RSS] New item from '{event.source_name}': {event.title}") - notification = event.to_email_notification() - self._dispatch_notification(notification) + self._dispatch_event(event) next_run_times['rss'] = current_time + rss_config.check_interval # ===================================================== @@ -660,8 +651,7 @@ def _log_enabled_sources(): if self.stop_event.is_set(): break logging.info(f"[Web] {event.title}") - notification = event.to_email_notification() - self._dispatch_notification(notification) + self._dispatch_event(event) next_run_times['web'] = current_time + web_config.check_interval # ===================================================== @@ -676,8 +666,7 @@ def _log_enabled_sources(): logging.warning(f"[HTTP] {event.title}") else: logging.info(f"[HTTP] {event.title}") - notification = event.to_email_notification() - self._dispatch_notification(notification) + self._dispatch_event(event) next_run_times['http'] = current_time + http_config.check_interval # ===================================================== @@ -692,8 +681,7 @@ def _log_enabled_sources(): logging.warning(f"[Database] {event.title}") else: logging.info(f"[Database] {event.title}") - notification = event.to_email_notification() - self._dispatch_notification(notification) + self._dispatch_event(event) next_run_times['database'] = current_time + db_config.check_interval # ===================================================== @@ -708,8 +696,7 @@ def _log_enabled_sources(): logging.warning(f"[Log] {event.title}") else: logging.info(f"[Log] {event.title}") - notification = event.to_email_notification() - self._dispatch_notification(notification) + self._dispatch_event(event) next_run_times['log'] = current_time + log_config.check_interval # Save monitoring state @@ -828,6 +815,29 @@ def _dispatch_notification(self, notification: EmailNotification) -> bool: True if at least one notification was sent successfully. """ results = self.dispatcher.dispatch(notification, callback=self._on_notification_result) + return self._report_results(results) + + def _dispatch_event(self, event: MonitorEvent) -> bool: + """ + Dispatch a monitoring event, keeping its source type and severity. + + Routing rules and templates that key off source type or severity only + work when those values reach the dispatcher. + + Args: + event: The monitoring event to send. + + Returns: + True if at least one notification was sent successfully. + """ + results = self.dispatcher.dispatch_event( + event, callback=self._on_notification_result + ) + return self._report_results(results) + + @staticmethod + def _report_results(results: List[NotificationResult]) -> bool: + """Log a dispatch summary and report whether anything was delivered.""" success_count = sum(1 for r in results if r.success) failure_count = len(results) - success_count if results: @@ -1063,7 +1073,7 @@ def _cleanup(daemon): protocol = email_config.protocol if protocol == 'pop3': - print(f"\nTesting POP3 connection...") + print("\nTesting POP3 connection...") pop3 = connect_to_pop3( email_config.pop3_server, email_config.pop3_port, @@ -1205,8 +1215,8 @@ def main(): # Create and start daemon daemon = EmailMonitorDaemon(args.config) - # Setup signal handling - signal_handler = SignalHandler(daemon) + # Setup signal handling; the handler stays alive for the process lifetime + SignalHandler(daemon) # Start the daemon if not daemon.start(): diff --git a/e2nb.py b/e2nb.py index 2189e18..4316a6a 100644 --- a/e2nb.py +++ b/e2nb.py @@ -13,7 +13,6 @@ from __future__ import annotations -import os import sys # --------------------------------------------------------------------------- @@ -44,10 +43,11 @@ import json import queue import threading +import time import tkinter as tk from tkinter import ttk, messagebox, scrolledtext from datetime import datetime -from typing import Optional, Dict, Callable, List, Any +from typing import Optional, Dict, Callable, List # Import shared core module from e2nb_core import ( @@ -70,32 +70,31 @@ SmtpConfig, SmtpReceiverConfig, SmtpReceiver, - TeamsConfig, - PushoverConfig, - NtfyConfig, - NotificationRulesConfig, RssFeedConfig, WebMonitorConfig, HttpEndpointConfig, + DatabaseMonitorConfig, + LogMonitorConfig, MonitorState, - MonitorEvent, + ensure_config_sections, test_imap_connection, test_pop3_connection, test_smtp_connection, test_smtp_receiver_port, test_rss_feed, test_http_endpoint, + test_database_connection, + test_log_file, check_rss_feeds, check_web_pages, check_http_endpoints, + check_databases, + check_log_files, DEFAULT_CHECK_INTERVAL, - DEFAULT_MAX_SMS_LENGTH, DEFAULT_IMAP_PORT, DEFAULT_POP3_PORT, DEFAULT_CONNECTION_TIMEOUT, TLS_MODE_IMPLICIT, - TLS_MODE_EXPLICIT, - TLS_MODE_NONE, FEEDPARSER_AVAILABLE, BS4_AVAILABLE, AIOSMTPD_AVAILABLE, @@ -212,25 +211,60 @@ class Theme: # ttk Theme Configuration # ============================================================================= +SCROLLBAR_WIDTH = 10 + +# Width of the label column in form rows; keeps every field aligned. +LABEL_COLUMN_WIDTH = 190 + +# Upper bound on how long the monitor loop sleeps between cycles, so edited +# settings are picked up within a minute even when every source polls slowly. +MAX_LOOP_SLEEP = 60 + + def configure_ttk_style(): """Configure ttk widgets to match application theme.""" style = ttk.Style() style.theme_use("clam") - # Scrollbar + # Scrollbars: a plain thumb on a flat trough, with no stepper arrows. + # The arrow elements have to be dropped from the layout rather than just + # sized to zero, which would collapse the whole scrollbar to a hairline. + thumb_only_layout = [ + ("Vertical.Scrollbar.trough", { + "sticky": "ns", + "children": [("Vertical.Scrollbar.thumb", {"expand": "1", "sticky": "nswe"})], + }), + ] + + style.layout("Vertical.TScrollbar", thumb_only_layout) style.configure( - "TScrollbar", - background=Theme.BG_TERTIARY, + "Vertical.TScrollbar", + background=Theme.BORDER, troughcolor=Theme.BG_SECONDARY, borderwidth=0, - arrowsize=0, + width=SCROLLBAR_WIDTH, relief="flat", ) style.map( - "TScrollbar", + "Vertical.TScrollbar", background=[("active", Theme.BORDER_HOVER), ("!active", Theme.BORDER)], ) + # Sidebar scrollbar - same shape, dark palette + style.layout("Sidebar.Vertical.TScrollbar", thumb_only_layout) + style.configure( + "Sidebar.Vertical.TScrollbar", + background=Theme.SIDEBAR_BADGE_BG, + troughcolor=Theme.SIDEBAR_BG, + borderwidth=0, + width=SCROLLBAR_WIDTH, + relief="flat", + ) + style.map( + "Sidebar.Vertical.TScrollbar", + background=[("active", Theme.SIDEBAR_SECTION), ("!active", Theme.SIDEBAR_BADGE_BG)], + ) + # Checkbutton style.configure( "TCheckbutton", @@ -322,11 +356,12 @@ def update_text(self, text: str): # ============================================================================= class Toast: - """An in-app toast notification that slides in and fades out.""" + """An in-app toast notification that dismisses itself after a few seconds. + + Only one toast is shown at a time; a new message replaces the current one. + """ _DURATION = 3000 - _FADE_STEPS = 10 - _FADE_DELAY = 30 def __init__(self, parent: tk.Widget): self._parent = parent @@ -397,28 +432,7 @@ def show(self, message: str, level: str = "success"): toast.place(relx=1.0, y=12, anchor="ne", x=-12) self._current = toast - aid = self._parent.after(self._DURATION, self._fade_out) - self._after_ids.append(aid) - - def _fade_out(self): - if not self._current: - return - self._fade_step(self._FADE_STEPS) - - def _fade_step(self, remaining: int): - if remaining <= 0 or not self._current: - self._cancel_pending() - return - try: - alpha = remaining / self._FADE_STEPS - self._current.attributes = None # placeholder - # tkinter frames don't support alpha; just destroy at end - if remaining == 1: - self._cancel_pending() - return - except tk.TclError: - return - aid = self._parent.after(self._FADE_DELAY, lambda: self._fade_step(remaining - 1)) + aid = self._parent.after(self._DURATION, self._cancel_pending) self._after_ids.append(aid) @@ -782,7 +796,6 @@ def get(self) -> str: def set_value(self, value: str): """Set the entry value, clearing placeholder if needed.""" - was_placeholder = self._placeholder_active if self._placeholder_active: self._placeholder_active = False self.configure(fg=self._real_fg, show=self._show_char) @@ -809,14 +822,20 @@ def __init__( super().__init__(parent, bg=Theme.BG_PRIMARY, **kwargs) # Left side - label - label_frame = tk.Frame(self, bg=Theme.BG_PRIMARY, width=180) + label_frame = tk.Frame(self, bg=Theme.BG_PRIMARY) label_frame.pack(side="left", fill="y", padx=(16, 0), pady=14) - label_frame.pack_propagate(False) + # A zero-height strut pins the label column to a fixed width so every + # row lines up, while still letting the column grow downwards when the + # help text wraps. A fixed-size frame would clip the wrapped text. + tk.Frame( + label_frame, bg=Theme.BG_PRIMARY, width=LABEL_COLUMN_WIDTH, height=1, + ).pack() self._label_widget = tk.Label( label_frame, text=label, bg=Theme.BG_PRIMARY, fg=Theme.TEXT_LABEL, font=(Theme.FONT, 10), anchor="w", + wraplength=LABEL_COLUMN_WIDTH, justify="left", ) self._label_widget.pack(anchor="w") @@ -825,6 +844,7 @@ def __init__( label_frame, text=help_text, bg=Theme.BG_PRIMARY, fg=Theme.TEXT_MUTED, font=(Theme.FONT, 9), anchor="w", + wraplength=LABEL_COLUMN_WIDTH, justify="left", ).pack(anchor="w", pady=(2, 0)) # Right side - entry with custom focus frame @@ -1020,6 +1040,171 @@ def _create_rounded_rect(self, x1, y1, x2, y2, radius, **kwargs): tk.Canvas.create_rounded_rect = _create_rounded_rect +class RadioOption(tk.Frame): + """A themed radio option with an optional description line. + + tk.Radiobutton draws a platform-native indicator that ignores most of the + surrounding styling, so the indicator is drawn on a canvas instead. + """ + + def __init__( + self, + parent, + text: str, + value: str, + variable: tk.StringVar, + description: str = "", + command: Optional[Callable] = None, + **kwargs + ): + super().__init__(parent, bg=Theme.BG_PRIMARY, **kwargs) + + self._value = value + self._variable = variable + self._command = command + + self._indicator = tk.Canvas( + self, width=16, height=16, bg=Theme.BG_PRIMARY, + highlightthickness=0, + ) + self._indicator.pack(side="left", padx=(0, 10), pady=(2, 0), anchor="n") + + text_frame = tk.Frame(self, bg=Theme.BG_PRIMARY) + text_frame.pack(side="left", fill="x", expand=True) + + self._label = tk.Label( + text_frame, text=text, + bg=Theme.BG_PRIMARY, fg=Theme.TEXT_PRIMARY, + font=(Theme.FONT, 10), anchor="w", justify="left", + ) + self._label.pack(anchor="w") + + self._description = None + if description: + self._description = tk.Label( + text_frame, text=description, + bg=Theme.BG_PRIMARY, fg=Theme.TEXT_MUTED, + font=(Theme.FONT, 9), anchor="w", justify="left", + ) + self._description.pack(anchor="w") + + for widget in self._clickable(): + widget.bind("", self._on_click) + widget.bind("", self._on_enter) + widget.bind("", self._on_leave) + + variable.trace_add("write", lambda *args: self._draw()) + self._draw() + + def _clickable(self) -> list: + widgets = [self, self._indicator, self._label] + if self._description is not None: + widgets.append(self._description) + return widgets + + def _on_click(self, event): + self._variable.set(self._value) + if self._command: + self._command() + + def _on_enter(self, event): + self.configure(cursor="hand2") + self._draw(hover=True) + + def _on_leave(self, event): + self._draw() + + def _draw(self, hover: bool = False): + selected = self._variable.get() == self._value + border = Theme.PRIMARY if selected else ( + Theme.BORDER_HOVER if hover else Theme.BORDER + ) + self._indicator.delete("all") + self._indicator.create_oval( + 2, 2, 14, 14, outline=border, width=2, fill=Theme.BG_INPUT, + ) + if selected: + self._indicator.create_oval(5, 5, 11, 11, fill=Theme.PRIMARY, outline="") + + +class CheckOption(tk.Frame): + """A themed checkbox with a hand-drawn indicator.""" + + def __init__( + self, + parent, + text: str, + variable: tk.BooleanVar, + description: str = "", + **kwargs + ): + super().__init__(parent, bg=Theme.BG_PRIMARY, **kwargs) + + self._variable = variable + + self._indicator = tk.Canvas( + self, width=16, height=16, bg=Theme.BG_PRIMARY, highlightthickness=0, + ) + self._indicator.pack(side="left", padx=(0, 10), pady=(2, 0), anchor="n") + + text_frame = tk.Frame(self, bg=Theme.BG_PRIMARY) + text_frame.pack(side="left", fill="x", expand=True) + + self._label = tk.Label( + text_frame, text=text, + bg=Theme.BG_PRIMARY, fg=Theme.TEXT_PRIMARY, + font=(Theme.FONT, 10), anchor="w", justify="left", + ) + self._label.pack(anchor="w") + + self._description = None + if description: + self._description = tk.Label( + text_frame, text=description, + bg=Theme.BG_PRIMARY, fg=Theme.TEXT_MUTED, + font=(Theme.FONT, 9), anchor="w", justify="left", + ) + self._description.pack(anchor="w") + + widgets = [self, self._indicator, self._label] + if self._description is not None: + widgets.append(self._description) + for widget in widgets: + widget.bind("", self._on_click) + widget.bind("", self._on_enter) + widget.bind("", self._on_leave) + + variable.trace_add("write", lambda *args: self._draw()) + self._draw() + + def _on_click(self, event): + self._variable.set(not self._variable.get()) + + def _on_enter(self, event): + self.configure(cursor="hand2") + self._draw(hover=True) + + def _on_leave(self, event): + self._draw() + + def _draw(self, hover: bool = False): + checked = bool(self._variable.get()) + self._indicator.delete("all") + border = Theme.PRIMARY if checked else ( + Theme.BORDER_HOVER if hover else Theme.BORDER + ) + _create_rounded_rect( + self._indicator, 2, 2, 14, 14, 3, + fill=Theme.PRIMARY if checked else Theme.BG_INPUT, + outline=border, width=2, + ) + if checked: + self._indicator.create_line( + 5, 8, 7, 11, 11, 5, fill="#ffffff", width=2, + capstyle="round", joinstyle="round", + ) + + class StatusBadge(tk.Frame): """A status badge showing active/inactive state with pulsing dot.""" @@ -1090,6 +1275,8 @@ class EmailMonitorApp: "rss": ("RSS Feeds", "Sources"), "webmon": ("Web Pages", "Sources"), "httpmon": ("HTTP Endpoints", "Sources"), + "database": ("Databases", "Sources"), + "logmon": ("Log Files", "Sources"), "sms": ("Twilio SMS", "Notifications"), "voice": ("Twilio Voice", "Notifications"), "whatsapp": ("WhatsApp", "Notifications"), @@ -1133,6 +1320,7 @@ def __init__(self, root: tk.Tk): self.current_page = "email" self.nav_items: Dict[str, SidebarItem] = {} self.pages: Dict[str, tk.Frame] = {} + self.unsaved_changes = False # Initialize variables self._init_variables() @@ -1164,6 +1352,9 @@ def __init__(self, root: tk.Tk): self._update_services_badge() self._update_sources_badge() + # Watch for edits so the user is told what is unsaved + self._track_changes() + def _init_variables(self): """Initialize tkinter variables.""" # Notification channels @@ -1191,6 +1382,11 @@ def _init_variables(self): self.rss_var = tk.BooleanVar(value=self.config.getboolean('RSS', 'enabled', fallback=False)) self.webmon_var = tk.BooleanVar(value=self.config.getboolean('WebMonitor', 'enabled', fallback=False)) self.httpmon_var = tk.BooleanVar(value=self.config.getboolean('HttpMonitor', 'enabled', fallback=False)) + self.dbmon_var = tk.BooleanVar(value=self.config.getboolean('DatabaseMonitor', 'enabled', fallback=False)) + self.dbmon_first_check_var = tk.BooleanVar( + value=self.config.getboolean('DatabaseMonitor', 'notify_on_first_check', fallback=False) + ) + self.logmon_var = tk.BooleanVar(value=self.config.getboolean('LogMonitor', 'enabled', fallback=False)) # Other self.auto_scroll_var = tk.BooleanVar(value=True) @@ -1216,7 +1412,10 @@ def _service_vars(self) -> list: ] def _source_vars(self) -> list: - return [self.email_source_var, self.rss_var, self.webmon_var, self.httpmon_var] + return [ + self.email_source_var, self.rss_var, self.webmon_var, + self.httpmon_var, self.dbmon_var, self.logmon_var, + ] def _update_services_badge(self): if hasattr(self, '_services_badge'): @@ -1225,8 +1424,9 @@ def _update_services_badge(self): def _update_sources_badge(self): if hasattr(self, '_sources_badge'): - count = sum(1 for v in self._source_vars() if v.get()) - self._sources_badge.update_count(count, total=4) + source_vars = self._source_vars() + count = sum(1 for v in source_vars if v.get()) + self._sources_badge.update_count(count, total=len(source_vars)) def _update_email_source_status(self): """Update email source status based on credentials and SMTP receiver.""" @@ -1308,11 +1508,21 @@ def _create_sidebar(self): font=(Theme.FONT, 9), padx=20, ).pack(anchor="w") - # Scrollable navigation area (fills remaining space) + # Scrollable navigation area (fills remaining space). The full + # navigation list is taller than the window on smaller screens, so it + # scrolls and shows a scrollbar whenever it does not fit. + nav_container = tk.Frame(self.sidebar, bg=Theme.SIDEBAR_BG) + nav_container.pack(fill="both", expand=True) + self._sb_canvas = tk.Canvas( - self.sidebar, bg=Theme.SIDEBAR_BG, highlightthickness=0, + nav_container, bg=Theme.SIDEBAR_BG, highlightthickness=0, ) - self._sb_canvas.pack(fill="both", expand=True) + self._sb_scrollbar = ttk.Scrollbar( + nav_container, orient="vertical", command=self._sb_canvas.yview, + style="Sidebar.Vertical.TScrollbar", + ) + self._sb_canvas.configure(yscrollcommand=self._sb_scrollbar.set) + self._sb_canvas.pack(side="left", fill="both", expand=True) nav = tk.Frame(self._sb_canvas, bg=Theme.SIDEBAR_BG) sb_canvas_win = self._sb_canvas.create_window( @@ -1321,11 +1531,13 @@ def _create_sidebar(self): def _on_sb_canvas_config(event): self._sb_canvas.itemconfigure(sb_canvas_win, width=event.width) + self._schedule_scrollbar_sync(self._sb_canvas, self._sb_scrollbar) self._sb_canvas.bind("", _on_sb_canvas_config) - nav.bind("", lambda e: self._sb_canvas.configure( - scrollregion=self._sb_canvas.bbox("all"), - )) + def _on_nav_config(event): + self._sb_canvas.configure(scrollregion=self._sb_canvas.bbox("all")) + self._schedule_scrollbar_sync(self._sb_canvas, self._sb_scrollbar) + nav.bind("", _on_nav_config) # Route mousewheel to sidebar when cursor is over it def _enter_sb(event): @@ -1344,6 +1556,8 @@ def _leave_sb(event): ("rss", "RSS Feeds", 0, self.rss_var, "Monitor RSS/Atom feeds for new items"), ("webmon", "Web Pages", 0, self.webmon_var, "Detect changes on web pages"), ("httpmon", "HTTP Endpoints", 0, self.httpmon_var, "Monitor API/service availability"), + ("database", "Databases", 0, self.dbmon_var, "Check database health and query results"), + ("logmon", "Log Files", 0, self.logmon_var, "Watch log files for error patterns"), ] for key, text, indent, var, tip in source_items: item = SidebarItem( @@ -1429,6 +1643,30 @@ def _leave_sb(event): font=(Theme.FONT, 8), padx=6, ).pack(side="left") + def _schedule_scrollbar_sync(self, canvas: tk.Canvas, scrollbar: ttk.Scrollbar): + """Queue a scrollbar visibility check once the layout has settled. + + Packing or unpacking from inside a handler would re-enter + the geometry manager, so the check runs on the idle queue instead. + """ + self.root.after_idle(lambda: self._sync_scrollbar(canvas, scrollbar)) + + @staticmethod + def _sync_scrollbar(canvas: tk.Canvas, scrollbar: ttk.Scrollbar): + """Show a scrollbar only while its canvas has content off-screen.""" + try: + first, last = canvas.yview() + except tk.TclError: + return # Widget went away mid-update + fits = first <= 0.0 and last >= 1.0 + if fits: + if scrollbar.winfo_manager(): + scrollbar.pack_forget() + elif not scrollbar.winfo_manager(): + # Pack ahead of the canvas: the canvas expands to fill its + # container, so a scrollbar added after it is squeezed to nothing. + scrollbar.pack(side="right", fill="y", before=canvas) + def _create_header(self): """Create the header with breadcrumb, status, and controls.""" left = tk.Frame(self.header, bg=Theme.BG_PRIMARY) @@ -1511,8 +1749,36 @@ def _create_status_bar(self): def _update_status_bar(self, text: str, right_text: str = ""): self._status_text.configure(text=text) + if self.unsaved_changes: + right_text = f"Unsaved changes{' • ' + right_text if right_text else ''}" self._status_right.configure(text=right_text) + def _track_changes(self): + """Flag unsaved edits so settings are not lost on exit.""" + tracked_vars = self._service_vars() + self._source_vars() + [ + self.rules_var, self.smtp_recv_var, self.dbmon_first_check_var, + ] + for var in tracked_vars: + var.trace_add("write", lambda *args: self._mark_unsaved()) + self._bind_edit_tracking(self.content) + + def _bind_edit_tracking(self, widget: tk.Misc): + """Recursively watch text inputs for edits.""" + for child in widget.winfo_children(): + if isinstance(child, (tk.Entry, tk.Text)): + child.bind("", lambda e: self._mark_unsaved(), add="+") + self._bind_edit_tracking(child) + + def _mark_unsaved(self): + if self.unsaved_changes: + return + self.unsaved_changes = True + self._update_status_bar(self._status_text.cget("text")) + + def _mark_saved(self): + self.unsaved_changes = False + self._update_status_bar("Settings saved", datetime.now().strftime('%H:%M:%S')) + def _create_pages(self): """Create all content pages.""" self._create_email_page() @@ -1532,6 +1798,8 @@ def _create_pages(self): self._create_rss_page() self._create_webmon_page() self._create_httpmon_page() + self._create_database_page() + self._create_logmon_page() self._create_logs_page() def _create_scrollable_page(self, name: str) -> tk.Frame: @@ -1543,13 +1811,18 @@ def _create_scrollable_page(self, name: str) -> tk.Frame: # Outer scrollable frame scrollable_outer = tk.Frame(canvas, bg=Theme.BG_SECONDARY) - scrollable_outer.bind("", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) + + def _on_content_config(event): + canvas.configure(scrollregion=canvas.bbox("all")) + self._schedule_scrollbar_sync(canvas, scrollbar) + scrollable_outer.bind("", _on_content_config) canvas_window = canvas.create_window((0, 0), window=scrollable_outer, anchor="nw") + # The scrollbar appears only for pages that are actually taller than + # the window, so short pages are not framed by an inert track. canvas.configure(yscrollcommand=scrollbar.set) canvas.pack(side="left", fill="both", expand=True) - scrollbar.pack(side="right", fill="y") # Max-width inner container (centered) inner = tk.Frame(scrollable_outer, bg=Theme.BG_SECONDARY) @@ -1567,6 +1840,7 @@ def _leave_content(event): # Keep scrollable frame width matched to canvas def _on_canvas_configure(event): canvas.itemconfigure(canvas_window, width=event.width) + self._schedule_scrollbar_sync(canvas, scrollbar) canvas.bind("", _on_canvas_configure) self.pages[name] = container @@ -1591,21 +1865,13 @@ def _create_email_page(self): ) for proto_val, proto_label, proto_desc in [ - ("IMAP", "IMAP", "Recommended - fetches unread emails, marks as read"), - ("POP3", "POP3", "Downloads and deletes emails from server"), + ("IMAP", "IMAP", "Recommended — fetches unread emails and marks them as read"), + ("POP3", "POP3", "Downloads and deletes emails from the server"), ]: - rb_frame = tk.Frame(proto_frame, bg=Theme.BG_PRIMARY) - rb_frame.pack(fill="x", pady=2) - tk.Radiobutton( - rb_frame, text=f"{proto_label} - {proto_desc}", - variable=self.protocol_var, value=proto_val, - bg=Theme.BG_PRIMARY, fg=Theme.TEXT_PRIMARY, - selectcolor=Theme.BG_PRIMARY, - activebackground=Theme.BG_PRIMARY, - activeforeground=Theme.TEXT_PRIMARY, - font=(Theme.FONT, 10), - command=self._on_protocol_changed, - ).pack(anchor="w") + RadioOption( + proto_frame, proto_label, proto_val, self.protocol_var, + description=proto_desc, command=self._on_protocol_changed, + ).pack(fill="x", pady=4) # IMAP Server settings self._imap_section = FormSection(page, "IMAP Server", "Configure your IMAP server connection") @@ -1700,40 +1966,34 @@ def _create_email_page(self): tk.Label( tls_frame, text="TLS Mode", bg=Theme.BG_PRIMARY, - fg=Theme.TEXT_PRIMARY, font=(Theme.FONT, 10), - ).pack(side="left") + fg=Theme.TEXT_LABEL, font=(Theme.FONT, 10), anchor="w", + ).pack(fill="x", pady=(0, 6)) self.tls_mode_var = tk.StringVar( value=self.config.get('Email', 'tls_mode', fallback='implicit') ) tls_options = [ - ("implicit", "Implicit SSL (port 993/995)"), - ("explicit", "STARTTLS (port 143/110)"), - ("none", "No encryption"), + ("implicit", "Implicit SSL", "Encrypted from the start (port 993 / 995)"), + ("explicit", "STARTTLS", "Upgrades a plain connection (port 143 / 110)"), + ("none", "No encryption", "Not recommended — credentials are sent in the clear"), ] - for i, (value, label) in enumerate(tls_options): - tk.Radiobutton( - tls_frame, text=label, variable=self.tls_mode_var, value=value, - bg=Theme.BG_PRIMARY, fg=Theme.TEXT_PRIMARY, - selectcolor=Theme.BG_PRIMARY, - activebackground=Theme.BG_PRIMARY, activeforeground=Theme.TEXT_PRIMARY, - font=(Theme.FONT, 9), - ).pack(side="left", padx=(20 if i == 0 else 10, 0)) + for value, label, description in tls_options: + RadioOption( + tls_frame, label, value, self.tls_mode_var, description=description, + ).pack(fill="x", pady=3) self._create_separator(adv_section.content) # Verify SSL checkbox ssl_frame = tk.Frame(adv_section.content, bg=Theme.BG_PRIMARY) - ssl_frame.pack(fill="x", padx=16, pady=8) + ssl_frame.pack(fill="x", padx=16, pady=12) self.verify_ssl_var = tk.BooleanVar( value=self.config.getboolean('Email', 'verify_ssl', fallback=True) ) - tk.Checkbutton( - ssl_frame, text="Verify SSL certificates", variable=self.verify_ssl_var, - bg=Theme.BG_PRIMARY, fg=Theme.TEXT_PRIMARY, - selectcolor=Theme.BG_PRIMARY, activebackground=Theme.BG_PRIMARY, - font=(Theme.FONT, 10), - ).pack(anchor="w") + CheckOption( + ssl_frame, "Verify SSL certificates", self.verify_ssl_var, + description="Leave on unless your server uses a self-signed certificate", + ).pack(fill="x") self._create_separator(adv_section.content) @@ -2692,6 +2952,250 @@ def _on_httpmon_test_result(self, success: bool, message: str): self._log(f"HTTP test failed: {message}", "ERROR") self.toast.show(f"Endpoint test failed: {message}", "error") + def _create_json_list_editor( + self, + parent: tk.Frame, + title: str, + description: str, + hint: str, + value: str, + height: int = 7, + ) -> scrolledtext.ScrolledText: + """Create a labelled JSON editor for a list of monitored items.""" + section = FormSection(parent, title, description) + section.pack(fill="x", pady=(0, 24)) + + frame = tk.Frame(section.content, bg=Theme.BG_PRIMARY) + frame.pack(fill="x", padx=16, pady=12) + + tk.Label( + frame, text=hint, + bg=Theme.BG_PRIMARY, fg=Theme.TEXT_MUTED, font=(Theme.FONT, 9), + justify="left", anchor="w", + ).pack(fill="x", pady=(0, 8)) + + editor = scrolledtext.ScrolledText( + frame, height=height, font=(Theme.FONT_MONO, 9), + bg=Theme.BG_INPUT, fg=Theme.TEXT_PRIMARY, + insertbackground=Theme.PRIMARY, wrap="word", + ) + editor.pack(fill="x") + editor.insert("1.0", value) + return editor + + def _parse_json_list(self, editor: scrolledtext.ScrolledText, label: str) -> Optional[list]: + """Parse a JSON array from an editor, reporting problems to the user.""" + raw = editor.get("1.0", tk.END).strip() or "[]" + try: + parsed = json.loads(raw) + except json.JSONDecodeError as e: + self._log(f"Invalid {label} JSON: {e}", "ERROR") + self.toast.show(f"Invalid JSON: {e}", "error") + return None + if not isinstance(parsed, list): + message = f"{label} must be a JSON array" + self._log(message, "ERROR") + self.toast.show(message, "error") + return None + return parsed + + def _create_database_page(self): + """Create database monitoring configuration page.""" + page = self._create_scrollable_page("database") + + toggle_frame = tk.Frame( + page, bg=Theme.BG_PRIMARY, + highlightbackground=Theme.CARD_BORDER, highlightthickness=1, + ) + toggle_frame.pack(fill="x", pady=(0, 24)) + ToggleSwitch(toggle_frame, "Enable Database Monitoring", self.dbmon_var).pack(fill="x") + + section = FormSection( + page, "Database Monitor Settings", + "Check database health and validate query results", + ) + section.pack(fill="x", pady=(0, 24)) + + self.dbmon_interval = FormRow( + section.content, "Check Interval", "Seconds between checks (default: 300)", + tooltip="How often to query the configured databases", + ) + self.dbmon_interval.pack(fill="x") + self.dbmon_interval.set( + self.config.get('DatabaseMonitor', 'check_interval', fallback='300') + ) + + self.dbmon_timeout = FormRow( + section.content, "Query Timeout", "Seconds before a query is abandoned (default: 30)", + tooltip="Applies to databases that do not set their own timeout", + ) + self.dbmon_timeout.pack(fill="x") + self.dbmon_timeout.set( + self.config.get('DatabaseMonitor', 'default_timeout', fallback='30') + ) + + notify_frame = tk.Frame(page, bg=Theme.BG_PRIMARY, + highlightbackground=Theme.CARD_BORDER, highlightthickness=1) + notify_frame.pack(fill="x", pady=(0, 24)) + ToggleSwitch( + notify_frame, "Notify on first check", self.dbmon_first_check_var, + ).pack(fill="x") + + self.dbmon_databases_text = self._create_json_list_editor( + page, + "Monitored Databases", + "Add databases to monitor (JSON format)", + 'Enter databases as a JSON array. Example:\n' + '[{"name": "App DB", "type": "postgresql", "host": "localhost", "port": 5432,\n' + ' "database": "myapp", "username": "admin", "password": "secret",\n' + ' "query": "SELECT COUNT(*) FROM active_users", "expected_value": ">0"}]\n' + 'Supported types: postgresql, mysql, mariadb, sqlite ' + '(sqlite uses "path" instead of host/port).', + self.config.get('DatabaseMonitor', 'databases', fallback='[]'), + ) + + btn_frame = tk.Frame(page, bg=Theme.BG_SECONDARY) + btn_frame.pack(fill="x", pady=(0, 24)) + ModernButton( + btn_frame, text="Validate & Test", + command=self._validate_databases, + bg=Theme.BG_PRIMARY, fg=Theme.TEXT_PRIMARY, + hover_bg=Theme.BG_TERTIARY, + tooltip="Validate the databases JSON and test the first connection", + ).pack(side="left") + + def _validate_databases(self): + """Validate database monitor configuration and test the first entry.""" + databases = self._parse_json_list(self.dbmon_databases_text, "databases") + if databases is None: + return + + self._log(f"Parsed {len(databases)} database(s)", "INFO") + if not databases or not isinstance(databases[0], dict): + self.toast.show(f"Valid JSON with {len(databases)} database(s)", "success") + return + + entry = databases[0] + name = entry.get('name', entry.get('database', 'database')) + self._log(f"Testing database connection: {name}", "INFO") + + def test(): + success, msg = test_database_connection( + db_type=entry.get('type', 'postgresql'), + host=entry.get('host', 'localhost'), + port=safe_int(entry.get('port'), 5432, min_val=1, max_val=65535), + database=entry.get('database', ''), + username=entry.get('username', ''), + password=entry.get('password', ''), + path=entry.get('path', ''), + timeout=safe_int( + entry.get('timeout', self.dbmon_timeout.get()), 30, min_val=1, max_val=300 + ), + ) + self.root.after(0, lambda: self._on_source_test_result("Database", success, msg)) + + threading.Thread(target=test, daemon=True).start() + + def _create_logmon_page(self): + """Create log file monitoring configuration page.""" + page = self._create_scrollable_page("logmon") + + toggle_frame = tk.Frame( + page, bg=Theme.BG_PRIMARY, + highlightbackground=Theme.CARD_BORDER, highlightthickness=1, + ) + toggle_frame.pack(fill="x", pady=(0, 24)) + ToggleSwitch(toggle_frame, "Enable Log File Monitoring", self.logmon_var).pack(fill="x") + + section = FormSection( + page, "Log Monitor Settings", + "Watch log files for error patterns", + ) + section.pack(fill="x", pady=(0, 24)) + + self.logmon_interval = FormRow( + section.content, "Check Interval", "Seconds between checks (default: 60)", + tooltip="How often to read new log lines", + ) + self.logmon_interval.pack(fill="x") + self.logmon_interval.set( + self.config.get('LogMonitor', 'check_interval', fallback='60') + ) + + self.logmon_max_lines = FormRow( + section.content, "Max Lines Per Check", "Lines read per file each cycle (default: 1000)", + tooltip="Caps the work done when a log grows quickly", + ) + self.logmon_max_lines.pack(fill="x") + self.logmon_max_lines.set( + self.config.get('LogMonitor', 'max_lines_per_check', fallback='1000') + ) + + self.logmon_rate_limit = FormRow( + section.content, "Alerts Per Check", "Maximum alerts per log per cycle (default: 10)", + tooltip="Prevents a flood of notifications when a log fills with errors", + ) + self.logmon_rate_limit.pack(fill="x") + self.logmon_rate_limit.set( + self.config.get('LogMonitor', 'default_rate_limit', fallback='10') + ) + + self.logmon_logs_text = self._create_json_list_editor( + page, + "Monitored Log Files", + "Add log files to monitor (JSON format)", + 'Enter log files as a JSON array. Example:\n' + '[{"name": "App errors", "path": "/var/log/myapp/error.log",\n' + ' "patterns": ["ERROR", "CRITICAL", "Exception"],\n' + ' "exclude_patterns": ["healthcheck"], "rate_limit": 5}]\n' + 'Patterns are regular expressions and are matched case-insensitively.', + self.config.get('LogMonitor', 'logs', fallback='[]'), + ) + + btn_frame = tk.Frame(page, bg=Theme.BG_SECONDARY) + btn_frame.pack(fill="x", pady=(0, 24)) + ModernButton( + btn_frame, text="Validate & Test", + command=self._validate_logs, + bg=Theme.BG_PRIMARY, fg=Theme.TEXT_PRIMARY, + hover_bg=Theme.BG_TERTIARY, + tooltip="Validate the logs JSON and check that the first file is readable", + ).pack(side="left") + + def _validate_logs(self): + """Validate log monitor configuration and test the first entry.""" + logs = self._parse_json_list(self.logmon_logs_text, "logs") + if logs is None: + return + + self._log(f"Parsed {len(logs)} log file(s)", "INFO") + if not logs or not isinstance(logs[0], dict) or 'path' not in logs[0]: + if logs: + self.toast.show("Each log entry needs a 'path'", "error") + else: + self.toast.show("Valid JSON with 0 log file(s)", "success") + return + + entry = logs[0] + patterns = entry.get('patterns', []) + if isinstance(patterns, str): + patterns = [p.strip() for p in patterns.split(',') if p.strip()] + + def test(): + success, msg = test_log_file(entry['path'], patterns) + self.root.after(0, lambda: self._on_source_test_result("Log file", success, msg)) + + threading.Thread(target=test, daemon=True).start() + + def _on_source_test_result(self, label: str, success: bool, message: str): + """Report the outcome of a monitoring source test.""" + if success: + self._log(f"{label}: {message}", "SUCCESS") + self.toast.show(message, "success") + else: + self._log(f"{label} test failed: {message}", "ERROR") + self.toast.show(f"{label} test failed: {message}", "error") + def _create_logs_page(self): page = tk.Frame(self.content, bg=Theme.BG_SECONDARY) self.pages["logs"] = page @@ -2798,6 +3302,10 @@ def _clear_logs(self): self._log_empty.place(relx=0.5, rely=0.4, anchor="center") def _update_config(self): + # A hand-edited config.ini may be missing sections; add them back + # before assigning so saving settings cannot fail with a KeyError. + ensure_config_sections(self.config) + self.config['Email']['protocol'] = self.protocol_var.get().lower() self.config['Email']['imap_server'] = self.imap_server.get() self.config['Email']['imap_port'] = self.imap_port.get() @@ -2929,16 +3437,32 @@ def _update_config(self): self.config['HttpMonitor']['check_interval'] = self.httpmon_interval.get() self.config['HttpMonitor']['endpoints'] = self.httpmon_endpoints_text.get("1.0", tk.END).strip() - def _save_settings(self): + # Database monitor settings + self.config['DatabaseMonitor']['enabled'] = str(self.dbmon_var.get()) + self.config['DatabaseMonitor']['check_interval'] = self.dbmon_interval.get() + self.config['DatabaseMonitor']['default_timeout'] = self.dbmon_timeout.get() + self.config['DatabaseMonitor']['notify_on_first_check'] = str(self.dbmon_first_check_var.get()) + self.config['DatabaseMonitor']['databases'] = self.dbmon_databases_text.get("1.0", tk.END).strip() + + # Log monitor settings + self.config['LogMonitor']['enabled'] = str(self.logmon_var.get()) + self.config['LogMonitor']['check_interval'] = self.logmon_interval.get() + self.config['LogMonitor']['max_lines_per_check'] = self.logmon_max_lines.get() + self.config['LogMonitor']['default_rate_limit'] = self.logmon_rate_limit.get() + self.config['LogMonitor']['logs'] = self.logmon_logs_text.get("1.0", tk.END).strip() + + def _save_settings(self) -> bool: try: self._update_config() save_config(self.config) self._log("Settings saved", "SUCCESS") self.toast.show("Settings saved to config.ini", "success") - self._update_status_bar("Settings saved", datetime.now().strftime('%H:%M:%S')) + self._mark_saved() + return True except Exception as e: self._log(f"Save failed: {e}", "ERROR") self.toast.show(f"Save failed: {e}", "error") + return False def _test_connection(self): proto = self.protocol_var.get() @@ -2983,12 +3507,21 @@ def _validate(self) -> bool: self.toast.show("Enable at least one notification method.", "error") return False has_email_creds = bool(self.username.get() and self.password.get()) - has_smtp_recv = self.smtp_recv_var.get() - has_rss = self.rss_var.get() - has_web = self.webmon_var.get() - has_http = self.httpmon_var.get() - if not has_email_creds and not has_smtp_recv and not has_rss and not has_web and not has_http: - self.toast.show("Configure at least one source: email, SMTP receiver, RSS, web, or HTTP.", "error") + has_any_source = any([ + has_email_creds, + self.smtp_recv_var.get(), + self.rss_var.get(), + self.webmon_var.get(), + self.httpmon_var.get(), + self.dbmon_var.get(), + self.logmon_var.get(), + ]) + if not has_any_source: + self.toast.show( + "Configure at least one source: email, SMTP receiver, RSS, web, " + "HTTP, database, or log files.", + "error", + ) return False return True @@ -3077,9 +3610,30 @@ def _on_smtp_received(self, notification: EmailNotification, dispatcher: Notific success_count = sum(1 for r in results if r.success) self._log(f"Dispatched {success_count}/{len(results)} notifications", "INFO") + def _dispatch_events(self, dispatcher: NotificationDispatcher, events: list, label: str): + """Send monitor events, logging each one and every delivery result.""" + for event in events: + if self.stop_event.is_set(): + return + level = "ERROR" if event.severity in ("error", "critical") else ( + "WARNING" if event.severity == "warning" else "INFO" + ) + self._log(f"{label}: {event.title}", level) + dispatcher.dispatch_event( + event, + callback=lambda r: self._log( + f"{r.service}: {r.message}", + "SUCCESS" if r.success else "ERROR", + ), + ) + def _monitor_loop(self): monitor_state = MonitorState() + # Each source keeps its own schedule so a five-minute RSS poll is not + # forced onto the one-minute email cadence (and vice versa). + next_run = {key: 0.0 for key in ('email', 'rss', 'web', 'http', 'database', 'log')} + while not self.stop_event.is_set(): imap = None pop3 = None @@ -3090,9 +3644,12 @@ def _monitor_loop(self): rss_config = RssFeedConfig.from_config(self.config) web_config = WebMonitorConfig.from_config(self.config) http_config = HttpEndpointConfig.from_config(self.config) + db_config = DatabaseMonitorConfig.from_config(self.config) + log_config = LogMonitorConfig.from_config(self.config) interval = safe_int(self.config.get('Settings', 'check_interval', fallback='60'), DEFAULT_CHECK_INTERVAL, min_val=10, max_val=86400) filters = [f.strip().lower() for f in self.config.get('Email', 'filter_emails', fallback='').split(',') if f.strip()] + now = time.monotonic() self.root.after(0, lambda: self._update_status_bar( "Checking sources...", @@ -3110,8 +3667,11 @@ def _monitor_loop(self): max_emails = safe_int(self.config.get('Email', 'max_emails_per_check', fallback='5'), 5, min_val=1, max_val=100) ca_bundle = self.config.get('Email', 'ca_bundle', fallback='') pop3 = None + check_email = email_username and now >= next_run['email'] + if check_email: + next_run['email'] = now + interval - if email_username and protocol == 'imap': + if check_email and protocol == 'imap': imap = connect_to_imap( self.config.get('Email', 'imap_server'), safe_int(self.config.get('Email', 'imap_port', fallback='993'), DEFAULT_IMAP_PORT, min_val=1, max_val=65535), @@ -3165,7 +3725,7 @@ def _monitor_loop(self): self._log(f"IMAP connection failed to {server}:{port} (TLS: {tls_mode})", "WARNING") self._log("Check email_monitor.log for details", "INFO") - elif email_username and protocol == 'pop3': + elif check_email and protocol == 'pop3': pop3 = connect_to_pop3( self.config.get('Email', 'pop3_server'), safe_int(self.config.get('Email', 'pop3_port', fallback='995'), DEFAULT_POP3_PORT, min_val=1, max_val=65535), @@ -3232,69 +3792,38 @@ def _monitor_loop(self): self._log("Check email_monitor.log for details", "INFO") # ===================================================== - # Check RSS Feeds + # Check the other sources, each on its own interval # ===================================================== - if rss_config.enabled: - rss_events = check_rss_feeds(rss_config, monitor_state) - for event in rss_events: - if self.stop_event.is_set(): - break - self._log(f"RSS: {event.title[:50]}...", "INFO") - notification = event.to_email_notification() - dispatcher.dispatch( - notification, - callback=lambda r: self._log( - f"{r.service}: {r.message}", - "SUCCESS" if r.success else "ERROR" - ) - ) - - # ===================================================== - # Check Web Pages - # ===================================================== - if web_config.enabled: - web_events = check_web_pages(web_config, monitor_state) - for event in web_events: - if self.stop_event.is_set(): - break - self._log(f"Web: {event.title}", "INFO") - notification = event.to_email_notification() - dispatcher.dispatch( - notification, - callback=lambda r: self._log( - f"{r.service}: {r.message}", - "SUCCESS" if r.success else "ERROR" - ) - ) - - # ===================================================== - # Check HTTP Endpoints - # ===================================================== - if http_config.enabled: - http_events = check_http_endpoints(http_config, monitor_state) - for event in http_events: - if self.stop_event.is_set(): - break - level = "ERROR" if event.severity == "error" else "INFO" - self._log(f"HTTP: {event.title}", level) - notification = event.to_email_notification() - dispatcher.dispatch( - notification, - callback=lambda r: self._log( - f"{r.service}: {r.message}", - "SUCCESS" if r.success else "ERROR" - ) - ) + for key, source_config, checker, label in ( + ('rss', rss_config, check_rss_feeds, 'RSS'), + ('web', web_config, check_web_pages, 'Web'), + ('http', http_config, check_http_endpoints, 'HTTP'), + ('database', db_config, check_databases, 'Database'), + ('log', log_config, check_log_files, 'Log'), + ): + if self.stop_event.is_set(): + break + if not source_config.enabled or now < next_run[key]: + continue + next_run[key] = now + source_config.check_interval + try: + events = checker(source_config, monitor_state) + except Exception as e: + self._log(f"{label} check failed: {e}", "ERROR") + continue + self._dispatch_events(dispatcher, events, label) # Save state monitor_state.save() # Update status sources_active = sum([ - 1 if imap else 0, + 1 if (imap or pop3) else 0, 1 if rss_config.enabled else 0, 1 if web_config.enabled else 0, 1 if http_config.enabled else 0, + 1 if db_config.enabled else 0, + 1 if log_config.enabled else 0, ]) self.root.after(0, lambda n=sources_active: self._update_status_bar( f"Monitoring {n} source(s)", @@ -3321,21 +3850,39 @@ def _monitor_loop(self): pass if not self.stop_event.is_set(): - interval = safe_int(self.config.get('Settings', 'check_interval', fallback='60'), DEFAULT_CHECK_INTERVAL, min_val=10, max_val=86400) - self.stop_event.wait(interval) + self.stop_event.wait(self._seconds_until_next_check(next_run)) + + def _seconds_until_next_check(self, next_run: Dict[str, float]) -> float: + """How long to sleep before the earliest source is due again.""" + due = [when for when in next_run.values() if when > 0] + if not due: + return float(DEFAULT_CHECK_INTERVAL) + remaining = min(due) - time.monotonic() + # Never spin, and never sleep so long that a stop takes ages to register. + return max(1.0, min(remaining, float(MAX_LOOP_SLEEP))) def _on_close(self): if self.monitoring: - if messagebox.askyesno("Confirm", "Stop monitoring and exit?"): - self._stop_monitoring() - else: + if not messagebox.askyesno("Confirm", "Stop monitoring and exit?"): return + self._stop_monitoring() + + if self.unsaved_changes: + answer = messagebox.askyesnocancel( + "Unsaved changes", + "You have unsaved settings. Save them before exiting?", + ) + if answer is None: + return # Cancel: stay open + if answer and not self._save_settings(): + return # Saving failed; do not discard the user's work + self.root.destroy() def main(): root = tk.Tk() - app = EmailMonitorApp(root) + EmailMonitorApp(root) root.mainloop() diff --git a/e2nb_core.py b/e2nb_core.py index 13cdd91..967b9ba 100644 --- a/e2nb_core.py +++ b/e2nb_core.py @@ -13,7 +13,6 @@ from __future__ import annotations -import asyncio import configparser import hashlib import imaplib @@ -24,10 +23,10 @@ import re import smtplib import ssl -import threading +import tempfile import time from dataclasses import dataclass, field -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone import email as email_module from email.header import decode_header from email.message import Message @@ -75,7 +74,6 @@ try: from aiosmtpd.controller import Controller as SmtpdController - from aiosmtpd.smtp import SMTP as AIOSMTP AIOSMTPD_AVAILABLE = True except ImportError: AIOSMTPD_AVAILABLE = False @@ -459,6 +457,10 @@ def from_config(cls, config: configparser.ConfigParser) -> 'NtfyConfig': ) +# Matches {variable_name} placeholders in message templates. +_TEMPLATE_VAR_RE = re.compile(r'\{([A-Za-z_][A-Za-z0-9_]*)\}') + + @dataclass class MessageTemplateConfig: """ @@ -497,24 +499,39 @@ def from_config(cls, config: configparser.ConfigParser) -> 'MessageTemplateConfi return cls() section = config['MessageTemplates'] - # Parse channel-specific templates - channel_templates = {} + # Parse channel-specific templates. Each value must itself be an object + # of {"subject": ..., "body": ...}; anything else is dropped so a typo + # cannot raise mid-dispatch. + channel_templates: Dict[str, Dict[str, str]] = {} channels_str = section.get('channel_templates', '{}') try: parsed = json.loads(channels_str) if isinstance(parsed, dict): - channel_templates = parsed + for channel, templates in parsed.items(): + if isinstance(templates, dict): + channel_templates[str(channel)] = { + str(k): str(v) for k, v in templates.items() + } + else: + logger.warning( + f"Ignoring channel_templates entry for '{channel}': " + "expected an object with 'subject'/'body' keys" + ) + elif channels_str.strip() not in ('{}', ''): + logger.error("channel_templates must be a JSON object; ignoring it") except json.JSONDecodeError as e: if channels_str.strip() not in ('{}', ''): logger.error(f"Failed to parse channel_templates JSON: {e}") # Parse custom variables - custom_variables = {} + custom_variables: Dict[str, str] = {} custom_str = section.get('custom_variables', '{}') try: parsed = json.loads(custom_str) if isinstance(parsed, dict): - custom_variables = parsed + custom_variables = {str(k): str(v) for k, v in parsed.items()} + elif custom_str.strip() not in ('{}', ''): + logger.error("custom_variables must be a JSON object; ignoring it") except json.JSONDecodeError as e: if custom_str.strip() not in ('{}', ''): logger.error(f"Failed to parse custom_variables JSON: {e}") @@ -582,13 +599,17 @@ def render( if key not in variables: variables[f'meta_{key}'] = str(value) if not isinstance(value, (str, bytes)) else str(value) - # Perform substitution using safe_substitute to avoid KeyError on unknown vars + # Substitute in a single pass. Replacing variables one at a time would + # let content pulled in by an earlier variable (an email body that + # happens to contain "{hostname}", say) be substituted again. + def _replace(match: 're.Match') -> str: + name = match.group(1) + if name in variables: + return str(variables[name]) + return match.group(0) # Unknown placeholder: leave it untouched + try: - # Use simple string replacement to handle {var} syntax - result = template - for key, value in variables.items(): - result = result.replace('{' + key + '}', str(value)) - return result + return _TEMPLATE_VAR_RE.sub(_replace, template) except Exception as e: logger.warning(f"Template rendering error: {e}") return template @@ -621,6 +642,26 @@ class NotificationRule: channels: str = "" # Comma-separated channel names to route to (empty = all enabled) suppress: bool = False # If True, suppress notification on matching channels priority_override: str = "" # Override priority for channels that support it + # Compiled-pattern cache; invalid patterns are reported once, not per event. + _compiled: Dict[str, Any] = field( + default_factory=dict, compare=False, repr=False + ) + + def _pattern_matches(self, pattern: str, value: str, field_name: str) -> bool: + """Test one condition pattern, treating an invalid regex as no match.""" + if field_name not in self._compiled: + try: + self._compiled[field_name] = re.compile(pattern, re.IGNORECASE) + except re.error as e: + logger.warning( + f"Notification rule '{self.name or 'unnamed'}' has an invalid " + f"{field_name} regex ({pattern!r}): {e}. The rule will never match." + ) + self._compiled[field_name] = None + compiled = self._compiled[field_name] + if compiled is None: + return False + return bool(compiled.search(value or "")) def matches(self, notification: 'EmailNotification', source_type: str = "email", severity: str = "info") -> bool: @@ -634,26 +675,20 @@ def matches(self, notification: 'EmailNotification', source_type: str = "email", if self.severity and self.severity.lower() != severity.lower(): return False - if self.sender_pattern: - try: - if not re.search(self.sender_pattern, notification.sender, re.IGNORECASE): - return False - except re.error: - return False + if self.sender_pattern and not self._pattern_matches( + self.sender_pattern, notification.sender, 'sender_pattern' + ): + return False - if self.subject_pattern: - try: - if not re.search(self.subject_pattern, notification.subject, re.IGNORECASE): - return False - except re.error: - return False + if self.subject_pattern and not self._pattern_matches( + self.subject_pattern, notification.subject, 'subject_pattern' + ): + return False - if self.body_pattern: - try: - if not re.search(self.body_pattern, notification.body, re.IGNORECASE): - return False - except re.error: - return False + if self.body_pattern and not self._pattern_matches( + self.body_pattern, notification.body, 'body_pattern' + ): + return False return True @@ -681,17 +716,22 @@ def from_config(cls, config: configparser.ConfigParser) -> 'NotificationRulesCon for rule_dict in parsed: if isinstance(rule_dict, dict): rules.append(NotificationRule( - name=rule_dict.get('name', ''), - enabled=rule_dict.get('enabled', True), - source_type=rule_dict.get('source_type', ''), - sender_pattern=rule_dict.get('sender_pattern', ''), - subject_pattern=rule_dict.get('subject_pattern', ''), - body_pattern=rule_dict.get('body_pattern', ''), - severity=rule_dict.get('severity', ''), - channels=rule_dict.get('channels', ''), - suppress=rule_dict.get('suppress', False), - priority_override=rule_dict.get('priority_override', ''), + name=str(rule_dict.get('name', '')), + enabled=coerce_bool(rule_dict.get('enabled', True), True), + source_type=str(rule_dict.get('source_type', '')), + sender_pattern=str(rule_dict.get('sender_pattern', '')), + subject_pattern=str(rule_dict.get('subject_pattern', '')), + body_pattern=str(rule_dict.get('body_pattern', '')), + severity=str(rule_dict.get('severity', '')), + channels=str(rule_dict.get('channels', '')), + suppress=coerce_bool(rule_dict.get('suppress', False), False), + priority_override=str(rule_dict.get('priority_override', '')), )) + else: + logger.warning( + "Ignoring notification rule that is not a JSON object: " + f"{rule_dict!r}" + ) except json.JSONDecodeError as e: if rules_str.strip() not in ('[]', ''): logger.error(f"Failed to parse notification rules JSON: {e}") @@ -725,26 +765,35 @@ def get_channel_decisions(self, notification: 'EmailNotification', # Start with all channels enabled decisions = {ch: True for ch in all_channels} + # Routing rules are additive: if two rules match and each routes to a + # different channel, the notification goes to both. Restricting to the + # last matching rule's channels would silently drop the others. + routed_channels: Set[str] = set() + has_routing_rule = False for rule in matching_rules: - if rule.suppress: - # Suppress specific channels - if rule.channels: - targets = [c.strip().lower() for c in rule.channels.split(',') if c.strip()] - for ch in targets: - if ch in decisions: - decisions[ch] = False - else: - # Suppress all channels - for ch in decisions: - decisions[ch] = False - elif rule.channels: - # Route only to specific channels - first disable all, then enable targets - for ch in decisions: - decisions[ch] = False + if not rule.suppress and rule.channels: + has_routing_rule = True + routed_channels.update( + c.strip().lower() for c in rule.channels.split(',') if c.strip() + ) + + if has_routing_rule: + for ch in decisions: + decisions[ch] = ch in routed_channels + + # Suppression is applied last so it always wins over routing. + for rule in matching_rules: + if not rule.suppress: + continue + if rule.channels: targets = [c.strip().lower() for c in rule.channels.split(',') if c.strip()] for ch in targets: if ch in decisions: - decisions[ch] = True + decisions[ch] = False + else: + # Suppress all channels + for ch in decisions: + decisions[ch] = False return decisions @@ -1305,21 +1354,15 @@ def from_config(cls, config: configparser.ConfigParser) -> 'AppSettings': return cls() section = config['Settings'] - # Safe integer conversion with validation - def _safe_int(val, default, min_v=None, max_v=None): - try: - result = int(val) - if min_v is not None and result < min_v: - return min_v - if max_v is not None and result > max_v: - return max_v - return result - except (ValueError, TypeError): - return default - return cls( - max_sms_length=_safe_int(section.get('max_sms_length'), DEFAULT_MAX_SMS_LENGTH, min_v=100, max_v=10000), - check_interval=_safe_int(section.get('check_interval'), DEFAULT_CHECK_INTERVAL, min_v=10, max_v=86400) + max_sms_length=safe_int( + section.get('max_sms_length'), DEFAULT_MAX_SMS_LENGTH, + min_val=100, max_val=10000, + ), + check_interval=safe_int( + section.get('check_interval'), DEFAULT_CHECK_INTERVAL, + min_val=10, max_val=86400, + ), ) @@ -1351,6 +1394,34 @@ def safe_int(value: Any, default: int, min_val: Optional[int] = None, max_val: O return default +def coerce_bool(value: Any, default: bool = False) -> bool: + """ + Interpret a JSON or INI value as a boolean. + + Accepts real booleans, numbers, and the usual string spellings + ("true"/"yes"/"on"/"1" and their negatives) so that a rule written as + ``"enabled": "false"`` behaves the way its author intended. + + Args: + value: Value to interpret. + default: Result for values that carry no clear boolean meaning. + + Returns: + The interpreted boolean. + """ + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return bool(value) + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in ('true', 'yes', 'on', '1'): + return True + if normalized in ('false', 'no', 'off', '0', ''): + return False + return default + + def validate_url(url: str, schemes: Optional[List[str]] = None) -> bool: """ Validate a URL string. @@ -1497,62 +1568,11 @@ def create_http_session( # Configuration Management # ============================================================================= -def load_config(config_file: str = CONFIG_FILE_PATH) -> configparser.ConfigParser: - """ - Load configuration from INI file. - - Args: - config_file: Path to the configuration file. - - Returns: - ConfigParser instance with loaded configuration. - - Raises: - ConfigurationError: If configuration cannot be loaded. - """ - # Disable interpolation to allow % characters in passwords/tokens - config = configparser.ConfigParser(interpolation=None) - - try: - if not os.path.exists(config_file): - logger.info(f"Config file not found. Creating default at {config_file}") - create_default_config(config_file) - config.read(config_file) - return config - except Exception as e: - raise ConfigurationError(f"Failed to load configuration: {e}") - - -def save_config(config: configparser.ConfigParser, config_file: str = CONFIG_FILE_PATH) -> None: - """ - Save configuration to INI file. - - Args: - config: ConfigParser instance to save. - config_file: Path to the configuration file. - - Raises: - ConfigurationError: If configuration cannot be saved. - """ - try: - with open(config_file, 'w') as file: - config.write(file) - logger.info(f"Configuration saved to {config_file}") - except Exception as e: - raise ConfigurationError(f"Failed to save configuration: {e}") - - -def create_default_config(config_file: str = CONFIG_FILE_PATH) -> None: - """ - Create a default configuration file. - - Args: - config_file: Path to the configuration file. - """ - # Disable interpolation to allow % characters in passwords/tokens - config = configparser.ConfigParser(interpolation=None) - - config['Email'] = { +# The full configuration schema. Every section and key E2NB reads lives here so +# that a fresh config file and a partially hand-edited one end up with the same +# shape. Values are the defaults used when a key is absent. +DEFAULT_CONFIG_SECTIONS: Dict[str, Dict[str, str]] = { + 'Email': { 'protocol': 'imap', 'imap_server': 'imap.gmail.com', 'imap_port': str(DEFAULT_IMAP_PORT), @@ -1560,88 +1580,76 @@ def create_default_config(config_file: str = CONFIG_FILE_PATH) -> None: 'pop3_port': str(DEFAULT_POP3_PORT), 'username': '', 'password': '', - 'filter_emails': '' - } - - config['Settings'] = { + 'filter_emails': '', + }, + 'Settings': { 'max_sms_length': str(DEFAULT_MAX_SMS_LENGTH), - 'check_interval': str(DEFAULT_CHECK_INTERVAL) - } - - config['Twilio'] = { + 'check_interval': str(DEFAULT_CHECK_INTERVAL), + }, + 'Twilio': { 'enabled': 'False', 'account_sid': '', 'auth_token': '', 'from_number': '', - 'destination_number': '' - } - - config['Voice'] = { + 'destination_number': '', + }, + 'Voice': { 'enabled': 'False', 'account_sid': '', 'auth_token': '', 'from_number': '', - 'destination_number': '' - } - - config['WhatsApp'] = { + 'destination_number': '', + }, + 'WhatsApp': { 'enabled': 'False', 'account_sid': '', 'auth_token': '', 'from_number': '', - 'to_number': '' - } - - config['Slack'] = { + 'to_number': '', + }, + 'Slack': { 'enabled': 'False', 'token': '', 'channel': '', 'mention_users': '', 'dm_users': '', - 'use_blocks': 'True' - } - - config['Telegram'] = { + 'use_blocks': 'True', + }, + 'Telegram': { 'enabled': 'False', 'bot_token': '', - 'chat_id': '' - } - - config['Discord'] = { + 'chat_id': '', + }, + 'Discord': { 'enabled': 'False', - 'webhook_url': '' - } - - config['Teams'] = { + 'webhook_url': '', + }, + 'Teams': { 'enabled': 'False', 'webhook_url': '', - 'use_adaptive_card': 'True' - } - - config['Pushover'] = { + 'use_adaptive_card': 'True', + }, + 'Pushover': { 'enabled': 'False', 'api_token': '', 'user_key': '', 'priority': '0', 'sound': '', - 'device': '' - } - - config['Ntfy'] = { + 'device': '', + }, + 'Ntfy': { 'enabled': 'False', 'server_url': 'https://ntfy.sh', 'topic': '', 'priority': 'default', 'tags': '', - 'auth_token': '' - } - - config['CustomWebhook'] = { + 'auth_token': '', + }, + 'CustomWebhook': { 'enabled': 'False', - 'webhook_url': '' - } - - config['SMTP'] = { + 'webhook_url': '', + }, + 'SMTP': { 'enabled': 'False', 'smtp_server': 'smtp.gmail.com', 'smtp_port': '587', @@ -1650,138 +1658,201 @@ def create_default_config(config_file: str = CONFIG_FILE_PATH) -> None: 'password': '', 'from_address': '', 'to_addresses': '', - 'subject_prefix': '[E2NB]' - } - - config['SmtpReceiver'] = { + 'subject_prefix': '[E2NB]', + }, + 'SmtpReceiver': { 'enabled': 'False', 'host': '0.0.0.0', 'port': '2525', 'use_auth': 'False', 'username': '', 'password': '', - 'filter_emails': '' - } - - # Monitoring Sources - config['RSS'] = { + 'filter_emails': '', + }, + # Monitoring sources + 'RSS': { 'enabled': 'False', 'feeds': '[]', 'check_interval': '300', 'max_age_hours': str(DEFAULT_RSS_MAX_AGE_HOURS), - 'max_items_per_check': str(DEFAULT_RSS_MAX_ITEMS) - } - - # Web page change monitoring - detects content changes on web pages - # Use JSON format in 'pages' OR individual [WebMonitor.name] sections - config['WebMonitor'] = { + 'max_items_per_check': str(DEFAULT_RSS_MAX_ITEMS), + }, + # Web page change monitoring. Pages may also be declared in their own + # [WebMonitor.] sections instead of the JSON list. + 'WebMonitor': { 'enabled': 'False', 'pages': '[]', 'check_interval': str(DEFAULT_WEB_CHECK_INTERVAL), - 'notify_on_first_check': 'False' - } - # Example section-based config (add to config.ini): - # [WebMonitor.product_price] - # url = https://example.com/product - # selector = .price - # timeout = 30 - - # HTTP endpoint monitoring - monitors uptime and response validation - # Use JSON format in 'endpoints' OR individual [HttpMonitor.name] sections - config['HttpMonitor'] = { + 'notify_on_first_check': 'False', + }, + # HTTP endpoint monitoring. Endpoints may also be declared in their own + # [HttpMonitor.] sections instead of the JSON list. + 'HttpMonitor': { 'enabled': 'False', 'endpoints': '[]', 'check_interval': '60', 'notify_on_first_check': 'False', - 'default_failure_threshold': '1' - } - # Example section-based config (add to config.ini): - # [HttpMonitor.api_health] - # url = https://api.example.com/health - # method = GET - # expected_status = 200 - # expected_text = "status":"ok" - # timeout = 30 - # failure_threshold = 3 - - # Notification routing rules - conditionally route notifications - config['NotificationRules'] = { + 'default_failure_threshold': '1', + }, + 'NotificationRules': { 'enabled': 'False', - 'rules': '[]' - } - # Example rules JSON: - # [ - # { - # "name": "Critical alerts to SMS", - # "enabled": true, - # "severity": "error", - # "channels": "sms,voice,slack", - # "suppress": false - # }, - # { - # "name": "Suppress RSS from Discord", - # "enabled": true, - # "source_type": "rss", - # "channels": "discord", - # "suppress": true - # } - # ] - - # Message templating for customizing notification content - config['MessageTemplates'] = { + 'rules': '[]', + }, + 'MessageTemplates': { 'enabled': 'False', 'default_subject': '[{severity}] {source_type}: {title}', 'default_body': '{body}', 'channel_templates': '{}', - 'custom_variables': '{}' - } - # Example channel_templates JSON: - # { - # "slack": {"subject": ":bell: {title}", "body": "*Source:* {source_name}\n{body}"}, - # "sms": {"subject": "{title}", "body": "{body}"} - # } - # Available variables: {source_type}, {source_name}, {title}, {body}, - # {timestamp}, {severity}, {url}, {hostname}, {date}, {time} - - # Database monitoring - monitors database health and query results - config['DatabaseMonitor'] = { + 'custom_variables': '{}', + }, + # Database monitoring. Databases may also be declared in their own + # [DatabaseMonitor.] sections instead of the JSON list. + 'DatabaseMonitor': { 'enabled': 'False', 'databases': '[]', 'check_interval': '300', 'notify_on_first_check': 'False', - 'default_timeout': '30' - } - # Example section-based config (add to config.ini): - # [DatabaseMonitor.main_db] - # type = postgresql - # host = localhost - # port = 5432 - # database = myapp - # username = admin - # password = secret - # query = SELECT COUNT(*) FROM active_users - # expected_value = >0 - # timeout = 30 - # slow_query_threshold = 5.0 - - # Log file monitoring - monitors logs for patterns/errors - config['LogMonitor'] = { + 'default_timeout': '30', + }, + # Log file monitoring. Logs may also be declared in their own + # [LogMonitor.] sections instead of the JSON list. + 'LogMonitor': { 'enabled': 'False', 'logs': '[]', 'check_interval': '60', 'max_lines_per_check': '1000', - 'default_rate_limit': '10' - } - # Example section-based config (add to config.ini): - # [LogMonitor.app_errors] - # path = /var/log/myapp/error.log - # patterns = ["ERROR", "CRITICAL", "Exception"] - # severity_patterns = {"error": "ERROR|EXCEPTION", "critical": "CRITICAL|FATAL"} - # exclude_patterns = ["DEBUG", "healthcheck"] - # rate_limit = 5 + 'default_rate_limit': '10', + }, +} + + +def new_config_parser() -> configparser.ConfigParser: + """Create a ConfigParser configured the way E2NB expects. + + Interpolation is disabled so ``%`` may appear in passwords and tokens. + """ + return configparser.ConfigParser(interpolation=None) + + +def ensure_config_sections(config: configparser.ConfigParser) -> bool: + """ + Add any missing sections and keys to a config, preserving existing values. + + A hand-edited or partially written ``config.ini`` used to make callers that + index sections directly (``config['Email']['username'] = ...``) raise + ``KeyError``. Filling in the gaps up front keeps the rest of the code able to + assume the full schema exists. + + Args: + config: ConfigParser instance to normalize in place. + + Returns: + True if anything was added, False if the config was already complete. + """ + changed = False + for section, defaults in DEFAULT_CONFIG_SECTIONS.items(): + if not config.has_section(section): + config.add_section(section) + changed = True + for key, value in defaults.items(): + if not config.has_option(section, key): + config.set(section, key, value) + changed = True + return changed - with open(config_file, 'w') as file: - config.write(file) + +def load_config(config_file: str = CONFIG_FILE_PATH) -> configparser.ConfigParser: + """ + Load configuration from INI file. + + Missing sections and keys are filled in with defaults so callers can rely on + the full schema being present. + + Args: + config_file: Path to the configuration file. + + Returns: + ConfigParser instance with loaded configuration. + + Raises: + ConfigurationError: If configuration cannot be loaded. + """ + config = new_config_parser() + + try: + if not os.path.exists(config_file): + logger.info(f"Config file not found. Creating default at {config_file}") + create_default_config(config_file) + config.read(config_file) + except configparser.Error as e: + raise ConfigurationError(f"Failed to parse configuration file {config_file}: {e}") + except OSError as e: + raise ConfigurationError(f"Failed to load configuration: {e}") + + if ensure_config_sections(config): + logger.info("Configuration was missing sections or keys; defaults applied in memory") + return config + + +def save_config(config: configparser.ConfigParser, config_file: str = CONFIG_FILE_PATH) -> None: + """ + Save configuration to an INI file atomically. + + The config holds credentials, so it is written through a temporary file in + the same directory and moved into place; an interrupted write can therefore + never truncate a working configuration. File permissions are restricted to + the owner where the platform supports it. + + Args: + config: ConfigParser instance to save. + config_file: Path to the configuration file. + + Raises: + ConfigurationError: If configuration cannot be saved. + """ + directory = os.path.dirname(os.path.abspath(config_file)) or '.' + tmp_path = '' + try: + os.makedirs(directory, exist_ok=True) + fd, tmp_path = tempfile.mkstemp(prefix='.config-', suffix='.tmp', dir=directory) + with os.fdopen(fd, 'w') as file: + config.write(file) + file.flush() + os.fsync(file.fileno()) + _restrict_file_permissions(tmp_path) + os.replace(tmp_path, config_file) + tmp_path = '' + logger.info(f"Configuration saved to {config_file}") + except Exception as e: + raise ConfigurationError(f"Failed to save configuration: {e}") + finally: + if tmp_path and os.path.exists(tmp_path): + try: + os.unlink(tmp_path) + except OSError: + pass + + +def _restrict_file_permissions(path: str) -> None: + """Restrict a file to owner read/write where the platform supports it.""" + if os.name == 'nt': + return + try: + os.chmod(path, 0o600) + except OSError as e: + logger.debug(f"Could not restrict permissions on {path}: {e}") + + +def create_default_config(config_file: str = CONFIG_FILE_PATH) -> None: + """ + Create a default configuration file. + + Args: + config_file: Path to the configuration file. + """ + config = new_config_parser() + ensure_config_sections(config) + save_config(config, config_file) # ============================================================================= @@ -3561,6 +3632,21 @@ def _apply_template( if not self.message_templates.enabled: return notification.subject, notification.body + try: + return self._render_template(channel, notification, source_type, severity) + except Exception: + # Templating is a presentation concern; never let it stop delivery. + logger.exception(f"Failed to apply {channel} message template; sending raw content") + return notification.subject, notification.body + + def _render_template( + self, + channel: str, + notification: EmailNotification, + source_type: str, + severity: str, + ) -> Tuple[str, str]: + """Render the subject and body templates for a channel.""" subject_template = self.message_templates.get_subject(channel) body_template = self.message_templates.get_body(channel) @@ -3586,6 +3672,34 @@ def _apply_template( return subject, body + @staticmethod + def _emit( + service: str, + send: Callable[[], NotificationResult], + results: List[NotificationResult], + callback: Optional[Callable[[NotificationResult], None]], + ) -> None: + """ + Run one channel's send function, recording the outcome. + + Any exception is converted into a failed result so that one + misconfigured channel cannot stop the remaining channels from being + notified — the whole point of fanning an alert out to several places. + """ + try: + result = send() + except Exception as e: + logger.exception(f"Unhandled error sending {service} notification") + result = NotificationResult(False, service, f"Unexpected error: {e}") + + results.append(result) + + if callback: + try: + callback(result) + except Exception: + logger.exception("Notification result callback raised") + def dispatch( self, notification: EmailNotification, @@ -3615,141 +3729,122 @@ def dispatch( logger.debug(f"Dispatching to enabled channels: {enabled_channels}") + channel_decisions = {ch: True for ch in enabled_channels} + priority_override = "" + if self.notification_rules.enabled: - channel_decisions = self.notification_rules.get_channel_decisions( - notification, enabled_channels, source_type, severity - ) - else: - channel_decisions = {ch: True for ch in enabled_channels} + # A broken rule set must not silence every channel, so fall back to + # "deliver everywhere" if rule evaluation fails. + try: + channel_decisions = self.notification_rules.get_channel_decisions( + notification, enabled_channels, source_type, severity + ) + # Resolve priority_override from matching rules (first non-empty wins) + for rule in self.notification_rules.get_matching_rules( + notification, source_type, severity + ): + if rule.priority_override: + priority_override = rule.priority_override + break + except Exception: + logger.exception( + "Failed to evaluate notification rules; delivering to all enabled channels" + ) + channel_decisions = {ch: True for ch in enabled_channels} def _should_send(channel_name: str) -> bool: return channel_decisions.get(channel_name, True) - # Resolve priority_override from matching rules (first non-empty wins) - priority_override = "" - if self.notification_rules.enabled: - matching_rules = self.notification_rules.get_matching_rules( - notification, source_type, severity - ) - for rule in matching_rules: - if rule.priority_override: - priority_override = rule.priority_override - break - # SMS via Twilio if self.twilio_sms.enabled and _should_send('sms'): sms_body = notification.truncate_for_sms(self.settings.max_sms_length) for to_number in self.twilio_sms.destination_numbers: - result = send_sms_via_twilio( + self._emit('SMS', lambda n=to_number: send_sms_via_twilio( self.twilio_sms.account_sid, self.twilio_sms.auth_token, self.twilio_sms.from_number, - to_number, + n, sms_body - ) - results.append(result) - if callback: - callback(result) + ), results, callback) # Voice calls via Twilio if self.twilio_voice.enabled and _should_send('voice'): for to_number in self.twilio_voice.destination_numbers: - result = make_voice_call( + self._emit('Voice', lambda n=to_number: make_voice_call( self.twilio_voice.account_sid, self.twilio_voice.auth_token, self.twilio_voice.from_number, - to_number, + n, notification.notification_message - ) - results.append(result) - if callback: - callback(result) + ), results, callback) # WhatsApp via Twilio if self.twilio_whatsapp.enabled and _should_send('whatsapp'): for to_number in self.twilio_whatsapp.destination_numbers: - result = send_whatsapp_message( + self._emit('WhatsApp', lambda n=to_number: send_whatsapp_message( self.twilio_whatsapp.account_sid, self.twilio_whatsapp.auth_token, self.twilio_whatsapp.from_number, - to_number, + n, notification.notification_message - ) - results.append(result) - if callback: - callback(result) + ), results, callback) # Slack channel message if self.slack.enabled and _should_send('slack'): slack_subject, slack_body = self._apply_template('slack', notification, source_type, severity) - result = send_slack_message( + self._emit('Slack', lambda: send_slack_message( self.slack.token, self.slack.channel, slack_subject, slack_body, mention_users=self.slack.mention_users, use_blocks=self.slack.use_blocks, - ) - results.append(result) - if callback: - callback(result) + ), results, callback) # Slack DMs to individual users if self.slack.enabled and self.slack.dm_users and _should_send('slack_dm'): slack_dm_subject, slack_dm_body = self._apply_template('slack_dm', notification, source_type, severity) dm_user_ids = [u.strip() for u in self.slack.dm_users.split(',') if u.strip()] for user_id in dm_user_ids: - result = send_slack_dm( + self._emit('Slack DM', lambda u=user_id: send_slack_dm( self.slack.token, - user_id, + u, slack_dm_subject, slack_dm_body, use_blocks=self.slack.use_blocks, - ) - results.append(result) - if callback: - callback(result) + ), results, callback) # Telegram if self.telegram.enabled and _should_send('telegram'): telegram_subject, telegram_body = self._apply_template('telegram', notification, source_type, severity) - result = send_telegram_message( + self._emit('Telegram', lambda: send_telegram_message( self.telegram.bot_token, self.telegram.chat_id, telegram_subject, telegram_body, self.http_session - ) - results.append(result) - if callback: - callback(result) + ), results, callback) # Discord if self.discord.enabled and _should_send('discord'): discord_subject, discord_body = self._apply_template('discord', notification, source_type, severity) - result = send_discord_message( + self._emit('Discord', lambda: send_discord_message( self.discord.webhook_url, discord_subject, discord_body, self.http_session - ) - results.append(result) - if callback: - callback(result) + ), results, callback) # Microsoft Teams if self.teams.enabled and _should_send('teams'): teams_subject, teams_body = self._apply_template('teams', notification, source_type, severity) - result = send_teams_message( + self._emit('Teams', lambda: send_teams_message( self.teams.webhook_url, teams_subject, teams_body, self.http_session, use_adaptive_card=self.teams.use_adaptive_card, - ) - results.append(result) - if callback: - callback(result) + ), results, callback) # Pushover if self.pushover.enabled and _should_send('pushover'): @@ -3758,7 +3853,7 @@ def _should_send(channel_name: str) -> bool: po_priority = self.pushover.priority if priority_override: po_priority = safe_int(priority_override, po_priority, min_val=-2, max_val=2) - result = send_pushover_notification( + self._emit('Pushover', lambda: send_pushover_notification( self.pushover.api_token, self.pushover.user_key, pushover_subject, @@ -3767,10 +3862,7 @@ def _should_send(channel_name: str) -> bool: sound=self.pushover.sound, device=self.pushover.device, session=self.http_session, - ) - results.append(result) - if callback: - callback(result) + ), results, callback) # Ntfy if self.ntfy.enabled and _should_send('ntfy'): @@ -3785,7 +3877,7 @@ def _should_send(channel_name: str) -> bool: elif priority_override in _ntfy_map: ntfy_priority = _ntfy_map[priority_override] ntfy_subject, ntfy_body = self._apply_template('ntfy', notification, source_type, severity) - result = send_ntfy_notification( + self._emit('Ntfy', lambda: send_ntfy_notification( self.ntfy.server_url, self.ntfy.topic, ntfy_subject, @@ -3794,10 +3886,7 @@ def _should_send(channel_name: str) -> bool: tags=self.ntfy.tags, auth_token=self.ntfy.auth_token, session=self.http_session, - ) - results.append(result) - if callback: - callback(result) + ), results, callback) # Custom Webhook if self.webhook.enabled and _should_send('webhook'): @@ -3810,38 +3899,59 @@ def _should_send(channel_name: str) -> bool: 'source_type': source_type, 'severity': severity } - result = send_custom_webhook( + self._emit('Webhook', lambda: send_custom_webhook( self.webhook.webhook_url, payload, self.http_session - ) - results.append(result) - if callback: - callback(result) + ), results, callback) # SMTP Email if self.smtp.enabled and _should_send('email'): email_subject, email_body = self._apply_template('email', notification, source_type, severity) for to_address in self.smtp.to_addresses: - result = send_email_notification( + self._emit('Email', lambda a=to_address: send_email_notification( self.smtp.smtp_server, self.smtp.smtp_port, self.smtp.username, self.smtp.password, self.smtp.from_address, - to_address, + a, email_subject, email_body, notification.sender, self.smtp.use_tls, self.smtp.subject_prefix - ) - results.append(result) - if callback: - callback(result) + ), results, callback) return results + def dispatch_event( + self, + event: 'MonitorEvent', + callback: Optional[Callable[[NotificationResult], None]] = None, + ) -> List[NotificationResult]: + """ + Dispatch a monitoring event, preserving its source type and severity. + + Routing rules and message templates key off ``source_type`` and + ``severity``; dispatching an RSS or HTTP event through + :meth:`dispatch` directly would label it as an email at default + severity and no source- or severity-specific rule would ever match. + + Args: + event: The monitoring event to send. + callback: Optional callback invoked with each result. + + Returns: + List of NotificationResult objects. + """ + return self.dispatch( + event.to_email_notification(), + callback=callback, + source_type=event.source_type, + severity=event.severity, + ) + def test_imap_connection(config: EmailConfig) -> Tuple[bool, str]: """ @@ -3940,11 +4050,21 @@ class MonitorState: State is persisted to a JSON file. """ + # Keys whose values must be plain dictionaries after loading. + _DICT_KEYS = ( + 'imap_last_uid', 'web_page_hashes', 'http_endpoint_status', + 'database_status', 'log_file_positions', 'log_file_inodes', + ) + def __init__(self, state_file: str = STATE_FILE_PATH): self.state_file = state_file + # Seen-item caches are insertion-ordered dicts (id -> 1) rather than + # sets: eviction has to drop the OLDEST entries, and a set gives no + # ordering to do that with, which previously let recent items be + # evicted and re-notified. self._state: Dict[str, Any] = { - 'seen_rss_items': {}, # feed_url -> set of item IDs - 'seen_pop3_messages': set(), # set of message hashes for POP3 + 'seen_rss_items': {}, # feed_url -> ordered dict of item IDs + 'seen_pop3_messages': {}, # ordered dict of POP3 message hashes 'imap_last_uid': {}, # server:username -> last seen UID 'web_page_hashes': {}, # page_url -> content hash 'http_endpoint_status': {}, # endpoint_url -> last status @@ -3955,91 +4075,130 @@ def __init__(self, state_file: str = STATE_FILE_PATH): } self._load() + @staticmethod + def _as_seen_cache(value: Any) -> Dict[str, int]: + """Coerce a stored value into an ordered seen-item cache. + + Accepts the legacy list format as well as the dict format written by + current versions; anything else yields an empty cache. + """ + if isinstance(value, dict): + return {str(k): 1 for k in value} + if isinstance(value, list): + return {str(k): 1 for k in value} + return {} + def _load(self): - """Load state from file.""" - if os.path.exists(self.state_file): - try: - with open(self.state_file, 'r') as f: - loaded = json.load(f) - # Convert lists back to sets for seen_rss_items - if 'seen_rss_items' in loaded and isinstance(loaded['seen_rss_items'], dict): - loaded['seen_rss_items'] = { - k: set(v) if isinstance(v, list) else set() - for k, v in loaded['seen_rss_items'].items() - } - else: - loaded.pop('seen_rss_items', None) - # Convert list back to set for seen_pop3_messages - if 'seen_pop3_messages' in loaded: - if isinstance(loaded['seen_pop3_messages'], list): - loaded['seen_pop3_messages'] = set(loaded['seen_pop3_messages']) - else: - loaded.pop('seen_pop3_messages', None) - # Ensure dict fields stay as dicts - for key in ('imap_last_uid', 'web_page_hashes', 'http_endpoint_status'): - if key in loaded and not isinstance(loaded[key], dict): - loaded.pop(key, None) - self._state.update(loaded) - except (json.JSONDecodeError, IOError) as e: - logger.warning(f"Could not load state file: {e}") + """Load state from file, ignoring anything malformed.""" + if not os.path.exists(self.state_file): + return + try: + with open(self.state_file, 'r') as f: + loaded = json.load(f) + except (json.JSONDecodeError, OSError, ValueError) as e: + logger.warning(f"Could not load state file: {e}") + return + + if not isinstance(loaded, dict): + logger.warning( + f"State file {self.state_file} is not a JSON object; starting from empty state" + ) + return + + # Seen-item caches + rss_items = loaded.pop('seen_rss_items', None) + if isinstance(rss_items, dict): + self._state['seen_rss_items'] = { + str(k): self._as_seen_cache(v) for k, v in rss_items.items() + } + pop3_items = loaded.pop('seen_pop3_messages', None) + if pop3_items is not None: + self._state['seen_pop3_messages'] = self._as_seen_cache(pop3_items) + + # Everything else must be a dict to be usable + for key in self._DICT_KEYS: + if key in loaded and not isinstance(loaded[key], dict): + logger.warning(f"Ignoring malformed '{key}' entry in state file") + loaded.pop(key, None) + + self._state.update(loaded) def save(self): """Save state to file atomically.""" + tmp_file = self.state_file + '.tmp' try: - # Convert sets to lists for JSON serialization - state_to_save = self._state.copy() - if 'seen_rss_items' in state_to_save: - state_to_save['seen_rss_items'] = { - k: list(v) for k, v in state_to_save['seen_rss_items'].items() - } - if 'seen_pop3_messages' in state_to_save: - state_to_save['seen_pop3_messages'] = list(state_to_save['seen_pop3_messages']) + state_to_save = dict(self._state) + # Persist the ordered caches as lists: they round-trip cleanly and + # keep the on-disk format readable and compact. + state_to_save['seen_rss_items'] = { + k: list(v) for k, v in self._state.get('seen_rss_items', {}).items() + } + state_to_save['seen_pop3_messages'] = list( + self._state.get('seen_pop3_messages', {}) + ) state_to_save['last_updated'] = datetime.now().isoformat() # Write to temp file then rename for atomicity - tmp_file = self.state_file + '.tmp' with open(tmp_file, 'w') as f: - json.dump(state_to_save, f, indent=2) + json.dump(state_to_save, f, indent=2, default=str) + f.flush() + os.fsync(f.fileno()) os.replace(tmp_file, self.state_file) - except IOError as e: + except Exception as e: + # A state file that cannot be written must never take down the + # monitor loop; the worst case is repeated notifications. logger.error(f"Could not save state file: {e}") + if os.path.exists(tmp_file): + try: + os.unlink(tmp_file) + except OSError: + pass + + @staticmethod + def _trim_cache(cache: Dict[str, int], max_items: int) -> int: + """Drop the oldest entries from a seen-item cache. Returns count removed.""" + excess = len(cache) - max_items + if excess <= 0: + return 0 + for key in list(cache)[:excess]: + del cache[key] + return excess def is_rss_item_seen(self, feed_url: str, item_id: str) -> bool: """Check if an RSS item has been seen before.""" - seen = self._state.get('seen_rss_items', {}).get(feed_url, set()) - return item_id in seen + return item_id in self._state.get('seen_rss_items', {}).get(feed_url, {}) def mark_rss_item_seen(self, feed_url: str, item_id: str): """Mark an RSS item as seen.""" - if feed_url not in self._state['seen_rss_items']: - self._state['seen_rss_items'][feed_url] = set() - self._state['seen_rss_items'][feed_url].add(item_id) + feeds = self._state.setdefault('seen_rss_items', {}) + feed_cache = feeds.setdefault(feed_url, {}) + # Re-insert so repeated sightings count as recent for eviction purposes + feed_cache.pop(item_id, None) + feed_cache[item_id] = 1 def is_pop3_message_seen(self, message_hash: str) -> bool: """Check if a POP3 message has been seen before.""" - return message_hash in self._state.get('seen_pop3_messages', set()) + return message_hash in self._state.get('seen_pop3_messages', {}) def mark_pop3_message_seen(self, message_hash: str): """Mark a POP3 message as seen.""" - if 'seen_pop3_messages' not in self._state: - self._state['seen_pop3_messages'] = set() - self._state['seen_pop3_messages'].add(message_hash) + cache = self._state.setdefault('seen_pop3_messages', {}) + cache.pop(message_hash, None) + cache[message_hash] = 1 def cleanup_old_pop3_messages(self, max_items: int = 1000): """Limit stored POP3 message hashes to prevent unbounded growth. - Note: Since sets are unordered, when we exceed max_items we simply - keep an arbitrary subset. For true FIFO behavior, consider using - an OrderedDict or tracking timestamps with each message hash. + The oldest hashes are dropped first, so recently seen messages are + never forgotten (and therefore never re-notified) while the cache is + being trimmed. """ - if 'seen_pop3_messages' in self._state: - items = self._state['seen_pop3_messages'] - if len(items) > max_items: - # Keep only max_items entries - this is arbitrary since sets are unordered - # but prevents unbounded memory growth - items_list = list(items) - self._state['seen_pop3_messages'] = set(items_list[:max_items]) - logger.debug(f"Cleaned up POP3 message cache: kept {max_items} of {len(items_list)} items") + cache = self._state.get('seen_pop3_messages') + if not isinstance(cache, dict): + return + removed = self._trim_cache(cache, max_items) + if removed: + logger.debug(f"Cleaned up POP3 message cache: dropped {removed} oldest entries") def get_imap_last_uid(self, server: str, username: str) -> int: """Get the last seen IMAP UID for persistent tracking.""" @@ -4076,18 +4235,17 @@ def set_http_status(self, url: str, status: Dict[str, Any]): def cleanup_old_rss_items(self, feed_url: str, max_items: int = 1000): """Limit stored RSS items to prevent unbounded growth. - Note: Since sets are unordered, when we exceed max_items we simply - keep an arbitrary subset. For true FIFO behavior, consider using - an OrderedDict or tracking timestamps with each item ID. + The oldest item IDs are dropped first so that items still present in + the feed are not forgotten and re-announced. """ - if feed_url in self._state.get('seen_rss_items', {}): - items = self._state['seen_rss_items'][feed_url] - if len(items) > max_items: - # Keep only max_items entries - this is arbitrary since sets are unordered - # but prevents unbounded memory growth - items_list = list(items) - self._state['seen_rss_items'][feed_url] = set(items_list[:max_items]) - logger.debug(f"Cleaned up RSS item cache for {feed_url}: kept {max_items} of {len(items_list)} items") + cache = self._state.get('seen_rss_items', {}).get(feed_url) + if not isinstance(cache, dict): + return + removed = self._trim_cache(cache, max_items) + if removed: + logger.debug( + f"Cleaned up RSS item cache for {feed_url}: dropped {removed} oldest entries" + ) # Database monitor state methods def get_database_status(self, db_name: str) -> Optional[Dict[str, Any]]: @@ -4194,19 +4352,25 @@ def get_rss_item_id(entry: Any) -> str: def get_rss_entry_date(entry: Any) -> Optional[datetime]: """ - Extract publication date from an RSS entry. + Extract publication date from an RSS entry as naive local time. + + feedparser normalizes ``*_parsed`` fields to UTC. The rest of E2NB works in + naive local time, so the value is converted here; comparing a UTC timestamp + against ``datetime.now()`` would otherwise skew item ages by the machine's + UTC offset and could drop fresh items as "too old". Args: entry: Feedparser entry object. Returns: - Datetime object or None if not available. + Datetime object in naive local time, or None if not available. """ for date_field in ['published_parsed', 'updated_parsed', 'created_parsed']: parsed_date = getattr(entry, date_field, None) if parsed_date: try: - return datetime(*parsed_date[:6]) + utc_dt = datetime(*parsed_date[:6], tzinfo=timezone.utc) + return utc_dt.astimezone().replace(tzinfo=None) except (TypeError, ValueError): continue return None @@ -4467,7 +4631,6 @@ def check_web_pages( # Use web: prefix to avoid key collision with HTTP endpoint monitor state_key = f"web:{url}" last_status = state.get_http_status(state_key) - is_first_check = last_status is None if not success: if notify_on_error: @@ -5449,18 +5612,29 @@ def check_log_files( logger.info(f"Log file '{log_name}' truncated, resetting position") last_position = 0 - # Read new content - with open(log_path, 'r', encoding='utf-8', errors='replace') as f: + # Read new content. The file is opened in binary mode so the stored + # position is an exact byte offset: in text mode, newline + # translation and the replacement of undecodable bytes make + # re-encoding a line an unreliable way to measure how far the read + # got, and the drift eventually re-reads or skips log lines. + with open(log_path, 'rb') as f: f.seek(last_position) lines = [] bytes_read = 0 for _ in range(max_lines): - line = f.readline() - if not line: + raw_line = f.readline() + if not raw_line: break - lines.append(line.rstrip('\n\r')) - bytes_read += len(line.encode('utf-8')) + if not raw_line.endswith(b'\n'): + # readline() only stops without a newline at EOF, so + # this line is still being written. Leave it for the + # next check rather than matching half a line. + break + bytes_read += len(raw_line) + lines.append( + raw_line.decode('utf-8', errors='replace').rstrip('\r\n') + ) new_position = last_position + bytes_read diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..66a5650 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,42 @@ +"""Shared pytest fixtures for the E2NB test suite.""" + +from __future__ import annotations + +import os +import sys + +import pytest + +# Make the project root importable without installing the package. +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import e2nb_core # noqa: E402 + + +@pytest.fixture +def config_path(tmp_path): + """Path to a config file inside a temporary directory.""" + return str(tmp_path / "config.ini") + + +@pytest.fixture +def config(config_path): + """A fully populated default configuration.""" + return e2nb_core.load_config(config_path) + + +@pytest.fixture +def state_path(tmp_path): + """Path to a monitor state file inside a temporary directory.""" + return str(tmp_path / "state.json") + + +@pytest.fixture +def notification(): + """A representative email notification.""" + return e2nb_core.EmailNotification( + email_id=b"1", + sender="alerts@example.com", + subject="Disk usage critical", + body="The primary volume is at 95% capacity.", + ) diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..cf48256 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,102 @@ +"""Tests for configuration loading, saving, and schema completion.""" + +from __future__ import annotations + +import os + +import pytest + +import e2nb_core +from e2nb_core import ( + ConfigurationError, + DEFAULT_CONFIG_SECTIONS, + ensure_config_sections, + load_config, + new_config_parser, + save_config, +) + + +def test_load_config_creates_file_with_full_schema(config_path): + config = load_config(config_path) + + assert os.path.exists(config_path) + for section in DEFAULT_CONFIG_SECTIONS: + assert config.has_section(section), f"missing section {section}" + + +def test_load_config_preserves_existing_values(config_path): + with open(config_path, "w") as handle: + handle.write("[Email]\nusername = me@example.com\npassword = secret%value\n") + + config = load_config(config_path) + + assert config["Email"]["username"] == "me@example.com" + # Interpolation is disabled so % is safe in credentials. + assert config["Email"]["password"] == "secret%value" + + +def test_load_config_fills_missing_sections(config_path): + """A hand-trimmed config must not make section access raise KeyError.""" + with open(config_path, "w") as handle: + handle.write("[Email]\nusername = me@example.com\n") + + config = load_config(config_path) + + # This is exactly what the GUI does when saving settings. + config["Voice"]["enabled"] = "True" + config["LogMonitor"]["logs"] = "[]" + assert config["Email"]["username"] == "me@example.com" + + +def test_ensure_config_sections_reports_changes(): + config = new_config_parser() + assert ensure_config_sections(config) is True + # Second call is a no-op because everything is already present. + assert ensure_config_sections(config) is False + + +def test_load_config_raises_on_malformed_file(config_path): + with open(config_path, "w") as handle: + handle.write("this is not an ini file\n= broken\n") + + with pytest.raises(ConfigurationError): + load_config(config_path) + + +def test_save_config_round_trips(config_path): + config = load_config(config_path) + config["Slack"]["token"] = "xoxb-test-token" + save_config(config, config_path) + + reloaded = load_config(config_path) + assert reloaded["Slack"]["token"] == "xoxb-test-token" + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX permissions only") +def test_save_config_restricts_permissions(config_path): + config = load_config(config_path) + save_config(config, config_path) + + mode = os.stat(config_path).st_mode & 0o777 + assert mode == 0o600, f"config holds credentials but is mode {oct(mode)}" + + +def test_save_config_leaves_original_intact_on_failure(config_path, monkeypatch): + config = load_config(config_path) + config["Slack"]["token"] = "original-token" + save_config(config, config_path) + + def boom(*args, **kwargs): + raise OSError("disk full") + + monkeypatch.setattr(e2nb_core.configparser.ConfigParser, "write", boom) + config["Slack"]["token"] = "replacement-token" + + with pytest.raises(ConfigurationError): + save_config(config, config_path) + + # The previous configuration survives a failed write. + assert load_config(config_path)["Slack"]["token"] == "original-token" + leftovers = [n for n in os.listdir(os.path.dirname(config_path)) if n.endswith(".tmp")] + assert leftovers == [], f"temp files left behind: {leftovers}" diff --git a/tests/test_dispatcher.py b/tests/test_dispatcher.py new file mode 100644 index 0000000..25476fa --- /dev/null +++ b/tests/test_dispatcher.py @@ -0,0 +1,286 @@ +"""Tests for NotificationDispatcher fan-out, routing, and failure isolation. + +Every outbound sender is monkeypatched, so nothing here touches the network. +""" + +from __future__ import annotations + +import json + +import pytest + +import e2nb_core +from e2nb_core import MonitorEvent, NotificationDispatcher, NotificationResult + +# Sender function name -> service label used in results. +SENDERS = { + "send_sms_via_twilio": "SMS", + "make_voice_call": "Voice", + "send_whatsapp_message": "WhatsApp", + "send_slack_message": "Slack", + "send_slack_dm": "Slack DM", + "send_telegram_message": "Telegram", + "send_discord_message": "Discord", + "send_teams_message": "Teams", + "send_pushover_notification": "Pushover", + "send_ntfy_notification": "Ntfy", + "send_custom_webhook": "Webhook", + "send_email_notification": "Email", +} + + +@pytest.fixture +def sent(monkeypatch): + """Replace every sender with a recorder and return the call log.""" + calls = [] + + def make_stub(name, service): + def stub(*args, **kwargs): + calls.append({"sender": name, "args": args, "kwargs": kwargs}) + return NotificationResult(True, service, "recorded") + + return stub + + for name, service in SENDERS.items(): + monkeypatch.setattr(e2nb_core, name, make_stub(name, service)) + return calls + + +@pytest.fixture +def all_channels_config(config): + """A config with every notification channel enabled and populated.""" + config["Twilio"].update( + { + "enabled": "True", + "account_sid": "AC" + "0" * 32, + "auth_token": "token", + "from_number": "+12025550100", + "destination_number": "+12025550142", + } + ) + config["Slack"].update({"enabled": "True", "token": "xoxb-x", "channel": "#alerts"}) + config["Telegram"].update({"enabled": "True", "bot_token": "1:abc", "chat_id": "-100"}) + config["Discord"].update( + {"enabled": "True", "webhook_url": "https://discord.com/api/webhooks/1/x"} + ) + return config + + +def services(calls): + return [call["sender"] for call in calls] + + +def test_dispatch_reaches_every_enabled_channel(all_channels_config, notification, sent): + dispatcher = NotificationDispatcher(all_channels_config) + + results = dispatcher.dispatch(notification) + + assert set(services(sent)) == { + "send_sms_via_twilio", + "send_slack_message", + "send_telegram_message", + "send_discord_message", + } + assert all(result.success for result in results) + + +def test_disabled_channels_are_skipped(all_channels_config, notification, sent): + all_channels_config["Slack"]["enabled"] = "False" + dispatcher = NotificationDispatcher(all_channels_config) + + dispatcher.dispatch(notification) + + assert "send_slack_message" not in services(sent) + + +def test_dispatch_without_channels_does_nothing(config, notification, sent): + dispatcher = NotificationDispatcher(config) + + assert dispatcher.dispatch(notification) == [] + assert sent == [] + + +def test_sms_goes_to_every_destination(all_channels_config, notification, sent): + all_channels_config["Twilio"]["destination_number"] = "+12025550142, +12025550143" + dispatcher = NotificationDispatcher(all_channels_config) + + dispatcher.dispatch(notification) + + sms_calls = [c for c in sent if c["sender"] == "send_sms_via_twilio"] + assert len(sms_calls) == 2 + + +def test_sms_body_is_truncated_to_the_configured_length(all_channels_config, sent): + all_channels_config["Settings"]["max_sms_length"] = "120" + notification = e2nb_core.EmailNotification( + email_id=b"1", sender="a@example.com", subject="Subject", body="x" * 500 + ) + dispatcher = NotificationDispatcher(all_channels_config) + + dispatcher.dispatch(notification) + + sms_body = [c for c in sent if c["sender"] == "send_sms_via_twilio"][0]["args"][4] + assert len(sms_body) == 120 + assert sms_body.endswith("...") + + +def test_sms_length_is_clamped_to_a_usable_minimum(all_channels_config): + """A nonsensically small limit falls back to the supported minimum.""" + all_channels_config["Settings"]["max_sms_length"] = "5" + assert NotificationDispatcher(all_channels_config).settings.max_sms_length == 100 + + +def test_one_failing_channel_does_not_block_the_others( + all_channels_config, notification, sent, monkeypatch +): + """A channel raising must not abort the fan-out. + + Alerts are sent to several places precisely so that one broken integration + is survivable. + """ + + def explode(*args, **kwargs): + raise RuntimeError("SDK blew up") + + monkeypatch.setattr(e2nb_core, "send_slack_message", explode) + dispatcher = NotificationDispatcher(all_channels_config) + + results = dispatcher.dispatch(notification) + + assert "send_discord_message" in services(sent) + assert "send_telegram_message" in services(sent) + failures = [r for r in results if not r.success] + assert len(failures) == 1 + assert "SDK blew up" in failures[0].message + + +def test_callback_errors_do_not_abort_dispatch(all_channels_config, notification, sent): + dispatcher = NotificationDispatcher(all_channels_config) + seen = [] + + def callback(result): + seen.append(result) + raise ValueError("callback bug") + + results = dispatcher.dispatch(notification, callback=callback) + + assert len(seen) == len(results) > 1 + + +def test_routing_rules_limit_the_fan_out(all_channels_config, notification, sent): + all_channels_config["NotificationRules"]["enabled"] = "True" + all_channels_config["NotificationRules"]["rules"] = json.dumps( + [{"name": "slack only", "channels": "slack"}] + ) + dispatcher = NotificationDispatcher(all_channels_config) + + dispatcher.dispatch(notification) + + assert services(sent) == ["send_slack_message"] + + +def test_broken_rules_fall_back_to_delivering_everywhere( + all_channels_config, notification, sent, monkeypatch +): + all_channels_config["NotificationRules"]["enabled"] = "True" + dispatcher = NotificationDispatcher(all_channels_config) + + def explode(*args, **kwargs): + raise RuntimeError("rule engine failure") + + monkeypatch.setattr(dispatcher.notification_rules, "get_channel_decisions", explode) + + dispatcher.dispatch(notification) + + assert len(sent) == 4, "a rule failure must not silence every channel" + + +def test_dispatch_event_preserves_source_type_and_severity( + all_channels_config, sent +): + """Rules keyed on source type must apply to non-email sources. + + Dispatching a monitor event as a plain email notification would label it + source_type="email", and no RSS- or severity-specific rule would match. + """ + all_channels_config["NotificationRules"]["enabled"] = "True" + all_channels_config["NotificationRules"]["rules"] = json.dumps( + [{"name": "no rss on sms", "source_type": "rss", "channels": "sms", "suppress": True}] + ) + dispatcher = NotificationDispatcher(all_channels_config) + event = MonitorEvent( + source_type="rss", + source_name="Example Feed", + title="New post", + body="Body text", + severity="info", + ) + + dispatcher.dispatch_event(event) + + assert "send_sms_via_twilio" not in services(sent) + assert "send_slack_message" in services(sent) + + +def test_dispatch_event_applies_severity_rules(all_channels_config, sent): + all_channels_config["NotificationRules"]["enabled"] = "True" + all_channels_config["NotificationRules"]["rules"] = json.dumps( + [{"name": "errors to sms", "severity": "error", "channels": "sms"}] + ) + dispatcher = NotificationDispatcher(all_channels_config) + event = MonitorEvent( + source_type="http", + source_name="API", + title="Endpoint down", + body="connection refused", + severity="error", + ) + + dispatcher.dispatch_event(event) + + assert services(sent) == ["send_sms_via_twilio"] + + +def test_templates_are_applied_when_enabled(all_channels_config, notification, sent): + all_channels_config["MessageTemplates"]["enabled"] = "True" + all_channels_config["MessageTemplates"]["channel_templates"] = json.dumps( + {"slack": {"subject": "ALERT: {title}", "body": "{body}"}} + ) + dispatcher = NotificationDispatcher(all_channels_config) + + dispatcher.dispatch(notification) + + slack_call = [c for c in sent if c["sender"] == "send_slack_message"][0] + assert slack_call["args"][2] == "ALERT: Disk usage critical" + + +def test_template_failure_falls_back_to_raw_content( + all_channels_config, notification, sent, monkeypatch +): + all_channels_config["MessageTemplates"]["enabled"] = "True" + dispatcher = NotificationDispatcher(all_channels_config) + + def explode(*args, **kwargs): + raise RuntimeError("template engine failure") + + monkeypatch.setattr(dispatcher.message_templates, "get_subject", explode) + + dispatcher.dispatch(notification) + + slack_call = [c for c in sent if c["sender"] == "send_slack_message"][0] + assert slack_call["args"][2] == notification.subject + + +def test_has_any_enabled_is_false_for_a_fresh_config(config): + assert NotificationDispatcher(config).has_any_enabled() is False + + +def test_has_any_enabled_is_true_once_a_channel_is_on(all_channels_config): + assert NotificationDispatcher(all_channels_config).has_any_enabled() is True + + +def test_close_is_idempotent(config): + dispatcher = NotificationDispatcher(config) + dispatcher.http_session # force creation + dispatcher.close() + dispatcher.close() diff --git a/tests/test_email_processing.py b/tests/test_email_processing.py new file mode 100644 index 0000000..2bb5c68 --- /dev/null +++ b/tests/test_email_processing.py @@ -0,0 +1,123 @@ +"""Tests for parsing incoming email and applying sender filters.""" + +from __future__ import annotations + +import email as email_module +from email.header import Header +from email.message import EmailMessage + +import pytest + +from e2nb_core import ( + _strip_html, + check_email_filter, + decode_email_subject, + extract_email_body, + get_sender_email, +) + + +def build_message(subject="Subject", sender="sender@example.com", body="Body text"): + message = EmailMessage() + message["Subject"] = subject + message["From"] = sender + message.set_content(body) + return message + + +def test_plain_text_body_is_extracted(): + assert "Body text" in extract_email_body(build_message(body="Body text")) + + +def test_html_only_body_is_converted_to_text(): + message = EmailMessage() + message["Subject"] = "HTML" + message["From"] = "a@example.com" + message.set_content("

Hello world

", subtype="html") + + body = extract_email_body(message) + + assert "Hello" in body and "world" in body + assert "

" not in body + + +def test_multipart_prefers_plain_text(): + message = EmailMessage() + message["Subject"] = "Multipart" + message["From"] = "a@example.com" + message.set_content("The plain part") + message.add_alternative("

The HTML part

", subtype="html") + + assert "The plain part" in extract_email_body(message) + + +def test_body_extraction_survives_bad_encoding(): + raw = ( + b"From: a@example.com\r\n" + b"Subject: Broken\r\n" + b"Content-Type: text/plain; charset=utf-8\r\n" + b"\r\n" + b"caf\xe9 not valid utf-8\r\n" + ) + message = email_module.message_from_bytes(raw) + + body = extract_email_body(message) + + assert "not valid utf-8" in body + + +def test_subject_is_decoded_from_encoded_words(): + message = build_message(subject=str(Header("Grüße aus München", "utf-8"))) + assert decode_email_subject(message) == "Grüße aus München" + + +def test_missing_subject_has_a_placeholder(): + message = EmailMessage() + message["From"] = "a@example.com" + message.set_content("body") + + assert decode_email_subject(message) + + +def test_sender_is_extracted_from_display_name_form(): + message = build_message(sender="Alerts Bot ") + assert get_sender_email(message) == "alerts@example.com" + + +@pytest.mark.parametrize( + "sender,filters,expected", + [ + ("anyone@example.com", [], True), + ("boss@company.com", ["boss@company.com"], True), + ("BOSS@Company.com", ["boss@company.com"], True), + ("other@company.com", ["boss@company.com"], False), + ("anyone@trusted.com", ["@trusted.com"], True), + ("anyone@untrusted.com", ["@trusted.com"], False), + ("someone@sub.trusted.com", ["@trusted.com"], False), + ("a@b.com", ["@trusted.com", "a@b.com"], True), + ], +) +def test_check_email_filter(sender, filters, expected): + assert check_email_filter(sender, filters) is expected + + +def test_domain_filter_does_not_match_a_lookalike_suffix(): + """'@trusted.com' must not accept 'eviltrusted.com'.""" + assert check_email_filter("attacker@eviltrusted.com", ["@trusted.com"]) is False + + +def test_strip_html_removes_scripts_and_styles(): + html = """ + +

Visible text

+ """ + + text = _strip_html(html) + + assert "Visible text" in text + assert "alert" not in text + assert "color: red" not in text + + +def test_strip_html_unescapes_entities(): + assert "AT&T" in _strip_html("

AT&T

") diff --git a/tests/test_gui.py b/tests/test_gui.py new file mode 100644 index 0000000..e463fdc --- /dev/null +++ b/tests/test_gui.py @@ -0,0 +1,216 @@ +"""GUI smoke tests. + +These build the real Tk application against a temporary config file. They are +skipped automatically when tkinter or a display is unavailable, so the rest of +the suite still runs on a headless machine without X. +""" + +from __future__ import annotations + +import json +import os + +import pytest + +tk = pytest.importorskip("tkinter", reason="tkinter is not installed") + + +@pytest.fixture +def app(tmp_path, monkeypatch): + """A fully built application rooted in a temporary working directory.""" + monkeypatch.chdir(tmp_path) + + import e2nb_core + + monkeypatch.setattr(e2nb_core, "CONFIG_FILE_PATH", str(tmp_path / "config.ini")) + + import e2nb + + try: + root = tk.Tk() + except tk.TclError as exc: # pragma: no cover - depends on the environment + pytest.skip(f"no display available: {exc}") + + root.withdraw() + application = e2nb.EmailMonitorApp(root) + root.update_idletasks() + try: + yield application + finally: + root.destroy() + + +def test_every_navigation_target_has_a_page(app): + """Clicking a sidebar entry must land somewhere.""" + missing = sorted(set(app.nav_items) - set(app.pages)) + assert missing == [], f"navigation items without a page: {missing}" + + +def test_every_page_has_a_title_and_section(app): + missing = sorted(set(app.pages) - set(app.PAGE_META)) + assert missing == [], f"pages without title metadata: {missing}" + + +def test_switching_pages_updates_the_header(app): + app._show_page("database") + + assert app.current_page == "database" + assert app.page_title.cget("text") == "Databases" + assert app._breadcrumb.cget("text") == "Sources" + assert app.pages["database"].winfo_ismapped() or True # root is withdrawn + + +def test_all_pages_can_be_shown(app): + for name in app.pages: + app._show_page(name) + assert app.current_page == name + + +def test_database_and_log_sources_are_present(app): + """The GUI must cover the same sources the daemon supports.""" + for key in ("email", "rss", "webmon", "httpmon", "database", "logmon"): + assert key in app.nav_items, f"{key} is missing from the sidebar" + + +def test_saving_writes_every_section(app, tmp_path): + app.dbmon_var.set(True) + app.dbmon_databases_text.delete("1.0", tk.END) + app.dbmon_databases_text.insert( + "1.0", json.dumps([{"name": "db", "type": "sqlite", "path": "/tmp/x.db"}]) + ) + app.logmon_var.set(True) + app.logmon_logs_text.delete("1.0", tk.END) + app.logmon_logs_text.insert( + "1.0", json.dumps([{"name": "app", "path": "/var/log/app.log", "patterns": ["ERROR"]}]) + ) + + assert app._save_settings() is True + + import e2nb_core + + saved = e2nb_core.load_config(str(tmp_path / "config.ini")) + assert saved["DatabaseMonitor"]["enabled"] == "True" + assert json.loads(saved["DatabaseMonitor"]["databases"])[0]["name"] == "db" + assert saved["LogMonitor"]["enabled"] == "True" + assert json.loads(saved["LogMonitor"]["logs"])[0]["path"] == "/var/log/app.log" + + +def test_saving_recovers_from_a_config_missing_sections(app, tmp_path): + """A hand-trimmed config.ini must not break the Save button.""" + del app.config["Voice"] + del app.config["LogMonitor"] + + assert app._save_settings() is True + + import e2nb_core + + saved = e2nb_core.load_config(str(tmp_path / "config.ini")) + assert saved.has_section("Voice") + assert saved.has_section("LogMonitor") + + +def test_editing_marks_settings_unsaved(app): + assert app.unsaved_changes is False + + app.slack_var.set(not app.slack_var.get()) + + assert app.unsaved_changes is True + assert "Unsaved" in app._status_right.cget("text") + + +def test_saving_clears_the_unsaved_marker(app): + app.slack_var.set(not app.slack_var.get()) + app._save_settings() + + assert app.unsaved_changes is False + assert "Unsaved" not in app._status_right.cget("text") + + +def test_start_is_refused_without_a_notification_channel(app): + for var in app._service_vars(): + var.set(False) + + assert app._validate() is False + + +def test_start_is_refused_without_a_source(app): + app.slack_var.set(True) + app.username.set("") + app.password.set("") + for var in (app.smtp_recv_var, app.rss_var, app.webmon_var, + app.httpmon_var, app.dbmon_var, app.logmon_var): + var.set(False) + + assert app._validate() is False + + +def test_validation_passes_with_a_channel_and_a_source(app): + app.slack_var.set(True) + app.logmon_var.set(True) + + assert app._validate() is True + + +def test_invalid_json_is_reported_not_raised(app): + app.logmon_logs_text.delete("1.0", tk.END) + app.logmon_logs_text.insert("1.0", "{not json") + + assert app._parse_json_list(app.logmon_logs_text, "logs") is None + + +def test_json_object_instead_of_array_is_rejected(app): + app.dbmon_databases_text.delete("1.0", tk.END) + app.dbmon_databases_text.insert("1.0", '{"name": "db"}') + + assert app._parse_json_list(app.dbmon_databases_text, "databases") is None + + +def test_valid_json_parses(app): + app.dbmon_databases_text.delete("1.0", tk.END) + app.dbmon_databases_text.insert("1.0", '[{"name": "db"}]') + + assert app._parse_json_list(app.dbmon_databases_text, "databases") == [{"name": "db"}] + + +def test_protocol_switch_updates_visible_sections(app): + app.protocol_var.set("POP3") + app._on_protocol_changed() + assert app._pop3_section.winfo_manager() != "" + + app.protocol_var.set("IMAP") + app._on_protocol_changed() + assert app._imap_section.winfo_manager() != "" + + +def test_log_panel_records_messages(app): + app._log("hello from the test", "INFO") + app._append_log("direct message", "SUCCESS") + + contents = app.log_text.get("1.0", tk.END) + assert "direct message" in contents + + +def test_next_check_delay_never_busy_loops(app): + import time as _time + + now = _time.monotonic() + assert app._seconds_until_next_check({"email": now - 100}) >= 1.0 + assert app._seconds_until_next_check({"email": 0.0}) > 0 + assert app._seconds_until_next_check({"email": now + 10_000}) <= 60 + + +def test_source_badge_counts_enabled_sources(app): + for var in app._source_vars(): + var.set(False) + app.rss_var.set(True) + app.logmon_var.set(True) + + assert "2 / 6" in app._sources_badge._label.cget("text") + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX permissions only") +def test_saved_config_is_not_world_readable(app, tmp_path): + app._save_settings() + + mode = os.stat(tmp_path / "config.ini").st_mode & 0o777 + assert mode == 0o600 diff --git a/tests/test_log_monitor.py b/tests/test_log_monitor.py new file mode 100644 index 0000000..3198afc --- /dev/null +++ b/tests/test_log_monitor.py @@ -0,0 +1,253 @@ +"""Tests for log file monitoring.""" + +from __future__ import annotations + +import json +import os + +import pytest + +from e2nb_core import LogMonitorConfig, MonitorState, check_log_files + + +@pytest.fixture +def log_file(tmp_path): + path = tmp_path / "app.log" + path.write_text("") + return path + + +def append(path, *lines): + with open(path, "a", encoding="utf-8") as handle: + for line in lines: + handle.write(line + "\n") + + +def make_config(path, **overrides): + log_entry = {"name": "app", "path": str(path), "patterns": ["ERROR"]} + log_entry.update(overrides) + return LogMonitorConfig(enabled=True, logs=[log_entry]) + + +def test_matching_lines_become_events(log_file, state_path): + append(log_file, "INFO all good", "ERROR something broke") + + events = check_log_files(make_config(log_file), MonitorState(state_path)) + + assert len(events) == 1 + assert "something broke" in events[0].body + assert events[0].source_type == "log" + assert events[0].source_name == "app" + + +def test_only_new_lines_are_reported(log_file, state_path): + state = MonitorState(state_path) + append(log_file, "ERROR first") + assert len(check_log_files(make_config(log_file), state)) == 1 + + # Nothing new appended: nothing to report. + assert check_log_files(make_config(log_file), state) == [] + + append(log_file, "ERROR second") + events = check_log_files(make_config(log_file), state) + assert len(events) == 1 + assert "second" in events[0].body + + +def test_position_survives_a_restart(log_file, state_path): + append(log_file, "ERROR first") + check_log_files(make_config(log_file), MonitorState(state_path)).clear() + MonitorState(state_path) # ensure file exists + + state = MonitorState(state_path) + state.set_log_file_position(str(log_file), os.path.getsize(log_file)) + state.set_log_file_inode(str(log_file), os.stat(log_file).st_ino) + state.save() + + append(log_file, "ERROR second") + events = check_log_files(make_config(log_file), MonitorState(state_path)) + + assert len(events) == 1 + assert "second" in events[0].body + + +def test_non_utf8_bytes_do_not_desynchronise_the_read_position(log_file, state_path): + """Byte offsets must stay exact even when a line cannot be decoded. + + Measuring progress by re-encoding decoded text drifts whenever a byte is + replaced with U+FFFD, and the drift makes later checks re-read old lines + and notify about them again. + """ + state = MonitorState(state_path) + with open(log_file, "ab") as handle: + handle.write(b"ERROR caf\xe9 broken encoding\n") + assert len(check_log_files(make_config(log_file), state)) == 1 + + append(log_file, "ERROR next line") + events = check_log_files(make_config(log_file), state) + + assert len(events) == 1, "a previously reported line was re-reported" + assert "next line" in events[0].body + + +def test_windows_line_endings_do_not_desynchronise_the_position(log_file, state_path): + state = MonitorState(state_path) + with open(log_file, "ab") as handle: + handle.write(b"ERROR windows line\r\n") + assert len(check_log_files(make_config(log_file), state)) == 1 + + with open(log_file, "ab") as handle: + handle.write(b"ERROR another\r\n") + events = check_log_files(make_config(log_file), state) + + assert len(events) == 1 + assert events[0].body.endswith("another") + + +def test_partial_final_line_is_held_until_complete(log_file, state_path): + state = MonitorState(state_path) + with open(log_file, "a", encoding="utf-8") as handle: + handle.write("ERROR half a li") + + assert check_log_files(make_config(log_file), state) == [] + + with open(log_file, "a", encoding="utf-8") as handle: + handle.write("ne here\n") + events = check_log_files(make_config(log_file), state) + + assert len(events) == 1 + assert events[0].body == "ERROR half a line here" + + +def test_rotation_is_detected_and_the_position_resets(log_file, state_path, tmp_path): + state = MonitorState(state_path) + append(log_file, "ERROR before rotation") + assert len(check_log_files(make_config(log_file), state)) == 1 + + # Rotate: move the old file aside and create a fresh one (new inode). + os.rename(log_file, tmp_path / "app.log.1") + append(log_file, "ERROR after rotation") + + events = check_log_files(make_config(log_file), state) + + assert len(events) == 1 + assert "after rotation" in events[0].body + + +def test_truncation_resets_the_position(log_file, state_path): + state = MonitorState(state_path) + append(log_file, "ERROR line one", "ERROR line two") + assert len(check_log_files(make_config(log_file), state)) == 2 + + with open(log_file, "w", encoding="utf-8") as handle: + handle.write("ERROR fresh start\n") + + events = check_log_files(make_config(log_file), state) + assert len(events) == 1 + assert "fresh start" in events[0].body + + +def test_exclude_patterns_filter_matches(log_file, state_path): + append(log_file, "ERROR healthcheck ping", "ERROR real problem") + + events = check_log_files( + make_config(log_file, exclude_patterns=["healthcheck"]), MonitorState(state_path) + ) + + assert len(events) == 1 + assert "real problem" in events[0].body + + +def test_severity_is_detected_from_the_line(log_file, state_path): + append(log_file, "CRITICAL disk failure") + + events = check_log_files( + make_config(log_file, patterns=["CRITICAL"]), MonitorState(state_path) + ) + + assert events[0].severity == "critical" + + +def test_explicit_severity_patterns_take_precedence(log_file, state_path): + append(log_file, "ERROR minor blip") + + events = check_log_files( + make_config(log_file, severity_patterns={"warning": "minor"}), + MonitorState(state_path), + ) + + assert events[0].severity == "warning" + + +def test_rate_limit_caps_events_per_check(log_file, state_path): + append(log_file, *[f"ERROR problem {i}" for i in range(20)]) + + events = check_log_files( + make_config(log_file, rate_limit=5), MonitorState(state_path) + ) + + assert len(events) == 5 + + +def test_one_event_per_line_even_with_several_patterns(log_file, state_path): + append(log_file, "ERROR and CRITICAL on one line") + + events = check_log_files( + make_config(log_file, patterns=["ERROR", "CRITICAL"]), MonitorState(state_path) + ) + + assert len(events) == 1 + + +def test_missing_file_is_skipped_quietly(tmp_path, state_path): + config = make_config(tmp_path / "does-not-exist.log") + assert check_log_files(config, MonitorState(state_path)) == [] + + +def test_invalid_pattern_does_not_raise(log_file, state_path): + append(log_file, "ERROR something") + + events = check_log_files( + make_config(log_file, patterns=["[unterminated"]), MonitorState(state_path) + ) + + assert events == [] + + +def test_disabled_config_does_nothing(log_file, state_path): + append(log_file, "ERROR something") + config = make_config(log_file) + config.enabled = False + + assert check_log_files(config, MonitorState(state_path)) == [] + + +def test_config_parses_json_log_list(config): + config["LogMonitor"]["enabled"] = "True" + config["LogMonitor"]["logs"] = json.dumps( + [{"name": "app", "path": "/var/log/app.log", "patterns": ["ERROR"]}] + ) + + parsed = LogMonitorConfig.from_config(config) + + assert parsed.enabled is True + assert parsed.logs[0]["path"] == "/var/log/app.log" + + +def test_config_parses_per_log_sections(config): + config["LogMonitor"]["enabled"] = "True" + config.add_section("LogMonitor.web") + config["LogMonitor.web"]["path"] = "/var/log/nginx/error.log" + config["LogMonitor.web"]["patterns"] = json.dumps(["ERROR", "crit"]) + config["LogMonitor.web"]["rate_limit"] = "3" + + parsed = LogMonitorConfig.from_config(config) + + entry = [log for log in parsed.logs if log["name"] == "web"][0] + assert entry["patterns"] == ["ERROR", "crit"] + assert entry["rate_limit"] == 3 + + +def test_config_entry_without_path_is_dropped(config): + config["LogMonitor"]["logs"] = json.dumps([{"name": "no path", "patterns": ["ERROR"]}]) + assert LogMonitorConfig.from_config(config).logs == [] diff --git a/tests/test_monitors.py b/tests/test_monitors.py new file mode 100644 index 0000000..4492fbf --- /dev/null +++ b/tests/test_monitors.py @@ -0,0 +1,158 @@ +"""Tests for the monitoring sources that do not need network access.""" + +from __future__ import annotations + +import time +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace + +import pytest + +from e2nb_core import ( + MonitorEvent, + compute_content_hash, + evaluate_expected_value, + get_rss_entry_date, + get_rss_item_id, +) + + +# --------------------------------------------------------------------------- +# RSS helpers +# --------------------------------------------------------------------------- + +def test_item_id_prefers_the_entry_id(): + entry = SimpleNamespace(id="urn:uuid:1", link="https://example.com/a", title="A") + assert get_rss_item_id(entry) == "urn:uuid:1" + + +def test_item_id_falls_back_to_link_then_title(): + assert get_rss_item_id(SimpleNamespace(link="https://example.com/a")) == ( + "https://example.com/a" + ) + hashed = get_rss_item_id(SimpleNamespace(title="A title", published="yesterday")) + assert isinstance(hashed, str) and len(hashed) == 32 + + +def test_item_id_is_stable_for_the_same_entry(): + entry = SimpleNamespace(title="A title", published="Mon, 01 Jan 2024 00:00:00 GMT") + assert get_rss_item_id(entry) == get_rss_item_id(entry) + + +@pytest.mark.skipif(not hasattr(time, "tzset"), reason="TZ switching needs POSIX tzset") +@pytest.mark.parametrize("tz", ["Australia/Sydney", "America/Los_Angeles", "UTC"]) +def test_entry_date_converts_utc_to_local_time(monkeypatch, tz): + """feedparser reports UTC; comparing that against local now() skews ages. + + A feed item published "just now" must not look hours old (or hours in the + future) purely because the machine is not on UTC — an item wrongly judged + too old is dropped and never announced. + """ + monkeypatch.setenv("TZ", tz) + time.tzset() + try: + published_utc = datetime.now(timezone.utc) - timedelta(minutes=5) + entry = SimpleNamespace(published_parsed=published_utc.timetuple()) + + parsed = get_rss_entry_date(entry) + + assert parsed is not None + age = datetime.now() - parsed + assert timedelta(minutes=4) < age < timedelta(minutes=6), ( + f"item aged {age} in {tz} instead of ~5 minutes; UTC offset was not applied" + ) + finally: + monkeypatch.undo() + time.tzset() + + +def test_entry_date_prefers_published_over_updated(): + published = time.struct_time((2024, 1, 1, 0, 0, 0, 0, 1, 0)) + updated = time.struct_time((2024, 6, 1, 0, 0, 0, 0, 1, 0)) + entry = SimpleNamespace(published_parsed=published, updated_parsed=updated) + + assert get_rss_entry_date(entry).year == 2024 + assert get_rss_entry_date(entry).month == 1 + + +def test_entry_date_is_none_without_dates(): + assert get_rss_entry_date(SimpleNamespace(title="no dates")) is None + + +def test_entry_date_tolerates_malformed_values(): + assert get_rss_entry_date(SimpleNamespace(published_parsed=("not", "a", "date"))) is None + + +# --------------------------------------------------------------------------- +# Web page hashing +# --------------------------------------------------------------------------- + +def test_content_hash_is_stable_and_distinct(): + assert compute_content_hash("hello") == compute_content_hash("hello") + assert compute_content_hash("hello") != compute_content_hash("hello!") + + +# --------------------------------------------------------------------------- +# Database expectations +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize( + "result,expected,matches", + [ + ([(5,)], "5", True), + ([(5,)], "=5", True), + ([(5,)], ">0", True), + ([(0,)], ">0", False), + ([(5,)], ">=5", True), + ([(5,)], "<10", True), + ([(5,)], "<=4", False), + ([(5,)], "!=5", False), + ([(5,)], "<>4", True), + ([("ok",)], "ok", True), + ([("ok",)], "degraded", False), + ([("ok",)], "!=degraded", True), + ], +) +def test_evaluate_expected_value(result, expected, matches): + assert evaluate_expected_value(result, expected)[0] is matches + + +def test_empty_result_matches_empty_expectations(): + assert evaluate_expected_value([], "empty")[0] is True + assert evaluate_expected_value([], "none")[0] is True + assert evaluate_expected_value([], ">0")[0] is False + + +def test_evaluation_describes_the_comparison(): + _, description = evaluate_expected_value([(7,)], ">10") + assert "7" in description and "10" in description + + +# --------------------------------------------------------------------------- +# Monitor events +# --------------------------------------------------------------------------- + +def test_event_converts_to_a_notification(): + event = MonitorEvent( + source_type="http", + source_name="API health", + title="Endpoint down", + body="connection refused", + severity="error", + url="https://api.example.com/health", + ) + + notification = event.to_email_notification() + + assert notification.sender == "API health" + assert notification.subject == "[HTTP] Endpoint down" + assert notification.body == "connection refused" + assert isinstance(notification.email_id, bytes) + + +def test_event_message_truncates_for_sms(): + event = MonitorEvent( + source_type="log", source_name="app", title="Match", body="x" * 500 + ) + + assert len(event.truncate_for_sms(100)) == 100 diff --git a/tests/test_rules.py b/tests/test_rules.py new file mode 100644 index 0000000..2d8a526 --- /dev/null +++ b/tests/test_rules.py @@ -0,0 +1,154 @@ +"""Tests for conditional notification routing rules.""" + +from __future__ import annotations + +import json + +from e2nb_core import NotificationRule, NotificationRulesConfig + +ALL_CHANNELS = ["sms", "slack", "discord", "email"] + + +def decisions(rules, notification, **kwargs): + config = NotificationRulesConfig(enabled=True, rules=rules) + return config.get_channel_decisions(notification, ALL_CHANNELS, **kwargs) + + +def test_no_rules_sends_everywhere(notification): + assert decisions([], notification) == {ch: True for ch in ALL_CHANNELS} + + +def test_disabled_config_is_not_consulted(notification): + config = NotificationRulesConfig( + enabled=False, rules=[NotificationRule(name="r", suppress=True)] + ) + assert config.get_matching_rules(notification) == [] + + +def test_routing_rule_restricts_to_named_channels(notification): + result = decisions([NotificationRule(name="r", channels="slack")], notification) + assert result == {"sms": False, "slack": True, "discord": False, "email": False} + + +def test_multiple_routing_rules_are_additive(notification): + """Two matching routing rules must both take effect. + + Applying only the last matching rule would silently drop deliveries the + other rule asked for. + """ + result = decisions( + [ + NotificationRule(name="a", channels="slack"), + NotificationRule(name="b", channels="sms"), + ], + notification, + ) + assert result["slack"] is True + assert result["sms"] is True + assert result["discord"] is False + + +def test_suppression_overrides_routing(notification): + result = decisions( + [ + NotificationRule(name="route", channels="slack,sms"), + NotificationRule(name="quiet", channels="slack", suppress=True), + ], + notification, + ) + assert result["slack"] is False + assert result["sms"] is True + + +def test_suppress_without_channels_silences_everything(notification): + result = decisions([NotificationRule(name="mute", suppress=True)], notification) + assert all(value is False for value in result.values()) + + +def test_unknown_channel_names_are_ignored(notification): + result = decisions([NotificationRule(name="r", channels="carrier-pigeon")], notification) + assert all(value is False for value in result.values()) + + +def test_source_type_condition(notification): + rule = NotificationRule(name="rss only", source_type="rss", channels="discord") + + assert rule.matches(notification, source_type="rss") is True + assert rule.matches(notification, source_type="email") is False + + +def test_severity_condition(notification): + rule = NotificationRule(name="errors", severity="error") + + assert rule.matches(notification, severity="error") is True + assert rule.matches(notification, severity="info") is False + + +def test_pattern_conditions_are_case_insensitive(notification): + assert NotificationRule(sender_pattern="ALERTS@").matches(notification) + assert NotificationRule(subject_pattern="disk usage").matches(notification) + assert NotificationRule(body_pattern="95%").matches(notification) + + +def test_all_conditions_must_match(notification): + rule = NotificationRule(sender_pattern="alerts@", subject_pattern="no-such-subject") + assert rule.matches(notification) is False + + +def test_disabled_rule_never_matches(notification): + assert NotificationRule(name="r", enabled=False).matches(notification) is False + + +def test_invalid_regex_never_matches_and_is_reported(notification, caplog): + rule = NotificationRule(name="broken", subject_pattern="[unterminated") + + assert rule.matches(notification) is False + assert rule.matches(notification) is False # cached, still no match + assert "invalid subject_pattern regex" in caplog.text + + +def test_rules_parse_from_config(config): + config["NotificationRules"]["enabled"] = "True" + config["NotificationRules"]["rules"] = json.dumps( + [ + {"name": "critical", "severity": "error", "channels": "sms"}, + {"name": "quiet rss", "source_type": "rss", "channels": "discord", "suppress": True}, + ] + ) + + parsed = NotificationRulesConfig.from_config(config) + + assert parsed.enabled is True + assert [rule.name for rule in parsed.rules] == ["critical", "quiet rss"] + assert parsed.rules[1].suppress is True + + +def test_string_booleans_in_rules_are_honoured(config): + config["NotificationRules"]["rules"] = json.dumps( + [{"name": "off", "enabled": "false", "suppress": "true"}] + ) + + rule = NotificationRulesConfig.from_config(config).rules[0] + + assert rule.enabled is False + assert rule.suppress is True + + +def test_malformed_rules_json_yields_no_rules(config): + config["NotificationRules"]["rules"] = "{not json" + assert NotificationRulesConfig.from_config(config).rules == [] + + +def test_non_object_rule_entries_are_skipped(config): + config["NotificationRules"]["rules"] = json.dumps(["nonsense", {"name": "ok"}]) + + parsed = NotificationRulesConfig.from_config(config) + + assert [rule.name for rule in parsed.rules] == ["ok"] + + +def test_priority_override_is_parsed(config): + config["NotificationRules"]["rules"] = json.dumps( + [{"name": "urgent", "priority_override": "2"}] + ) + assert NotificationRulesConfig.from_config(config).rules[0].priority_override == "2" diff --git a/tests/test_state.py b/tests/test_state.py new file mode 100644 index 0000000..1f7b74b --- /dev/null +++ b/tests/test_state.py @@ -0,0 +1,174 @@ +"""Tests for MonitorState persistence and seen-item bookkeeping.""" + +from __future__ import annotations + +import json +import os + +from e2nb_core import MonitorState + + +def test_state_round_trips_through_disk(state_path): + state = MonitorState(state_path) + state.mark_rss_item_seen("https://feed.example/rss", "item-1") + state.mark_pop3_message_seen("hash-1") + state.set_imap_last_uid("imap.example.com", "me@example.com", 42) + state.set_web_page_hash("https://example.com", "abc123") + state.set_http_status("https://api.example.com", {"up": True}) + state.set_log_file_position("/var/log/app.log", 1024) + state.set_log_file_inode("/var/log/app.log", 99) + state.save() + + reloaded = MonitorState(state_path) + assert reloaded.is_rss_item_seen("https://feed.example/rss", "item-1") + assert reloaded.is_pop3_message_seen("hash-1") + assert reloaded.get_imap_last_uid("imap.example.com", "me@example.com") == 42 + assert reloaded.get_web_page_hash("https://example.com") == "abc123" + assert reloaded.get_http_status("https://api.example.com") == {"up": True} + assert reloaded.get_log_file_position("/var/log/app.log") == 1024 + assert reloaded.get_log_file_inode("/var/log/app.log") == 99 + + +def test_unseen_items_report_false(state_path): + state = MonitorState(state_path) + assert state.is_rss_item_seen("https://feed.example/rss", "nope") is False + assert state.is_pop3_message_seen("nope") is False + assert state.get_imap_last_uid("imap.example.com", "me@example.com") == 0 + assert state.get_web_page_hash("https://example.com") is None + + +def test_rss_cleanup_keeps_the_most_recent_items(state_path): + """Eviction must drop the oldest IDs, never recently seen ones. + + Keeping an arbitrary subset lets a just-seen item fall out of the cache, + and the next check then re-announces it. + """ + state = MonitorState(state_path) + feed = "https://feed.example/rss" + for index in range(150): + state.mark_rss_item_seen(feed, f"item-{index}") + + state.cleanup_old_rss_items(feed, max_items=100) + + for index in range(50, 150): + assert state.is_rss_item_seen(feed, f"item-{index}"), f"lost recent item-{index}" + for index in range(50): + assert not state.is_rss_item_seen(feed, f"item-{index}") + + +def test_pop3_cleanup_keeps_the_most_recent_hashes(state_path): + state = MonitorState(state_path) + for index in range(150): + state.mark_pop3_message_seen(f"hash-{index}") + + state.cleanup_old_pop3_messages(max_items=100) + + assert state.is_pop3_message_seen("hash-149") + assert not state.is_pop3_message_seen("hash-0") + + +def test_cleanup_is_a_no_op_below_the_limit(state_path): + state = MonitorState(state_path) + feed = "https://feed.example/rss" + state.mark_rss_item_seen(feed, "only-item") + + state.cleanup_old_rss_items(feed, max_items=100) + + assert state.is_rss_item_seen(feed, "only-item") + + +def test_reseeing_an_item_refreshes_its_position(state_path): + state = MonitorState(state_path) + feed = "https://feed.example/rss" + state.mark_rss_item_seen(feed, "old") + state.mark_rss_item_seen(feed, "new") + state.mark_rss_item_seen(feed, "old") # still present in the feed + + state.cleanup_old_rss_items(feed, max_items=1) + + assert state.is_rss_item_seen(feed, "old") + assert not state.is_rss_item_seen(feed, "new") + + +def test_legacy_list_state_files_are_readable(state_path): + """State written by older versions stored seen items as JSON lists.""" + with open(state_path, "w") as handle: + json.dump( + { + "seen_rss_items": {"https://feed.example/rss": ["a", "b"]}, + "seen_pop3_messages": ["hash-1"], + "imap_last_uid": {"imap.example.com:me@example.com": 7}, + }, + handle, + ) + + state = MonitorState(state_path) + + assert state.is_rss_item_seen("https://feed.example/rss", "a") + assert state.is_pop3_message_seen("hash-1") + assert state.get_imap_last_uid("imap.example.com", "me@example.com") == 7 + + +def test_corrupt_state_file_does_not_raise(state_path): + with open(state_path, "w") as handle: + handle.write("{ this is not json") + + state = MonitorState(state_path) + + assert state.is_pop3_message_seen("anything") is False + state.mark_pop3_message_seen("hash") + state.save() + assert MonitorState(state_path).is_pop3_message_seen("hash") + + +def test_non_object_state_file_does_not_raise(state_path): + with open(state_path, "w") as handle: + json.dump(["unexpected", "list"], handle) + + state = MonitorState(state_path) + + assert state.get_imap_last_uid("imap.example.com", "me@example.com") == 0 + + +def test_malformed_field_types_are_ignored(state_path): + with open(state_path, "w") as handle: + json.dump({"imap_last_uid": "not-a-dict", "web_page_hashes": 5}, handle) + + state = MonitorState(state_path) + + assert state.get_imap_last_uid("imap.example.com", "me@example.com") == 0 + assert state.get_web_page_hash("https://example.com") is None + + +def test_save_failure_does_not_raise(state_path, monkeypatch): + state = MonitorState(state_path) + state.mark_pop3_message_seen("hash") + + def boom(*args, **kwargs): + raise OSError("read-only filesystem") + + monkeypatch.setattr("builtins.open", boom) + state.save() # must not propagate: losing state is better than stopping + + +def test_save_is_atomic_and_leaves_no_temp_file(state_path): + state = MonitorState(state_path) + state.mark_pop3_message_seen("hash") + state.save() + + assert not os.path.exists(state_path + ".tmp") + with open(state_path) as handle: + payload = json.load(handle) + assert payload["seen_pop3_messages"] == ["hash"] + assert payload["last_updated"] + + +def test_log_rotation_state_reset(state_path): + state = MonitorState(state_path) + state.set_log_file_position("/var/log/app.log", 500) + state.set_log_file_inode("/var/log/app.log", 12) + + state.reset_log_file_state("/var/log/app.log") + + assert state.get_log_file_position("/var/log/app.log") == 0 + assert state.get_log_file_inode("/var/log/app.log") is None diff --git a/tests/test_templates.py b/tests/test_templates.py new file mode 100644 index 0000000..fef7b53 --- /dev/null +++ b/tests/test_templates.py @@ -0,0 +1,120 @@ +"""Tests for message templating.""" + +from __future__ import annotations + +import json +from datetime import datetime + +from e2nb_core import MessageTemplateConfig, load_config + + +def _config_with(config, **values): + for key, value in values.items(): + config["MessageTemplates"][key] = value + return MessageTemplateConfig.from_config(config) + + +def test_defaults_when_section_absent(): + import configparser + + empty = configparser.ConfigParser(interpolation=None) + templates = MessageTemplateConfig.from_config(empty) + + assert templates.enabled is False + assert templates.default_body == "{body}" + + +def test_render_substitutes_known_variables(): + templates = MessageTemplateConfig() + rendered = templates.render( + "[{severity}] {source_type}: {title}", + source_type="http", + title="API down", + severity="error", + ) + assert rendered == "[error] http: API down" + + +def test_render_leaves_unknown_placeholders_alone(): + templates = MessageTemplateConfig() + assert templates.render("{not_a_variable}") == "{not_a_variable}" + + +def test_render_is_single_pass(): + """Content substituted in must not itself be re-substituted. + + An email body containing something like "{hostname}" is data, not a + placeholder, and must survive rendering untouched. + """ + templates = MessageTemplateConfig() + rendered = templates.render("{body}", body="literal {hostname} and {title}") + assert rendered == "literal {hostname} and {title}" + + +def test_render_uses_supplied_timestamp(): + templates = MessageTemplateConfig() + when = datetime(2024, 5, 4, 13, 30, 15) + assert templates.render("{date} {time}", timestamp=when) == "2024-05-04 13:30:15" + + +def test_render_includes_custom_variables(): + templates = MessageTemplateConfig(custom_variables={"team": "platform"}) + assert templates.render("owner={team}") == "owner=platform" + + +def test_render_exposes_metadata_with_prefix(): + templates = MessageTemplateConfig() + rendered = templates.render("{meta_status_code}", metadata={"status_code": 503}) + assert rendered == "503" + + +def test_channel_templates_override_defaults(config): + templates = _config_with( + config, + enabled="True", + channel_templates=json.dumps( + {"slack": {"subject": ":bell: {title}", "body": "from {source_name}"}} + ), + ) + + assert templates.get_subject("slack") == ":bell: {title}" + assert templates.get_body("slack") == "from {source_name}" + # Channels without an override fall back to the defaults. + assert templates.get_subject("telegram") == templates.default_subject + + +def test_channel_template_partial_override_keeps_default_body(config): + templates = _config_with( + config, + channel_templates=json.dumps({"sms": {"subject": "{title}"}}), + ) + assert templates.get_body("sms") == templates.default_body + + +def test_malformed_channel_templates_are_ignored(config): + """A wrong-shaped entry must not raise at dispatch time.""" + templates = _config_with( + config, + channel_templates=json.dumps({"slack": "just a string"}), + ) + + assert templates.get_subject("slack") == templates.default_subject + assert templates.get_body("slack") == templates.default_body + + +def test_invalid_channel_templates_json_is_ignored(config): + templates = _config_with(config, channel_templates="{not json") + assert templates.channel_templates == {} + + +def test_invalid_custom_variables_json_is_ignored(config): + templates = _config_with(config, custom_variables="[1, 2, 3]") + assert templates.custom_variables == {} + + +def test_defaults_survive_a_config_round_trip(config_path): + config = load_config(config_path) + templates = MessageTemplateConfig.from_config(config) + + assert templates.default_subject == "[{severity}] {source_type}: {title}" + assert templates.default_body == "{body}" diff --git a/tests/test_validation.py b/tests/test_validation.py new file mode 100644 index 0000000..99e0653 --- /dev/null +++ b/tests/test_validation.py @@ -0,0 +1,133 @@ +"""Tests for the small validation and sanitisation helpers.""" + +from __future__ import annotations + +import pytest + +from e2nb_core import ( + coerce_bool, + safe_int, + sanitize_markdown, + sanitize_twiml, + validate_email, + validate_phone_number, + validate_url, +) + + +@pytest.mark.parametrize( + "value,default,expected", + [ + ("42", 0, 42), + (42, 0, 42), + ("not a number", 7, 7), + (None, 7, 7), + ("", 7, 7), + ([], 7, 7), + ], +) +def test_safe_int_conversion(value, default, expected): + assert safe_int(value, default) == expected + + +def test_safe_int_clamps_to_bounds(): + assert safe_int("0", 60, min_val=10, max_val=86400) == 10 + assert safe_int("999999", 60, min_val=10, max_val=86400) == 86400 + assert safe_int("120", 60, min_val=10, max_val=86400) == 120 + + +def test_safe_int_uses_default_before_clamping(): + """An unparseable value yields the default, even outside the bounds.""" + assert safe_int("abc", 5, min_val=10, max_val=20) == 5 + + +@pytest.mark.parametrize( + "value,expected", + [ + (True, True), + (False, False), + ("true", True), + ("True", True), + ("YES", True), + ("on", True), + ("1", True), + ("false", False), + ("no", False), + ("off", False), + ("0", False), + ("", False), + (1, True), + (0, False), + ], +) +def test_coerce_bool(value, expected): + assert coerce_bool(value) is expected + + +def test_coerce_bool_falls_back_to_default(): + assert coerce_bool("maybe", default=True) is True + assert coerce_bool(None, default=False) is False + + +@pytest.mark.parametrize( + "url,expected", + [ + ("https://example.com/hook", True), + ("http://example.com", True), + ("ftp://example.com", False), + ("javascript:alert(1)", False), + ("not a url", False), + ("", False), + ("https://" + "a" * 3000, False), + ], +) +def test_validate_url(url, expected): + assert validate_url(url) is expected + + +def test_validate_url_custom_schemes(): + assert validate_url("ftp://example.com", schemes=["ftp"]) is True + + +@pytest.mark.parametrize( + "number,expected", + [ + ("+12025550142", True), + ("+1 (202) 555-0142", True), + ("whatsapp:+12025550142", True), + ("12025550142", True), + ("", False), + ("not-a-number", False), + ("+1", False), + ], +) +def test_validate_phone_number(number, expected): + assert validate_phone_number(number) is expected + + +@pytest.mark.parametrize( + "address,expected", + [ + ("user@example.com", True), + ("first.last+tag@sub.example.co.uk", True), + ("user@example", False), + ("@example.com", False), + ("", False), + ], +) +def test_validate_email(address, expected): + assert validate_email(address) is expected + + +def test_sanitize_twiml_strips_markup_and_escapes(): + result = sanitize_twiml('hello & "quoted"') + assert "<" not in result and ">" not in result + assert "&" in result + + +def test_sanitize_twiml_limits_length(): + assert len(sanitize_twiml("a" * 5000)) == 3000 + + +def test_sanitize_markdown_escapes_special_characters(): + assert sanitize_markdown("*bold*") == r"\*bold\*" From 0f057268697a89439526955933bbc185c4625e5e Mon Sep 17 00:00:00 2001 From: Seth Morrow Date: Sun, 13 Sep 2026 13:38:47 +0000 Subject: [PATCH 2/6] Add CI, lint config, and document the undocumented monitoring sources - GitHub Actions workflow running ruff and the full test suite on Python 3.9-3.13, with Tk and Xvfb installed so the GUI tests run rather than skip. - pyproject.toml configuring pytest and ruff; requirements-dev.txt for the test and lint tooling. - Fixed the findings ruff reported and silenced the one intentional late import with an explanation. - .gitignore now covers state files, logs, temp config writes, and editor and virtualenv directories, none of which belonged in the repository. - README: documented database and log file monitoring, which were fully implemented but appeared nowhere in the configuration reference; added a Development section covering tests, linting and CI; described the new GUI pages, per-source intervals and unsaved-change handling; and made the plain-text credential storage explicit under Security. - requirements.txt lists the optional database drivers. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011UHBtwQGR4gt7w4zrsDvCe --- .github/workflows/ci.yml | 55 ++++++++++ .gitignore | 27 +++++ e2nb.py | 2 +- e2nb_core.py | 12 ++- pyproject.toml | 26 +++++ readme.md | 219 ++++++++++++++++++++++++++++++++++++++- requirements-dev.txt | 8 ++ requirements.txt | 5 + tests/test_dispatcher.py | 2 +- tests/test_rules.py | 2 +- 10 files changed, 348 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 pyproject.toml create mode 100644 requirements-dev.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..90f5fcf --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,55 @@ +name: CI + +on: + push: + branches: ["**"] + pull_request: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Test on Python ${{ matrix.python-version }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install Tk and a virtual display + run: sudo apt-get update && sudo apt-get install -y python3-tk xvfb + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install -r requirements-dev.txt + + - name: Lint + run: ruff check . + + - name: Run tests + # xvfb-run gives the GUI smoke tests a display; they skip themselves + # if one is unavailable, so the suite still passes without it. + run: xvfb-run -a python -m pytest -q + + compile: + name: Byte-compile all sources + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: python -m compileall -q e2nb.py e2nb_core.py e2nb-headless.py tests diff --git a/.gitignore b/.gitignore index 42e708c..c5df3fd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,30 @@ +# Byte-compiled / cache __pycache__/ *.py[cod] *.pyo +.pytest_cache/ +.ruff_cache/ + +# Virtual environments +.venv/ +venv/ +env/ + +# Runtime artifacts +# config.ini holds credentials once it is filled in. The committed copy is a +# template with placeholder values; if you would rather keep yours out of git +# entirely, run: git update-index --skip-worktree config.ini +config.local.ini +config.ini.bak +*.ini.tmp +.config-*.tmp +e2nb_state.json +e2nb_state.json.tmp +email_monitor.log +*.log + +# Editors / OS +.idea/ +.vscode/ +*.swp +.DS_Store diff --git a/e2nb.py b/e2nb.py index 4316a6a..5acab84 100644 --- a/e2nb.py +++ b/e2nb.py @@ -3632,7 +3632,7 @@ def _monitor_loop(self): # Each source keeps its own schedule so a five-minute RSS poll is not # forced onto the one-minute email cadence (and vice versa). - next_run = {key: 0.0 for key in ('email', 'rss', 'web', 'http', 'database', 'log')} + next_run = dict.fromkeys(('email', 'rss', 'web', 'http', 'database', 'log'), 0.0) while not self.stop_event.is_set(): imap = None diff --git a/e2nb_core.py b/e2nb_core.py index 967b9ba..a27bc4f 100644 --- a/e2nb_core.py +++ b/e2nb_core.py @@ -760,10 +760,10 @@ def get_channel_decisions(self, notification: 'EmailNotification', if not matching_rules: # No rules match - send to all enabled channels (default) - return {ch: True for ch in all_channels} + return dict.fromkeys(all_channels, True) # Start with all channels enabled - decisions = {ch: True for ch in all_channels} + decisions = dict.fromkeys(all_channels, True) # Routing rules are additive: if two rules match and each routes to a # different channel, the notification goes to both. Restricting to the @@ -3729,7 +3729,7 @@ def dispatch( logger.debug(f"Dispatching to enabled channels: {enabled_channels}") - channel_decisions = {ch: True for ch in enabled_channels} + channel_decisions = dict.fromkeys(enabled_channels, True) priority_override = "" if self.notification_rules.enabled: @@ -3750,7 +3750,7 @@ def dispatch( logger.exception( "Failed to evaluate notification rules; delivering to all enabled channels" ) - channel_decisions = {ch: True for ch in enabled_channels} + channel_decisions = dict.fromkeys(enabled_channels, True) def _should_send(channel_name: str) -> bool: return channel_decisions.get(channel_name, True) @@ -5050,7 +5050,9 @@ def test_http_endpoint( MYSQL_AVAILABLE = False mysql_connector = None -import sqlite3 # Built-in, always available +# Kept next to the optional database drivers above so all database imports +# read together, rather than moved up with the unrelated stdlib imports. +import sqlite3 # noqa: E402 - built-in, always available def check_database_connection( diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b6d7c4b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,26 @@ +# Tooling configuration for E2NB. The application itself is run from the +# scripts in this directory; this file only configures pytest and ruff. + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +addopts = "-ra" +filterwarnings = ["error::DeprecationWarning:e2nb_core"] + +[tool.ruff] +line-length = 120 +target-version = "py39" +exclude = [".git", "__pycache__", "build", "dist"] + +[tool.ruff.lint] +# Pyflakes, pycodestyle errors, bugbear, and comprehension checks. The codebase +# predates this config, so stylistic-only rules stay off to keep the signal high. +select = ["F", "E4", "E7", "E9", "B", "C4", "UP032"] +ignore = [ + "B008", # Function call in default argument - not used here in a risky way + "B904", # raise ... from err - the existing error messages are self-contained +] + +[tool.ruff.lint.per-file-ignores] +# Tests intentionally reach into private helpers to check behaviour. +"tests/*" = ["E402"] diff --git a/readme.md b/readme.md index ae2c0e5..e695123 100644 --- a/readme.md +++ b/readme.md @@ -21,6 +21,8 @@ A professional Python application that monitors multiple sources (Email via IMAP - [RSS Feed Monitoring](#rss-feed-monitoring) - [Web Page Monitoring](#web-page-monitoring) - [HTTP Endpoint Monitoring](#http-endpoint-monitoring) + - [Database Monitoring](#database-monitoring) + - [Log File Monitoring](#log-file-monitoring) - [Notification Channels](#notification-channels) - [Twilio SMS](#twilio-sms) - [Twilio Voice](#twilio-voice) @@ -51,6 +53,7 @@ A professional Python application that monitors multiple sources (Email via IMAP - [Security](#security) - [API Reference](#api-reference) - [Troubleshooting](#troubleshooting) +- [Development](#development) - [Contributing](#contributing) - [License](#license) @@ -133,6 +136,10 @@ Sources Core Channels | `feedparser` | 6.0.0 | RSS/Atom feed parsing | Only if using RSS monitoring | | `beautifulsoup4` | 4.12.0 | HTML parsing for web monitoring & RSS content extraction | Only if using web monitoring or RSS | | `aiosmtpd` | 1.4.0 | Local SMTP server for receiving emails | Only if using SMTP receiver | +| `psycopg2-binary` | 2.9.0 | PostgreSQL driver | Only if monitoring PostgreSQL | +| `mysql-connector-python` | 8.0.0 | MySQL/MariaDB driver | Only if monitoring MySQL or MariaDB | + +SQLite monitoring needs no driver — it uses the `sqlite3` module bundled with Python. The GUI version additionally requires `tkinter`, which is included with most Python installations. On some Linux distributions you may need to install it separately (e.g., `sudo apt install python3-tk`). @@ -155,6 +162,8 @@ pip install slack_sdk # For Slack pip install feedparser # For RSS feeds pip install beautifulsoup4 # For web page monitoring pip install aiosmtpd # For SMTP receiver +pip install psycopg2-binary # For PostgreSQL monitoring +pip install mysql-connector-python # For MySQL/MariaDB monitoring ``` ## Quick Start @@ -510,6 +519,134 @@ headers = {"X-Custom-Header": "value"} --- +### Database Monitoring + +Check that databases are reachable and that key queries still return the values you expect. + +```ini +[DatabaseMonitor] +enabled = False +databases = [{"name": "App DB", "type": "postgresql", "host": "localhost", "database": "myapp", "username": "admin", "password": "secret", "query": "SELECT COUNT(*) FROM active_users", "expected_value": ">0"}] +check_interval = 300 +notify_on_first_check = False +default_timeout = 30 +``` + +| Setting | Description | Default | +|---------|-------------|---------| +| `enabled` | Enable database monitoring | `False` | +| `databases` | JSON array of database objects (see below) | `[]` | +| `check_interval` | Seconds between database checks | `300` | +| `notify_on_first_check` | Send notification on the first check | `False` | +| `default_timeout` | Query timeout for databases that do not set their own | `30` | + +**Database object format (JSON array)**: + +```json +[ + { + "name": "App DB", + "type": "postgresql", + "host": "localhost", + "port": 5432, + "database": "myapp", + "username": "admin", + "password": "secret", + "query": "SELECT COUNT(*) FROM active_users", + "expected_value": ">0", + "expected_rows": 1, + "timeout": 30, + "failure_threshold": 1, + "slow_query_threshold": 5.0 + } +] +``` + +| Field | Description | Default | +|-------|-------------|---------| +| `name` | Display name for notifications | *(required)* | +| `type` | `postgresql`, `mysql`, `mariadb`, or `sqlite` | *(required)* | +| `host` / `port` | Server address (not used by SQLite) | `localhost` / driver default | +| `path` | Database file path (SQLite only) | *(empty)* | +| `database` | Database name | *(empty)* | +| `username` / `password` | Credentials | *(empty)* | +| `query` | Optional query to run; omit for a connection-only health check | *(empty)* | +| `expected_value` | Comparison the first result must satisfy, e.g. `>100`, `=5`, `!=error` | *(empty)* | +| `expected_rows` | Expected number of returned rows | *(empty)* | +| `timeout` | Query timeout in seconds | `default_timeout` | +| `failure_threshold` | Consecutive failures before alerting | `1` | +| `slow_query_threshold` | Seconds after which a query is reported as slow | `5.0` | + +**Driver requirements**: SQLite works out of the box. PostgreSQL needs `psycopg2-binary`; +MySQL and MariaDB need `mysql-connector-python`. A database configured without its +driver installed is reported once and then skipped. + +**Notification triggers**: +- **Connection failure**: The database cannot be reached +- **Unexpected result**: The query result does not satisfy `expected_value` or `expected_rows` +- **Slow query**: The query exceeded `slow_query_threshold` +- **Recovery**: A previously failing database is healthy again + +--- + +### Log File Monitoring + +Watch log files for error patterns. Position and inode are tracked between checks, so +each line is only ever reported once and log rotation is handled. + +```ini +[LogMonitor] +enabled = False +logs = [{"name": "App errors", "path": "/var/log/myapp/error.log", "patterns": ["ERROR", "CRITICAL"]}] +check_interval = 60 +max_lines_per_check = 1000 +default_rate_limit = 10 +``` + +| Setting | Description | Default | +|---------|-------------|---------| +| `enabled` | Enable log file monitoring | `False` | +| `logs` | JSON array of log objects (see below) | `[]` | +| `check_interval` | Seconds between log checks | `60` | +| `max_lines_per_check` | Lines read per file each cycle | `1000` | +| `default_rate_limit` | Maximum alerts per log per cycle | `10` | + +**Log object format (JSON array)**: + +```json +[ + { + "name": "App errors", + "path": "/var/log/myapp/error.log", + "patterns": ["ERROR", "CRITICAL", "Exception"], + "exclude_patterns": ["DEBUG", "healthcheck"], + "severity_patterns": {"error": "ERROR|EXCEPTION", "critical": "CRITICAL|FATAL"}, + "rate_limit": 5, + "max_lines_per_check": 500 + } +] +``` + +| Field | Description | Default | +|-------|-------------|---------| +| `name` | Display name for notifications | *(required)* | +| `path` | Absolute path to the log file | *(required)* | +| `patterns` | Regex patterns that trigger an alert (case-insensitive) | *(required)* | +| `exclude_patterns` | Regex patterns that suppress a matching line | `[]` | +| `severity_patterns` | Map of severity name to regex, checked before the built-in keywords | `{}` | +| `rate_limit` | Maximum alerts from this log per cycle | `default_rate_limit` | +| `max_lines_per_check` | Lines read from this log per cycle | `max_lines_per_check` | + +**Behaviour notes**: +- Severity is taken from `severity_patterns` first, then inferred from `CRITICAL`/`FATAL`, + `ERROR`/`EXCEPTION`, and `WARNING`/`WARN` appearing in the line +- A line still being written (no trailing newline) is left until the next check +- Rotation is detected by inode change, and truncation by the file shrinking; both reset + the read position to the start of the new file +- `rate_limit` caps how many alerts a single burst of errors can produce + +--- + ## Notification Channels Each channel has an `enabled` toggle. You can enable any combination of channels simultaneously. All enabled channels receive every notification. @@ -908,17 +1045,27 @@ python e2nb.py The GUI uses a modern dark-sidebar design with four sections: **Sidebar Sections**: -- **Sources** (with active count badge): Email, SMTP Receiver, RSS Feeds, Web Pages, HTTP Endpoints - each with a green/gray status dot +- **Sources** (with active count badge): Email, RSS Feeds, Web Pages, HTTP Endpoints, Databases, Log Files - each with a green/gray status dot. The SMTP Receiver is configured on the Email page. - **Notifications** (with active count badge): SMS, Voice, WhatsApp, Slack, Telegram, Discord, Teams, Pushover, Ntfy, Webhook, Email (SMTP) - each with a status dot - **Configuration**: General settings, Notification Rules - **Monitor**: Activity Logs with real-time color-coded output +The sidebar scrolls when the window is too short to show every entry. + **Header Controls**: - **Save** button (also Ctrl+S): Saves all settings to config.ini - **Start Monitoring** button: Begins monitoring all enabled sources - **Stop** button: Gracefully stops monitoring - Status badge showing monitoring state +**Status Bar**: shows the current activity, the time of the last check, and an +"Unsaved changes" marker while edits have not been written to `config.ini`. Closing the +window with unsaved edits offers to save them first. + +**Check intervals**: each source runs on its own `check_interval`. Email uses the global +interval from General settings; RSS, web, HTTP, database, and log monitoring each use the +interval configured on their own page. + ### Keyboard Shortcuts | Shortcut | Action | @@ -935,6 +1082,8 @@ Each source/notification page includes test buttons: - **RSS page**: "Validate Feeds" - parses the JSON and tests the first feed URL - **HTTP Endpoints page**: "Validate & Test" - parses JSON and tests the first endpoint - **Web Pages page**: "Validate Pages" - validates the JSON configuration +- **Databases page**: "Validate & Test" - parses JSON and connects to the first database +- **Log Files page**: "Validate & Test" - parses JSON, checks the first file is readable, and compiles its patterns - **SMTP Notifications page**: "Test Connection" - verifies SMTP server connectivity ### Log Viewer @@ -1250,10 +1399,20 @@ E2NB persists monitoring state in `e2nb_state.json` to survive restarts. This fi - **SSL/TLS certificate verification**: Enabled by default for all connections; configurable per-source - **Input validation**: URLs, phone numbers, and email addresses are validated before use - **Atomic state writes**: Prevents state file corruption on crash +- **Atomic config writes**: `config.ini` is written through a temporary file and moved into + place, so an interrupted save cannot truncate a working configuration +- **Restricted config permissions**: `config.ini` is written mode `0600` (owner read/write) + on POSIX systems, because it holds credentials in plain text - **No credential logging**: Passwords and tokens are never written to log output - **SMTP receiver authentication**: Optional authentication for the local SMTP server - **Sandboxed systemd deployment**: Example service file includes security hardening directives +**Credentials are stored in plain text.** `config.ini` is a plain INI file; file permissions +are the only thing protecting it. Keep it off shared machines, do not commit a filled-in +copy (the tracked file is a placeholder template — `git update-index --skip-worktree +config.ini` keeps your edits out of `git status`), and prefer app-specific passwords or +OAuth2 over your primary account password. + --- ## API Reference @@ -1510,11 +1669,67 @@ rm e2nb_state.json --- +## Development + +### Running the test suite + +```bash +pip install -r requirements-dev.txt +pytest +``` + +The suite is offline — every notification sender and network call is stubbed — so it runs +in about a second and never sends a real message. + +The GUI smoke tests build the real Tk application against a temporary config directory. +They skip themselves when `tkinter` or a display is unavailable, so the rest of the suite +still runs on a headless server. To run them there, use a virtual display: + +```bash +sudo apt-get install -y python3-tk xvfb +xvfb-run -a pytest +``` + +Useful invocations: + +```bash +pytest tests/test_dispatcher.py # one module +pytest -k "routing or template" # by name +pytest -q --no-header -x # stop at the first failure +``` + +### Linting + +```bash +ruff check . # report problems +ruff check . --fix # apply the safe fixes +``` + +### Continuous integration + +`.github/workflows/ci.yml` runs `ruff` and the full test suite on Python 3.9 through 3.13 +for every push and pull request, and byte-compiles all three entry points. + +### Layout + +| Path | Contents | +|------|----------| +| `e2nb_core.py` | All shared logic: config, sources, notification channels, state | +| `e2nb.py` | Tk desktop application | +| `e2nb-headless.py` | Daemon entry point for servers | +| `tests/` | pytest suite | + +When adding a monitoring source or notification channel, put the logic in `e2nb_core.py` +so the GUI and the daemon both get it, add its defaults to `DEFAULT_CONFIG_SECTIONS`, +and cover it with tests. + +--- + ## Contributing 1. Fork the repository 2. Create a feature branch: `git checkout -b feature/new-feature` -3. Make changes and test thoroughly +3. Make changes, then run `ruff check .` and `pytest` — add tests for the behaviour you change 4. Commit with clear messages: `git commit -m "Add new feature"` 5. Push to your branch: `git push origin feature/new-feature` 6. Open a Pull Request diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..d1686c1 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,8 @@ +# E2NB - development and test dependencies +# Install with: pip install -r requirements-dev.txt + +# Test runner +pytest>=7.0.0 + +# Linter (also covers the checks pyflakes performs) +ruff>=0.4.0 diff --git a/requirements.txt b/requirements.txt index 98c311a..64b73d3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,3 +21,8 @@ beautifulsoup4>=4.12.0 # SMTP receiver for receiving emails via SMTP (alternative to IMAP) aiosmtpd>=1.4.0 + +# Optional database drivers, needed only for database monitoring. +# SQLite works without any driver. Uncomment the one you need: +# psycopg2-binary>=2.9.0 # PostgreSQL +# mysql-connector-python>=8.0.0 # MySQL / MariaDB diff --git a/tests/test_dispatcher.py b/tests/test_dispatcher.py index 25476fa..4f5a35f 100644 --- a/tests/test_dispatcher.py +++ b/tests/test_dispatcher.py @@ -281,6 +281,6 @@ def test_has_any_enabled_is_true_once_a_channel_is_on(all_channels_config): def test_close_is_idempotent(config): dispatcher = NotificationDispatcher(config) - dispatcher.http_session # force creation + assert dispatcher.http_session is not None # force lazy creation dispatcher.close() dispatcher.close() diff --git a/tests/test_rules.py b/tests/test_rules.py index 2d8a526..f3a567f 100644 --- a/tests/test_rules.py +++ b/tests/test_rules.py @@ -15,7 +15,7 @@ def decisions(rules, notification, **kwargs): def test_no_rules_sends_everywhere(notification): - assert decisions([], notification) == {ch: True for ch in ALL_CHANNELS} + assert decisions([], notification) == dict.fromkeys(ALL_CHANNELS, True) def test_disabled_config_is_not_consulted(notification): From 64b00c7179d88837b2026ffc6a37f8c90cb28013 Mon Sep 17 00:00:00 2001 From: Seth Morrow Date: Sun, 13 Sep 2026 13:42:40 +0000 Subject: [PATCH 3/6] Mask credentials in the GUI and guard config against concurrent access - Credential fields (auth tokens, bot tokens, API keys, passwords) are now masked with a per-field Show/Hide control instead of sitting in plain view; several were not masked at all. - self.config is read by the monitor thread while the UI thread rewrites it on save. Reads and writes now take a lock, and the monitor loop snapshots the whole configuration once per cycle rather than reading it field by field across a cycle that can span minutes. - Email settings in the loop come from EmailConfig, so the GUI honours the same TLS, CA bundle and timeout handling as the daemon. - Removed the dead Toast fade scaffolding, which assigned over a widget method and computed an unused alpha on every step. - Added monitor-loop tests: log matches are delivered, a source-type routing rule reaches the dispatcher, one failing source does not stop the others, and a line already reported is not reported again. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011UHBtwQGR4gt7w4zrsDvCe --- e2nb.py | 147 +++++++++++++++++++++++++++++-------------- tests/test_gui.py | 155 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 255 insertions(+), 47 deletions(-) diff --git a/e2nb.py b/e2nb.py index 5acab84..318a06b 100644 --- a/e2nb.py +++ b/e2nb.py @@ -91,10 +91,7 @@ check_databases, check_log_files, DEFAULT_CHECK_INTERVAL, - DEFAULT_IMAP_PORT, - DEFAULT_POP3_PORT, DEFAULT_CONNECTION_TIMEOUT, - TLS_MODE_IMPLICIT, FEEDPARSER_AVAILABLE, BS4_AVAILABLE, AIOSMTPD_AVAILABLE, @@ -805,6 +802,12 @@ def set_value(self, value: str): elif self._placeholder and self != self.focus_get(): self._show_placeholder() + def set_masked(self, masked: bool): + """Mask or reveal the value, leaving placeholder text visible.""" + self._show_char = "•" if masked else "" + if not self._placeholder_active: + self.configure(show=self._show_char) + class FormRow(tk.Frame): """A single row in a form with label and input with focus states.""" @@ -817,6 +820,7 @@ def __init__( show: str = "", tooltip: str = "", placeholder: str = "", + secret: bool = False, **kwargs ): super().__init__(parent, bg=Theme.BG_PRIMARY, **kwargs) @@ -860,8 +864,17 @@ def __init__( # Use placeholder if provided, otherwise use help_text as placeholder ph = placeholder or help_text + # Secret fields are masked by default with a control to reveal them, + # so tokens are not left on screen during a demo or screen share. + self._secret = secret + if secret and not show: + show = "•" + + input_row = tk.Frame(self._entry_wrapper, bg=Theme.BG_INPUT) + input_row.pack(fill="x", padx=1, pady=1) + self.entry = PlaceholderEntry( - self._entry_wrapper, + input_row, placeholder=ph, font=(Theme.FONT, 10), bg=Theme.BG_INPUT, @@ -872,7 +885,18 @@ def __init__( insertbackground=Theme.PRIMARY, show=show, ) - self.entry.pack(fill="x", ipady=8, padx=1, pady=1) + self.entry.pack(side="left", fill="x", expand=True, ipady=8) + + self._reveal_button = None + if secret: + self._revealed = False + self._reveal_button = tk.Label( + input_row, text="Show", + bg=Theme.BG_INPUT, fg=Theme.TEXT_MUTED, + font=(Theme.FONT, 9), cursor="hand2", padx=10, + ) + self._reveal_button.pack(side="right", fill="y") + self._reveal_button.bind("", self._toggle_reveal) # Focus events self.entry.bind("", self._on_focus_in, add="+") @@ -883,6 +907,12 @@ def __init__( if tooltip: Tooltip(self.entry, tooltip) + def _toggle_reveal(self, event=None): + """Switch a secret field between masked and plain text.""" + self._revealed = not self._revealed + self.entry.set_masked(not self._revealed) + self._reveal_button.configure(text="Hide" if self._revealed else "Show") + def _on_focus_in(self, event): self._entry_wrapper.configure(bg=Theme.BORDER_FOCUS) self.entry.configure(bg=Theme.BG_INPUT_FOCUS) @@ -1321,6 +1351,9 @@ def __init__(self, root: tk.Tk): self.nav_items: Dict[str, SidebarItem] = {} self.pages: Dict[str, tk.Frame] = {} self.unsaved_changes = False + # Guards self.config: the UI thread rewrites it on save while the + # monitor thread reads it at the start of every cycle. + self._config_lock = threading.RLock() # Initialize variables self._init_variables() @@ -1929,7 +1962,7 @@ def _create_email_page(self): self._create_separator(section2.content) self.password = FormRow( - section2.content, "Password", "App password if 2FA enabled", show="*", + section2.content, "Password", "App password if 2FA enabled", secret=True, tooltip="Use an app-specific password if 2FA is enabled", ) self.password.pack(fill="x") @@ -2077,7 +2110,7 @@ def _create_email_page(self): self._create_separator(section4.content) self.smtp_recv_password = FormRow( - section4.content, "Password", "For SMTP auth (optional)", show="*", + section4.content, "Password", "For SMTP auth (optional)", secret=True, tooltip="Password clients must use to authenticate", ) self.smtp_recv_password.pack(fill="x") @@ -2175,7 +2208,12 @@ def _create_notification_page(self, name, toggle_text, toggle_var, sections): for i, (key, label, help_text, show, tip) in enumerate(rows): if i > 0: self._create_separator(section.content) - row = FormRow(section.content, label, help_text, show=show, tooltip=tip) + # A field the page marks for masking is a credential: show it + # bulleted with a control to reveal it on demand. + row = FormRow( + section.content, label, help_text, tooltip=tip, + secret=bool(show), + ) row.pack(fill="x") created_rows[key] = row return created_rows @@ -2554,7 +2592,7 @@ def _create_smtp_page(self): self._create_separator(section2.content) self.smtp_password = FormRow( - section2.content, "Password", "App password if 2FA enabled", show="*", + section2.content, "Password", "App password if 2FA enabled", secret=True, tooltip="Password for SMTP authentication", ) self.smtp_password.pack(fill="x") @@ -3302,6 +3340,11 @@ def _clear_logs(self): self._log_empty.place(relx=0.5, rely=0.4, anchor="center") def _update_config(self): + """Copy every form field into the in-memory configuration.""" + with self._config_lock: + self._write_config_from_form() + + def _write_config_from_form(self): # A hand-edited config.ini may be missing sections; add them back # before assigning so saving settings cannot fail with a KeyError. ensure_config_sections(self.config) @@ -3453,8 +3496,9 @@ def _update_config(self): def _save_settings(self) -> bool: try: - self._update_config() - save_config(self.config) + with self._config_lock: + self._write_config_from_form() + save_config(self.config) self._log("Settings saved", "SUCCESS") self.toast.show("Settings saved to config.ini", "success") self._mark_saved() @@ -3555,7 +3599,8 @@ def _start_monitoring(self): ) # Store dispatcher as instance variable to prevent premature garbage collection # and allow proper cleanup when stopping - self._smtp_recv_dispatcher = NotificationDispatcher(self.config) + with self._config_lock: + self._smtp_recv_dispatcher = NotificationDispatcher(self.config) self.smtp_receiver = SmtpReceiver( config=smtp_recv_config, callback=lambda notif: self._on_smtp_received(notif, self._smtp_recv_dispatcher), @@ -3639,16 +3684,23 @@ def _monitor_loop(self): pop3 = None dispatcher = None try: - # Reload configs each cycle to pick up any changes - dispatcher = NotificationDispatcher(self.config) - rss_config = RssFeedConfig.from_config(self.config) - web_config = WebMonitorConfig.from_config(self.config) - http_config = HttpEndpointConfig.from_config(self.config) - db_config = DatabaseMonitorConfig.from_config(self.config) - log_config = LogMonitorConfig.from_config(self.config) - - interval = safe_int(self.config.get('Settings', 'check_interval', fallback='60'), DEFAULT_CHECK_INTERVAL, min_val=10, max_val=86400) - filters = [f.strip().lower() for f in self.config.get('Email', 'filter_emails', fallback='').split(',') if f.strip()] + # Read the configuration once per cycle, under the lock: the UI + # thread rewrites it whenever settings are saved, and + # ConfigParser is not safe to read while it is being mutated. + with self._config_lock: + dispatcher = NotificationDispatcher(self.config) + rss_config = RssFeedConfig.from_config(self.config) + web_config = WebMonitorConfig.from_config(self.config) + http_config = HttpEndpointConfig.from_config(self.config) + db_config = DatabaseMonitorConfig.from_config(self.config) + log_config = LogMonitorConfig.from_config(self.config) + email_config = EmailConfig.from_config(self.config) + interval = safe_int( + self.config.get('Settings', 'check_interval', fallback='60'), + DEFAULT_CHECK_INTERVAL, min_val=10, max_val=86400, + ) + + filters = [f.strip().lower() for f in email_config.filter_emails if f.strip()] now = time.monotonic() self.root.after(0, lambda: self._update_status_bar( @@ -3659,13 +3711,10 @@ def _monitor_loop(self): # ===================================================== # Check Email (IMAP or POP3) # ===================================================== - email_username = self.config.get('Email', 'username', fallback='') - protocol = self.config.get('Email', 'protocol', fallback='imap').lower() - tls_mode = self.config.get('Email', 'tls_mode', fallback=TLS_MODE_IMPLICIT) - verify_ssl = self.config.getboolean('Email', 'verify_ssl', fallback=True) - conn_timeout = safe_int(self.config.get('Email', 'connection_timeout', fallback=str(DEFAULT_CONNECTION_TIMEOUT)), DEFAULT_CONNECTION_TIMEOUT, min_val=5, max_val=300) - max_emails = safe_int(self.config.get('Email', 'max_emails_per_check', fallback='5'), 5, min_val=1, max_val=100) - ca_bundle = self.config.get('Email', 'ca_bundle', fallback='') + email_username = email_config.username + protocol = email_config.protocol + tls_mode = email_config.tls_mode + max_emails = email_config.max_emails_per_check pop3 = None check_email = email_username and now >= next_run['email'] if check_email: @@ -3673,14 +3722,14 @@ def _monitor_loop(self): if check_email and protocol == 'imap': imap = connect_to_imap( - self.config.get('Email', 'imap_server'), - safe_int(self.config.get('Email', 'imap_port', fallback='993'), DEFAULT_IMAP_PORT, min_val=1, max_val=65535), + email_config.imap_server, + email_config.imap_port, email_username, - self.config.get('Email', 'password'), - timeout=conn_timeout, + email_config.password, + timeout=email_config.connection_timeout, tls_mode=tls_mode, - verify_ssl=verify_ssl, - ca_bundle=ca_bundle, + verify_ssl=email_config.verify_ssl, + ca_bundle=email_config.ca_bundle, ) if imap: @@ -3720,21 +3769,23 @@ def _monitor_loop(self): mark_as_read(imap, email_id) else: # Log connection details for debugging - server = self.config.get('Email', 'imap_server') - port = self.config.get('Email', 'imap_port', fallback='993') - self._log(f"IMAP connection failed to {server}:{port} (TLS: {tls_mode})", "WARNING") + self._log( + f"IMAP connection failed to {email_config.imap_server}:" + f"{email_config.imap_port} (TLS: {tls_mode})", + "WARNING", + ) self._log("Check email_monitor.log for details", "INFO") elif check_email and protocol == 'pop3': pop3 = connect_to_pop3( - self.config.get('Email', 'pop3_server'), - safe_int(self.config.get('Email', 'pop3_port', fallback='995'), DEFAULT_POP3_PORT, min_val=1, max_val=65535), + email_config.pop3_server, + email_config.pop3_port, email_username, - self.config.get('Email', 'password'), - timeout=conn_timeout, + email_config.password, + timeout=email_config.connection_timeout, tls_mode=tls_mode, - verify_ssl=verify_ssl, - ca_bundle=ca_bundle, + verify_ssl=email_config.verify_ssl, + ca_bundle=email_config.ca_bundle, ) if pop3: @@ -3786,9 +3837,11 @@ def _monitor_loop(self): monitor_state.cleanup_old_pop3_messages() else: # Log connection details for debugging - server = self.config.get('Email', 'pop3_server') - port = self.config.get('Email', 'pop3_port', fallback='995') - self._log(f"POP3 connection failed to {server}:{port} (TLS: {tls_mode})", "WARNING") + self._log( + f"POP3 connection failed to {email_config.pop3_server}:" + f"{email_config.pop3_port} (TLS: {tls_mode})", + "WARNING", + ) self._log("Check email_monitor.log for details", "INFO") # ===================================================== diff --git a/tests/test_gui.py b/tests/test_gui.py index e463fdc..d45d706 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -214,3 +214,158 @@ def test_saved_config_is_not_world_readable(app, tmp_path): mode = os.stat(tmp_path / "config.ini").st_mode & 0o777 assert mode == 0o600 + + +# --------------------------------------------------------------------------- +# Monitor loop +# --------------------------------------------------------------------------- + +@pytest.fixture +def one_cycle(monkeypatch): + """Make the monitor loop run exactly one cycle and return.""" + + def arm(app): + def stop_after_this_cycle(timeout=None): + app.stop_event.set() + return True + + monkeypatch.setattr(app.stop_event, "wait", stop_after_this_cycle) + + return arm + + +@pytest.fixture +def slack_sent(monkeypatch): + """Record Slack sends instead of performing them.""" + import e2nb_core + + calls = [] + + def record(token, channel, subject, body, **kwargs): + calls.append({"subject": subject, "body": body}) + return e2nb_core.NotificationResult(True, "Slack", "recorded") + + monkeypatch.setattr(e2nb_core, "send_slack_message", record) + return calls + + +def enable_slack(app): + app.slack_var.set(True) + app.slack_token.set("xoxb-test") + app.slack_channel.set("#alerts") + + +def test_monitor_loop_reports_log_matches(app, tmp_path, one_cycle, slack_sent): + log_path = tmp_path / "app.log" + log_path.write_text("ERROR the roof is on fire\n") + + enable_slack(app) + app.logmon_var.set(True) + app.logmon_logs_text.delete("1.0", tk.END) + app.logmon_logs_text.insert( + "1.0", json.dumps([{"name": "app", "path": str(log_path), "patterns": ["ERROR"]}]) + ) + app._update_config() + + one_cycle(app) + app._monitor_loop() + + assert len(slack_sent) == 1 + assert "roof is on fire" in slack_sent[0]["body"] + + +def test_monitor_loop_applies_source_specific_rules(app, tmp_path, one_cycle, + slack_sent, monkeypatch): + """A rule keyed on source type must apply to a log event. + + Discord stays enabled and unsuppressed, so this fails loudly if the event + was simply never dispatched rather than correctly routed. + """ + import e2nb_core + + discord_sent = [] + + def record_discord(url, subject, body, *args, **kwargs): + discord_sent.append(body) + return e2nb_core.NotificationResult(True, "Discord", "recorded") + + monkeypatch.setattr(e2nb_core, "send_discord_message", record_discord) + + log_path = tmp_path / "app.log" + log_path.write_text("ERROR suppressed on slack only\n") + + enable_slack(app) + app.discord_var.set(True) + app.discord_url.set("https://discord.com/api/webhooks/1/token") + app.logmon_var.set(True) + app.logmon_logs_text.delete("1.0", tk.END) + app.logmon_logs_text.insert( + "1.0", json.dumps([{"name": "app", "path": str(log_path), "patterns": ["ERROR"]}]) + ) + app.rules_var.set(True) + app.rules_text.delete("1.0", tk.END) + app.rules_text.insert( + "1.0", + json.dumps([{"name": "quiet logs", "source_type": "log", + "channels": "slack", "suppress": True}]), + ) + app._update_config() + + one_cycle(app) + app._monitor_loop() + + assert len(discord_sent) == 1, "the log event never reached the dispatcher" + assert slack_sent == [], "the source_type rule did not suppress Slack" + + +def test_monitor_loop_survives_a_failing_source(app, tmp_path, one_cycle, slack_sent, monkeypatch): + """One source raising must not stop the others from being checked.""" + import e2nb_core + + log_path = tmp_path / "app.log" + log_path.write_text("ERROR still delivered\n") + + enable_slack(app) + app.logmon_var.set(True) + app.logmon_logs_text.delete("1.0", tk.END) + app.logmon_logs_text.insert( + "1.0", json.dumps([{"name": "app", "path": str(log_path), "patterns": ["ERROR"]}]) + ) + app.rss_var.set(True) + app._update_config() + + def explode(*args, **kwargs): + raise RuntimeError("feed parser exploded") + + monkeypatch.setattr("e2nb.check_rss_feeds", explode) + assert e2nb_core # imported for clarity about what is being stubbed + + one_cycle(app) + app._monitor_loop() + + assert len(slack_sent) == 1 + + +def test_monitor_loop_skips_sources_that_are_not_due(app, tmp_path, one_cycle, slack_sent): + log_path = tmp_path / "app.log" + log_path.write_text("ERROR first pass\n") + + enable_slack(app) + app.logmon_var.set(True) + app.logmon_interval.set("3600") + app.logmon_logs_text.delete("1.0", tk.END) + app.logmon_logs_text.insert( + "1.0", json.dumps([{"name": "app", "path": str(log_path), "patterns": ["ERROR"]}]) + ) + app._update_config() + + one_cycle(app) + app._monitor_loop() + assert len(slack_sent) == 1 + + # A second run within the hour-long interval still starts from a fresh + # schedule, but the log position has advanced so nothing is re-reported. + app.stop_event.clear() + one_cycle(app) + app._monitor_loop() + assert len(slack_sent) == 1, "a log line was reported twice" From 887e46173a0f47717cd047e7533c10f170dce881 Mon Sep 17 00:00:00 2001 From: Seth Morrow Date: Sun, 13 Sep 2026 13:45:27 +0000 Subject: [PATCH 4/6] Let the daemon's --test accept non-email deployments `e2nb-headless.py --test` failed any configuration without email or the SMTP receiver, even though the daemon's own start-up validation accepts RSS, web, HTTP, database, and log sources on their own. A log-only or endpoint-only install therefore looked broken in the exact step the deployment docs tell people to run first. It now reports which sources are configured and only fails when there is genuinely nothing to monitor. Also: - The daemon schedules on the monotonic clock instead of wall time, so an NTP step or a daylight-saving change cannot stall or double-trigger checks. - The setup script's --help listed defaults that no longer matched the values it uses, and carried a STATE_FILE variable assigned twice and never read. - New tests for the daemon's validation, the --test path, and event severity propagation. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011UHBtwQGR4gt7w4zrsDvCe --- e2nb-headless.py | 32 +++++-- setup-headless-service-kubuntu.sh | 14 ++- tests/test_headless.py | 141 ++++++++++++++++++++++++++++++ 3 files changed, 173 insertions(+), 14 deletions(-) create mode 100644 tests/test_headless.py diff --git a/e2nb-headless.py b/e2nb-headless.py index 453a285..741af83 100644 --- a/e2nb-headless.py +++ b/e2nb-headless.py @@ -482,7 +482,9 @@ def _log_enabled_sources(): max_val=86400 ) - current_time = time.time() + # Monotonic: scheduling must not be disturbed by NTP steps or + # daylight-saving changes moving the wall clock. + current_time = time.monotonic() # ===================================================== # Check Email (IMAP or POP3) with full TLS/OAuth2 support @@ -721,7 +723,7 @@ def _log_enabled_sources(): # Calculate sleep time as minimum of all pending intervals if not self.stop_event.is_set(): - current_time = time.time() + current_time = time.monotonic() pending_intervals = [] if email_config.username: pending_intervals.append(max(0, next_run_times['email'] - current_time)) @@ -1121,10 +1123,28 @@ def _cleanup(daemon): else: print(" (SMTP receiver is enabled as alternative)") elif not smtp_recv_enabled: - print("\nNo email source configured (IMAP or SMTP Receiver)") - print("\nConfiguration test FAILED") - _cleanup(daemon) - return False + # Email is only one of the supported sources. A deployment that only + # watches log files or HTTP endpoints is perfectly valid, so check for + # any configured source before declaring the configuration unusable. + other_sources = [ + name for name, section in ( + ('RSS feeds', 'RSS'), + ('web pages', 'WebMonitor'), + ('HTTP endpoints', 'HttpMonitor'), + ('databases', 'DatabaseMonitor'), + ('log files', 'LogMonitor'), + ) + if daemon.config.getboolean(section, 'enabled', fallback=False) + ] + if other_sources: + print(f"\nNo email source configured; monitoring {', '.join(other_sources)}") + else: + print("\nNo monitoring source configured.") + print("Configure email (IMAP/POP3), the SMTP receiver, or enable RSS,") + print("web page, HTTP endpoint, database, or log file monitoring.") + print("\nConfiguration test FAILED") + _cleanup(daemon) + return False # Show enabled notifications print("\nEnabled notification methods:") diff --git a/setup-headless-service-kubuntu.sh b/setup-headless-service-kubuntu.sh index 1c0badd..10ce714 100755 --- a/setup-headless-service-kubuntu.sh +++ b/setup-headless-service-kubuntu.sh @@ -7,7 +7,6 @@ RUN_GROUP="e2nb2" INSTALL_DIR="/opt/e2nb2" CONFIG_DIR="/etc/e2nb2" LOG_DIR="/var/log/e2nb2" -STATE_FILE="${INSTALL_DIR}/e2nb_state.json" VENV_DIR="${INSTALL_DIR}/.venv" PYTHON_BIN="python3" NON_INTERACTIVE=0 @@ -22,12 +21,12 @@ Usage: $0 [options] Set up E2NB headless mode as a systemd service on Kubuntu/Ubuntu. Options: - --service-name NAME systemd service name (default: e2nb) - --run-user USER service user account (default: e2nb) - --install-dir PATH application install directory (default: /opt/e2nb) - --config-dir PATH configuration directory (default: /etc/e2nb) - --log-dir PATH log directory (default: /var/log/e2nb) - --python-bin PATH python executable to use (default: python3) + --service-name NAME systemd service name (default: ${SERVICE_NAME}) + --run-user USER service user account (default: ${RUN_USER}) + --install-dir PATH application install directory (default: ${INSTALL_DIR}) + --config-dir PATH configuration directory (default: ${CONFIG_DIR}) + --log-dir PATH log directory (default: ${LOG_DIR}) + --python-bin PATH python executable to use (default: ${PYTHON_BIN}) --non-interactive fail instead of prompting for confirmations -y, --yes auto-accept prompts -h, --help show this help message @@ -89,7 +88,6 @@ for required in e2nb_core.py e2nb-headless.py requirements.txt config.ini; do done SERVICE_PATH="/etc/systemd/system/${SERVICE_NAME}.service" -STATE_FILE="${INSTALL_DIR}/e2nb_state.json" log "Installing system packages..." apt-get update diff --git a/tests/test_headless.py b/tests/test_headless.py new file mode 100644 index 0000000..e6b7a25 --- /dev/null +++ b/tests/test_headless.py @@ -0,0 +1,141 @@ +"""Tests for the headless daemon's configuration handling. + +The daemon script is not an importable module name (it contains a hyphen), so +it is loaded by path. +""" + +from __future__ import annotations + +import importlib.util +import json +import os +import sys + +import pytest + +import e2nb_core + +HEADLESS_PATH = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "e2nb-headless.py" +) + + +@pytest.fixture(scope="module") +def headless(): + """Import e2nb-headless.py as a module.""" + spec = importlib.util.spec_from_file_location("e2nb_headless", HEADLESS_PATH) + module = importlib.util.module_from_spec(spec) + sys.modules["e2nb_headless"] = module + spec.loader.exec_module(module) + return module + + +@pytest.fixture +def daemon_config(config_path): + """A config with one notification channel enabled and no sources.""" + config = e2nb_core.load_config(config_path) + config["CustomWebhook"]["enabled"] = "True" + config["CustomWebhook"]["webhook_url"] = "https://example.com/hook" + e2nb_core.save_config(config, config_path) + return config_path + + +def make_daemon(headless, config_path): + daemon = headless.EmailMonitorDaemon(config_path) + daemon.config = e2nb_core.load_config(config_path) + daemon.dispatcher = e2nb_core.NotificationDispatcher(daemon.config) + return daemon + + +def test_validation_requires_a_notification_channel(headless, config_path): + e2nb_core.load_config(config_path) # create the file + daemon = make_daemon(headless, config_path) + + assert daemon._validate_configuration() is False + + +def test_validation_requires_a_source(headless, daemon_config): + daemon = make_daemon(headless, daemon_config) + + assert daemon._validate_configuration() is False + + +@pytest.mark.parametrize( + "section", + ["RSS", "WebMonitor", "HttpMonitor", "DatabaseMonitor", "LogMonitor", "SmtpReceiver"], +) +def test_any_single_source_is_enough(headless, daemon_config, section): + """Email is one source among several; none of them is mandatory.""" + daemon = make_daemon(headless, daemon_config) + daemon.config[section]["enabled"] = "True" + + assert daemon._validate_configuration() is True + + +def test_email_only_config_is_accepted(headless, daemon_config): + daemon = make_daemon(headless, daemon_config) + daemon.config["Email"].update( + { + "protocol": "imap", + "imap_server": "imap.example.com", + "imap_port": "993", + "username": "me@example.com", + "password": "secret", + } + ) + + assert daemon._validate_configuration() is True + + +def test_email_config_missing_a_field_is_rejected(headless, daemon_config): + daemon = make_daemon(headless, daemon_config) + daemon.config["Email"].update( + {"imap_server": "imap.example.com", "username": "me@example.com", "password": ""} + ) + + assert daemon._validate_configuration() is False + + +def test_test_configuration_accepts_a_log_only_deployment(headless, daemon_config, tmp_path): + """`--test` must not reject a valid configuration that has no email source. + + Watching only log files (or HTTP endpoints, or a database) is a supported + deployment, and the daemon's own start-up validation accepts it. + """ + log_file = tmp_path / "app.log" + log_file.write_text("ERROR something\n") + + config = e2nb_core.load_config(daemon_config) + config["LogMonitor"]["enabled"] = "True" + config["LogMonitor"]["logs"] = json.dumps( + [{"name": "app", "path": str(log_file), "patterns": ["ERROR"]}] + ) + e2nb_core.save_config(config, daemon_config) + + assert headless.test_configuration(daemon_config) is True + + +def test_test_configuration_rejects_a_config_with_no_sources(headless, daemon_config): + assert headless.test_configuration(daemon_config) is False + + +def test_dispatch_event_preserves_severity(headless, daemon_config, monkeypatch): + """The daemon must not flatten monitor events into default-severity email.""" + seen = {} + + def capture(event, callback=None): + seen["source_type"] = event.source_type + seen["severity"] = event.severity + return [] + + daemon = make_daemon(headless, daemon_config) + monkeypatch.setattr(daemon.dispatcher, "dispatch_event", capture) + + daemon._dispatch_event( + e2nb_core.MonitorEvent( + source_type="log", source_name="app", title="Match", + body="ERROR something", severity="critical", + ) + ) + + assert seen == {"source_type": "log", "severity": "critical"} From 2a71380874720b4a6957be13925c0bc297622d61 Mon Sep 17 00:00:00 2001 From: Seth Morrow Date: Sun, 13 Sep 2026 13:48:39 +0000 Subject: [PATCH 5/6] Write a log file from the GUI and rotate it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The GUI told users to "check email_monitor.log for details" after a failed connection, but never configured logging, so the file did not exist and the underlying reason — the TLS error, the DNS failure, the API's response body — was discarded. It now sets up file logging at startup like the daemon does. The log file also had no rotation, so a monitor left running for months grew one unbounded file. Both entry points now rotate at 5 MB keeping three older files, and a log path that cannot be opened falls back to console output with a warning instead of stopping startup. Also: themed the plain Tk scrollbars inside the JSON editors and the activity log, and corrected the routing-rule help, which still listed only email, rss, web and http as source types and described last-rule-wins precedence that no longer matches the behaviour. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011UHBtwQGR4gt7w4zrsDvCe --- e2nb.py | 45 ++++++++++++++++++++++++-- e2nb_core.py | 41 ++++++++++++++++++----- readme.md | 26 +++++++++++---- tests/test_logging.py | 75 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 169 insertions(+), 18 deletions(-) create mode 100644 tests/test_logging.py diff --git a/e2nb.py b/e2nb.py index 318a06b..4cb0e08 100644 --- a/e2nb.py +++ b/e2nb.py @@ -54,6 +54,7 @@ __version__, load_config, save_config, + setup_logging, connect_to_imap, connect_to_pop3, fetch_unread_emails, @@ -218,6 +219,32 @@ class Theme: MAX_LOOP_SLEEP = 60 +def style_text_scrollbar(widget, dark: bool = False): + """Flatten the classic Tk scrollbar that ScrolledText builds for itself. + + ScrolledText creates a plain tk.Scrollbar rather than a ttk one, so it does + not pick up the ttk theme and otherwise stands out against the rest of the + interface. + """ + scrollbar = getattr(widget, "vbar", None) + if scrollbar is None: + return + if dark: + trough, thumb, active = Theme.LOG_BG, "#334155", Theme.SIDEBAR_SECTION + else: + trough, thumb, active = Theme.BG_TERTIARY, Theme.BORDER, Theme.BORDER_HOVER + scrollbar.configure( + width=SCROLLBAR_WIDTH, + troughcolor=trough, + background=thumb, + activebackground=active, + borderwidth=0, + elementborderwidth=0, + highlightthickness=0, + relief="flat", + ) + + def configure_ttk_style(): """Configure ttk widgets to match application theme.""" style = ttk.Style() @@ -2419,14 +2446,16 @@ def _create_rules_page(self): help_section.pack(fill="x", pady=(0, 24)) help_text = ( - "Rules are evaluated in order. Each rule can:\n" - " - Match by source_type: email, rss, web, http\n" + "Each rule can:\n" + " - Match by source_type: email, rss, web, http, database, log\n" " - Match by sender_pattern / subject_pattern / body_pattern (regex)\n" " - Match by severity: info, warning, error, critical\n" " - Route to specific channels: sms, voice, whatsapp, slack, slack_dm,\n" " telegram, discord, teams, pushover, ntfy, webhook, email\n" " - Suppress notifications on specific channels\n\n" - "If no rules match a notification, it goes to all enabled channels (default)." + "If no rules match, the notification goes to all enabled channels.\n" + "When several rules match, their routed channels are combined, and\n" + "any rule that suppresses a channel wins over one that routes to it." ) help_label = tk.Label( @@ -2452,6 +2481,7 @@ def _create_rules_page(self): wrap="word", ) self.rules_text.pack(fill="x", padx=16, pady=12) + style_text_scrollbar(self.rules_text) rules_json = self.config.get('NotificationRules', 'rules', fallback='[]') # Pretty-print the JSON for readability @@ -2753,6 +2783,7 @@ def on_rss_toggle(*args): insertbackground=Theme.PRIMARY, wrap="word", ) self.rss_feeds_text.pack(fill="x") + style_text_scrollbar(self.rss_feeds_text) self.rss_feeds_text.insert("1.0", self.config.get('RSS', 'feeds', fallback='[]')) # Test button @@ -2858,6 +2889,7 @@ def _create_webmon_page(self): insertbackground=Theme.PRIMARY, wrap="word", ) self.webmon_pages_text.pack(fill="x") + style_text_scrollbar(self.webmon_pages_text) self.webmon_pages_text.insert("1.0", self.config.get('WebMonitor', 'pages', fallback='[]')) # Validate button @@ -2933,6 +2965,7 @@ def _create_httpmon_page(self): insertbackground=Theme.PRIMARY, wrap="word", ) self.httpmon_endpoints_text.pack(fill="x") + style_text_scrollbar(self.httpmon_endpoints_text) self.httpmon_endpoints_text.insert("1.0", self.config.get('HttpMonitor', 'endpoints', fallback='[]')) # Validate button @@ -3018,6 +3051,7 @@ def _create_json_list_editor( insertbackground=Theme.PRIMARY, wrap="word", ) editor.pack(fill="x") + style_text_scrollbar(editor) editor.insert("1.0", value) return editor @@ -3271,6 +3305,7 @@ def _create_logs_page(self): spacing3=2, ) self.log_text.pack(fill="both", expand=True) + style_text_scrollbar(self.log_text, dark=True) self.log_text.tag_configure('INFO', foreground=Theme.LOG_INFO) self.log_text.tag_configure('WARNING', foreground=Theme.LOG_WARNING) @@ -3934,6 +3969,10 @@ def _on_close(self): def main(): + # The activity panel shows what happened; the log file records why. Several + # failures (TLS handshakes, API error bodies) are only reported through the + # core module's logger, and the UI points users at this file for detail. + setup_logging(console=False) root = tk.Tk() EmailMonitorApp(root) root.mainloop() diff --git a/e2nb_core.py b/e2nb_core.py index a27bc4f..6549901 100644 --- a/e2nb_core.py +++ b/e2nb_core.py @@ -22,10 +22,12 @@ import os import re import smtplib +import sys import ssl import tempfile import time from dataclasses import dataclass, field +from logging.handlers import RotatingFileHandler from datetime import datetime, timedelta, timezone import email as email_module from email.header import decode_header @@ -100,6 +102,10 @@ DEFAULT_RSS_MAX_AGE_HOURS = 24 DEFAULT_RSS_MAX_ITEMS = 10 +# Log rotation: keep the monitor's own log from growing without bound. +LOG_MAX_BYTES = 5 * 1024 * 1024 +LOG_BACKUP_COUNT = 3 + # TLS mode constants TLS_MODE_IMPLICIT = 'implicit' # SSL connection from the start (port 993/995/465) TLS_MODE_EXPLICIT = 'explicit' # STARTTLS/STLS upgrade (port 143/110/587) @@ -633,7 +639,7 @@ class NotificationRule: name: str = "" enabled: bool = True # Conditions (all must match if specified) - source_type: str = "" # 'email', 'rss', 'web', 'http', '' = any + source_type: str = "" # 'email', 'rss', 'web', 'http', 'database', 'log', '' = any sender_pattern: str = "" # Regex pattern to match sender/source subject_pattern: str = "" # Regex pattern to match subject/title body_pattern: str = "" # Regex pattern to match body content @@ -1305,7 +1311,7 @@ class MonitorEvent: This unified event class allows all monitoring sources (email, RSS, web, HTTP) to produce events that can be dispatched through the same notification channels. """ - source_type: str # 'email', 'rss', 'web', 'http' + source_type: str # 'email', 'rss', 'web', 'http', 'database', 'log' source_name: str # User-defined name or identifier title: str # Event title/subject body: str # Event details/content @@ -1862,21 +1868,33 @@ def create_default_config(config_file: str = CONFIG_FILE_PATH) -> None: def setup_logging( log_file: str = LOG_FILE_PATH, level: int = logging.INFO, - console: bool = True + console: bool = True, + max_bytes: int = LOG_MAX_BYTES, + backup_count: int = LOG_BACKUP_COUNT, ) -> None: """ Configure logging for the application. + The log file is rotated by size: a monitor that runs for months would + otherwise fill the disk with a single ever-growing file. + Args: log_file: Path to the log file. level: Logging level. console: Whether to also log to console. + max_bytes: Rotate once the log file reaches this size. + backup_count: How many rotated files to keep. """ root_logger = logging.getLogger() root_logger.setLevel(level) # Clear existing handlers - root_logger.handlers.clear() + for handler in root_logger.handlers[:]: + root_logger.removeHandler(handler) + try: + handler.close() + except Exception: + pass formatter = logging.Formatter( '[%(asctime)s] %(levelname)s: %(message)s', @@ -1884,10 +1902,17 @@ def setup_logging( ) # File handler - file_handler = logging.FileHandler(log_file) - file_handler.setLevel(level) - file_handler.setFormatter(formatter) - root_logger.addHandler(file_handler) + try: + file_handler = RotatingFileHandler( + log_file, maxBytes=max_bytes, backupCount=backup_count, + ) + file_handler.setLevel(level) + file_handler.setFormatter(formatter) + root_logger.addHandler(file_handler) + except OSError as e: + # An unwritable log location must not stop the application from running. + console = True + print(f"Warning: could not open log file {log_file}: {e}", file=sys.stderr) # Console handler if console: diff --git a/readme.md b/readme.md index e695123..2ead23f 100644 --- a/readme.md +++ b/readme.md @@ -1009,7 +1009,7 @@ rules = [ |-------|-------------|---------| | `name` | Display name for the rule | *(required)* | | `enabled` | Enable/disable this specific rule | `true` | -| `source_type` | Match source type: `email`, `rss`, `web`, `http`, or empty for any | *(empty)* | +| `source_type` | Match source type: `email`, `rss`, `web`, `http`, `database`, `log`, or empty for any | *(empty)* | | `sender_pattern` | Regex pattern to match against sender | *(empty)* | | `subject_pattern` | Regex pattern to match against subject/title | *(empty)* | | `body_pattern` | Regex pattern to match against body content | *(empty)* | @@ -1019,12 +1019,20 @@ rules = [ | `priority_override` | Override Pushover priority for matching notifications | *(empty)* | **How rules work**: -1. Rules are evaluated in order for each notification -2. All non-empty pattern fields must match for a rule to apply -3. If `suppress` is true, the notification is dropped entirely -4. If `channels` is specified, only those channels receive the notification -5. Multiple rules can match; the last matching rule's settings take precedence -6. If no rules match, all enabled channels receive the notification (default behavior) +1. Every rule is evaluated against each notification +2. All non-empty condition fields must match for a rule to apply +3. If no rules match, all enabled channels receive the notification (default behavior) +4. If any matching rule names `channels`, delivery is limited to the union of the + channels named by all matching routing rules — two rules routing to different + channels both take effect +5. Suppression is applied last, so a rule with `suppress` wins over a rule that routes + to the same channel. A `suppress` rule with no `channels` drops the notification + entirely +6. The first matching rule with a `priority_override` sets the priority + +Rules match on the source type and severity of the event that produced the notification, +so a rule with `"source_type": "log"` or `"severity": "critical"` applies to events from +log, database, HTTP, web, and RSS monitoring as well as to email. **Example use cases**: - Route critical alerts to SMS and voice calls @@ -1094,6 +1102,10 @@ The Logs page shows real-time monitoring activity with: - Auto-scroll toggle - Clear Logs button +The panel shows what happened; `email_monitor.log` in the working directory records the +detail behind it — TLS handshake errors, API error bodies, and similar. Both the GUI and +the daemon write it, and it is rotated at 5 MB with three older files kept. + --- ## Headless Daemon diff --git a/tests/test_logging.py b/tests/test_logging.py new file mode 100644 index 0000000..82781b5 --- /dev/null +++ b/tests/test_logging.py @@ -0,0 +1,75 @@ +"""Tests for log file setup and rotation.""" + +from __future__ import annotations + +import logging +import os + +import pytest + +from e2nb_core import setup_logging + + +@pytest.fixture(autouse=True) +def restore_root_logger(): + """setup_logging reconfigures the root logger; put it back afterwards.""" + root = logging.getLogger() + saved_handlers = root.handlers[:] + saved_level = root.level + yield + for handler in root.handlers[:]: + root.removeHandler(handler) + try: + handler.close() + except Exception: + pass + for handler in saved_handlers: + root.addHandler(handler) + root.setLevel(saved_level) + + +def test_messages_reach_the_log_file(tmp_path): + log_file = tmp_path / "monitor.log" + setup_logging(str(log_file), console=False) + + logging.getLogger("e2nb_core").error("connection refused") + + assert "connection refused" in log_file.read_text() + + +def test_log_file_is_rotated_by_size(tmp_path): + """A monitor left running for months must not fill the disk.""" + log_file = tmp_path / "monitor.log" + setup_logging(str(log_file), console=False, max_bytes=2048, backup_count=2) + + for index in range(200): + logging.getLogger("e2nb_core").info("x" * 100 + f" {index}") + + rotated = sorted(p.name for p in tmp_path.iterdir()) + assert "monitor.log" in rotated + assert "monitor.log.1" in rotated, f"log was never rotated: {rotated}" + # backup_count caps the number of files kept around. + assert "monitor.log.3" not in rotated + for path in tmp_path.iterdir(): + assert path.stat().st_size < 8192 + + +def test_unwritable_log_location_does_not_raise(tmp_path, capsys): + """A bad log path must degrade to console logging, not stop startup.""" + unwritable = tmp_path / "no-such-directory" / "monitor.log" + + setup_logging(str(unwritable), console=False) + + logging.getLogger("e2nb_core").warning("still running") + assert "could not open log file" in capsys.readouterr().err + assert not os.path.exists(unwritable) + + +def test_repeated_setup_does_not_duplicate_handlers(tmp_path): + log_file = tmp_path / "monitor.log" + setup_logging(str(log_file), console=False) + setup_logging(str(log_file), console=False) + + logging.getLogger("e2nb_core").error("once") + + assert log_file.read_text().count("once") == 1 From f2a5478d7f569d6ae312fcc75e2894bf6ac51a0a Mon Sep 17 00:00:00 2001 From: Seth Morrow Date: Sun, 13 Sep 2026 13:59:57 +0000 Subject: [PATCH 6/6] Ship config.ini.sample and stop tracking config.ini config.ini held credentials once filled in, yet was tracked, so a working install had to either commit its secrets or carry a permanent dirty file. It is now gitignored, and config.ini.sample is the tracked template. The sample is a rewrite rather than a rename: - It documents all 21 sections. The old tracked file had drifted and was missing [Teams], [Pushover] and [Ntfy] entirely, along with several keys. - Every channel ships disabled with empty credentials. The old file enabled Twilio, WhatsApp, Slack, Telegram and Discord with placeholder tokens, so a copied config immediately failed against five fake endpoints. - Each setting carries a comment explaining what it does and, where the value is JSON, an example. A test asserts the sample and DEFAULT_CONFIG_SECTIONS describe the same settings, so they cannot drift apart again the way the old template did. Supporting changes: - The advanced email keys the GUI writes (tls_mode, verify_ssl, ca_bundle, connection_timeout, max_emails_per_check) are part of the schema, so a generated config and the sample match. - E2NB writes config.ini mode 0600, but a file copied from the sample by hand keeps the default 0644. Loading a config other users can read now logs a warning naming the chmod that fixes it. - The installer deploys config.ini.sample as both the starter config and a reference copy next to it. - README documents the copy step, the permissions, and what existing users should expect when pulling a commit that untracks their config.ini. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011UHBtwQGR4gt7w4zrsDvCe --- .gitignore | 8 +- config.ini | 142 -------------- config.ini.sample | 302 ++++++++++++++++++++++++++++++ e2nb_core.py | 39 ++++ readme.md | 35 +++- setup-headless-service-kubuntu.sh | 12 +- tests/test_config.py | 23 +++ tests/test_sample_config.py | 100 ++++++++++ 8 files changed, 505 insertions(+), 156 deletions(-) delete mode 100644 config.ini create mode 100644 config.ini.sample create mode 100644 tests/test_sample_config.py diff --git a/.gitignore b/.gitignore index c5df3fd..b9d849c 100644 --- a/.gitignore +++ b/.gitignore @@ -11,11 +11,11 @@ venv/ env/ # Runtime artifacts -# config.ini holds credentials once it is filled in. The committed copy is a -# template with placeholder values; if you would rather keep yours out of git -# entirely, run: git update-index --skip-worktree config.ini +# config.ini holds credentials in plain text and is never committed. +# config.ini.sample is the tracked template; copy it to config.ini to start. +config.ini config.local.ini -config.ini.bak +config.ini.bak* *.ini.tmp .config-*.tmp e2nb_state.json diff --git a/config.ini b/config.ini deleted file mode 100644 index f968ac2..0000000 --- a/config.ini +++ /dev/null @@ -1,142 +0,0 @@ -[Email] -protocol = imap -imap_server = imap.gmail.com -imap_port = 993 -pop3_server = pop.gmail.com -pop3_port = 995 -username = your.email@gmail.com -password = your-app-specific-password -filter_emails = important@company.com, @trusted-domain.com - -[Settings] -max_sms_length = 1600 -check_interval = 60 - -[Twilio] -enabled = True -account_sid = AC123456789abcdef123456789abcdef12 -auth_token = 12345678901234567890abcdefghijkl -from_number = +11234567890 -destination_number = +10987654321, +10987654322 - -[Voice] -enabled = False -account_sid = AC123456789abcdef123456789abcdef12 -auth_token = 12345678901234567890abcdefghijkl -from_number = +11234567890 -destination_number = +10987654321 - -[WhatsApp] -enabled = True -account_sid = AC123456789abcdef123456789abcdef12 -auth_token = 12345678901234567890abcdefghijkl -from_number = whatsapp:+11234567890 -to_number = whatsapp:+10987654321 - -[Slack] -enabled = True -token = xoxb-1234567890-1234567890123-abcdefghijklmnopqrstuvwx -channel = #notifications - -[Telegram] -enabled = True -bot_token = 1234567890:ABCdefGHIjklMNOpqrsTUVwxyz12345678 -chat_id = -1001234567890 - -[Discord] -enabled = True -webhook_url = https://discord.com/api/webhooks/123456789012345678/ABCdefGHIjklMNOpqrsTUVwxyz12345678901234567890abcdefghijklmno - -[CustomWebhook] -enabled = False -webhook_url = https://api.your-service.com/webhook/12345678-90ab-cdef-1234-567890abcdef - -[SMTP] -enabled = False -smtp_server = smtp.gmail.com -smtp_port = 587 -use_tls = True -username = -password = -from_address = -to_addresses = -subject_prefix = [E2NB] - -[SmtpReceiver] -enabled = False -host = 0.0.0.0 -port = 2525 -use_auth = False -username = -password = -filter_emails = - -[RSS] -enabled = False -feeds = [] -check_interval = 300 -max_age_hours = 24 -max_items_per_check = 10 - -[WebMonitor] -enabled = False -pages = [] -check_interval = 300 - -[HttpMonitor] -enabled = False -endpoints = [] -check_interval = 60 - -# Database Monitoring - monitors database health and query results -# Supports: postgresql, mysql, mariadb, sqlite -[DatabaseMonitor] -enabled = False -databases = [] -check_interval = 300 -notify_on_first_check = False -default_timeout = 30 -# Example section-based config: -# [DatabaseMonitor.main_db] -# type = postgresql -# host = localhost -# port = 5432 -# database = myapp -# username = admin -# password = secret -# query = SELECT COUNT(*) FROM active_users -# expected_value = >0 -# timeout = 30 -# slow_query_threshold = 5.0 - -# Log File Monitoring - monitors logs for patterns/errors -[LogMonitor] -enabled = False -logs = [] -check_interval = 60 -max_lines_per_check = 1000 -default_rate_limit = 10 -# Example section-based config: -# [LogMonitor.app_errors] -# path = /var/log/myapp/error.log -# patterns = ["ERROR", "CRITICAL", "Exception"] -# severity_patterns = {"error": "ERROR|EXCEPTION", "critical": "CRITICAL|FATAL"} -# exclude_patterns = ["DEBUG", "healthcheck"] -# rate_limit = 5 - -# Message Templates - customize notification content with variables -# Available variables: {source_type}, {source_name}, {title}, {body}, -# {timestamp}, {severity}, {url}, {hostname}, {date}, {time} -[MessageTemplates] -enabled = False -default_subject = [{severity}] {source_type}: {title} -default_body = {body} -channel_templates = {} -custom_variables = {} -# Example channel_templates JSON: -# {"slack": {"subject": ":bell: {title}", "body": "*Source:* {source_name}\n{body}"}} - -# Notification Routing Rules -[NotificationRules] -enabled = False -rules = [] diff --git a/config.ini.sample b/config.ini.sample new file mode 100644 index 0000000..192e88f --- /dev/null +++ b/config.ini.sample @@ -0,0 +1,302 @@ +# ============================================================================= +# E2NB - Email to Notification Blaster - sample configuration +# ============================================================================= +# +# Copy this file to config.ini and fill in your own values: +# +# cp config.ini.sample config.ini +# +# config.ini is ignored by git because it holds credentials in plain text. +# Running e2nb.py or e2nb-headless.py without a config.ini creates one with +# these defaults, so copying this file is optional - it exists to document +# every setting in one place. +# +# Everything is disabled by default. Enable the sources you want to watch and +# the channels you want alerts delivered to, then start monitoring. +# +# Full reference: readme.md +# ============================================================================= + + +# ----------------------------------------------------------------------------- +# Email source (IMAP or POP3) +# ----------------------------------------------------------------------------- +# Leave username empty to monitor something other than email. +# Gmail and most providers with 2FA need an app-specific password, not your +# account password. +[Email] +protocol = imap +imap_server = imap.gmail.com +imap_port = 993 +pop3_server = pop.gmail.com +pop3_port = 995 +username = +password = +# Only notify for mail from these senders. Comma-separated; an entry starting +# with @ matches a whole domain. Empty means accept everything. +# Example: alerts@company.com, @trusted-domain.com +filter_emails = +# implicit = SSL from the start (993/995), explicit = STARTTLS (143/110), +# none = unencrypted (not recommended) +tls_mode = implicit +verify_ssl = True +# Path to a custom CA bundle; empty uses the system trust store +ca_bundle = +connection_timeout = 30 +max_emails_per_check = 5 +# OAuth2 (XOAUTH2) instead of a password - add these keys if you need it: +# oauth2_enabled = True +# oauth2_client_id = +# oauth2_client_secret = +# oauth2_refresh_token = +# oauth2_token_url = https://oauth2.googleapis.com/token + + +# ----------------------------------------------------------------------------- +# General settings +# ----------------------------------------------------------------------------- +[Settings] +# Characters before an SMS is truncated (100-10000) +max_sms_length = 1600 +# Seconds between email checks (10-86400). Other sources have their own +# check_interval in their own section. +check_interval = 60 + + +# ============================================================================= +# Notification channels +# ============================================================================= +# Every enabled channel receives every notification, unless [NotificationRules] +# says otherwise. + +# Twilio SMS - destination_number accepts a comma-separated list +[Twilio] +enabled = False +account_sid = +auth_token = +from_number = +destination_number = + +# Twilio voice calls - the message is read aloud +[Voice] +enabled = False +account_sid = +auth_token = +from_number = +destination_number = + +# Twilio WhatsApp - numbers must carry the whatsapp: prefix +# Example: whatsapp:+11234567890 +[WhatsApp] +enabled = False +account_sid = +auth_token = +from_number = +to_number = + +# Slack - token starts with xoxb-, channel like #alerts or a channel ID +[Slack] +enabled = False +token = +channel = +# Comma-separated user IDs to @-mention in the message +mention_users = +# Comma-separated user IDs to also send a direct message to +dm_users = +# Rich Block Kit formatting; set to False for plain text +use_blocks = True + +# Telegram - create a bot with @BotFather to get the token +[Telegram] +enabled = False +bot_token = +chat_id = + +# Discord - server settings > integrations > webhooks +[Discord] +enabled = False +webhook_url = + +# Microsoft Teams - incoming webhook or workflow URL +[Teams] +enabled = False +webhook_url = +use_adaptive_card = True + +# Pushover - register an application at pushover.net +[Pushover] +enabled = False +api_token = +user_key = +# -2 (lowest) to 2 (emergency) +priority = 0 +# Sound name, empty for the account default +sound = +# Target a single device, empty for all +device = + +# ntfy - self-hosted or ntfy.sh +[Ntfy] +enabled = False +server_url = https://ntfy.sh +topic = +# min, low, default, high, urgent +priority = default +# Comma-separated emoji shortcodes or tags, e.g. warning,skull +tags = +# Bearer token for protected topics +auth_token = + +# Any HTTP endpoint. Receives a JSON body with subject, body, sender, +# timestamp, source_type and severity. +[CustomWebhook] +enabled = False +webhook_url = + +# Forward as email over SMTP - to_addresses accepts a comma-separated list +[SMTP] +enabled = False +smtp_server = smtp.gmail.com +smtp_port = 587 +use_tls = True +username = +password = +from_address = +to_addresses = +subject_prefix = [E2NB] + + +# ============================================================================= +# Monitoring sources +# ============================================================================= + +# Receive mail by running a local SMTP server instead of polling a mailbox. +# Point a device or application at this host and port. +[SmtpReceiver] +enabled = False +host = 0.0.0.0 +port = 2525 +use_auth = False +username = +password = +# Comma-separated senders/domains to accept; empty accepts everything +filter_emails = + +# RSS / Atom feeds +# feeds example: +# [{"name": "Status blog", "url": "https://example.com/feed.xml", +# "keywords": ["outage", "incident"]}] +[RSS] +enabled = False +feeds = [] +check_interval = 300 +# Ignore items published more than this many hours ago +max_age_hours = 24 +max_items_per_check = 10 + +# Web page change detection +# pages example: +# [{"name": "Pricing", "url": "https://example.com/pricing", "selector": ".price"}] +[WebMonitor] +enabled = False +pages = [] +check_interval = 300 +notify_on_first_check = False +# Pages may also be declared in their own sections: +# [WebMonitor.product_price] +# url = https://example.com/product +# selector = .price +# timeout = 30 + +# HTTP endpoint availability +# endpoints example: +# [{"name": "API health", "url": "https://api.example.com/health", +# "method": "GET", "expected_status": 200, "failure_threshold": 3}] +[HttpMonitor] +enabled = False +endpoints = [] +check_interval = 60 +notify_on_first_check = False +default_failure_threshold = 1 +# Endpoints may also be declared in their own sections: +# [HttpMonitor.api_health] +# url = https://api.example.com/health +# method = GET +# expected_status = 200 +# expected_text = "status":"ok" +# timeout = 30 +# failure_threshold = 3 + +# Database health and query results +# Supports postgresql, mysql, mariadb (drivers optional) and sqlite (built in). +# databases example: +# [{"name": "App DB", "type": "postgresql", "host": "localhost", "port": 5432, +# "database": "myapp", "username": "admin", "password": "secret", +# "query": "SELECT COUNT(*) FROM active_users", "expected_value": ">0"}] +[DatabaseMonitor] +enabled = False +databases = [] +check_interval = 300 +notify_on_first_check = False +default_timeout = 30 +# Databases may also be declared in their own sections: +# [DatabaseMonitor.main_db] +# type = postgresql +# host = localhost +# port = 5432 +# database = myapp +# username = admin +# password = secret +# query = SELECT COUNT(*) FROM active_users +# expected_value = >0 +# timeout = 30 +# slow_query_threshold = 5.0 + +# Log file pattern matching +# logs example: +# [{"name": "App errors", "path": "/var/log/myapp/error.log", +# "patterns": ["ERROR", "CRITICAL"], "exclude_patterns": ["healthcheck"]}] +[LogMonitor] +enabled = False +logs = [] +check_interval = 60 +# Lines read per file per cycle +max_lines_per_check = 1000 +# Maximum alerts per log per cycle, so one burst cannot flood you +default_rate_limit = 10 +# Logs may also be declared in their own sections: +# [LogMonitor.app_errors] +# path = /var/log/myapp/error.log +# patterns = ["ERROR", "CRITICAL", "Exception"] +# severity_patterns = {"error": "ERROR|EXCEPTION", "critical": "CRITICAL|FATAL"} +# exclude_patterns = ["DEBUG", "healthcheck"] +# rate_limit = 5 + + +# ============================================================================= +# Delivery behaviour +# ============================================================================= + +# Route notifications conditionally. Matching rules combine their channels, and +# a rule that suppresses a channel wins over one that routes to it. With no +# matching rule, every enabled channel is used. +# rules example: +# [{"name": "Errors to SMS", "severity": "error", "channels": "sms,slack"}, +# {"name": "No RSS on Discord", "source_type": "rss", "channels": "discord", +# "suppress": true}] +[NotificationRules] +enabled = False +rules = [] + +# Customise message text. +# Variables: {source_type} {source_name} {title} {body} {timestamp} {severity} +# {url} {hostname} {date} {time} +[MessageTemplates] +enabled = False +default_subject = [{severity}] {source_type}: {title} +default_body = {body} +# Per-channel overrides, e.g. +# {"slack": {"subject": ":bell: {title}", "body": "*Source:* {source_name}\n{body}"}} +channel_templates = {} +# Extra variables of your own, e.g. {"team": "platform"} for use as {team} +custom_variables = {} diff --git a/e2nb_core.py b/e2nb_core.py index 6549901..68ebe82 100644 --- a/e2nb_core.py +++ b/e2nb_core.py @@ -1587,6 +1587,11 @@ def create_http_session( 'username': '', 'password': '', 'filter_emails': '', + 'tls_mode': TLS_MODE_IMPLICIT, + 'verify_ssl': 'True', + 'ca_bundle': '', + 'connection_timeout': str(DEFAULT_CONNECTION_TIMEOUT), + 'max_emails_per_check': str(MAX_EMAILS_PER_CHECK), }, 'Settings': { 'max_sms_length': str(DEFAULT_MAX_SMS_LENGTH), @@ -1797,9 +1802,43 @@ def load_config(config_file: str = CONFIG_FILE_PATH) -> configparser.ConfigParse if ensure_config_sections(config): logger.info("Configuration was missing sections or keys; defaults applied in memory") + + warn_if_config_is_exposed(config_file) return config +def warn_if_config_is_exposed(config_file: str) -> bool: + """ + Warn when the configuration file is readable by other users. + + E2NB writes the file with owner-only permissions, but one copied from + config.ini.sample by hand keeps the default mode and can end up + world-readable with real credentials in it. + + Args: + config_file: Path to the configuration file. + + Returns: + True if a warning was issued. + """ + if os.name == 'nt': + return False + try: + mode = os.stat(config_file).st_mode + except OSError: + return False + + if not mode & 0o077: + return False + + logger.warning( + f"{config_file} is readable by other users (mode {oct(mode & 0o777)}) and " + "stores credentials in plain text. Restrict it with: " + f"chmod 600 {config_file}" + ) + return True + + def save_config(config: configparser.ConfigParser, config_file: str = CONFIG_FILE_PATH) -> None: """ Save configuration to an INI file atomically. diff --git a/readme.md b/readme.md index 2ead23f..212ac6c 100644 --- a/readme.md +++ b/readme.md @@ -166,6 +166,27 @@ pip install psycopg2-binary # For PostgreSQL monitoring pip install mysql-connector-python # For MySQL/MariaDB monitoring ``` +### Configuration file + +Settings live in `config.ini`, which is **not** tracked in git because it holds +credentials in plain text. `config.ini.sample` is the tracked template and documents +every available setting: + +```bash +cp config.ini.sample config.ini +chmod 600 config.ini # it will hold credentials in plain text +``` + +Copying is optional — starting either the GUI or the daemon without a `config.ini` +creates one with the same defaults, already restricted to owner-only. Everything in the +sample ships disabled, so nothing is sent until you enable a source and a channel. E2NB +logs a warning at startup if it finds a `config.ini` other users can read. + +> **Upgrading from a version that tracked `config.ini`:** your existing file is left +> alone. Git will report a modify/delete conflict on pull if you had edited it; keep +> your copy (`git checkout --ours config.ini` or simply restore it from a backup) — it +> is now ignored, so it will stay out of `git status` from then on. + ## Quick Start ### 1. Run the GUI to configure @@ -178,7 +199,7 @@ A config.ini file is created automatically on first run with default values. ### 2. Configure a source -In the sidebar under **Sources**, click **Email** and enter your IMAP/POP3 server details and credentials. Or enable one of the other sources (RSS, Web, HTTP). +In the sidebar under **Sources**, click **Email** and enter your IMAP/POP3 server details and credentials. Or enable one of the other sources (RSS, Web Pages, HTTP Endpoints, Databases, Log Files). ### 3. Configure a notification channel @@ -1420,10 +1441,10 @@ E2NB persists monitoring state in `e2nb_state.json` to survive restarts. This fi - **Sandboxed systemd deployment**: Example service file includes security hardening directives **Credentials are stored in plain text.** `config.ini` is a plain INI file; file permissions -are the only thing protecting it. Keep it off shared machines, do not commit a filled-in -copy (the tracked file is a placeholder template — `git update-index --skip-worktree -config.ini` keeps your edits out of `git status`), and prefer app-specific passwords or -OAuth2 over your primary account password. +are the only thing protecting it. It is listed in `.gitignore` so it cannot be committed +by accident — `config.ini.sample` is the tracked template and carries no credentials. +Keep your `config.ini` off shared machines, and prefer app-specific passwords or OAuth2 +over your primary account password. --- @@ -1729,10 +1750,12 @@ for every push and pull request, and byte-compiles all three entry points. | `e2nb_core.py` | All shared logic: config, sources, notification channels, state | | `e2nb.py` | Tk desktop application | | `e2nb-headless.py` | Daemon entry point for servers | +| `config.ini.sample` | Tracked configuration template; `config.ini` is gitignored | | `tests/` | pytest suite | When adding a monitoring source or notification channel, put the logic in `e2nb_core.py` -so the GUI and the daemon both get it, add its defaults to `DEFAULT_CONFIG_SECTIONS`, +so the GUI and the daemon both get it, add its defaults to `DEFAULT_CONFIG_SECTIONS` +and to `config.ini.sample` (a test enforces that the two agree), and cover it with tests. --- diff --git a/setup-headless-service-kubuntu.sh b/setup-headless-service-kubuntu.sh index 10ce714..0ee1a2e 100755 --- a/setup-headless-service-kubuntu.sh +++ b/setup-headless-service-kubuntu.sh @@ -79,7 +79,7 @@ if [[ -f /etc/os-release ]]; then fi fi -for required in e2nb_core.py e2nb-headless.py requirements.txt config.ini; do +for required in e2nb_core.py e2nb-headless.py requirements.txt config.ini.sample; do if [[ ! -f "${SCRIPT_DIR}/${required}" ]]; then err "Missing required file in script directory: ${required}" err "Run this script from the cloned e2nb repository." @@ -109,16 +109,20 @@ install -m 0644 "${SCRIPT_DIR}/e2nb_core.py" "${INSTALL_DIR}/e2nb_core.py" install -m 0644 "${SCRIPT_DIR}/e2nb-headless.py" "${INSTALL_DIR}/e2nb-headless.py" install -m 0644 "${SCRIPT_DIR}/requirements.txt" "${INSTALL_DIR}/requirements.txt" +# Always refresh the sample so the reference next to the live config matches +# the installed version. +install -m 0640 "${SCRIPT_DIR}/config.ini.sample" "${CONFIG_DIR}/config.ini.sample" + if [[ -f "${CONFIG_DIR}/config.ini" ]]; then warn "Existing config found: ${CONFIG_DIR}/config.ini" - if confirm "Back up and replace config.ini with repository template?"; then + if confirm "Back up and replace config.ini with the sample template?"; then cp -a "${CONFIG_DIR}/config.ini" "${CONFIG_DIR}/config.ini.bak.$(date +%Y%m%d%H%M%S)" - install -m 0640 "${SCRIPT_DIR}/config.ini" "${CONFIG_DIR}/config.ini" + install -m 0640 "${SCRIPT_DIR}/config.ini.sample" "${CONFIG_DIR}/config.ini" else log "Keeping existing config.ini" fi else - install -m 0640 "${SCRIPT_DIR}/config.ini" "${CONFIG_DIR}/config.ini" + install -m 0640 "${SCRIPT_DIR}/config.ini.sample" "${CONFIG_DIR}/config.ini" log "Installed starter config at ${CONFIG_DIR}/config.ini" fi diff --git a/tests/test_config.py b/tests/test_config.py index cf48256..43dde8a 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -82,6 +82,29 @@ def test_save_config_restricts_permissions(config_path): assert mode == 0o600, f"config holds credentials but is mode {oct(mode)}" +@pytest.mark.skipif(os.name == "nt", reason="POSIX permissions only") +def test_loading_a_world_readable_config_warns(config_path, caplog): + """A config copied from the sample by hand keeps the default 644.""" + load_config(config_path) + os.chmod(config_path, 0o644) + + with caplog.at_level("WARNING"): + load_config(config_path) + + assert "readable by other users" in caplog.text + assert "chmod 600" in caplog.text + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX permissions only") +def test_owner_only_config_does_not_warn(config_path, caplog): + load_config(config_path) # written 0600 by save_config + + with caplog.at_level("WARNING"): + load_config(config_path) + + assert "readable by other users" not in caplog.text + + def test_save_config_leaves_original_intact_on_failure(config_path, monkeypatch): config = load_config(config_path) config["Slack"]["token"] = "original-token" diff --git a/tests/test_sample_config.py b/tests/test_sample_config.py new file mode 100644 index 0000000..2535bbf --- /dev/null +++ b/tests/test_sample_config.py @@ -0,0 +1,100 @@ +"""Checks that config.ini.sample stays in step with the configuration schema. + +The sample is the file users copy and the installer deploys, so a setting that +exists in code but not here is effectively undocumented. The previous tracked +template had drifted far enough to be missing [Teams], [Pushover] and [Ntfy] +entirely. +""" + +from __future__ import annotations + +import os + +import pytest + +from e2nb_core import ( + DEFAULT_CONFIG_SECTIONS, + NotificationDispatcher, + load_config, + new_config_parser, +) + +SAMPLE_PATH = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config.ini.sample" +) + + +@pytest.fixture(scope="module") +def sample(): + config = new_config_parser() + read = config.read(SAMPLE_PATH) + assert read, f"config.ini.sample not found at {SAMPLE_PATH}" + return config + + +def test_sample_covers_every_section(sample): + missing = sorted(set(DEFAULT_CONFIG_SECTIONS) - set(sample.sections())) + assert missing == [], f"config.ini.sample is missing sections: {missing}" + + +def test_sample_has_no_unknown_sections(sample): + extra = sorted(set(sample.sections()) - set(DEFAULT_CONFIG_SECTIONS)) + assert extra == [], f"config.ini.sample documents unknown sections: {extra}" + + +@pytest.mark.parametrize("section", sorted(DEFAULT_CONFIG_SECTIONS)) +def test_sample_covers_every_key(sample, section): + expected = set(DEFAULT_CONFIG_SECTIONS[section]) + missing = sorted(expected - set(sample[section])) + assert missing == [], f"[{section}] in config.ini.sample is missing: {missing}" + + +def test_sample_ships_everything_disabled(sample): + """A copied sample must sit quiet until the user turns something on.""" + enabled = [ + section for section in sample.sections() + if sample.has_option(section, "enabled") + and sample.getboolean(section, "enabled") + ] + assert enabled == [], f"these sections are enabled in the sample: {enabled}" + + +def test_sample_carries_no_credentials(sample): + """Placeholder secrets get copied into real deployments; ship none.""" + secret_keys = ( + "password", "auth_token", "token", "account_sid", "api_token", + "user_key", "bot_token", "webhook_url", "username", + ) + populated = [ + f"{section}.{key}" + for section in sample.sections() + for key in sample[section] + if key in secret_keys and sample[section][key].strip() + ] + assert populated == [], f"sample contains credential values: {populated}" + + +def test_sample_loads_as_a_usable_config(tmp_path): + """Copying the sample to config.ini must produce a working configuration.""" + target = tmp_path / "config.ini" + with open(SAMPLE_PATH) as source: + target.write_text(source.read()) + + config = load_config(str(target)) + + # Nothing is enabled, so there is nothing to dispatch to yet, but the + # configuration objects must all build without error. + dispatcher = NotificationDispatcher(config) + assert dispatcher.has_any_enabled() is False + dispatcher.close() + + +def test_generated_default_config_matches_the_sample_schema(tmp_path): + """The file the app writes on first run and the sample describe the same settings.""" + generated = load_config(str(tmp_path / "config.ini")) + sample = new_config_parser() + sample.read(SAMPLE_PATH) + + for section in generated.sections(): + missing = sorted(set(generated[section]) - set(sample[section])) + assert missing == [], f"[{section}] generated keys absent from sample: {missing}"