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..b9d849c 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 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* +*.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/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-headless.py b/e2nb-headless.py index 5f6f22c..741af83 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) @@ -490,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 @@ -647,8 +641,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 +653,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 +668,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 +683,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 +698,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 @@ -734,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)) @@ -828,6 +817,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 +1075,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, @@ -1111,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:") @@ -1205,8 +1235,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..4cb0e08 100644 --- a/e2nb.py +++ b/e2nb.py @@ -13,7 +13,6 @@ from __future__ import annotations -import os import sys # --------------------------------------------------------------------------- @@ -44,16 +43,18 @@ 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 ( __version__, load_config, save_config, + setup_logging, connect_to_imap, connect_to_pop3, fetch_unread_emails, @@ -70,32 +71,28 @@ 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 +209,86 @@ 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 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() 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 +380,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 +456,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 +820,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) @@ -792,6 +829,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.""" @@ -804,19 +847,26 @@ def __init__( show: str = "", tooltip: str = "", placeholder: str = "", + secret: bool = False, **kwargs ): 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 +875,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 @@ -840,8 +891,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, @@ -852,7 +912,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="+") @@ -863,6 +934,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) @@ -1020,6 +1097,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 +1332,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 +1377,10 @@ 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 + # 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() @@ -1164,6 +1412,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 +1442,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 +1472,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 +1484,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 +1568,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_scrollbar = ttk.Scrollbar( + nav_container, orient="vertical", command=self._sb_canvas.yview, + style="Sidebar.Vertical.TScrollbar", ) - self._sb_canvas.pack(fill="both", expand=True) + 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 +1591,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 +1616,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 +1703,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 +1809,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 +1858,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 +1871,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 +1900,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 +1925,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") @@ -1663,7 +1989,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") @@ -1700,40 +2026,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) @@ -1817,7 +2137,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") @@ -1915,7 +2235,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 @@ -2121,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( @@ -2154,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 @@ -2294,7 +2622,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") @@ -2455,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 @@ -2560,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 @@ -2635,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 @@ -2692,6 +3023,251 @@ 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") + style_text_scrollbar(editor) + 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 @@ -2729,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) @@ -2798,6 +3375,15 @@ 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) + 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 +3515,33 @@ 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) + 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._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 +3586,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 @@ -3022,7 +3634,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), @@ -3077,22 +3690,53 @@ 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 = dict.fromkeys(('email', 'rss', 'web', 'http', 'database', 'log'), 0.0) + while not self.stop_event.is_set(): imap = None 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) + # 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, + ) - 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()] + 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( "Checking sources...", @@ -3102,25 +3746,25 @@ 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: + 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), + 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: @@ -3160,21 +3804,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 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), + 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: @@ -3226,75 +3872,46 @@ 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("Check email_monitor.log for details", "INFO") - - # ===================================================== - # Check RSS Feeds - # ===================================================== - 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" - ) + 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") # ===================================================== - # Check HTTP Endpoints + # Check the other sources, each on its own interval # ===================================================== - 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 +3938,43 @@ 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(): + # 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() - app = EmailMonitorApp(root) + EmailMonitorApp(root) root.mainloop() diff --git a/e2nb_core.py b/e2nb_core.py index 13cdd91..68ebe82 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 @@ -23,11 +22,13 @@ import os import re import smtplib +import sys import ssl -import threading +import tempfile import time from dataclasses import dataclass, field -from datetime import datetime, timedelta +from logging.handlers import RotatingFileHandler +from datetime import datetime, timedelta, timezone import email as email_module from email.header import decode_header from email.message import Message @@ -75,7 +76,6 @@ try: from aiosmtpd.controller import Controller as SmtpdController - from aiosmtpd.smtp import SMTP as AIOSMTP AIOSMTPD_AVAILABLE = True except ImportError: AIOSMTPD_AVAILABLE = False @@ -102,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) @@ -459,6 +463,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 +505,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 +605,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 @@ -612,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 @@ -621,6 +648,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 +681,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 +722,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}") @@ -720,31 +766,40 @@ 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 + # 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 @@ -1256,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 @@ -1305,21 +1360,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 +1400,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 +1574,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 +1586,81 @@ 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': '', + '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), - '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 +1669,235 @@ 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', + }, +} - with open(config_file, 'w') as file: - config.write(file) + +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 + + +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") + + 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. + + 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) # ============================================================================= @@ -1791,21 +1907,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', @@ -1813,10 +1941,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: @@ -3561,6 +3696,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 +3736,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 +3793,122 @@ def dispatch( logger.debug(f"Dispatching to enabled channels: {enabled_channels}") + channel_decisions = dict.fromkeys(enabled_channels, True) + 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 = dict.fromkeys(enabled_channels, True) 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 +3917,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 +3926,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 +3941,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 +3950,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 +3963,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 +4114,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 +4139,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 +4299,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 +4416,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 +4695,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: @@ -4887,7 +5114,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( @@ -5449,18 +5678,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/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..212ac6c 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,8 +162,31 @@ 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 +``` + +### 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 @@ -169,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 @@ -510,6 +540,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. @@ -872,7 +1030,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)* | @@ -882,12 +1040,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 @@ -908,17 +1074,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 +1111,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 @@ -945,6 +1123,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 @@ -1250,10 +1432,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. 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. + --- ## API Reference @@ -1510,11 +1702,69 @@ 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 | +| `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` +and to `config.ini.sample` (a test enforces that the two agree), +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/setup-headless-service-kubuntu.sh b/setup-headless-service-kubuntu.sh index 1c0badd..0ee1a2e 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 @@ -80,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." @@ -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 @@ -111,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/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..43dde8a --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,125 @@ +"""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)}" + + +@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" + 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..4f5a35f --- /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) + assert dispatcher.http_session is not None # force lazy 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..d45d706 --- /dev/null +++ b/tests/test_gui.py @@ -0,0 +1,371 @@ +"""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 + + +# --------------------------------------------------------------------------- +# 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" 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"} 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_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 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..f3a567f --- /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) == dict.fromkeys(ALL_CHANNELS, True) + + +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_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}" 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\*"