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()