[pve-devel] [PATCH proxmox 02/18] notification: implement sendmail endpoint

Lukas Wagner l.wagner at proxmox.com
Mon Mar 27 17:18:41 CEST 2023


Add everything needed for a simple endpoint of type 'sendmail', which
uses the 'sendmail' binary on the system to send emails to users.

The current implementation  makes it easy to implement other notification
providers with minimal changes to the code. All one has to do is to
implement the 'Endpoint' trait and register the plugin in the configuration
parser.

A notification contains the following data: title, body, severity,
and a map of arbitrary metadata fields. The metadata fields will
later be useful for filtering notifications. I chose the 'dynamic'
metadata map approach over static fields to be more flexible: This
allows us to have different kind of metadata for different products,
without the proxmox-notification crate being aware of this.
'Type safety' for metadata properties can be enforced in the glue code
for any given product.

In terms of configuration: The configuration for notification endpoints
and filters will live in a new configuration file, `notifications.cfg`.
However, since the crate should be product-agnostic, we process the
configuration as a raw string. Loading/storing of the configuration
file will happen in product-specific code.

Signed-off-by: Lukas Wagner <l.wagner at proxmox.com>
---
 Cargo.toml                                    |   1 +
 proxmox-notification/Cargo.toml               |  12 ++
 proxmox-notification/src/config.rs            |  53 ++++++
 proxmox-notification/src/endpoints/mod.rs     |   1 +
 .../src/endpoints/sendmail.rs                 |  71 +++++++
 proxmox-notification/src/lib.rs               | 178 ++++++++++++++++++
 6 files changed, 316 insertions(+)
 create mode 100644 proxmox-notification/src/config.rs
 create mode 100644 proxmox-notification/src/endpoints/mod.rs
 create mode 100644 proxmox-notification/src/endpoints/sendmail.rs

diff --git a/Cargo.toml b/Cargo.toml
index 9ccfa1a..d167c73 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -89,6 +89,7 @@ proxmox-lang = { version = "1.1", path = "proxmox-lang" }
 proxmox-rest-server = { version = "0.3.0", path = "proxmox-rest-server" }
 proxmox-router = { version = "1.3.1", path = "proxmox-router" }
 proxmox-schema = { version = "1.3.7", path = "proxmox-schema" }
+proxmox-section-config = { version = "1.0.2", path = "proxmox-section-config" }
 proxmox-serde = { version = "0.1.1", path = "proxmox-serde", features = [ "serde_json" ] }
 proxmox-sortable-macro = { version = "0.1.2", path = "proxmox-sortable-macro" }
 proxmox-sys = { version = "0.4.2", path = "proxmox-sys" }
diff --git a/proxmox-notification/Cargo.toml b/proxmox-notification/Cargo.toml
index 47c85a3..0286c8f 100644
--- a/proxmox-notification/Cargo.toml
+++ b/proxmox-notification/Cargo.toml
@@ -8,3 +8,15 @@ repository.workspace = true
 exclude.workspace = true
 
 [dependencies]
+anyhow.workspace = true
+lazy_static.workspace = true
+log.workspace = true
+openssl.workspace = true
+proxmox-http = { workspace = true, features = ["client-sync"]}
+proxmox-schema = { workspace = true, features = ["api-macro"]}
+proxmox-section-config = { workspace = true }
+proxmox-sys.workspace = true
+regex.workspace = true
+serde.workspace = true
+serde_json.workspace = true
+handlebars.workspace = true
diff --git a/proxmox-notification/src/config.rs b/proxmox-notification/src/config.rs
new file mode 100644
index 0000000..58c79d4
--- /dev/null
+++ b/proxmox-notification/src/config.rs
@@ -0,0 +1,53 @@
+use anyhow::Error;
+use lazy_static::lazy_static;
+use proxmox_schema::{const_regex, ApiStringFormat, ApiType, ObjectSchema, Schema, StringSchema};
+use proxmox_section_config::{SectionConfig, SectionConfigData, SectionConfigPlugin};
+
+use crate::endpoints::sendmail::SendmailConfig;
+use crate::endpoints::sendmail::SENDMAIL_TYPENAME;
+
+// Copied from PBS
+#[rustfmt::skip]
+#[macro_export]
+macro_rules! PROXMOX_SAFE_ID_REGEX_STR { () => { r"(?:[A-Za-z0-9_][A-Za-z0-9._\-]*)" }; }
+
+const_regex! {
+
+    pub PROXMOX_SAFE_ID_REGEX = concat!(r"^", PROXMOX_SAFE_ID_REGEX_STR!(), r"$");
+}
+pub const PROXMOX_SAFE_ID_FORMAT: ApiStringFormat =
+    ApiStringFormat::Pattern(&PROXMOX_SAFE_ID_REGEX);
+
+pub const BACKEND_NAME_SCHEMA: Schema = StringSchema::new("Notification backend name.")
+    .format(&PROXMOX_SAFE_ID_FORMAT)
+    .min_length(3)
+    .max_length(32)
+    .schema();
+
+lazy_static! {
+    pub static ref CONFIG: SectionConfig = init();
+}
+
+fn init() -> SectionConfig {
+    let mut config = SectionConfig::new(&BACKEND_NAME_SCHEMA);
+
+    const SENDMAIL_SCHEMA: &ObjectSchema = SendmailConfig::API_SCHEMA.unwrap_object_schema();
+
+    config.register_plugin(SectionConfigPlugin::new(
+        SENDMAIL_TYPENAME.to_string(),
+        Some(String::from("name")),
+        SENDMAIL_SCHEMA,
+    ));
+
+    config
+}
+
+pub fn config(raw_config: &str) -> Result<(SectionConfigData, [u8; 32]), Error> {
+    let digest = openssl::sha::sha256(raw_config.as_bytes());
+    let data = CONFIG.parse("notifications.cfg", raw_config)?;
+    Ok((data, digest))
+}
+
+pub fn write(config: &SectionConfigData) -> Result<String, Error> {
+    CONFIG.write("notifications.cfg", config)
+}
diff --git a/proxmox-notification/src/endpoints/mod.rs b/proxmox-notification/src/endpoints/mod.rs
new file mode 100644
index 0000000..2d7b9ba
--- /dev/null
+++ b/proxmox-notification/src/endpoints/mod.rs
@@ -0,0 +1 @@
+pub(crate) mod sendmail;
diff --git a/proxmox-notification/src/endpoints/sendmail.rs b/proxmox-notification/src/endpoints/sendmail.rs
new file mode 100644
index 0000000..2c43ab1
--- /dev/null
+++ b/proxmox-notification/src/endpoints/sendmail.rs
@@ -0,0 +1,71 @@
+use crate::{Endpoint, Notification};
+use anyhow::Error;
+
+use proxmox_schema::{api, const_regex, ApiStringFormat, Schema, StringSchema, Updater};
+use serde::{Deserialize, Serialize};
+
+// Copied from PBS
+const_regex! {
+    pub SINGLE_LINE_COMMENT_REGEX = r"^[[:^cntrl:]]*$";
+}
+const SINGLE_LINE_COMMENT_FORMAT: ApiStringFormat =
+    ApiStringFormat::Pattern(&SINGLE_LINE_COMMENT_REGEX);
+const EMAIL_SCHEMA: Schema = StringSchema::new("E-Mail Address.")
+    .format(&SINGLE_LINE_COMMENT_FORMAT)
+    .min_length(2)
+    .max_length(64)
+    .schema();
+
+pub(crate) const SENDMAIL_TYPENAME: &str = "sendmail";
+
+#[api(
+    properties: {
+        recipient: {
+            type: Array,
+            items: {
+                schema: EMAIL_SCHEMA,
+            },
+        },
+    },
+)]
+#[derive(Debug, Serialize, Deserialize, Updater)]
+#[serde(rename_all = "kebab-case")]
+/// Config for Sendmail notification endpoints
+pub struct SendmailConfig {
+    /// Name of the endpoint
+    pub name: String,
+    /// Mail recipients
+    pub recipient: Vec<String>,
+    /// `From` address for the mail
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub from_address: Option<String>,
+    /// Author of the mail
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub author: Option<String>,
+}
+
+impl Endpoint for SendmailConfig {
+    fn send(&self, notification: &Notification) -> Result<(), Error> {
+        let recipients: Vec<&str> = self.recipient.iter().map(String::as_str).collect();
+
+        // Note: OX has serious problems displaying text mails,
+        // so we include html as well
+        let html = format!(
+            "<html><body><pre>\n{}\n<pre>",
+            handlebars::html_escape(&notification.body)
+        );
+
+        proxmox_sys::email::sendmail(
+            &recipients,
+            &notification.title,
+            Some(&notification.body),
+            Some(&html),
+            self.from_address.as_deref(),
+            self.author.as_deref(),
+        )
+    }
+
+    fn name(&self) -> &str {
+        &self.name
+    }
+}
diff --git a/proxmox-notification/src/lib.rs b/proxmox-notification/src/lib.rs
index e69de29..f076c88 100644
--- a/proxmox-notification/src/lib.rs
+++ b/proxmox-notification/src/lib.rs
@@ -0,0 +1,178 @@
+use std::collections::HashMap;
+
+use anyhow::Error;
+
+use endpoints::sendmail::SendmailConfig;
+use endpoints::sendmail::SENDMAIL_TYPENAME;
+use proxmox_schema::api;
+use proxmox_section_config::SectionConfigData;
+use serde::{Deserialize, Serialize};
+
+mod config;
+mod endpoints;
+
+#[api()]
+#[derive(Clone, Debug, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd)]
+#[serde(rename_all = "kebab-case")]
+/// Severity of a notification
+pub enum Severity {
+    /// General information
+    Info,
+    /// A noteworthy event
+    Notice,
+    /// Warning
+    Warning,
+    /// Error
+    Error,
+}
+
+/// Notification endpoint trait, implemented by all endpoint plugins
+pub trait Endpoint {
+    /// Send a documention
+    fn send(&self, notification: &Notification) -> Result<(), Error>;
+
+    /// The name/identifier for this endpoint
+    fn name(&self) -> &str;
+}
+
+#[derive(Debug, Clone)]
+/// A sendable notifiction
+pub struct Notification {
+    /// The title of the notification
+    pub title: String,
+    /// Notification text
+    pub body: String,
+    /// Notification severity
+    pub severity: Severity,
+    /// Additional metadata for the notification
+    pub properties: HashMap<String, String>,
+}
+
+/// Notification configuration
+pub struct Config(SectionConfigData);
+
+impl Clone for Config {
+    fn clone(&self) -> Self {
+        Self(SectionConfigData {
+            sections: self.0.sections.clone(),
+            order: self.0.order.clone(),
+        })
+    }
+}
+
+impl Config {
+    /// Parse raw config
+    pub fn new(raw_config: &str) -> Result<Self, Error> {
+        // TODO: save and compare digest? Do we need this here?
+        let (config, _digest) = crate::config::config(raw_config)?;
+
+        Ok(Self(config))
+    }
+
+    /// Serialize config
+    pub fn write(&self) -> Result<String, Error> {
+        config::write(&self.0)
+    }
+
+    /// 'Instantiate' all notification endpoints from their configuration
+    pub fn instantiate(&self) -> Result<Bus, Error> {
+        let mut endpoints = Vec::new();
+
+        let sendmail_endpoints: Vec<SendmailConfig> =
+            self.0.convert_to_typed_array(SENDMAIL_TYPENAME)?;
+
+        endpoints.extend(
+            sendmail_endpoints
+                .into_iter()
+                .map(Box::new)
+                .map(|e| e as Box<dyn Endpoint>),
+        );
+
+        Ok(Bus { endpoints })
+    }
+}
+
+/// Notification bus - distributes notifications to all registered endpoints
+// The reason for the split between `Config` and this struct is to make testing with mocked
+// endpoints a bit easier.
+#[derive(Default)]
+pub struct Bus {
+    endpoints: Vec<Box<dyn Endpoint>>,
+}
+
+impl Bus {
+    pub fn add_endpoint(&mut self, endpoint: Box<dyn Endpoint>) {
+        self.endpoints.push(endpoint);
+    }
+
+    /// Send a notification to all registered endpoints
+    pub fn send(&self, notification: &Notification) -> Result<(), Error> {
+        log::info!(
+            "sending notification with title '{title}'",
+            title = notification.title
+        );
+
+        for endpoint in &self.endpoints {
+            endpoint.send(notification).unwrap_or_else(|e| {
+                log::error!(
+                    "could not notfiy via endpoint `{name}`: {e}",
+                    name = endpoint.name()
+                )
+            })
+        }
+
+        Ok(())
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use std::{cell::RefCell, rc::Rc};
+
+    use anyhow::Error;
+
+    use super::*;
+
+    #[derive(Default, Clone)]
+    struct MockEndpoint {
+        messages: Rc<RefCell<Vec<Notification>>>,
+    }
+
+    impl Endpoint for MockEndpoint {
+        fn send(&self, message: &Notification) -> Result<(), Error> {
+            self.messages.borrow_mut().push(message.clone());
+
+            Ok(())
+        }
+
+        fn name(&self) -> &str {
+            "mock-endpoint"
+        }
+    }
+
+    impl MockEndpoint {
+        fn messages(&self) -> Vec<Notification> {
+            self.messages.borrow().clone()
+        }
+    }
+
+    #[test]
+    fn test_add_mock_endpoint() -> Result<(), Error> {
+        let mock = MockEndpoint::default();
+
+        let mut bus = Bus::default();
+
+        bus.add_endpoint(Box::new(mock.clone()));
+
+        bus.send(&Notification {
+            title: "Title".into(),
+            body: "Body".into(),
+            severity: Severity::Info,
+            properties: Default::default(),
+        })?;
+        let messages = mock.messages();
+        assert_eq!(messages.len(), 1);
+
+        Ok(())
+    }
+}
-- 
2.30.2






More information about the pve-devel mailing list