From 5fa39d18031f49435e4bbffcd385cc9ff813214a Mon Sep 17 00:00:00 2001 From: Omkar Kesarkhane <127078473+omkarK06@users.noreply.github.com> Date: Tue, 1 Oct 2024 12:38:09 +0530 Subject: [PATCH] feat: SMTP support in alert destinations (#3976) feat #1401 ## Summary by CodeRabbit - **New Features** - Introduced a tabbed interface for selecting "Web Hook" and "Email" destination types in the Add Destination section. - Added a new tabbed interface for template types in the Add Template section, with conditional input fields based on selection. - Implemented a method to navigate to the email template creation page. - **Improvements** - Enhanced user experience with dynamic input fields for email addresses and validation in the Add Destination section. - Updated the save functionality to handle new properties for templates and destinations. - Improved email notification logic to use template titles for email subjects, ensuring better customization. - Added validation to ensure destination emails are associated with the correct organization. - Expanded data structure to include a new title property for templates. --------- Co-authored-by: Subhradeep Chakraborty --- src/common/meta/alerts/templates.rs | 2 + src/service/alerts/alert.rs | 20 +- src/service/alerts/destinations.rs | 21 +- web/src/components/alerts/AddDestination.vue | 383 ++++++++++++------ web/src/components/alerts/AddTemplate.vue | 128 +++++- web/src/components/alerts/DestinationList.vue | 28 +- web/src/components/alerts/TemplateList.vue | 16 +- web/src/composables/useLogs.ts | 156 +++---- web/src/locales/languages/en.json | 3 +- web/src/ts/interfaces/alert.ts | 15 + 10 files changed, 524 insertions(+), 248 deletions(-) diff --git a/src/common/meta/alerts/templates.rs b/src/common/meta/alerts/templates.rs index 51f32dc500..b712eddbf3 100644 --- a/src/common/meta/alerts/templates.rs +++ b/src/common/meta/alerts/templates.rs @@ -32,4 +32,6 @@ pub struct Template { #[serde(rename = "type")] #[serde(default)] pub template_type: DestinationType, + #[serde(default)] + pub title: String, } diff --git a/src/service/alerts/alert.rs b/src/service/alerts/alert.rs index 37a9a1db5f..feeb697e8d 100644 --- a/src/service/alerts/alert.rs +++ b/src/service/alerts/alert.rs @@ -439,9 +439,23 @@ pub async fn send_notification( ) .await; + let email_subject = if !dest.template.title.is_empty() { + process_dest_template( + &dest.template.title, + alert, + rows, + &rows_tpl_val, + rows_end_time, + start_time, + ) + .await + } else { + dest.template.name.clone() + }; + match dest.destination_type { DestinationType::Http => send_http_notification(dest, msg.clone()).await, - DestinationType::Email => send_email_notification(&alert.name, dest, msg).await, + DestinationType::Email => send_email_notification(&email_subject, dest, msg).await, } } @@ -507,7 +521,7 @@ pub async fn send_http_notification( } pub async fn send_email_notification( - alert_name: &str, + email_subject: &str, dest: &DestinationWithTemplate, msg: String, ) -> Result { @@ -523,7 +537,7 @@ pub async fn send_email_notification( let mut email = Message::builder() .from(cfg.smtp.smtp_from_email.parse()?) - .subject(format!("Openobserve Alert - {}", alert_name)); + .subject(format!("Openobserve Alert - {}", email_subject)); for recipient in recipients { email = email.to(recipient.parse()?); diff --git a/src/service/alerts/destinations.rs b/src/service/alerts/destinations.rs index d60e277370..f10a9f1bfe 100644 --- a/src/service/alerts/destinations.rs +++ b/src/service/alerts/destinations.rs @@ -24,7 +24,7 @@ use crate::{ }, utils::auth::{is_ofga_unsupported, remove_ownership, set_ownership}, }, - service::db, + service::db::{self, user}, }; pub async fn save( @@ -50,6 +50,25 @@ pub async fn save( anyhow::anyhow!("Atleast one alert destination email is required"), )); } + for email in destination.emails.iter() { + // Check if the email is part of the org + match user::get(Some(org_id), email).await { + Ok(user) => { + if user.is_none() { + return Err(( + http::StatusCode::BAD_REQUEST, + anyhow::anyhow!("Destination email must be part of this org"), + )); + } + } + Err(_) => { + return Err(( + http::StatusCode::BAD_REQUEST, + anyhow::anyhow!("Destination email must be part of this org"), + )); + } + } + } if !config::get_config().smtp.smtp_enabled { return Err(( http::StatusCode::INTERNAL_SERVER_ERROR, diff --git a/web/src/components/alerts/AddDestination.vue b/web/src/components/alerts/AddDestination.vue index ce13dbebdb..f8168af825 100644 --- a/web/src/components/alerts/AddDestination.vue +++ b/web/src/components/alerts/AddDestination.vue @@ -16,7 +16,7 @@ along with this program. If not, see . +
@@ -231,19 +285,26 @@ import { onBeforeMount, onActivated, defineEmits, + reactive, } from "vue"; import type { Ref } from "vue"; import { useI18n } from "vue-i18n"; import destinationService from "@/services/alert_destination"; import { useStore } from "vuex"; import { useQuasar } from "quasar"; -import type { Template, DestinationData, Headers } from "@/ts/interfaces"; +import type { + Template, + DestinationData, + Headers, + DestinationPayload, +} from "@/ts/interfaces"; import { useRouter } from "vue-router"; import { isValidResourceName } from "@/utils/zincutils"; +import AppTabs from "@/components/common/AppTabs.vue"; const props = defineProps<{ templates: Template[] | []; - destination: DestinationData | null; + destination: DestinationPayload | null; }>(); const emit = defineEmits(["get:destinations", "cancel:hideform"]); const q = useQuasar(); @@ -257,6 +318,8 @@ const formData: Ref = ref({ skip_tls_verify: false, template: "", headers: {}, + emails: "", + type: "http", }); const isUpdatingDestination = ref(false); @@ -274,6 +337,32 @@ const apiHeaders: Ref< uuid: string; }[] > = ref([{ key: "", value: "", uuid: getUUID() }]); + +const tabs = computed(() => [ + { + label: "Web Hook", + value: "http", + style: { + width: "fit-content", + padding: "4px 14px", + background: formData.value.type === "http" ? "#5960B2" : "", + border: "none !important", + color: formData.value.type === "http" ? "#ffffff !important" : "", + }, + }, + { + label: "Email", + value: "email", + style: { + width: "fit-content", + padding: "4px 14px", + background: formData.value.type === "email" ? "#5960B2" : "#ffffff", + border: "none !important", + color: formData.value.type === "email" ? "#ffffff !important" : "", + }, + }, +]); + onActivated(() => setupDestinationData()); onBeforeMount(() => { setupDestinationData(); @@ -288,6 +377,9 @@ const setupDestinationData = () => { formData.value.skip_tls_verify = props.destination.skip_tls_verify; formData.value.template = props.destination.template; formData.value.headers = props.destination.headers; + formData.value.emails = (props.destination.emails || []).join(", "); + formData.value.type = props.destination.type || "http"; + if (Object.keys(formData.value.headers).length) { apiHeaders.value = []; Object.entries(formData.value.headers).forEach(([key, value]) => { @@ -297,13 +389,22 @@ const setupDestinationData = () => { } }; const getFormattedTemplates = computed(() => - props.templates.map((template: any) => template.name), + props.templates + .filter((template: any) => { + if (formData.value.type === "email" && template.type === "email") + return true; + else if (formData.value.type !== "email") return true; + }) + .map((template: any) => template.name), ); + const isValidDestination = computed( () => formData.value.name && - formData.value.url && - formData.value.method && + ((formData.value.url && + formData.value.method && + formData.value.type === "http") || + (formData.value.type === "email" && formData.value.emails.length)) && formData.value.template, ); const saveDestination = () => { @@ -324,19 +425,29 @@ const saveDestination = () => { apiHeaders.value.forEach((header) => { if (header["key"] && header["value"]) headers[header.key] = header.value; }); + + const payload: any = { + url: formData.value.url, + method: formData.value.method, + skip_tls_verify: formData.value.skip_tls_verify, + template: formData.value.template, + headers: headers, + name: formData.value.name, + }; + + if (formData.value.type === "email") { + payload["type"] = "email"; + payload["emails"] = formData.value.emails + .split(/[;,]/) + .map((email: string) => email.trim()); + } + if (isUpdatingDestination.value) { destinationService .update({ org_identifier: store.state.selectedOrganization.identifier, destination_name: formData.value.name, - data: { - url: formData.value.url, - method: formData.value.method, - skip_tls_verify: formData.value.skip_tls_verify, - template: formData.value.template, - headers: headers, - name: formData.value.name, - }, + data: payload, }) .then(() => { dismiss(); @@ -347,7 +458,7 @@ const saveDestination = () => { message: `Destination saved successfully.`, }); }) - .catch((err) => { + .catch((err: any) => { dismiss(); q.notify({ type: "negative", @@ -359,14 +470,7 @@ const saveDestination = () => { .create({ org_identifier: store.state.selectedOrganization.identifier, destination_name: formData.value.name, - data: { - url: formData.value.url, - method: formData.value.method, - skip_tls_verify: formData.value.skip_tls_verify, - template: formData.value.template, - headers: headers, - name: formData.value.name, - }, + data: payload, }) .then(() => { dismiss(); @@ -377,7 +481,7 @@ const saveDestination = () => { message: `Destination saved successfully.`, }); }) - .catch((err) => { + .catch((err: any) => { dismiss(); q.notify({ type: "negative", @@ -397,6 +501,17 @@ const deleteApiHeader = (header: any) => { delete formData.value.headers[header.key]; if (!apiHeaders.value.length) addApiHeader(); }; + +const createEmailTemplate = () => { + router.push({ + name: "alertTemplates", + query: { + action: "add", + type: "email", + org_identifier: store.state.selectedOrganization.identifier, + }, + }); +};