diff --git a/CHANGELOG.md b/CHANGELOG.md index cb66fb3..88e022a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,28 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [1.2.0] - unreleased + +### Breaking changes +- **Template field `body_html` renamed to `email_body_html`** to reflect that + only the email notifier consumes it (Telegram, Mattermost, and webhook always + ignored it). Migration: in every template, replace `body_html:` with + `email_body_html:`. This applies to templates defined inline in `config.yaml` + *and* to any split files under `templates.d/`. Configs using the old name are + rejected at load time with a clear error that lists `email_body_html` among + the expected fields, so `valerter --validate` will point out every template + that needs updating on the first run. + +### Fixed +- **Dotted field access in templates** (issue #25) — fields like + `server.hostname` or `http.request.method` can now be referenced directly in + Jinja templates using dotted notation, matching the shape users see in log + payloads. +- **Empty Telegram message guard** (issue #26) — Telegram no longer 400s when a + rendered body is empty. The notifier now substitutes a fallback string and + records the drop reason, and the template documentation explicitly calls out + that `body_html` (now `email_body_html`) is email-only so users do not + accidentally leave `body` empty. ## [1.1.0] - 2026-04-15 diff --git a/Cargo.lock b/Cargo.lock index 85b3699..ed2bc47 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2334,7 +2334,7 @@ dependencies = [ [[package]] name = "valerter" -version = "1.1.0" +version = "1.2.0" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index bc384ea..e397f04 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "valerter" -version = "1.1.0" +version = "1.2.0" edition = "2024" description = "Real-time log alerting for VictoriaLogs" license = "Apache-2.0" diff --git a/config/config.example.yaml b/config/config.example.yaml index 607bbc9..ae1bc1d 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -97,24 +97,38 @@ defaults: # └────────────────────────────────────────────────────────────────────────────┘ # Message templates using Jinja2 syntax (minijinja). # -# Built-in variables: -# rule_name - Name of the triggered rule -# log_timestamp - Original log timestamp (ISO8601) -# log_timestamp_formatted - Human-readable timestamp (uses defaults.timestamp_timezone) +# A template entry has two sides: # -# Parser variables: -# Any field extracted by the rule's parser (JSON fields or regex named groups) +# 1. Template variables AVAILABLE on the right-hand side of each line below. +# These come from the matched VictoriaLogs event and the runtime: +# _msg, _time, _stream VL built-in event fields +# (`_stream_id` is present only on +# streams configured server-side) +# JSON field or named regex group +# rule_name name of the triggered rule +# log_timestamp event timestamp (ISO8601) +# log_timestamp_formatted human timestamp (defaults.timestamp_timezone) # -# Fields: -# title - Alert title (required) -# body - Alert body, plain text or Markdown (required) -# body_html - HTML version for email (optional, falls back to body) -# accent_color - Hex color for visual indicators (optional, e.g., "#ff0000") +# 2. Template OUTPUT KEYS that you define below (left-hand side). These are +# the slots valerter fills and hands off to notifiers: +# title Alert title (required) +# body Alert body, plain text or Markdown (required) +# email_body_html HTML-enriched version used ONLY by the email notifier +# (Telegram, Mattermost, and webhook all read `body` and +# ignore `email_body_html`). To format a Telegram alert, put +# ``/``/`` inline in `body` — `parse_mode: HTML` +# is Telegram's default. +# accent_color Hex color for visual indicators (optional, e.g., "#ff0000") +# +# Note: `title` and `body` are NOT variables you can reference on the right. +# If you want the raw log line, use `{{ _msg }}`. templates: default_alert: - title: "{{ title | default('Alert') }}" - body: "{{ body }}" + title: "{{ rule_name }}" + # _msg is the raw log line from VictoriaLogs — works on any event without + # needing a parser. Replace with a parsed field for richer templates. + body: "{{ _msg }}" accent_color: "#3498db" # ── Example: Severity-based template ─────────────────────────────────────── @@ -124,7 +138,7 @@ templates: # body: | # **Host:** {{ host }} # **Message:** {{ message }} - # body_html: | + # email_body_html: | #

{{ title }}

#

Host: {{ host }}

#

{{ message }}

diff --git a/docs/architecture.md b/docs/architecture.md index 143bbac..8c5039e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -157,7 +157,7 @@ At startup, Valerter validates (in order): 3. **Template syntax** — All templates compile (minijinja) 4. **Notifier config** — URLs, credentials, env vars resolve correctly 5. **Destinations exist** — Rule destinations match notifier names in registry -6. **Email body_html** — Templates used with email destinations have `body_html` +6. **Email template body** — Templates used with email destinations have `email_body_html` 7. **Mattermost channel warning** — Warns if `mattermost_channel` set but no Mattermost notifier in destinations If any validation fails, Valerter exits immediately with a clear error message. @@ -211,7 +211,7 @@ tokio::spawn(async { process().unwrap(); }); // Silent crash ## Security - **Config file:** `chmod 600` recommended (secrets in plaintext) -- **HTML escaping:** `body_html` templates auto-escape variables (XSS prevention) +- **HTML escaping:** `email_body_html` templates auto-escape variables (XSS prevention) - **TLS verification:** Enabled by default (`tls.verify: true`) - **No shell execution:** No user input ever reaches a shell diff --git a/docs/configuration.md b/docs/configuration.md index 14771f4..a4f0fca 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -188,7 +188,7 @@ templates: default_alert: title: "{{ title | default('Alert') }}" # REQUIRED body: "{{ body }}" # REQUIRED - body_html: "

{{ body }}

" # REQUIRED for email destinations + email_body_html: "

{{ body }}

" # REQUIRED for email destinations accent_color: "#ff0000" # Optional: hex color ``` @@ -211,9 +211,9 @@ Variables come from the parser output plus built-in fields: - Webhook `body_template` - Mattermost footer (automatically includes `log_timestamp_formatted`) -### body_html Requirement +### email_body_html Requirement -**Important:** Templates used with email destinations MUST include `body_html`. Valerter validates this at startup and will fail if missing. +**Important:** Templates used with email destinations MUST include `email_body_html`. Valerter validates this at startup and will fail if missing. ## Rules @@ -350,7 +350,7 @@ This checks: - Template syntax - Notifier configuration - Rule destinations exist -- Email templates have `body_html` +- Email templates have `email_body_html` ## See Also diff --git a/docs/getting-started.md b/docs/getting-started.md index 8b52c07..b438978 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -94,7 +94,7 @@ templates: default_alert: title: "{{ title | default('Alert') }}" body: "{{ body }}" - body_html: "

{{ body }}

" + email_body_html: "

{{ body }}

" rules: - name: "error_alert" diff --git a/docs/notifiers.md b/docs/notifiers.md index 259a0ed..b8dcf1b 100644 --- a/docs/notifiers.md +++ b/docs/notifiers.md @@ -15,6 +15,13 @@ Valerter supports multiple notification channels. Configure them in the `notifie The most flexible notifier - works with any HTTP API. +> **Note about `email_body_html`** — Webhook reads the outer template's `body` +> output key (and `title`, `rule_name`, `log_timestamp`, `log_timestamp_formatted`) +> inside its own `body_template`. It does **not** receive `email_body_html`; +> `email_body_html` is email-only. If your HTTP target needs HTML, put it in `body` +> at the outer template and reference `{{ body }}` from the webhook +> `body_template`. + ### Configuration ```yaml @@ -178,16 +185,16 @@ notifiers: | `starttls` | 587 | STARTTLS upgrade (recommended) | | `tls` | 465 | Direct TLS connection | -### body_html Requirement +### email_body_html Requirement -**Important:** When using email destinations, your message template MUST include `body_html`: +**Important:** When using email destinations, your message template MUST include `email_body_html`: ```yaml templates: my_template: title: "{{ title }}" body: "{{ body }}" - body_html: "

{{ body }}

" # REQUIRED for email + email_body_html: "

{{ body }}

" # REQUIRED for email ``` Valerter validates this at startup and will fail if missing. @@ -218,6 +225,11 @@ notifiers: Send alerts to Mattermost channels via incoming webhooks. +> **Note about `email_body_html`** — Mattermost reads the outer template's `body` +> output key, **not** `email_body_html`. `email_body_html` is email-only. Mattermost +> renders Markdown in `body` (`**bold**`, `*italic*`, fenced code blocks, +> lists, links); write your formatting there. + ### Configuration ```yaml @@ -265,6 +277,13 @@ This helps operators quickly locate the original log entry in VictoriaLogs. Send alerts to one or more Telegram chats via the Bot API. +> **Note about `email_body_html`** — Telegram reads the outer template's `body` +> output key, **not** `email_body_html`. `email_body_html` is email-only. For rich +> formatting inside Telegram, put the markup directly in `body` using +> Telegram's supported HTML subset: ``, ``, ``, ``, ``, +> `
`, `
`, ``, ``, ``. +> Keep `parse_mode: HTML` (the default) so the Bot API interprets those tags. + ### Prerequisites 1. Create a bot via [@BotFather](https://t.me/BotFather) and note the token it gives you. diff --git a/examples/cisco-switches/README.md b/examples/cisco-switches/README.md index d0dd438..373f1b6 100644 --- a/examples/cisco-switches/README.md +++ b/examples/cisco-switches/README.md @@ -125,7 +125,7 @@ templates: ``` {{ _msg }} ``` - body_html: | + email_body_html: | ``` diff --git a/examples/cisco-switches/config.yaml b/examples/cisco-switches/config.yaml index 7f9f069..c3f3532 100644 --- a/examples/cisco-switches/config.yaml +++ b/examples/cisco-switches/config.yaml @@ -42,7 +42,7 @@ templates: ``` {{ _msg }} ``` - body_html: | + email_body_html: |

🚨 BPDU Guard Violation

A port has been disabled due to BPDU reception

diff --git a/scripts/test-notifiers/config-test-notifiers.yaml b/scripts/test-notifiers/config-test-notifiers.yaml index e1966d5..43f465f 100644 --- a/scripts/test-notifiers/config-test-notifiers.yaml +++ b/scripts/test-notifiers/config-test-notifiers.yaml @@ -17,7 +17,7 @@ templates: Alert triggered at {{ timestamp }} Host: {{ host | default("unknown") }} Message: {{ _msg | default("N/A") }} - body_html: | + email_body_html: |

Alert triggered at {{ timestamp }}

Host: {{ host | default("unknown") }}

Message: {{ _msg | default("N/A") }}

diff --git a/src/config/runtime.rs b/src/config/runtime.rs index 188cb56..08bfc4e 100644 --- a/src/config/runtime.rs +++ b/src/config/runtime.rs @@ -53,7 +53,7 @@ pub struct CompiledThrottle { pub struct CompiledTemplate { pub title: String, pub body: String, - pub body_html: Option, + pub email_body_html: Option, pub accent_color: Option, } @@ -146,7 +146,7 @@ impl Config { CompiledTemplate { title: template.title, body: template.body, - body_html: template.body_html, + email_body_html: template.email_body_html, accent_color: template.accent_color, }, ) diff --git a/src/config/tests.rs b/src/config/tests.rs index 04aec00..59bc574 100644 --- a/src/config/tests.rs +++ b/src/config/tests.rs @@ -345,7 +345,7 @@ fn make_runtime_config_with_destinations(destinations: Vec) -> RuntimeCo CompiledTemplate { title: "{{ title }}".to_string(), body: "{{ body }}".to_string(), - body_html: None, + email_body_html: None, accent_color: None, }, ); @@ -451,7 +451,7 @@ fn validate_collects_all_errors() { TemplateConfig { title: "{{ title }}".to_string(), body: "{{ body }}".to_string(), - body_html: None, + email_body_html: None, accent_color: None, }, ); @@ -528,7 +528,7 @@ fn validate_throttle_key_template() { TemplateConfig { title: "{{ title }}".to_string(), body: "{{ body }}".to_string(), - body_html: None, + email_body_html: None, accent_color: None, }, ); @@ -591,7 +591,7 @@ fn validate_nonexistent_notify_template_fails() { TemplateConfig { title: "{{ title }}".to_string(), body: "{{ body }}".to_string(), - body_html: None, + email_body_html: None, accent_color: None, }, ); @@ -710,7 +710,7 @@ fn load_config_with_unknown_notifier_type_fails() { } #[test] -fn validate_body_html_syntax_error_detected() { +fn validate_email_body_html_syntax_error_detected() { let yaml = r#" victorialogs: url: http://localhost:9428 @@ -726,7 +726,7 @@ templates: test: title: "Test" body: "Test body" - body_html: "{% if unclosed" + email_body_html: "{% if unclosed" rules: [] "#; let config: Config = serde_yaml::from_str(yaml).unwrap(); @@ -736,13 +736,54 @@ rules: [] let errors = result.unwrap_err(); assert!(errors.iter().any(|e| { if let crate::error::ConfigError::InvalidTemplate { message, .. } = e { - message.contains("body_html") + message.contains("email_body_html") } else { false } })); } +// Regression guard for the v1.2.0 rename of `body_html` → `email_body_html`. +// The old field name must be rejected at parse time with an error message that +// mentions both names, so users upgrading from 1.1.x get an actionable hint. +#[test] +fn parse_rejects_old_body_html_field_name() { + let yaml = r#" +victorialogs: + url: http://localhost:9428 +notifiers: + test: + type: mattermost + webhook_url: "https://example.com/hooks/test" +defaults: + throttle: + count: 5 + window: 1m +templates: + test: + title: "Test" + body: "Test body" + body_html: "

legacy

" +rules: [] +"#; + let result: Result = serde_yaml::from_str(yaml); + assert!( + result.is_err(), + "YAML with legacy `body_html` field must be rejected at parse time" + ); + let err_msg = result.unwrap_err().to_string(); + assert!( + err_msg.contains("body_html"), + "Error should mention the legacy field name `body_html`, got: {}", + err_msg + ); + assert!( + err_msg.contains("email_body_html"), + "Error should list the new field name `email_body_html` among expected fields, got: {}", + err_msg + ); +} + #[test] fn validate_config_with_invalid_accent_color_fails() { let yaml = r#" @@ -925,7 +966,7 @@ fn validate_rule_destinations_collects_all_errors() { CompiledTemplate { title: "{{ title }}".to_string(), body: "{{ body }}".to_string(), - body_html: None, + email_body_html: None, accent_color: None, }, ); diff --git a/src/config/types.rs b/src/config/types.rs index 81425d1..952d1f6 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -157,7 +157,7 @@ pub struct TemplateConfig { pub title: String, pub body: String, #[serde(default)] - pub body_html: Option, + pub email_body_html: Option, #[serde(default)] pub accent_color: Option, } @@ -596,12 +596,12 @@ impl Config { message: format!("body: {}", e), }); } - if let Some(body_html) = &template.body_html - && let Err(e) = validate_jinja_template(body_html) + if let Some(email_body_html) = &template.email_body_html + && let Err(e) = validate_jinja_template(email_body_html) { errors.push(ConfigError::InvalidTemplate { rule: format!("template:{}", name), - message: format!("body_html: {}", e), + message: format!("email_body_html: {}", e), }); } } @@ -620,12 +620,12 @@ impl Config { message: format!("body render: {}", e), }); } - if let Some(body_html) = &template.body_html - && let Err(e) = super::validation::validate_template_render(body_html) + if let Some(email_body_html) = &template.email_body_html + && let Err(e) = super::validation::validate_template_render(email_body_html) { errors.push(ConfigError::InvalidTemplate { rule: format!("template:{}", name), - message: format!("body_html render: {}", e), + message: format!("email_body_html render: {}", e), }); } } diff --git a/src/config/validation.rs b/src/config/validation.rs index b4b992c..ea2af8e 100644 --- a/src/config/validation.rs +++ b/src/config/validation.rs @@ -1,8 +1,45 @@ //! Template and color validation utilities. +use minijinja::value::{Enumerator, Object, ObjectRepr, Value}; use minijinja::{Environment, UndefinedBehavior}; use regex::Regex; -use std::sync::LazyLock; +use std::sync::{Arc, LazyLock}; + +/// Sentinel context object used during template validation. +/// +/// Issue #25: validating templates that reference dotted VictoriaLogs fields +/// (`{{ nginx.http.request_id }}`) used to fail because chained attribute access +/// against an empty `json!({})` returned `undefined` instead of another value. +/// +/// `TruthyChainable` returns itself on every attribute access (so chains never +/// hit `undefined`), is always truthy (so `{% if x.y %}` walks the body and +/// validates filters/syntax inside), stringifies as empty, and iterates as an +/// empty sequence (so `{% for x in tc %}` and `{{ tc | length }}` do not error +/// — matching the prior `Lenient + json!({})` behaviour). +#[derive(Debug)] +struct TruthyChainable; + +impl Object for TruthyChainable { + fn repr(self: &Arc) -> ObjectRepr { + ObjectRepr::Seq + } + + fn get_value(self: &Arc, _key: &Value) -> Option { + Some(Value::from_dyn_object(self.clone())) + } + + fn enumerate(self: &Arc) -> Enumerator { + Enumerator::Empty + } + + fn is_true(self: &Arc) -> bool { + true + } + + fn render(self: &Arc, _f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + Ok(()) + } +} /// Validates Jinja template syntax. pub(crate) fn validate_jinja_template(source: &str) -> Result<(), String> { @@ -26,7 +63,7 @@ pub fn validate_template_render(source: &str) -> Result<(), String> { let tmpl = env .get_template("_render_test") .map_err(|e| e.to_string())?; - tmpl.render(serde_json::json!({})) + tmpl.render(Value::from_object(TruthyChainable)) .map_err(|e| e.to_string())?; Ok(()) @@ -110,4 +147,62 @@ mod tests { let result = validate_jinja_template("{{ name }} - {% if x %}yes{% endif %}"); assert!(result.is_ok()); } + + // ============================================================ + // Issue #25: dotted-field template validation + // ============================================================ + + #[test] + fn validate_template_render_accepts_chained_undefined() { + // The bug: `{{ a.b.c }}` against `json!({})` returned "undefined value". + // With TruthyChainable, chained access on undefined should resolve. + let result = validate_template_render("{{ a.b.c }}"); + assert!( + result.is_ok(), + "chained undefined access should validate cleanly: {:?}", + result + ); + } + + #[test] + fn validate_template_render_still_catches_filter_in_if_block() { + // Regression guard: a straight switch to UndefinedBehavior::Chainable + // would silently skip this `{% if %}` body (falsy undefined) and miss + // the unknown filter. TruthyChainable keeps is_true() = true so the + // body is walked. + let result = validate_template_render("{% if a.b %}{{ x | nosuchfilter }}{% endif %}"); + assert!( + result.is_err(), + "unknown filter inside if-block should still be caught" + ); + assert!(result.unwrap_err().contains("nosuchfilter")); + } + + #[test] + fn validate_template_render_catches_syntax_errors() { + let result = validate_template_render("{{ foo."); + assert!(result.is_err()); + } + + #[test] + fn validate_template_render_allows_for_loop_over_undefined() { + // Regression guard: initial TruthyChainable had NonEnumerable + Plain repr, + // which errored on `{% for %}` even though Lenient + json!({}) didn't. + let result = validate_template_render("{% for x in items %}{{ x }}{% endfor %}"); + assert!( + result.is_ok(), + "for-loop over undefined should validate cleanly: {:?}", + result + ); + } + + #[test] + fn validate_template_render_allows_length_on_undefined() { + let result = validate_template_render("{{ (items | length) > 0 }}"); + assert!( + result.is_ok(), + "length on undefined should validate cleanly: {:?}", + result + ); + } } diff --git a/src/engine.rs b/src/engine.rs index 57610f8..0d527f3 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -640,7 +640,7 @@ mod tests { CompiledTemplate { title: "{{ title }}".to_string(), body: "{{ body }}".to_string(), - body_html: None, + email_body_html: None, accent_color: None, }, ); @@ -839,7 +839,7 @@ mod tests { CompiledTemplate { title: "Alert: {{ _msg }}".to_string(), body: "Log: {{ _msg }}".to_string(), - body_html: None, + email_body_html: None, accent_color: Some("#ff0000".to_string()), }, ); diff --git a/src/main.rs b/src/main.rs index 0fa237f..194cb91 100644 --- a/src/main.rs +++ b/src/main.rs @@ -110,10 +110,10 @@ fn create_notifier_registry( Ok(registry) } -/// Validate that templates used with email destinations have body_html. +/// Validate that templates used with email destinations have email_body_html. /// /// For each enabled rule, if any of its destinations is an email notifier, -/// the template must have body_html defined. This is a fail-fast validation +/// the template must have email_body_html defined. This is a fail-fast validation /// to prevent runtime errors. fn validate_email_templates( config: &valerter::config::RuntimeConfig, @@ -149,9 +149,9 @@ fn validate_email_templates( // Get the template name for this rule let template_name = &rule.notify.template; - // Check if template has body_html + // Check if template has email_body_html if let Some(template) = config.templates.get(template_name) - && template.body_html.is_none() + && template.email_body_html.is_none() { let email_dests: Vec<_> = destinations .iter() @@ -164,7 +164,7 @@ fn validate_email_templates( .collect(); errors.push(format!( - "template '{}' requires body_html field when used with email destination{} {} (rule '{}')", + "template '{}' requires email_body_html field when used with email destination{} {} (rule '{}')", template_name, if email_dests.len() > 1 { "s" } else { "" }, email_dests.iter().map(|s| format!("'{}'", s)).collect::>().join(", "), @@ -307,7 +307,7 @@ async fn run(runtime_config: valerter::config::RuntimeConfig) -> Result<()> { } info!("All rule destinations validated successfully"); - // Validate that templates used with email destinations have body_html (fail-fast) + // Validate that templates used with email destinations have email_body_html (fail-fast) if let Err(errors) = validate_email_templates(&runtime_config, ®istry) { for e in &errors { error!(error = %e, "Email template validation error"); diff --git a/src/notify/email.rs b/src/notify/email.rs index 8c0eb6f..f4d417d 100644 --- a/src/notify/email.rs +++ b/src/notify/email.rs @@ -391,7 +391,7 @@ impl EmailNotifier { /// Render the body template with alert context. /// - /// Uses `body_html` from the template engine (already HTML-escaped) if available, + /// Uses `email_body_html` from the template engine (already HTML-escaped) if available, /// otherwise falls back to `body`. The body is marked as "safe" (pre-escaped) so /// the template doesn't need `| safe` filter - this prevents user errors if they /// edit the email template and accidentally remove the filter. @@ -406,10 +406,10 @@ impl EmailNotifier { .get_template("body") .map_err(|e| NotifyError::TemplateError(format!("body template error: {}", e)))?; - // Use body_html if available (already HTML-escaped), otherwise fall back to body + // Use email_body_html if available (already HTML-escaped), otherwise fall back to body let body_content = alert .message - .body_html + .email_body_html .as_ref() .unwrap_or(&alert.message.body); @@ -795,7 +795,7 @@ mod tests { message: RenderedMessage { title: "Test Alert".to_string(), body: "Something happened".to_string(), - body_html: None, + email_body_html: None, accent_color: Some("#ff0000".to_string()), }, rule_name: rule_name.to_string(), @@ -1325,9 +1325,9 @@ mod tests { ); let mut alert = make_alert_payload("html_test"); - // body_html is pre-escaped by TemplateEngine, injected with | safe + // email_body_html is pre-escaped by TemplateEngine, injected with | safe // Using pre-escaped content simulates what TemplateEngine produces - alert.message.body_html = + alert.message.email_body_html = Some("<h1>Alert!</h1><p>Something happened</p>".to_string()); let result = notifier.send(&alert).await; @@ -1335,10 +1335,10 @@ mod tests { assert!(result.is_ok()); let emails = mock.sent_emails(); assert_eq!(emails.len(), 1); - // body_html content (pre-escaped) is injected directly with | safe + // email_body_html content (pre-escaped) is injected directly with | safe assert!( emails[0].body.contains("<h1>Alert!<"), - "Pre-escaped body_html should be in email, got: {}", + "Pre-escaped email_body_html should be in email, got: {}", emails[0].body ); assert!( diff --git a/src/notify/mattermost.rs b/src/notify/mattermost.rs index dbe8108..ede5e6e 100644 --- a/src/notify/mattermost.rs +++ b/src/notify/mattermost.rs @@ -298,7 +298,7 @@ mod tests { let message = RenderedMessage { title: "Test Alert".to_string(), body: "Something happened".to_string(), - body_html: None, + email_body_html: None, accent_color: Some("#ff0000".to_string()), }; @@ -332,7 +332,7 @@ mod tests { let message = RenderedMessage { title: "Simple Alert".to_string(), body: "Body text".to_string(), - body_html: None, + email_body_html: None, accent_color: None, }; @@ -354,7 +354,7 @@ mod tests { let message = RenderedMessage { title: "Test".to_string(), body: "Body".to_string(), - body_html: None, + email_body_html: None, accent_color: None, }; @@ -380,7 +380,7 @@ mod tests { let message = RenderedMessage { title: "Test".to_string(), body: "Body".to_string(), - body_html: None, + email_body_html: None, accent_color: Some("#00ff00".to_string()), }; @@ -411,7 +411,7 @@ mod tests { let message = RenderedMessage { title: "Test".to_string(), body: "Body".to_string(), - body_html: None, + email_body_html: None, accent_color: None, }; diff --git a/src/notify/telegram.rs b/src/notify/telegram.rs index c35eeec..cec1f49 100644 --- a/src/notify/telegram.rs +++ b/src/notify/telegram.rs @@ -10,7 +10,9 @@ use crate::error::{ConfigError, NotifyError}; use crate::notify::{AlertPayload, Notifier, backoff_delay}; use async_trait::async_trait; use minijinja::{Environment, context}; +use regex::Regex; use serde::Serialize; +use std::sync::LazyLock; use std::time::Duration; use tracing::Instrument; @@ -102,6 +104,56 @@ fn render_body_template(source: &str, alert: &AlertPayload) -> Result`, `` from a rendered Telegram HTML body. +static HTML_TAG_REGEX: LazyLock = + LazyLock::new(|| Regex::new(r"<[^>]*>").expect("valid regex")); + +/// Cap on title length when wrapping into `` for the fallback, to stay +/// under `TELEGRAM_TEXT_MAX_CODEPOINTS` even after HTML-escaping and wrapping. +const FALLBACK_TITLE_MAX_CODEPOINTS: usize = TELEGRAM_TEXT_MAX_CODEPOINTS - 16; + +/// Escape the three characters Telegram's HTML parse_mode treats as markup. +fn escape_telegram_html(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + _ => out.push(c), + } + } + out +} + +/// Return a fallback text + reason when `text` has no visible content, else `None`. +fn fallback_if_empty(text: &str, alert: &AlertPayload) -> Option<(String, &'static str)> { + let reason = if text.is_empty() { + "empty_after_render" + } else if text.trim().is_empty() { + "whitespace_only" + } else if HTML_TAG_REGEX.replace_all(text, "").trim().is_empty() { + "no_text_content" + } else { + return None; + }; + + let fallback = if !alert.message.title.trim().is_empty() { + let capped: String = alert + .message + .title + .chars() + .take(FALLBACK_TITLE_MAX_CODEPOINTS) + .collect(); + format!("{}", escape_telegram_html(&capped)) + } else if !alert.rule_name.trim().is_empty() { + format!("Alert: {}", escape_telegram_html(&alert.rule_name)) + } else { + "(valerter alert, empty render)".to_string() + }; + Some((fallback, reason)) +} + /// Clamp a parsed retry-after value to the `[RETRY_AFTER_MIN, RETRY_AFTER_MAX]` /// window. A zero or near-zero value is bumped up to 1s to avoid tight loops; /// a very large value is capped so we never hold an executor hostage on @@ -237,7 +289,18 @@ impl TelegramNotifier { .as_deref() .unwrap_or(DEFAULT_BODY_TEMPLATE); let rendered = render_body_template(template, alert)?; - Ok(truncate_text(&rendered)) + let guarded = if let Some((fallback, reason)) = fallback_if_empty(&rendered, alert) { + tracing::warn!( + rule_name = %alert.rule_name, + notifier_name = %self.name, + reason = reason, + "Telegram body_template rendered empty, applied fallback" + ); + fallback + } else { + rendered + }; + Ok(truncate_text(&guarded)) } /// Send the prepared text to a single chat_id with retry. Returns `Ok` on @@ -457,7 +520,7 @@ mod tests { message: RenderedMessage { title: title.to_string(), body: body.to_string(), - body_html: None, + email_body_html: None, accent_color: None, }, rule_name: "test_rule".to_string(), @@ -928,4 +991,135 @@ mod tests { // churn in the rest of the module). use std::sync::Arc; use std::sync::atomic::{AtomicU32, Ordering}; + + // ── fallback_if_empty (#26 empty-render guard) ─────────────────────── + + #[test] + fn fallback_if_empty_triggers_on_empty() { + let alert = sample_alert("", ""); + let out = fallback_if_empty("", &alert); + assert!(out.is_some(), "empty string should trigger fallback"); + let (_, reason) = out.unwrap(); + assert_eq!(reason, "empty_after_render"); + } + + #[test] + fn fallback_if_empty_triggers_on_whitespace() { + let alert = sample_alert("", ""); + let out = fallback_if_empty(" \n\t ", &alert); + assert!(out.is_some(), "whitespace-only should trigger fallback"); + let (_, reason) = out.unwrap(); + assert_eq!(reason, "whitespace_only"); + } + + #[test] + fn fallback_if_empty_triggers_on_html_only() { + let alert = sample_alert("", ""); + let out = fallback_if_empty("\n", &alert); + assert!(out.is_some(), "html-only should trigger fallback"); + let (_, reason) = out.unwrap(); + assert_eq!(reason, "no_text_content"); + } + + #[test] + fn fallback_if_empty_uses_title_when_available() { + let alert = sample_alert("Disk full", ""); + let (text, _) = fallback_if_empty("\n", &alert).unwrap(); + assert_eq!(text, "Disk full"); + } + + #[test] + fn fallback_if_empty_uses_rule_name_as_fallback() { + // title is empty → fallback leans on rule_name. + let mut alert = sample_alert("", ""); + alert.rule_name = "nginx-5xx".to_string(); + let (text, _) = fallback_if_empty("", &alert).unwrap(); + assert_eq!(text, "Alert: nginx-5xx"); + } + + #[test] + fn fallback_if_empty_uses_literal_when_all_empty() { + let mut alert = sample_alert("", ""); + alert.rule_name = String::new(); + let (text, _) = fallback_if_empty("", &alert).unwrap(); + assert_eq!(text, "(valerter alert, empty render)"); + } + + #[test] + fn fallback_escapes_html_specials_in_title() { + // Post-review guard: a title containing `<`, `>`, or `&` must not + // produce a payload that Telegram's HTML parse_mode would reject. + let alert = sample_alert("", ""); + let (text, _) = fallback_if_empty("", &alert).unwrap(); + assert_eq!(text, "<script>alert(&amp;)</script>"); + } + + #[test] + fn fallback_escapes_html_specials_in_rule_name() { + let mut alert = sample_alert("", ""); + alert.rule_name = "a`. + let long_title: String = "x".repeat(10_000); + let alert = sample_alert(&long_title, ""); + let (text, _) = fallback_if_empty("", &alert).unwrap(); + assert!(text.chars().count() <= TELEGRAM_TEXT_MAX_CODEPOINTS); + assert!(text.starts_with("")); + assert!(text.ends_with("")); + } + + #[test] + fn fallback_if_empty_returns_none_for_non_empty() { + let alert = sample_alert("", ""); + assert!(fallback_if_empty("Hello world", &alert).is_none()); + } + + #[test] + fn fallback_if_empty_returns_none_for_html_with_text() { + let alert = sample_alert("", ""); + assert!(fallback_if_empty("ok", &alert).is_none()); + } + + // ── prepare_text empty-guard integration ───────────────────────────── + + #[test] + fn prepare_text_substitutes_on_empty_render() { + // A body_template that renders to literally empty (no vars in context). + let client = reqwest::Client::new(); + let mut cfg = config_with(vec!["-100".to_string()]); + cfg.body_template = Some("".to_string()); + let notifier = TelegramNotifier::from_config("tg", &cfg, client).unwrap(); + let alert = sample_alert("Disk full", ""); + let (text, _) = notifier.prepare_text(&alert).unwrap(); + assert!(!text.trim().is_empty(), "fallback text must not be empty"); + assert_eq!(text, "Disk full"); + } + + #[test] + fn prepare_text_substitutes_on_html_only_render() { + // Reproduces issue #26: default template + empty title/body → "\n". + let client = reqwest::Client::new(); + let cfg = config_with(vec!["-100".to_string()]); + let notifier = TelegramNotifier::from_config("tg", &cfg, client).unwrap(); + let mut alert = sample_alert("", ""); + alert.rule_name = "nginx-5xx".to_string(); + let (text, _) = notifier.prepare_text(&alert).unwrap(); + assert_eq!(text, "Alert: nginx-5xx"); + } + + #[test] + fn prepare_text_preserves_non_empty_render() { + let client = reqwest::Client::new(); + let cfg = config_with(vec!["-100".to_string()]); + let notifier = TelegramNotifier::from_config("tg", &cfg, client).unwrap(); + let alert = sample_alert("hello", "world"); + let (text, _) = notifier.prepare_text(&alert).unwrap(); + assert_eq!(text, "hello\nworld"); + } } diff --git a/src/notify/tests.rs b/src/notify/tests.rs index 87ac20a..506d77d 100644 --- a/src/notify/tests.rs +++ b/src/notify/tests.rs @@ -23,7 +23,7 @@ fn make_payload(rule_name: &str) -> AlertPayload { message: RenderedMessage { title: format!("Alert from {}", rule_name), body: "Test body".to_string(), - body_html: None, + email_body_html: None, accent_color: Some("#ff0000".to_string()), }, rule_name: rule_name.to_string(), @@ -38,7 +38,7 @@ fn make_payload_with_destinations(rule_name: &str, destinations: Vec) -> message: RenderedMessage { title: format!("Alert from {}", rule_name), body: "Test body".to_string(), - body_html: None, + email_body_html: None, accent_color: Some("#ff0000".to_string()), }, rule_name: rule_name.to_string(), @@ -636,7 +636,7 @@ fn alert_payload_clone_works() { message: RenderedMessage { title: "Test".to_string(), body: "Body".to_string(), - body_html: None, + email_body_html: None, accent_color: Some("#ff0000".to_string()), }, rule_name: "my_rule".to_string(), diff --git a/src/notify/webhook.rs b/src/notify/webhook.rs index e1fcba8..79a34d0 100644 --- a/src/notify/webhook.rs +++ b/src/notify/webhook.rs @@ -383,7 +383,7 @@ mod tests { message: RenderedMessage { title: "Test Alert".to_string(), body: "Something happened".to_string(), - body_html: None, + email_body_html: None, accent_color: Some("#ff0000".to_string()), }, rule_name: rule_name.to_string(), @@ -653,7 +653,7 @@ mod tests { message: RenderedMessage { title: "Simple".to_string(), body: "Body".to_string(), - body_html: None, + email_body_html: None, accent_color: None, }, rule_name: "simple_rule".to_string(), @@ -793,7 +793,7 @@ mod tests { message: RenderedMessage { title: "Test".to_string(), body: "Body".to_string(), - body_html: None, + email_body_html: None, accent_color: Some("#ff0000".to_string()), }, rule_name: "test_rule".to_string(), diff --git a/src/parser.rs b/src/parser.rs index 7893dbf..45cfcec 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -256,10 +256,168 @@ impl RuleParser { } } +/// Expand flat dotted keys (e.g. `"a.b.c": "x"`) into nested objects, additively. +/// +/// VictoriaLogs emits fields like `{"nginx.http.request_id": "x"}` as literal flat +/// keys. minijinja interprets `a.b.c` as nested attribute access, so users writing +/// `{{ nginx.http.request_id }}` need a nested view of the data. +/// +/// Behavior: +/// - Original flat key is preserved (compat with existing `event['a.b']` workarounds). +/// - Nested objects are added; deep-merged with any pre-existing nested structure. +/// - On collision (top-level scalar already exists for the first segment), the +/// scalar wins, the dotted key is left untouched, and a `warn!` is emitted. +/// - Non-objects pass through unchanged. +pub(crate) fn unflatten_dotted_keys(value: &Value) -> Value { + let Some(map) = value.as_object() else { + return value.clone(); + }; + + let mut out: Map = map.clone(); + + for (key, val) in map { + if !key.contains('.') { + continue; + } + let segments: Vec<&str> = key.split('.').collect(); + let head = segments[0]; + + match out.get(head) { + Some(existing) if !existing.is_object() => { + tracing::warn!( + conflicting_key = %key, + head_segment = %head, + "skipping dotted-key expansion: top-level scalar already exists" + ); + continue; + } + _ => {} + } + + insert_nested(&mut out, &segments, val.clone()); + } + + Value::Object(out) +} + +fn insert_nested(out: &mut Map, segments: &[&str], leaf: Value) { + let head = segments[0]; + if segments.len() == 1 { + out.insert(head.to_string(), leaf); + return; + } + + let entry = out + .entry(head.to_string()) + .or_insert_with(|| Value::Object(Map::new())); + + if !entry.is_object() { + tracing::warn!( + head_segment = %head, + "skipping nested merge: intermediate scalar collides with nested path" + ); + return; + } + + let nested = entry.as_object_mut().expect("checked is_object above"); + insert_nested(nested, &segments[1..], leaf); +} + #[cfg(test)] mod tests { use super::*; + // ============================================================ + // unflatten_dotted_keys tests (issue #25) + // ============================================================ + + #[test] + fn unflatten_basic_single_dotted_key() { + let input = serde_json::json!({"nginx.http.request_id": "x"}); + let out = unflatten_dotted_keys(&input); + let obj = out.as_object().unwrap(); + // Original flat key preserved (additive) + assert_eq!(obj.get("nginx.http.request_id").unwrap(), "x"); + // Nested structure added + assert_eq!( + obj.get("nginx") + .unwrap() + .get("http") + .unwrap() + .get("request_id") + .unwrap(), + "x" + ); + } + + #[test] + fn unflatten_depth_one() { + let input = serde_json::json!({"a.b": 1}); + let out = unflatten_dotted_keys(&input); + let obj = out.as_object().unwrap(); + assert_eq!(obj.get("a.b").unwrap(), 1); + assert_eq!(obj.get("a").unwrap().get("b").unwrap(), 1); + } + + #[test] + fn unflatten_no_dots_unchanged() { + let input = serde_json::json!({"simple": 1, "_msg": "x"}); + let out = unflatten_dotted_keys(&input); + let obj = out.as_object().unwrap(); + assert_eq!(obj.len(), 2); + assert_eq!(obj.get("simple").unwrap(), 1); + assert_eq!(obj.get("_msg").unwrap(), "x"); + } + + #[test] + fn unflatten_deep_merges_with_existing_nested() { + let input = serde_json::json!({"a": {"c": 1}, "a.b": 2}); + let out = unflatten_dotted_keys(&input); + let obj = out.as_object().unwrap(); + // Flat key preserved + assert_eq!(obj.get("a.b").unwrap(), 2); + let a = obj.get("a").unwrap(); + // Existing nested key preserved + assert_eq!(a.get("c").unwrap(), 1); + // Dotted key merged in + assert_eq!(a.get("b").unwrap(), 2); + } + + #[test] + fn unflatten_scalar_collision_skips_expansion() { + let input = serde_json::json!({"a": "scalar", "a.b": 1}); + let out = unflatten_dotted_keys(&input); + let obj = out.as_object().unwrap(); + // Scalar wins + assert_eq!(obj.get("a").unwrap(), "scalar"); + // Flat key preserved + assert_eq!(obj.get("a.b").unwrap(), 1); + // No nested expansion + assert_eq!(obj.len(), 2); + } + + #[test] + fn unflatten_multiple_dotted_keys_share_root() { + let input = serde_json::json!({ + "host": "h1", + "nginx.http.request_id": "abc", + "nginx.http.method": "GET" + }); + let out = unflatten_dotted_keys(&input); + let obj = out.as_object().unwrap(); + let nginx_http = obj.get("nginx").unwrap().get("http").unwrap(); + assert_eq!(nginx_http.get("request_id").unwrap(), "abc"); + assert_eq!(nginx_http.get("method").unwrap(), "GET"); + assert_eq!(obj.get("host").unwrap(), "h1"); + } + + #[test] + fn unflatten_non_object_passes_through() { + let input = serde_json::json!("just a string"); + let out = unflatten_dotted_keys(&input); + assert_eq!(out, input); + } + // ============================================================ // Task 1: VictoriaLogs envelope parsing tests // ============================================================ diff --git a/src/template.rs b/src/template.rs index c0c81c1..af5b147 100644 --- a/src/template.rs +++ b/src/template.rs @@ -41,7 +41,7 @@ pub struct RenderedMessage { /// Body text of the message (attachment text for Mattermost). pub body: String, /// Optional HTML body for email notifications (rendered with HTML auto-escape). - pub body_html: Option, + pub email_body_html: Option, /// Optional accent color for visual indicators (hex format: #rrggbb). /// Used for email colored dot and Mattermost sidebar color. pub accent_color: Option, @@ -59,7 +59,7 @@ pub struct RenderedMessage { pub struct TemplateEngine { /// Pre-created Jinja environment (created once, reused for performance). env: Environment<'static>, - /// Pre-created Jinja environment with HTML auto-escape (for body_html rendering). + /// Pre-created Jinja environment with HTML auto-escape (for email_body_html rendering). html_env: Environment<'static>, /// Compiled templates indexed by name. templates: HashMap, @@ -87,7 +87,7 @@ impl TemplateEngine { // This returns empty string instead of erroring on undefined variables env.set_undefined_behavior(UndefinedBehavior::Lenient); - // Pre-create HTML environment for body_html rendering (performance optimization) + // Pre-create HTML environment for email_body_html rendering (performance optimization) let mut html_env = Environment::new(); html_env.set_undefined_behavior(UndefinedBehavior::Lenient); html_env.set_auto_escape_callback(|_| minijinja::AutoEscape::Html); @@ -137,9 +137,9 @@ impl TemplateEngine { let title = self.render_string(&template.title, fields)?; let body = self.render_string(&template.body, fields)?; - // Render body_html with HTML auto-escape if present - let body_html = if let Some(body_html_template) = &template.body_html { - Some(self.render_string_html_escaped(body_html_template, fields)?) + // Render email_body_html with HTML auto-escape if present + let email_body_html = if let Some(email_body_html_template) = &template.email_body_html { + Some(self.render_string_html_escaped(email_body_html_template, fields)?) } else { None }; @@ -149,35 +149,37 @@ impl TemplateEngine { tracing::trace!( title_len = title.len(), body_len = body.len(), - has_body_html = body_html.is_some(), + has_email_body_html = email_body_html.is_some(), "Template rendered successfully" ); Ok(RenderedMessage { title, body, - body_html, + email_body_html, accent_color: template.accent_color.clone(), }) } /// Render a single template string with fields (no auto-escape). fn render_string(&self, template_str: &str, fields: &Value) -> Result { + let ctx = crate::parser::unflatten_dotted_keys(fields); self.env - .render_str(template_str, fields) + .render_str(template_str, &ctx) .map_err(|e| TemplateError::RenderFailed { message: e.to_string(), }) } /// Render a single template string with HTML auto-escape for security. - /// Used for body_html to prevent XSS from log data injected into emails. + /// Used for email_body_html to prevent XSS from log data injected into emails. fn render_string_html_escaped( &self, template_str: &str, fields: &Value, ) -> Result { + let ctx = crate::parser::unflatten_dotted_keys(fields); self.html_env - .render_str(template_str, fields) + .render_str(template_str, &ctx) .map_err(|e| TemplateError::RenderFailed { message: e.to_string(), }) @@ -223,7 +225,7 @@ impl TemplateEngine { RenderedMessage { title: format!("[{}] Alert", rule_name), body: format!("Template render failed: {}\n\nCheck logs for details.", e), - body_html: None, + email_body_html: None, accent_color: Some("#ff0000".to_string()), // Red for error } } @@ -249,7 +251,7 @@ mod tests { CompiledTemplate { title: title.to_string(), body: body.to_string(), - body_html: None, + email_body_html: None, accent_color: None, } } @@ -262,7 +264,7 @@ mod tests { CompiledTemplate { title: title.to_string(), body: body.to_string(), - body_html: None, + email_body_html: None, accent_color: accent_color.map(String::from), } } @@ -551,14 +553,14 @@ mod tests { let msg1 = RenderedMessage { title: "Title".to_string(), body: "Body".to_string(), - body_html: None, + email_body_html: None, accent_color: Some("#000000".to_string()), }; let msg2 = RenderedMessage { title: "Title".to_string(), body: "Body".to_string(), - body_html: None, + email_body_html: None, accent_color: Some("#000000".to_string()), }; @@ -628,24 +630,28 @@ mod tests { } // =================================================================== - // Task 9: Tests body_html rendering with HTML auto-escape + // Task 9: Tests email_body_html rendering with HTML auto-escape // =================================================================== - fn make_template_with_body_html(title: &str, body: &str, body_html: &str) -> CompiledTemplate { + fn make_template_with_email_body_html( + title: &str, + body: &str, + email_body_html: &str, + ) -> CompiledTemplate { CompiledTemplate { title: title.to_string(), body: body.to_string(), - body_html: Some(body_html.to_string()), + email_body_html: Some(email_body_html.to_string()), accent_color: None, } } #[test] - fn render_body_html_is_populated() { + fn render_email_body_html_is_populated() { let mut templates = HashMap::new(); templates.insert( "email_alert".to_string(), - make_template_with_body_html( + make_template_with_email_body_html( "Alert: {{ host }}", "Host {{ host }} down", "

Host: {{ host }}

", @@ -659,20 +665,20 @@ mod tests { assert_eq!(result.title, "Alert: server-01"); assert_eq!(result.body, "Host server-01 down"); - assert!(result.body_html.is_some()); + assert!(result.email_body_html.is_some()); assert_eq!( - result.body_html.unwrap(), + result.email_body_html.unwrap(), "

Host: server-01

" ); } #[test] - fn render_body_html_escapes_html_in_variables() { + fn render_email_body_html_escapes_html_in_variables() { // AC3: Variables with HTML should be escaped let mut templates = HashMap::new(); templates.insert( "email_alert".to_string(), - make_template_with_body_html("Alert", "body", "

Hostname: {{ hostname }}

"), + make_template_with_email_body_html("Alert", "body", "

Hostname: {{ hostname }}

"), ); let engine = TemplateEngine::new(templates); @@ -680,22 +686,81 @@ mod tests { let result = engine.render("email_alert", &fields).unwrap(); - let body_html = result.body_html.unwrap(); + let email_body_html = result.email_body_html.unwrap(); // HTML should be escaped assert!( - body_html.contains("<script>"), + email_body_html.contains("<script>"), "Script tags should be escaped: {}", - body_html + email_body_html ); assert!( - !body_html.contains("