From b64d0020cbb8662f2ba7855599ae56d73e52536f Mon Sep 17 00:00:00 2001
From: Sergio Conde <skgsergio@gmail.com>
Date: Wed, 12 Mar 2025 18:42:56 +0100
Subject: [PATCH] feat: create templated components and unify notification
 templating code

---
 .../notification-provider.js                  | 47 +++++++++++
 server/notification-providers/smtp.js         | 47 +----------
 server/notification-providers/telegram.js     | 13 +--
 server/notification-providers/webhook.js      | 13 +--
 src/components/TemplatedInput.vue             | 75 +++++++++++++++++
 src/components/TemplatedTextarea.vue          | 80 +++++++++++++++++++
 src/components/notifications/SMTP.vue         | 20 ++---
 src/components/notifications/Telegram.vue     | 37 +++------
 src/components/notifications/Webhook.vue      | 20 ++---
 src/lang/en.json                              | 11 ++-
 10 files changed, 233 insertions(+), 130 deletions(-)
 create mode 100644 src/components/TemplatedInput.vue
 create mode 100644 src/components/TemplatedTextarea.vue

diff --git a/server/notification-providers/notification-provider.js b/server/notification-providers/notification-provider.js
index b9fb3d863..42e8e616d 100644
--- a/server/notification-providers/notification-provider.js
+++ b/server/notification-providers/notification-provider.js
@@ -1,3 +1,6 @@
+const { Liquid } = require("liquidjs");
+const { DOWN } = require("../../src/util");
+
 class NotificationProvider {
 
     /**
@@ -49,6 +52,50 @@ class NotificationProvider {
         }
     }
 
+    /**
+     * Renders a message template with notification context
+     * @param {string} template the template
+     * @param {string} msg the message that will be included in the context
+     * @param {?object} monitorJSON Monitor details (For Up/Down/Cert-Expiry only)
+     * @param {?object} heartbeatJSON Heartbeat details (For Up/Down only)
+     * @returns {Promise<string>} rendered template
+     */
+    async renderTemplate(template, msg, monitorJSON, heartbeatJSON) {
+        const engine = new Liquid();
+        const parsedTpl = engine.parse(template);
+
+        // Let's start with dummy values to simplify code
+        let monitorName = "Monitor Name not available";
+        let monitorHostnameOrURL = "testing.hostname";
+
+        if (monitorJSON !== null) {
+            monitorName = monitorJSON["name"];
+            monitorHostnameOrURL = this.extractAddress(monitorJSON);
+        }
+
+        let serviceStatus = "⚠️ Test";
+        if (heartbeatJSON !== null) {
+            serviceStatus = (heartbeatJSON["status"] === DOWN) ? "🔴 Down" : "✅ Up";
+        }
+
+        const context = {
+            // for v1 compatibility, to be removed in v3
+            "STATUS": serviceStatus,
+            "NAME": monitorName,
+            "HOSTNAME_OR_URL": monitorHostnameOrURL,
+
+            // variables which are officially supported
+            "status": serviceStatus,
+            "name": monitorName,
+            "hostnameOrURL": monitorHostnameOrURL,
+            monitorJSON,
+            heartbeatJSON,
+            msg,
+        };
+
+        return engine.render(parsedTpl, context);
+    }
+
     /**
      * Throws an error
      * @param {any} error The error to throw
diff --git a/server/notification-providers/smtp.js b/server/notification-providers/smtp.js
index 9f3defa5e..980c7dfd3 100644
--- a/server/notification-providers/smtp.js
+++ b/server/notification-providers/smtp.js
@@ -1,7 +1,5 @@
 const nodemailer = require("nodemailer");
 const NotificationProvider = require("./notification-provider");
-const { DOWN } = require("../../src/util");
-const { Liquid } = require("liquidjs");
 
 class SMTP extends NotificationProvider {
     name = "smtp";
@@ -53,15 +51,11 @@ class SMTP extends NotificationProvider {
             const customSubject = notification.customSubject?.trim() || "";
             const customBody = notification.customBody?.trim() || "";
 
-            const context = this.generateContext(msg, monitorJSON, heartbeatJSON);
-            const engine = new Liquid();
             if (customSubject !== "") {
-                const tpl = engine.parse(customSubject);
-                subject = await engine.render(tpl, context);
+                subject = await this.renderTemplate(customSubject, msg, monitorJSON, heartbeatJSON);
             }
             if (customBody !== "") {
-                const tpl = engine.parse(customBody);
-                body = await engine.render(tpl, context);
+                body = await this.renderTemplate(customBody, msg, monitorJSON, heartbeatJSON);
             }
         }
 
@@ -78,43 +72,6 @@ class SMTP extends NotificationProvider {
 
         return okMsg;
     }
-
-    /**
-     * Generate context for LiquidJS
-     * @param {string} msg  the message that will be included in the context
-     * @param {?object} monitorJSON Monitor details (For Up/Down/Cert-Expiry only)
-     * @param {?object} heartbeatJSON Heartbeat details (For Up/Down only)
-     * @returns {{STATUS: string, status: string, HOSTNAME_OR_URL: string, hostnameOrUrl: string, NAME: string, name: string, monitorJSON: ?object, heartbeatJSON: ?object, msg: string}} the context
-     */
-    generateContext(msg, monitorJSON, heartbeatJSON) {
-        // Let's start with dummy values to simplify code
-        let monitorName = "Monitor Name not available";
-        let monitorHostnameOrURL = "testing.hostname";
-
-        if (monitorJSON !== null) {
-            monitorName = monitorJSON["name"];
-            monitorHostnameOrURL = this.extractAddress(monitorJSON);
-        }
-
-        let serviceStatus = "⚠️ Test";
-        if (heartbeatJSON !== null) {
-            serviceStatus = (heartbeatJSON["status"] === DOWN) ? "🔴 Down" : "✅ Up";
-        }
-        return {
-            // for v1 compatibility, to be removed in v3
-            "STATUS": serviceStatus,
-            "NAME": monitorName,
-            "HOSTNAME_OR_URL": monitorHostnameOrURL,
-
-            // variables which are officially supported
-            "status": serviceStatus,
-            "name": monitorName,
-            "hostnameOrURL": monitorHostnameOrURL,
-            monitorJSON,
-            heartbeatJSON,
-            msg,
-        };
-    }
 }
 
 module.exports = SMTP;
diff --git a/server/notification-providers/telegram.js b/server/notification-providers/telegram.js
index a8f15d107..62263db07 100644
--- a/server/notification-providers/telegram.js
+++ b/server/notification-providers/telegram.js
@@ -1,6 +1,5 @@
 const NotificationProvider = require("./notification-provider");
 const axios = require("axios");
-const { Liquid } = require("liquidjs");
 
 class Telegram extends NotificationProvider {
     name = "telegram";
@@ -24,17 +23,7 @@ class Telegram extends NotificationProvider {
             }
 
             if (notification.telegramUseTemplate) {
-                const engine = new Liquid();
-                const tpl = engine.parse(notification.telegramTemplate);
-
-                params.text = await engine.render(
-                    tpl,
-                    {
-                        msg,
-                        heartbeatJSON,
-                        monitorJSON
-                    }
-                );
+                params.text = await this.renderTemplate(notification.telegramTemplate, msg, monitorJSON, heartbeatJSON);
 
                 if (notification.telegramTemplateParseMode !== "plain") {
                     params.parse_mode = notification.telegramTemplateParseMode;
diff --git a/server/notification-providers/webhook.js b/server/notification-providers/webhook.js
index 986986d44..537f94bd5 100644
--- a/server/notification-providers/webhook.js
+++ b/server/notification-providers/webhook.js
@@ -1,7 +1,6 @@
 const NotificationProvider = require("./notification-provider");
 const axios = require("axios");
 const FormData = require("form-data");
-const { Liquid } = require("liquidjs");
 
 class Webhook extends NotificationProvider {
     name = "webhook";
@@ -28,17 +27,7 @@ class Webhook extends NotificationProvider {
                 config.headers = formData.getHeaders();
                 data = formData;
             } else if (notification.webhookContentType === "custom") {
-                // Initialize LiquidJS and parse the custom Body Template
-                const engine = new Liquid();
-                const tpl = engine.parse(notification.webhookCustomBody);
-
-                // Insert templated values into Body
-                data = await engine.render(tpl,
-                    {
-                        msg,
-                        heartbeatJSON,
-                        monitorJSON
-                    });
+                data = await this.renderTemplate(notification.webhookCustomBody, msg, monitorJSON, heartbeatJSON);
             }
 
             if (notification.webhookAdditionalHeaders) {
diff --git a/src/components/TemplatedInput.vue b/src/components/TemplatedInput.vue
new file mode 100644
index 000000000..43c5382e0
--- /dev/null
+++ b/src/components/TemplatedInput.vue
@@ -0,0 +1,75 @@
+<template>
+    <div class="form-text mb-2">
+        <i18n-t tag="div" keypath="liquidIntroduction">
+            <a href="https://liquidjs.com/" target="_blank">{{ $t("documentation") }}</a>
+        </i18n-t>
+
+        <code v-pre>{{ msg }}</code>: {{ $t("templateMsg") }}<br />
+        <code v-pre>{{ name }}</code>: {{ $t("templateServiceName") }}<br />
+        <code v-pre>{{ status }}</code>: {{ $t("templateStatus") }}<br />
+        <code v-pre>{{ hostnameOrURL }}</code>: {{ $t("templateHostnameOrURL") }}<br />
+        <code v-pre>{{ heartbeatJSON }}</code>: {{ $t("templateHeartbeatJSON") }} <b>({{ $t("templateLimitedToUpDownNotifications") }})</b><br />
+        <code v-pre>{{ monitorJSON }}</code>: {{ $t("templateMonitorJSON") }} <b>({{ $t("templateLimitedToUpDownCertNotifications") }})</b><br />
+    </div>
+
+    <input
+        :id="id"
+        ref="templatedInput"
+        v-model="model"
+        type="text"
+        class="form-control"
+        :placeholder="placeholder"
+        :required="required"
+        autocomplete="false"
+    >
+</template>
+
+<script>
+export default {
+    props: {
+        /**
+         * The value of the templated input.
+         */
+        modelValue: {
+            type: String,
+            default: ""
+        },
+        /**
+         * id for the templated input.
+         */
+        id: {
+            type: String,
+            required: true,
+        },
+        /**
+         * Whether the templated input is required.
+         * @example true
+         */
+        required: {
+            type: Boolean,
+            required: true,
+        },
+        /**
+         * Placeholder text for the templated input.
+         */
+        placeholder: {
+            type: String,
+            default: ""
+        },
+    },
+    emits: [ "update:modelValue" ],
+    computed: {
+        /**
+         * Send value update to parent on change.
+         */
+        model: {
+            get() {
+                return this.modelValue;
+            },
+            set(value) {
+                this.$emit("update:modelValue", value);
+            }
+        }
+    },
+};
+</script>
diff --git a/src/components/TemplatedTextarea.vue b/src/components/TemplatedTextarea.vue
new file mode 100644
index 000000000..ff0c0f9fb
--- /dev/null
+++ b/src/components/TemplatedTextarea.vue
@@ -0,0 +1,80 @@
+<template>
+    <div class="form-text mb-2">
+        <i18n-t tag="div" keypath="liquidIntroduction">
+            <a href="https://liquidjs.com/" target="_blank">{{ $t("documentation") }}</a>
+        </i18n-t>
+
+        <code v-pre>{{ msg }}</code>: {{ $t("templateMsg") }}<br />
+        <code v-pre>{{ name }}</code>: {{ $t("templateServiceName") }}<br />
+        <code v-pre>{{ status }}</code>: {{ $t("templateStatus") }}<br />
+        <code v-pre>{{ hostnameOrURL }}</code>: {{ $t("templateHostnameOrURL") }}<br />
+        <code v-pre>{{ heartbeatJSON }}</code>: {{ $t("templateHeartbeatJSON") }} <b>({{ $t("templateLimitedToUpDownNotifications") }})</b><br />
+        <code v-pre>{{ monitorJSON }}</code>: {{ $t("templateMonitorJSON") }} <b>({{ $t("templateLimitedToUpDownCertNotifications") }})</b><br />
+    </div>
+
+    <textarea
+        :id="id"
+        ref="templatedTextarea"
+        v-model="model"
+        class="form-control"
+        :placeholder="placeholder"
+        :required="required"
+        autocomplete="false"
+    ></textarea>
+</template>
+
+<script>
+export default {
+    props: {
+        /**
+         * The value of the templated textarea.
+         */
+        modelValue: {
+            type: String,
+            default: ""
+        },
+        /**
+         * id for the templated textarea.
+         */
+        id: {
+            type: String,
+            required: true,
+        },
+        /**
+         * Whether the templated textarea is required.
+         * @example true
+         */
+        required: {
+            type: Boolean,
+            required: true,
+        },
+        /**
+         * Placeholder text for the templated textarea.
+         */
+        placeholder: {
+            type: String,
+            default: ""
+        },
+    },
+    emits: [ "update:modelValue" ],
+    computed: {
+        /**
+         * Send value update to parent on change.
+         */
+        model: {
+            get() {
+                return this.modelValue;
+            },
+            set(value) {
+                this.$emit("update:modelValue", value);
+            }
+        }
+    },
+};
+</script>
+
+<style lang="scss" scoped>
+textarea {
+    min-height: 150px;
+}
+</style>
diff --git a/src/components/notifications/SMTP.vue b/src/components/notifications/SMTP.vue
index 003f90556..4e0fb4b57 100644
--- a/src/components/notifications/SMTP.vue
+++ b/src/components/notifications/SMTP.vue
@@ -67,25 +67,15 @@
             <input id="to-bcc" v-model="$parent.notification.smtpBCC" type="text" class="form-control" autocomplete="false" :required="!hasRecipient">
         </div>
 
-        <p class="form-text">
-            <i18n-t tag="div" keypath="smtpLiquidIntroduction" class="form-text mb-3">
-                <a href="https://liquidjs.com/" target="_blank">{{ $t("documentation") }}</a>
-            </i18n-t>
-            <code v-pre>{{name}}</code>: {{ $t("emailTemplateServiceName") }}<br />
-            <code v-pre>{{msg}}</code>: {{ $t("emailTemplateMsg") }}<br />
-            <code v-pre>{{status}}</code>: {{ $t("emailTemplateStatus") }}<br />
-            <code v-pre>{{heartbeatJSON}}</code>: {{ $t("emailTemplateHeartbeatJSON") }}<b>{{ $t("emailTemplateLimitedToUpDownNotification") }}</b><br />
-            <code v-pre>{{monitorJSON}}</code>: {{ $t("emailTemplateMonitorJSON") }} <b>{{ $t("emailTemplateLimitedToUpDownNotification") }}</b><br />
-            <code v-pre>{{hostnameOrURL}}</code>: {{ $t("emailTemplateHostnameOrURL") }}<br />
-        </p>
         <div class="mb-3">
             <label for="subject-email" class="form-label">{{ $t("emailCustomSubject") }}</label>
-            <input id="subject-email" v-model="$parent.notification.customSubject" type="text" class="form-control" autocomplete="false" placeholder="">
+            <TemplatedInput id="subject-email" v-model="$parent.notification.customSubject" :required="false" placeholder=""></TemplatedInput>
             <div class="form-text">{{ $t("leave blank for default subject") }}</div>
         </div>
+
         <div class="mb-3">
             <label for="body-email" class="form-label">{{ $t("emailCustomBody") }}</label>
-            <textarea id="body-email" v-model="$parent.notification.customBody" type="text" class="form-control" autocomplete="false" placeholder=""></textarea>
+            <TemplatedTextarea id="body-email" v-model="$parent.notification.customBody" :required="false" placeholder=""></TemplatedTextarea>
             <div class="form-text">{{ $t("leave blank for default body") }}</div>
         </div>
 
@@ -124,11 +114,15 @@
 
 <script>
 import HiddenInput from "../HiddenInput.vue";
+import TemplatedInput from "../TemplatedInput.vue";
+import TemplatedTextarea from "../TemplatedTextarea.vue";
 import ToggleSection from "../ToggleSection.vue";
 
 export default {
     components: {
         HiddenInput,
+        TemplatedInput,
+        TemplatedTextarea,
         ToggleSection,
     },
     computed: {
diff --git a/src/components/notifications/Telegram.vue b/src/components/notifications/Telegram.vue
index 04b49ed00..7f04e44c8 100644
--- a/src/components/notifications/Telegram.vue
+++ b/src/components/notifications/Telegram.vue
@@ -47,12 +47,7 @@
 
     <template v-if="$parent.notification.telegramUseTemplate">
         <div class="mb-3">
-            <label class="form-label" for="message_parse_mode">{{ $t("Template Format") }}</label>
-
-            <i18n-t tag="div" keypath="telegramTemplateFormatDescription" class="form-text mb-3">
-                <a href="https://core.telegram.org/bots/api#formatting-options" target="_blank">{{ $t("documentation") }}</a>
-            </i18n-t>
-
+            <label class="form-label" for="message_parse_mode">{{ $t("Message Format") }}</label>
             <select
                 id="message_parse_mode"
                 v-model="$parent.notification.telegramTemplateParseMode"
@@ -63,28 +58,12 @@
                 <option value="HTML">HTML</option>
                 <option value="MarkdownV2">MarkdownV2</option>
             </select>
-        </div>
+            <i18n-t tag="p" keypath="telegramTemplateFormatDescription" class="form-text">
+                <a href="https://core.telegram.org/bots/api#formatting-options" target="_blank">{{ $t("documentation") }}</a>
+            </i18n-t>
 
-        <div class="mb-3">
-            <label class="form-label" for="message_parse_mode">{{ $t("Template") }}</label>
-
-            <div class="form-text mb-3">
-                <i18n-t tag="div" keypath="liquidIntroduction">
-                    <a href="https://liquidjs.com/" target="_blank">{{ $t("documentation") }}</a>
-                </i18n-t>
-
-                <code v-pre>{{ msg }}</code>: {{ $t("templateMsg") }}<br />
-                <code v-pre>{{ heartbeatJSON }}</code>: {{ $t("templateHeartbeatJSON") }} <b>({{ $t("templateLimitedToUpDownNotifications") }})</b><br />
-                <code v-pre>{{ monitorJSON }}</code>: {{ $t("templateMonitorJSON") }} <b>({{ $t("templateLimitedToUpDownCertNotifications") }})</b><br />
-            </div>
-
-            <textarea
-                id="message_template"
-                v-model="$parent.notification.telegramTemplate"
-                class="form-control mb-3"
-                :placeholder="telegramMessageTemplatePlaceholder"
-                required
-            ></textarea>
+            <label class="form-label" for="message_template">{{ $t('Message Template') }}</label>
+            <TemplatedTextarea id="message_template" v-model="$parent.notification.telegramTemplate" :required="true" :placeholder="telegramTemplatedTextareaPlaceholder"></TemplatedTextarea>
         </div>
     </template>
 
@@ -113,14 +92,16 @@
 
 <script>
 import HiddenInput from "../HiddenInput.vue";
+import TemplatedTextarea from "../TemplatedTextarea.vue";
 import axios from "axios";
 
 export default {
     components: {
         HiddenInput,
+        TemplatedTextarea,
     },
     computed: {
-        telegramMessageTemplatePlaceholder() {
+        telegramTemplatedTextareaPlaceholder() {
             return this.$t("Example:", [
                 `
 Uptime Kuma Alert{% if monitorJSON %} - {{ monitorJSON['name'] }}{% endif %}
diff --git a/src/components/notifications/Webhook.vue b/src/components/notifications/Webhook.vue
index 8c67a2745..7775a3fdd 100644
--- a/src/components/notifications/Webhook.vue
+++ b/src/components/notifications/Webhook.vue
@@ -32,20 +32,7 @@
             </template>
         </i18n-t>
         <template v-else-if="$parent.notification.webhookContentType == 'custom'">
-            <i18n-t tag="div" keypath="liquidIntroduction" class="form-text">
-                <a href="https://liquidjs.com/" target="_blank">{{ $t("documentation") }}</a>
-            </i18n-t>
-            <code v-pre>{{msg}}</code>: {{ $t("templateMsg") }}<br />
-            <code v-pre>{{heartbeatJSON}}</code>: {{ $t("templateHeartbeatJSON") }} <b>({{ $t("templateLimitedToUpDownNotifications") }})</b><br />
-            <code v-pre>{{monitorJSON}}</code>: {{ $t("templateMonitorJSON") }} <b>({{ $t("templateLimitedToUpDownCertNotifications") }})</b><br />
-
-            <textarea
-                id="customBody"
-                v-model="$parent.notification.webhookCustomBody"
-                class="form-control"
-                :placeholder="customBodyPlaceholder"
-                required
-            ></textarea>
+            <TemplatedTextarea id="customBody" v-model="$parent.notification.webhookCustomBody" :required="true" :placeholder="customBodyPlaceholder"></TemplatedTextarea>
         </template>
     </div>
 
@@ -67,7 +54,12 @@
 </template>
 
 <script>
+import TemplatedTextarea from "../TemplatedTextarea.vue";
+
 export default {
+    components: {
+        TemplatedTextarea,
+    },
     data() {
         return {
             showAdditionalHeadersField: this.$parent.notification.webhookAdditionalHeaders != null,
diff --git a/src/lang/en.json b/src/lang/en.json
index 86e24d348..118dd7fc8 100644
--- a/src/lang/en.json
+++ b/src/lang/en.json
@@ -231,6 +231,9 @@
     "templateMonitorJSON": "object describing the monitor",
     "templateLimitedToUpDownCertNotifications": "only available for UP/DOWN/Certificate expiry notifications",
     "templateLimitedToUpDownNotifications": "only available for UP/DOWN notifications",
+    "templateServiceName": "service name",
+    "templateHostnameOrURL": "hostname or URL",
+    "templateStatus": "status",
     "webhookAdditionalHeadersTitle": "Additional Headers",
     "webhookAdditionalHeadersDesc": "Sets additional headers sent with the webhook. Each header should be defined as a JSON key/value.",
     "webhookBodyPresetOption": "Preset - {0}",
@@ -423,7 +426,7 @@
     "telegramProtectContentDescription": "If enabled, the bot messages in Telegram will be protected from forwarding and saving.",
     "telegramUseTemplate": "Use custom message template",
     "telegramUseTemplateDescription": "If enabled, the message will be sent using a custom template.",
-    "telegramTemplateFormatDescription": "See Telegram {0} for more details on formatting.",
+    "telegramTemplateFormatDescription": "Telegram allows using different markup languages for messages, see Telegram {0} for specifc details.",
     "supportTelegramChatID": "Support Direct Chat / Group / Channel's Chat ID",
     "wayToGetTelegramChatID": "You can get your chat ID by sending a message to the bot and going to this URL to view the chat_id:",
     "YOUR BOT TOKEN HERE": "YOUR BOT TOKEN HERE",
@@ -522,9 +525,6 @@
     "leave blank for default subject": "leave blank for default subject",
     "emailCustomBody": "Custom Body",
     "leave blank for default body": "leave blank for default body",
-    "emailTemplateServiceName": "Service Name",
-    "emailTemplateHostnameOrURL": "Hostname or URL",
-    "emailTemplateStatus": "Status",
     "emailTemplateMonitorJSON": "object describing the monitor",
     "emailTemplateHeartbeatJSON": "object describing the heartbeat",
     "emailTemplateMsg": "message of the notification",
@@ -1057,8 +1057,7 @@
     "Separate multiple email addresses with commas": "Separate multiple email addresses with commas",
     "YZJ Webhook URL": "YZJ Webhook URL",
     "YZJ Robot Token": "YZJ Robot token",
-    "Separate multiple email addresses with commas": "Separate multiple email addresses with commas",
     "Plain Text": "Plain Text",
-    "Template": "Template",
+    "Message Template": "Message Template",
     "Template Format": "Template Format"
 }