From f75c53fc5df477468feef7148f841ef9bdc466d6 Mon Sep 17 00:00:00 2001 From: "Jokob @NetAlertX" <96159884+jokob-sk@users.noreply.github.com> Date: Sat, 14 Mar 2026 23:27:29 +0000 Subject: [PATCH] Implement notification text templates and update related settings for customizable notifications --- .github/skills/code-standards/SKILL.md | 2 +- docs/NOTIFICATIONS.md | 3 + docs/NOTIFICATION_TEMPLATES.md | 110 ++++++++ .../notification_processing/config.json | 144 ++++++++++ mkdocs.yml | 1 + server/models/notification_instance.py | 36 ++- test/backend/test_notification_templates.py | 266 ++++++++++++++++++ 7 files changed, 553 insertions(+), 9 deletions(-) create mode 100644 docs/NOTIFICATION_TEMPLATES.md create mode 100644 test/backend/test_notification_templates.py diff --git a/.github/skills/code-standards/SKILL.md b/.github/skills/code-standards/SKILL.md index 0e9bd1f3..98c7516b 100644 --- a/.github/skills/code-standards/SKILL.md +++ b/.github/skills/code-standards/SKILL.md @@ -64,7 +64,7 @@ Use timeNowUTC(as_string=False) for datetime operations (scheduling, comparisons ## String Sanitization -Use sanitizers from `server/helper.py` before storing user input. +Use sanitizers from `server/helper.py` before storing user input. MAC addresses are always lowercased and normalized. IP addresses should be validated. ## Devcontainer Constraints diff --git a/docs/NOTIFICATIONS.md b/docs/NOTIFICATIONS.md index c6a90f58..3bd12d5a 100755 --- a/docs/NOTIFICATIONS.md +++ b/docs/NOTIFICATIONS.md @@ -1,5 +1,8 @@ # Notifications 📧 +> [!TIP] +> Want to customize how devices appear in text notifications? See [Notification Text Templates](NOTIFICATION_TEMPLATES.md). + There are 4 ways how to influence notifications: 1. On the device itself diff --git a/docs/NOTIFICATION_TEMPLATES.md b/docs/NOTIFICATION_TEMPLATES.md new file mode 100644 index 00000000..1557416c --- /dev/null +++ b/docs/NOTIFICATION_TEMPLATES.md @@ -0,0 +1,110 @@ +# Notification Text Templates + +> Customize how devices and events appear in **text** notifications (email previews, push notifications, Apprise messages). + +By default, NetAlertX formats each device as a vertical list of `Header: Value` pairs. Text templates let you define a **single-line format per device** using `{FieldName}` placeholders — ideal for mobile notification previews and high-volume alerts. + +HTML email tables are **not affected** by these templates. + +## Quick Start + +1. Go to **Settings → Notification Processing**. +2. Set a template string for the section you want to customize, e.g.: + - **Text Template: New Devices** → `{Device name} ({MAC}) - {IP}` +3. Save. The next notification will use your format. + +**Before (default):** +``` +🆕 New devices +--------- +MAC: aa:bb:cc:dd:ee:ff +Datetime: 2025-01-15 10:30:00 +IP: 192.168.1.42 +Event Type: New Device +Device name: MyPhone +Comments: +``` + +**After (with template `{Device name} ({MAC}) - {IP}`):** +``` +🆕 New devices +--------- +MyPhone (aa:bb:cc:dd:ee:ff) - 192.168.1.42 +``` + +## Settings Reference + +| Setting | Type | Default | Description | +|---------|------|---------|-------------| +| `NTFPRCS_TEXT_SECTION_HEADERS` | Boolean | `true` | Show/hide section titles (e.g. `🆕 New devices \n---------`). | +| `NTFPRCS_TEXT_TEMPLATE_new_devices` | String | *(empty)* | Template for new device rows. | +| `NTFPRCS_TEXT_TEMPLATE_down_devices` | String | *(empty)* | Template for down device rows. | +| `NTFPRCS_TEXT_TEMPLATE_down_reconnected` | String | *(empty)* | Template for reconnected device rows. | +| `NTFPRCS_TEXT_TEMPLATE_events` | String | *(empty)* | Template for event rows. | +| `NTFPRCS_TEXT_TEMPLATE_plugins` | String | *(empty)* | Template for plugin event rows. | + +When a template is **empty**, the section uses the original vertical `Header: Value` format (full backward compatibility). + +## Template Syntax + +Use `{FieldName}` to insert a value from the notification data. Field names are **case-sensitive** and must match the column names exactly. + +``` +{Device name} ({MAC}) connected at {Datetime} +``` + +- No loops, conditionals, or nesting — just simple string replacement. +- If a `{FieldName}` does not exist in the data, it is left as-is in the output (safe failure). For example, `{NonExistent}` renders literally as `{NonExistent}`. + +## Variable Availability by Section + +Each section has different available fields because they come from different database queries. + +### `new_devices` and `events` + +| Variable | Description | +|----------|-------------| +| `{MAC}` | Device MAC address | +| `{Datetime}` | Event timestamp | +| `{IP}` | Device IP address | +| `{Event Type}` | Type of event (e.g. `New Device`, `Connected`) | +| `{Device name}` | Device display name | +| `{Comments}` | Device comments | + +**Example:** `{Device name} ({MAC}) - {IP} [{Event Type}]` + +### `down_devices` and `down_reconnected` + +| Variable | Description | +|----------|-------------| +| `{devName}` | Device display name | +| `{eve_MAC}` | Device MAC address | +| `{devVendor}` | Device vendor/manufacturer | +| `{eve_IP}` | Device IP address | +| `{eve_DateTime}` | Event timestamp | +| `{eve_EventType}` | Type of event | + +**Example:** `{devName} ({eve_MAC}) {devVendor} - went down at {eve_DateTime}` + +### `plugins` + +| Variable | Description | +|----------|-------------| +| `{Plugin}` | Plugin code name | +| `{Object_PrimaryId}` | Primary identifier of the object | +| `{Object_SecondaryId}` | Secondary identifier | +| `{DateTimeChanged}` | Timestamp of change | +| `{Watched_Value1}` | First watched value | +| `{Watched_Value2}` | Second watched value | +| `{Watched_Value3}` | Third watched value | +| `{Watched_Value4}` | Fourth watched value | +| `{Status}` | Plugin event status | + +**Example:** `{Plugin}: {Object_PrimaryId} - {Status}` + +> [!NOTE] +> Field names differ between sections because they come from different SQL queries. A template configured for `new_devices` cannot use `{devName}` — that field is only available in `down_devices` and `down_reconnected`. + +## Section Headers Toggle + +Set **Text Section Headers** (`NTFPRCS_TEXT_SECTION_HEADERS`) to `false` to remove the section title separators from text notifications. This is useful when you want compact output without the `🆕 New devices \n---------` banners. diff --git a/front/plugins/notification_processing/config.json b/front/plugins/notification_processing/config.json index 87c52093..6136fbf3 100755 --- a/front/plugins/notification_processing/config.json +++ b/front/plugins/notification_processing/config.json @@ -180,6 +180,150 @@ "string": "You can specify a SQL where condition to filter out Events from notifications. For example AND devLastIP NOT LIKE '192.168.3.%' will always exclude any Event notifications for all devices with the IP starting with 192.168.3.%." } ] + }, + { + "function": "TEXT_SECTION_HEADERS", + "type": { + "dataType": "boolean", + "elements": [ + { "elementType": "input", "elementOptions": [{ "type": "checkbox" }], "transformers": [] } + ] + }, + "default_value": true, + "options": [], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "Text Section Headers" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "Enable or disable section titles (e.g. 🆕 New devices \\n---------) in text notifications. Enabled by default for backward compatibility." + } + ] + }, + { + "function": "TEXT_TEMPLATE_new_devices", + "type": { + "dataType": "string", + "elements": [ + { "elementType": "input", "elementOptions": [], "transformers": [] } + ] + }, + "default_value": "", + "options": [], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "Text Template: New Devices" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "Custom text template for new device notifications. Use {FieldName} placeholders, e.g. {Device name} ({MAC}) - {IP}. Leave empty for default formatting. Available fields: {MAC}, {Datetime}, {IP}, {Event Type}, {Device name}, {Comments}." + } + ] + }, + { + "function": "TEXT_TEMPLATE_down_devices", + "type": { + "dataType": "string", + "elements": [ + { "elementType": "input", "elementOptions": [], "transformers": [] } + ] + }, + "default_value": "", + "options": [], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "Text Template: Down Devices" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "Custom text template for down device notifications. Use {FieldName} placeholders, e.g. {devName} ({eve_MAC}) - {eve_IP}. Leave empty for default formatting. Available fields: {devName}, {eve_MAC}, {devVendor}, {eve_IP}, {eve_DateTime}, {eve_EventType}." + } + ] + }, + { + "function": "TEXT_TEMPLATE_down_reconnected", + "type": { + "dataType": "string", + "elements": [ + { "elementType": "input", "elementOptions": [], "transformers": [] } + ] + }, + "default_value": "", + "options": [], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "Text Template: Reconnected" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "Custom text template for reconnected device notifications. Use {FieldName} placeholders. Leave empty for default formatting. Available fields: {devName}, {eve_MAC}, {devVendor}, {eve_IP}, {eve_DateTime}, {eve_EventType}." + } + ] + }, + { + "function": "TEXT_TEMPLATE_events", + "type": { + "dataType": "string", + "elements": [ + { "elementType": "input", "elementOptions": [], "transformers": [] } + ] + }, + "default_value": "", + "options": [], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "Text Template: Events" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "Custom text template for event notifications. Use {FieldName} placeholders, e.g. {Device name} ({MAC}) {Event Type} at {Datetime}. Leave empty for default formatting. Available fields: {MAC}, {Datetime}, {IP}, {Event Type}, {Device name}, {Comments}." + } + ] + }, + { + "function": "TEXT_TEMPLATE_plugins", + "type": { + "dataType": "string", + "elements": [ + { "elementType": "input", "elementOptions": [], "transformers": [] } + ] + }, + "default_value": "", + "options": [], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "Text Template: Plugins" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "Custom text template for plugin event notifications. Use {FieldName} placeholders, e.g. {Plugin}: {Object_PrimaryId} - {Status}. Leave empty for default formatting. Available fields: {Plugin}, {Object_PrimaryId}, {Object_SecondaryId}, {DateTimeChanged}, {Watched_Value1}, {Watched_Value2}, {Watched_Value3}, {Watched_Value4}, {Status}." + } + ] } ], diff --git a/mkdocs.yml b/mkdocs.yml index 1db4a653..f6ead95a 100755 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -53,6 +53,7 @@ nav: - Advanced guides: - Remote Networks: REMOTE_NETWORKS.md - Notifications Guide: NOTIFICATIONS.md + - Notification Text Templates: NOTIFICATION_TEMPLATES.md - Custom PUID/GUID: PUID_PGID_SECURITY.md - Name Resolution: NAME_RESOLUTION.md - Authelia: AUTHELIA.md diff --git a/server/models/notification_instance.py b/server/models/notification_instance.py index e45f97d3..d2ac6f4c 100755 --- a/server/models/notification_instance.py +++ b/server/models/notification_instance.py @@ -1,4 +1,5 @@ import json +import re import uuid import socket from yattag import indent @@ -345,8 +346,16 @@ def construct_notifications(JSON, section): build_direction = "TOP_TO_BOTTOM" text_line = "{}\t{}\n" + # Read template settings + show_headers = get_setting_value("NTFPRCS_TEXT_SECTION_HEADERS") + if show_headers is None or show_headers == "": + show_headers = True + text_template = get_setting_value(f"NTFPRCS_TEXT_TEMPLATE_{section}") or "" + if len(jsn) > 0: - text = tableTitle + "\n---------\n" + # Section header (text) + if show_headers: + text = tableTitle + "\n---------\n" # Convert a JSON into an HTML table html = convert( @@ -363,13 +372,24 @@ def construct_notifications(JSON, section): ) # prepare text-only message - for device in jsn: - for header in headers: - padding = "" - if len(header) < 4: - padding = "\t" - text += text_line.format(header + ": " + padding, device[header]) - text += "\n" + if text_template: + # Custom template: replace {FieldName} placeholders per device + for device in jsn: + line = re.sub( + r'\{(.+?)\}', + lambda m: str(device.get(m.group(1), m.group(0))), + text_template, + ) + text += line + "\n" + else: + # Legacy fallback: vertical Header: Value list + for device in jsn: + for header in headers: + padding = "" + if len(header) < 4: + padding = "\t" + text += text_line.format(header + ": " + padding, device[header]) + text += "\n" # Format HTML table headers for header in headers: diff --git a/test/backend/test_notification_templates.py b/test/backend/test_notification_templates.py new file mode 100644 index 00000000..11de4fbe --- /dev/null +++ b/test/backend/test_notification_templates.py @@ -0,0 +1,266 @@ +""" +NetAlertX Notification Text Template Tests + +Tests the template substitution and section header toggle in +construct_notifications(). All tests mock get_setting_value to avoid +database/config dependencies. + +License: GNU GPLv3 +""" + +import sys +import os +import unittest +from unittest.mock import patch + +# Add the server directory to the path for imports +INSTALL_PATH = os.getenv("NETALERTX_APP", "/app") +sys.path.extend([f"{INSTALL_PATH}/server"]) + + +def _make_json(section, devices, column_names, title="Test Section"): + """Helper to build the JSON structure expected by construct_notifications.""" + return { + section: devices, + f"{section}_meta": { + "title": title, + "columnNames": column_names, + }, + } + + +SAMPLE_NEW_DEVICES = [ + { + "MAC": "AA:BB:CC:DD:EE:FF", + "Datetime": "2025-01-15 10:30:00", + "IP": "192.168.1.42", + "Event Type": "New Device", + "Device name": "MyPhone", + "Comments": "", + }, + { + "MAC": "11:22:33:44:55:66", + "Datetime": "2025-01-15 11:00:00", + "IP": "192.168.1.99", + "Event Type": "New Device", + "Device name": "Laptop", + "Comments": "Office", + }, +] + +NEW_DEVICE_COLUMNS = ["MAC", "Datetime", "IP", "Event Type", "Device name", "Comments"] + + +class TestConstructNotificationsTemplates(unittest.TestCase): + """Tests for template substitution in construct_notifications.""" + + def _setting_factory(self, overrides=None): + """Return a mock get_setting_value that resolves from overrides dict.""" + settings = overrides or {} + + def mock_get(key): + return settings.get(key, "") + + return mock_get + + # ----------------------------------------------------------------- + # Empty section should always return ("", "") regardless of settings + # ----------------------------------------------------------------- + @patch("models.notification_instance.get_setting_value") + def test_empty_section_returns_empty(self, mock_setting): + from models.notification_instance import construct_notifications + + mock_setting.return_value = "" + json_data = _make_json("new_devices", [], []) + html, text = construct_notifications(json_data, "new_devices") + self.assertEqual(html, "") + self.assertEqual(text, "") + + # ----------------------------------------------------------------- + # Legacy fallback: no template → vertical Header: Value per device + # ----------------------------------------------------------------- + @patch("models.notification_instance.get_setting_value") + def test_legacy_fallback_no_template(self, mock_setting): + from models.notification_instance import construct_notifications + + mock_setting.side_effect = self._setting_factory({ + "NTFPRCS_TEXT_SECTION_HEADERS": True, + "NTFPRCS_TEXT_TEMPLATE_new_devices": "", + }) + + json_data = _make_json( + "new_devices", SAMPLE_NEW_DEVICES, NEW_DEVICE_COLUMNS, "🆕 New devices" + ) + html, text = construct_notifications(json_data, "new_devices") + + # Section header must be present + self.assertIn("🆕 New devices", text) + self.assertIn("---------", text) + + # Legacy format: each header appears as "Header: \tValue" + self.assertIn("MAC:", text) + self.assertIn("AA:BB:CC:DD:EE:FF", text) + self.assertIn("Device name:", text) + self.assertIn("MyPhone", text) + + # HTML must still be generated + self.assertNotEqual(html, "") + + # ----------------------------------------------------------------- + # Template substitution: single-line format per device + # ----------------------------------------------------------------- + @patch("models.notification_instance.get_setting_value") + def test_template_substitution(self, mock_setting): + from models.notification_instance import construct_notifications + + mock_setting.side_effect = self._setting_factory({ + "NTFPRCS_TEXT_SECTION_HEADERS": True, + "NTFPRCS_TEXT_TEMPLATE_new_devices": "{Device name} ({MAC}) - {IP}", + }) + + json_data = _make_json( + "new_devices", SAMPLE_NEW_DEVICES, NEW_DEVICE_COLUMNS, "🆕 New devices" + ) + _, text = construct_notifications(json_data, "new_devices") + + self.assertIn("MyPhone (AA:BB:CC:DD:EE:FF) - 192.168.1.42", text) + self.assertIn("Laptop (11:22:33:44:55:66) - 192.168.1.99", text) + + # ----------------------------------------------------------------- + # Missing field: {NonExistent} left as-is (safe failure) + # ----------------------------------------------------------------- + @patch("models.notification_instance.get_setting_value") + def test_missing_field_safe_failure(self, mock_setting): + from models.notification_instance import construct_notifications + + mock_setting.side_effect = self._setting_factory({ + "NTFPRCS_TEXT_SECTION_HEADERS": True, + "NTFPRCS_TEXT_TEMPLATE_new_devices": "{Device name} - {NonExistent}", + }) + + json_data = _make_json( + "new_devices", SAMPLE_NEW_DEVICES, NEW_DEVICE_COLUMNS, "🆕 New devices" + ) + _, text = construct_notifications(json_data, "new_devices") + + self.assertIn("MyPhone - {NonExistent}", text) + self.assertIn("Laptop - {NonExistent}", text) + + # ----------------------------------------------------------------- + # Section headers disabled: no title/separator in text output + # ----------------------------------------------------------------- + @patch("models.notification_instance.get_setting_value") + def test_section_headers_disabled(self, mock_setting): + from models.notification_instance import construct_notifications + + mock_setting.side_effect = self._setting_factory({ + "NTFPRCS_TEXT_SECTION_HEADERS": False, + "NTFPRCS_TEXT_TEMPLATE_new_devices": "{Device name} ({MAC})", + }) + + json_data = _make_json( + "new_devices", SAMPLE_NEW_DEVICES, NEW_DEVICE_COLUMNS, "🆕 New devices" + ) + _, text = construct_notifications(json_data, "new_devices") + + self.assertNotIn("🆕 New devices", text) + self.assertNotIn("---------", text) + # Template output still present + self.assertIn("MyPhone (AA:BB:CC:DD:EE:FF)", text) + + # ----------------------------------------------------------------- + # Section headers enabled (default when setting absent/empty) + # ----------------------------------------------------------------- + @patch("models.notification_instance.get_setting_value") + def test_section_headers_default_enabled(self, mock_setting): + from models.notification_instance import construct_notifications + + # Simulate setting not configured (returns empty string) + mock_setting.side_effect = self._setting_factory({}) + + json_data = _make_json( + "new_devices", SAMPLE_NEW_DEVICES, NEW_DEVICE_COLUMNS, "🆕 New devices" + ) + _, text = construct_notifications(json_data, "new_devices") + + # Headers should be shown by default + self.assertIn("🆕 New devices", text) + self.assertIn("---------", text) + + # ----------------------------------------------------------------- + # Mixed valid and invalid fields in same template + # ----------------------------------------------------------------- + @patch("models.notification_instance.get_setting_value") + def test_mixed_valid_and_invalid_fields(self, mock_setting): + from models.notification_instance import construct_notifications + + mock_setting.side_effect = self._setting_factory({ + "NTFPRCS_TEXT_SECTION_HEADERS": True, + "NTFPRCS_TEXT_TEMPLATE_new_devices": "{Device name} ({BadField}) - {IP}", + }) + + json_data = _make_json( + "new_devices", SAMPLE_NEW_DEVICES, NEW_DEVICE_COLUMNS, "🆕 New devices" + ) + _, text = construct_notifications(json_data, "new_devices") + + self.assertIn("MyPhone ({BadField}) - 192.168.1.42", text) + + # ----------------------------------------------------------------- + # Down devices section uses different column names + # ----------------------------------------------------------------- + @patch("models.notification_instance.get_setting_value") + def test_down_devices_template(self, mock_setting): + from models.notification_instance import construct_notifications + + mock_setting.side_effect = self._setting_factory({ + "NTFPRCS_TEXT_SECTION_HEADERS": True, + "NTFPRCS_TEXT_TEMPLATE_down_devices": "{devName} ({eve_MAC}) down since {eve_DateTime}", + }) + + down_devices = [ + { + "devName": "Router", + "eve_MAC": "FF:EE:DD:CC:BB:AA", + "devVendor": "Cisco", + "eve_IP": "10.0.0.1", + "eve_DateTime": "2025-01-15 08:00:00", + "eve_EventType": "Device Down", + } + ] + columns = ["devName", "eve_MAC", "devVendor", "eve_IP", "eve_DateTime", "eve_EventType"] + + json_data = _make_json("down_devices", down_devices, columns, "🔴 Down devices") + _, text = construct_notifications(json_data, "down_devices") + + self.assertIn("Router (FF:EE:DD:CC:BB:AA) down since 2025-01-15 08:00:00", text) + + # ----------------------------------------------------------------- + # HTML output is unchanged regardless of template config + # ----------------------------------------------------------------- + @patch("models.notification_instance.get_setting_value") + def test_html_unchanged_with_template(self, mock_setting): + from models.notification_instance import construct_notifications + + # Get HTML without template + mock_setting.side_effect = self._setting_factory({ + "NTFPRCS_TEXT_SECTION_HEADERS": True, + "NTFPRCS_TEXT_TEMPLATE_new_devices": "", + }) + json_data = _make_json( + "new_devices", SAMPLE_NEW_DEVICES, NEW_DEVICE_COLUMNS, "🆕 New devices" + ) + html_without, _ = construct_notifications(json_data, "new_devices") + + # Get HTML with template + mock_setting.side_effect = self._setting_factory({ + "NTFPRCS_TEXT_SECTION_HEADERS": True, + "NTFPRCS_TEXT_TEMPLATE_new_devices": "{Device name} ({MAC})", + }) + html_with, _ = construct_notifications(json_data, "new_devices") + + self.assertEqual(html_without, html_with) + + +if __name__ == "__main__": + unittest.main()