public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: Lukas Wagner <l.wagner@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: [pve-devel] [PATCH proxmox 02/18] notification: implement sendmail endpoint
Date: Mon, 27 Mar 2023 17:18:41 +0200	[thread overview]
Message-ID: <20230327151857.495565-3-l.wagner@proxmox.com> (raw)
In-Reply-To: <20230327151857.495565-1-l.wagner@proxmox.com>

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@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





  parent reply	other threads:[~2023-03-27 15:19 UTC|newest]

Thread overview: 22+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2023-03-27 15:18 [pve-devel] [PATCH cluster/manager/ha-manager/proxmox{, -perl-rs} 00/18] fix #4156: introduce new notification module Lukas Wagner
2023-03-27 15:18 ` [pve-devel] [PATCH proxmox 01/18] add proxmox-notification crate Lukas Wagner
2023-03-27 15:18 ` Lukas Wagner [this message]
2023-03-27 15:18 ` [pve-devel] [PATCH proxmox 03/18] notification: add notification filter mechanism Lukas Wagner
2023-03-27 15:18 ` [pve-devel] [PATCH proxmox 04/18] notification: implement gotify endpoint Lukas Wagner
2023-03-27 15:18 ` [pve-devel] [PATCH proxmox 05/18] notification: allow adding new sendmail endpoints / filters Lukas Wagner
2023-03-27 15:18 ` [pve-devel] [PATCH proxmox 06/18] notification: add debian packaging Lukas Wagner
2023-03-27 15:18 ` [pve-devel] [PATCH proxmox-perl-rs 07/18] log: set default log level to 'info', add product specfig logging env var1 Lukas Wagner
2023-03-31  9:17   ` Lukas Wagner
2023-03-27 15:18 ` [pve-devel] [PATCH proxmox-perl-rs 08/18] add basic bindings for the proxmox_notification crate Lukas Wagner
2023-03-27 15:18 ` [pve-devel] [PATCH pve-cluster 09/18] cluster files: add notifications.cfg Lukas Wagner
2023-03-27 15:18 ` [pve-devel] [PATCH pve-manager 10/18] test: fix names of .PHONY targets Lukas Wagner
2023-03-27 15:18 ` [pve-devel] [PATCH pve-manager 11/18] add PVE::Notification module Lukas Wagner
2023-03-27 15:18 ` [pve-devel] [PATCH pve-manager 12/18] vzdump: send notifications via new notification module Lukas Wagner
2023-03-27 15:18 ` [pve-devel] [PATCH pve-manager 13/18] vzdump: rename 'sendmail' sub to 'send_notification' Lukas Wagner
2023-03-27 15:18 ` [pve-devel] [PATCH pve-manager 14/18] test: rename mail_test.pl to vzdump_notification_test.pl Lukas Wagner
2023-03-27 15:18 ` [pve-devel] [PATCH pve-manager 15/18] api: apt: send notification via new notification module Lukas Wagner
2023-03-27 15:18 ` [pve-devel] [PATCH pve-manager 16/18] api: replication: send notifications " Lukas Wagner
2023-03-27 15:18 ` [pve-devel] [PATCH pve-ha-manager 17/18] manager: " Lukas Wagner
2023-03-27 15:18 ` [pve-devel] [PATCH pve-ha-manager 18/18] manager: rename 'sendmail' --> 'send_notification' Lukas Wagner
2023-04-14  6:19 ` [pve-devel] [PATCH cluster/manager/ha-manager/proxmox{, -perl-rs} 00/18] fix #4156: introduce new notification module Thomas Lamprecht
2023-04-14  9:47   ` Lukas Wagner

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20230327151857.495565-3-l.wagner@proxmox.com \
    --to=l.wagner@proxmox.com \
    --cc=pve-devel@lists.proxmox.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal