diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/notify/MustacheRenderer.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/notify/MustacheRenderer.java
new file mode 100644
index 00000000..ec208597
--- /dev/null
+++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/notify/MustacheRenderer.java
@@ -0,0 +1,92 @@
+package com.cameleer.server.app.alerting.notify;
+
+import com.samskivert.mustache.Mustache;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Renders Mustache templates against a context map.
+ *
+ * Contract:
+ *
+ * - Unresolved {@code {{x.y.z}}} tokens render as the literal {@code {{x.y.z}}} and log WARN.
+ * - Malformed templates (e.g. unclosed {@code {{}) return the original template string and log WARN.
+ * - Never throws on template content.
+ *
+ */
+@Component
+public class MustacheRenderer {
+
+ private static final Logger log = LoggerFactory.getLogger(MustacheRenderer.class);
+
+ /** Matches {{path}} tokens, capturing the trimmed path. Ignores triple-mustache and comments. */
+ private static final Pattern TOKEN = Pattern.compile("\\{\\{\\s*([^#/!>{\\s][^}]*)\\s*\\}\\}");
+
+ /** Sentinel prefix/suffix to survive Mustache compilation so we can post-replace. */
+ private static final String SENTINEL_PREFIX = "\u0000TPL\u0001";
+ private static final String SENTINEL_SUFFIX = "\u0001LPT\u0000";
+
+ public String render(String template, Map ctx) {
+ if (template == null) return "";
+ try {
+ // 1) Walk all {{path}} tokens. Those unresolved get replaced with a unique sentinel.
+ Map literals = new LinkedHashMap<>();
+ StringBuilder pre = new StringBuilder();
+ Matcher m = TOKEN.matcher(template);
+ int sentinelIdx = 0;
+ boolean anyUnresolved = false;
+ while (m.find()) {
+ String path = m.group(1).trim();
+ if (resolvePath(ctx, path) == null) {
+ anyUnresolved = true;
+ String sentinelKey = SENTINEL_PREFIX + sentinelIdx++ + SENTINEL_SUFFIX;
+ literals.put(sentinelKey, "{{" + path + "}}");
+ m.appendReplacement(pre, Matcher.quoteReplacement(sentinelKey));
+ }
+ }
+ m.appendTail(pre);
+ if (anyUnresolved) {
+ log.warn("MustacheRenderer: unresolved template variables; rendering as literals. template={}",
+ template.length() > 200 ? template.substring(0, 200) + "..." : template);
+ }
+
+ // 2) Compile & render the pre-processed template (sentinels are plain text — not Mustache tags).
+ String rendered = Mustache.compiler()
+ .defaultValue("")
+ .escapeHTML(false)
+ .compile(pre.toString())
+ .execute(ctx);
+
+ // 3) Restore the sentinel placeholders back to their original {{path}} literals.
+ for (Map.Entry e : literals.entrySet()) {
+ rendered = rendered.replace(e.getKey(), e.getValue());
+ }
+ return rendered;
+ } catch (Exception e) {
+ log.warn("MustacheRenderer: template render failed, returning raw template: {}", e.getMessage());
+ return template;
+ }
+ }
+
+ /**
+ * Resolves a dotted path like "alert.state" against a nested Map context.
+ * Returns null if any segment is missing or the value is null.
+ */
+ @SuppressWarnings("unchecked")
+ Object resolvePath(Map ctx, String path) {
+ if (ctx == null || path == null || path.isBlank()) return null;
+ String[] parts = path.split("\\.");
+ Object current = ctx.get(parts[0]);
+ for (int i = 1; i < parts.length; i++) {
+ if (!(current instanceof Map)) return null;
+ current = ((Map) current).get(parts[i]);
+ }
+ return current;
+ }
+}
diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/notify/MustacheRendererTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/notify/MustacheRendererTest.java
new file mode 100644
index 00000000..e74d1542
--- /dev/null
+++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/notify/MustacheRendererTest.java
@@ -0,0 +1,77 @@
+package com.cameleer.server.app.alerting.notify;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class MustacheRendererTest {
+
+ private MustacheRenderer renderer;
+
+ @BeforeEach
+ void setUp() {
+ renderer = new MustacheRenderer();
+ }
+
+ @Test
+ void simpleVariable_rendersValue() {
+ var ctx = Map.of("name", "production");
+ assertThat(renderer.render("Env: {{name}}", ctx)).isEqualTo("Env: production");
+ }
+
+ @Test
+ void nestedPath_rendersValue() {
+ var ctx = Map.of(
+ "alert", Map.of("state", "FIRING"));
+ assertThat(renderer.render("State: {{alert.state}}", ctx)).isEqualTo("State: FIRING");
+ }
+
+ @Test
+ void missingVariable_rendersLiteralMustache() {
+ var ctx = Map.of("known", "yes");
+ String result = renderer.render("{{known}} and {{missing.path}}", ctx);
+ assertThat(result).isEqualTo("yes and {{missing.path}}");
+ }
+
+ @Test
+ void missingVariable_exactLiteralNoPadding() {
+ // The rendered literal must be exactly {{x}} — no surrounding whitespace or delimiter residue.
+ String result = renderer.render("{{unknown}}", Map.of());
+ assertThat(result).isEqualTo("{{unknown}}");
+ }
+
+ @Test
+ void malformedTemplate_returnsRawTemplate() {
+ String broken = "Hello {{unclosed";
+ String result = renderer.render(broken, Map.of());
+ assertThat(result).isEqualTo(broken);
+ }
+
+ @Test
+ void nullTemplate_returnsEmptyString() {
+ assertThat(renderer.render(null, Map.of())).isEmpty();
+ }
+
+ @Test
+ void emptyTemplate_returnsEmptyString() {
+ assertThat(renderer.render("", Map.of())).isEmpty();
+ }
+
+ @Test
+ void mixedResolvedAndUnresolved_rendersCorrectly() {
+ var ctx = Map.of(
+ "env", Map.of("slug", "prod"),
+ "alert", Map.of("id", "abc-123"));
+ String tmpl = "{{env.slug}} / {{alert.id}} / {{alert.resolvedAt}}";
+ String result = renderer.render(tmpl, ctx);
+ assertThat(result).isEqualTo("prod / abc-123 / {{alert.resolvedAt}}");
+ }
+
+ @Test
+ void plainText_noTokens_returnsAsIs() {
+ assertThat(renderer.render("No tokens here.", Map.of())).isEqualTo("No tokens here.");
+ }
+}