From 97879f908d9e8df71fff4e4ddb9e591d78ae80d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois-Xavier=20THIRY?= Date: Wed, 15 Apr 2026 11:15:19 +0200 Subject: [PATCH 1/6] fix(templates): accept dotted VictoriaLogs fields in templates (#25) VictoriaLogs emits fields like `nginx.http.request_id` as literal flat keys, but minijinja interprets `a.b.c` as nested attribute access. Both `--validate` and runtime rendering would therefore fail on user-written templates referencing these fields. Two complementary fixes: - At render time, deflate the event additively in `TemplateEngine` before handing it to minijinja: flat dotted keys stay accessible, and a nested structure is added on top so `{{ nginx.http.request_id }}` resolves. Deep-merges with existing nested values; on collision with an existing top-level scalar, the scalar wins and a `warn!` is emitted. - Swap the `validate_template_render` sentinel from `json!({})` to a `TruthyChainable` Object that returns itself on any attribute access, is always truthy, iterates as an empty sequence, and stringifies as empty. Keeps `{% if x.y %}` bodies walked so unknown-filter detection doesn't regress, while allowing chained undefined access and `{% for %}` / length against undefined to validate cleanly. --- src/config/validation.rs | 101 ++++++++++++++++++++++++- src/parser.rs | 158 +++++++++++++++++++++++++++++++++++++++ src/template.rs | 65 +++++++++++++++- 3 files changed, 320 insertions(+), 4 deletions(-) diff --git a/src/config/validation.rs b/src/config/validation.rs index b4b992c..b3c4977 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,64 @@ 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/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..f6ba214 100644 --- a/src/template.rs +++ b/src/template.rs @@ -162,8 +162,9 @@ impl TemplateEngine { /// 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(), }) @@ -176,8 +177,9 @@ impl TemplateEngine { 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(), }) @@ -694,6 +696,65 @@ mod tests { ); } + // =================================================================== + // Issue #25: dotted flat-key resolution at render time + // =================================================================== + + #[test] + fn render_resolves_dotted_flat_key_via_unflatten() { + let mut templates = HashMap::new(); + templates.insert( + "alert".to_string(), + make_template( + "{{ nginx.http.request_id }}", + "id={{ nginx.http.request_id }}", + ), + ); + + let engine = TemplateEngine::new(templates); + let fields = json!({"nginx.http.request_id": "abc"}); + + let result = engine.render("alert", &fields).unwrap(); + assert_eq!(result.title, "abc"); + assert_eq!(result.body, "id=abc"); + } + + #[test] + fn render_issue_25_full_template_end_to_end() { + // Mirrors the user's config in issue #25. + let mut templates = HashMap::new(); + templates.insert( + "my_template".to_string(), + make_template_with_body_html( + "{{ title }}", + "{{ body }}", + "

{{ hostname }} - {{ nginx.http.request_id }} - {{ nginx.http.method }}

", + ), + ); + + let engine = TemplateEngine::new(templates); + let fields = json!({ + "title": "T", + "body": "B", + "hostname": "srv-01", + "nginx.http.request_id": "req-42", + "nginx.http.method": "GET", + "nginx.http.status_code": "400" + }); + + let result = engine.render("my_template", &fields).unwrap(); + assert_eq!(result.title, "T"); + assert_eq!(result.body, "B"); + let body_html = result.body_html.unwrap(); + assert!( + body_html.contains("srv-01") + && body_html.contains("req-42") + && body_html.contains("GET"), + "expected all dotted fields rendered, got: {}", + body_html + ); + } + #[test] fn render_body_html_none_when_template_has_no_body_html() { let mut templates = HashMap::new(); From 99518fb370e61d342963f3440f026f3a91d5aecb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois-Xavier=20THIRY?= Date: Wed, 15 Apr 2026 11:50:22 +0200 Subject: [PATCH 2/6] fix(telegram): guard against empty renders and fix misleading example (#26) VictoriaLogs events don't carry `title` / `body` fields. When a user copies the example config verbatim, `{{ title }}` and `{{ body }}` at the outer template layer resolve to empty strings. Telegram's `parse_mode: HTML` default then receives `"\n"` after `DEFAULT_BODY_TEMPLATE` renders, and responds 400 Bad Request. The alert is lost. Two coupled fixes: - `TelegramNotifier::prepare_text` now runs a `fallback_if_empty` guard: if the rendered text is empty, whitespace-only, or contains no text after stripping HTML tags, substitute with a priority-ordered fallback (`{title}` if title non-empty, else `Alert: {rule_name}`, else `"(valerter alert, empty render)"`) and emit a structured `warn!` with `rule_name`, `notifier_name`, and the matching reason. Title and rule_name are HTML-escaped and the title is capped to stay under Telegram's 4096 codepoint limit once wrapped. - `config/config.example.yaml` now separates "variables available inside the template (VL fields + rule_name + log_timestamp)" from "template output keys (title, body, body_html, accent_color)". The default_alert example switches to `title: "{{ rule_name }}"` and `body: "{{ _msg }}"` so it renders non-empty content out of the box on any VL event. --- config/config.example.yaml | 36 ++++--- src/notify/telegram.rs | 199 ++++++++++++++++++++++++++++++++++++- 2 files changed, 221 insertions(+), 14 deletions(-) diff --git a/config/config.example.yaml b/config/config.example.yaml index 607bbc9..0bd318f 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -97,24 +97,34 @@ 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) +# body_html HTML version for email (optional, falls back to body) +# 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 ─────────────────────────────────────── diff --git a/src/notify/telegram.rs b/src/notify/telegram.rs index c35eeec..f20a11f 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 @@ -928,4 +991,138 @@ 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"); + } } From 6019a5e51e8ca64b4c032b5f4353613f4de7880d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois-Xavier=20THIRY?= Date: Wed, 15 Apr 2026 12:18:56 +0200 Subject: [PATCH 3/6] docs(notifiers): clarify body_html is email-only The previous wording ("HTML version for email (optional, falls back to body)") was ambiguous enough to let users infer that body_html would be picked up by any notifier whose parse_mode was HTML. Issue #26 was triggered exactly by that inference: a Telegram user put meaningful content in body_html, left body as "{{ body }}" (which rendered to empty because the VictoriaLogs event has no `body` field), and got a 400 from Telegram. Reinforce in both the example config header and the docs/notifiers.md sections for Telegram, Mattermost, and Webhook that: - body_html is read only by the email notifier. - All other notifiers (Telegram, Mattermost, webhook) read `body`. - For rich formatting in Telegram, write markup inline in `body` using Telegram's supported HTML subset. No code changes - the runtime guard landed in the previous commit still catches the empty-body case as a safety net. --- config/config.example.yaml | 6 +++++- docs/notifiers.md | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/config/config.example.yaml b/config/config.example.yaml index 0bd318f..b5bfab3 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -113,7 +113,11 @@ defaults: # the slots valerter fills and hands off to notifiers: # title Alert title (required) # body Alert body, plain text or Markdown (required) -# body_html HTML version for email (optional, falls back to body) +# body_html HTML-enriched version used ONLY by the email notifier +# (Telegram, Mattermost, and webhook all read `body` and +# ignore `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. diff --git a/docs/notifiers.md b/docs/notifiers.md index 259a0ed..ef6a6be 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 `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 `body_html`; +> `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 @@ -218,6 +225,11 @@ notifiers: Send alerts to Mattermost channels via incoming webhooks. +> **Note about `body_html`** — Mattermost reads the outer template's `body` +> output key, **not** `body_html`. `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 `body_html`** — Telegram reads the outer template's `body` +> output key, **not** `body_html`. `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. From 632aa7a1826c0fa552171dc8b4449985eb6767de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois-Xavier=20THIRY?= Date: Wed, 15 Apr 2026 12:46:53 +0200 Subject: [PATCH 4/6] refactor!: rename template field body_html to email_body_html MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only the email notifier consumes this field. Telegram, Mattermost, and webhook have always ignored it. The old name suggested a generic HTML output slot, which directly misled at least one user into putting Telegram content in body_html and leaving body empty (issue #26). Breaking change: configs using `body_html:` are rejected at load with serde's "unknown field" error, which lists `email_body_html` among the expected fields. Migration is a one-liner per template and applies equally to inline templates and split `templates.d/` files. No behavior change elsewhere. Email fallback preserved exactly: `email_body_html.or(body)`. The runtime semantics are identical after the rename — only the field identifier changed, at every call site. CHANGELOG entry added for v1.2.0 bundling this breaking change with the previously merged fixes for #25 (dotted fields) and #26 (empty telegram guard + honest example). --- CHANGELOG.md | 23 +++++- config/config.example.yaml | 6 +- docs/architecture.md | 4 +- docs/configuration.md | 8 +- docs/getting-started.md | 2 +- docs/notifiers.md | 20 ++--- examples/cisco-switches/README.md | 2 +- examples/cisco-switches/config.yaml | 2 +- .../test-notifiers/config-test-notifiers.yaml | 2 +- src/config/runtime.rs | 4 +- src/config/tests.rs | 57 ++++++++++++-- src/config/types.rs | 14 ++-- src/engine.rs | 4 +- src/main.rs | 12 +-- src/notify/email.rs | 16 ++-- src/notify/mattermost.rs | 10 +-- src/notify/telegram.rs | 2 +- src/notify/tests.rs | 6 +- src/notify/webhook.rs | 6 +- src/template.rs | 74 +++++++++---------- ...config_email_missing_email_body_html.yaml} | 4 +- tests/integration_notify.rs | 2 +- tests/integration_validate.rs | 16 ++-- tests/smtp_integration.rs | 26 +++---- 24 files changed, 192 insertions(+), 130 deletions(-) rename tests/fixtures/{config_email_missing_body_html.yaml => config_email_missing_email_body_html.yaml} (81%) 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/config/config.example.yaml b/config/config.example.yaml index b5bfab3..ae1bc1d 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -113,9 +113,9 @@ defaults: # the slots valerter fills and hands off to notifiers: # title Alert title (required) # body Alert body, plain text or Markdown (required) -# body_html HTML-enriched version used ONLY by the email notifier +# email_body_html HTML-enriched version used ONLY by the email notifier # (Telegram, Mattermost, and webhook all read `body` and -# ignore `body_html`). To format a Telegram alert, put +# 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") @@ -138,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 ef6a6be..b8dcf1b 100644 --- a/docs/notifiers.md +++ b/docs/notifiers.md @@ -15,10 +15,10 @@ Valerter supports multiple notification channels. Configure them in the `notifie The most flexible notifier - works with any HTTP API. -> **Note about `body_html`** — Webhook reads the outer template's `body` +> **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 `body_html`; -> `body_html` is email-only. If your HTTP target needs HTML, put it in `body` +> 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`. @@ -185,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. @@ -225,8 +225,8 @@ notifiers: Send alerts to Mattermost channels via incoming webhooks. -> **Note about `body_html`** — Mattermost reads the outer template's `body` -> output key, **not** `body_html`. `body_html` is email-only. Mattermost +> **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. @@ -277,8 +277,8 @@ 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 `body_html`** — Telegram reads the outer template's `body` -> output key, **not** `body_html`. `body_html` is email-only. For rich +> **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: ``, ``, ``, ``, ``, > `
`, `
`, ``, ``, ``. 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/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 f20a11f..a45674c 100644 --- a/src/notify/telegram.rs +++ b/src/notify/telegram.rs @@ -520,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(), 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/template.rs b/src/template.rs index f6ba214..9776e08 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,13 +149,13 @@ 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(), }) } @@ -171,7 +171,7 @@ impl TemplateEngine { } /// 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, @@ -225,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 } } @@ -251,7 +251,7 @@ mod tests { CompiledTemplate { title: title.to_string(), body: body.to_string(), - body_html: None, + email_body_html: None, accent_color: None, } } @@ -264,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), } } @@ -553,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()), }; @@ -630,24 +630,24 @@ 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 }}

", @@ -661,20 +661,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); @@ -682,17 +682,17 @@ 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("", ""); let (text, _) = fallback_if_empty("", &alert).unwrap(); - assert_eq!( - text, - "<script>alert(&amp;)</script>" - ); + assert_eq!(text, "<script>alert(&amp;)</script>"); } #[test] diff --git a/src/template.rs b/src/template.rs index 9776e08..af5b147 100644 --- a/src/template.rs +++ b/src/template.rs @@ -633,7 +633,11 @@ mod tests { // Task 9: Tests email_body_html rendering with HTML auto-escape // =================================================================== - fn make_template_with_email_body_html(title: &str, body: &str, email_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(),