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 v4 proxmox 05/11] notify: add 'smtp' endpoint
Date: Wed,  8 Nov 2023 16:39:59 +0100	[thread overview]
Message-ID: <20231108154005.895814-6-l.wagner@proxmox.com> (raw)
In-Reply-To: <20231108154005.895814-1-l.wagner@proxmox.com>

This commit adds a new endpoint type, namely 'smtp'. This endpoint
uses the `lettre` crate to directly send emails to SMTP relays.

The `lettre` crate was chosen since it is by far the most popular SMTP
implementation for Rust that looks like it is well maintained.
Also, it includes async support (for when we want to extend
proxmox-notify to be async).

For this new endpoint type, a new section-config type was introduced
(smtp). It has the same fields as the type for `sendmail`, with the
addition of some new options (smtp server, authentication, tls mode,
etc.).

Some of the behavior that is shared between sendmail and smtp
endpoints has been moved to a new `endpoints::common::mail` module.

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 Cargo.toml                                  |   1 +
 proxmox-notify/Cargo.toml                   |   4 +-
 proxmox-notify/src/config.rs                |  23 ++
 proxmox-notify/src/endpoints/common/mail.rs |  24 ++
 proxmox-notify/src/endpoints/common/mod.rs  |   2 +
 proxmox-notify/src/endpoints/mod.rs         |   4 +
 proxmox-notify/src/endpoints/sendmail.rs    |  22 +-
 proxmox-notify/src/endpoints/smtp.rs        | 258 ++++++++++++++++++++
 proxmox-notify/src/lib.rs                   |  16 ++
 9 files changed, 336 insertions(+), 18 deletions(-)
 create mode 100644 proxmox-notify/src/endpoints/common/mail.rs
 create mode 100644 proxmox-notify/src/endpoints/common/mod.rs
 create mode 100644 proxmox-notify/src/endpoints/smtp.rs

diff --git a/Cargo.toml b/Cargo.toml
index 3d81d85..9f247be 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -62,6 +62,7 @@ http = "0.2"
 hyper = "0.14.5"
 lazy_static = "1.4"
 ldap3 = { version = "0.11", default-features = false }
+lettre = "0.11.1"
 libc = "0.2.107"
 log = "0.4.17"
 mail-parser = "0.8.2"
diff --git a/proxmox-notify/Cargo.toml b/proxmox-notify/Cargo.toml
index f2b4db5..8741b9b 100644
--- a/proxmox-notify/Cargo.toml
+++ b/proxmox-notify/Cargo.toml
@@ -11,6 +11,7 @@ exclude.workspace = true
 anyhow.workspace = true
 handlebars = { workspace = true }
 lazy_static.workspace = true
+lettre = { workspace = true, optional = true }
 log.workspace = true
 mail-parser = { workspace = true, optional = true }
 once_cell.workspace = true
@@ -28,7 +29,8 @@ serde = { workspace = true, features = ["derive"]}
 serde_json.workspace = true
 
 [features]
-default = ["sendmail", "gotify"]
+default = ["sendmail", "gotify", "smtp"]
 mail-forwarder = ["dep:mail-parser"]
 sendmail = ["dep:proxmox-sys"]
 gotify = ["dep:proxmox-http"]
+smtp = ["dep:lettre"]
diff --git a/proxmox-notify/src/config.rs b/proxmox-notify/src/config.rs
index a86995e..fe25ea7 100644
--- a/proxmox-notify/src/config.rs
+++ b/proxmox-notify/src/config.rs
@@ -28,6 +28,17 @@ fn config_init() -> SectionConfig {
             SENDMAIL_SCHEMA,
         ));
     }
+    #[cfg(feature = "smtp")]
+    {
+        use crate::endpoints::smtp::{SmtpConfig, SMTP_TYPENAME};
+
+        const SMTP_SCHEMA: &ObjectSchema = SmtpConfig::API_SCHEMA.unwrap_object_schema();
+        config.register_plugin(SectionConfigPlugin::new(
+            SMTP_TYPENAME.to_string(),
+            Some(String::from("name")),
+            SMTP_SCHEMA,
+        ));
+    }
     #[cfg(feature = "gotify")]
     {
         use crate::endpoints::gotify::{GotifyConfig, GOTIFY_TYPENAME};
@@ -80,6 +91,18 @@ fn private_config_init() -> SectionConfig {
         ));
     }
 
+    #[cfg(feature = "smtp")]
+    {
+        use crate::endpoints::smtp::{SmtpPrivateConfig, SMTP_TYPENAME};
+
+        const SMTP_SCHEMA: &ObjectSchema = SmtpPrivateConfig::API_SCHEMA.unwrap_object_schema();
+        config.register_plugin(SectionConfigPlugin::new(
+            SMTP_TYPENAME.to_string(),
+            Some(String::from("name")),
+            SMTP_SCHEMA,
+        ));
+    }
+
     config
 }
 
diff --git a/proxmox-notify/src/endpoints/common/mail.rs b/proxmox-notify/src/endpoints/common/mail.rs
new file mode 100644
index 0000000..0929d7c
--- /dev/null
+++ b/proxmox-notify/src/endpoints/common/mail.rs
@@ -0,0 +1,24 @@
+use std::collections::HashSet;
+
+use crate::context;
+
+pub(crate) fn get_recipients(
+    email_addrs: Option<&[String]>,
+    users: Option<&[String]>,
+) -> HashSet<String> {
+    let mut recipients = HashSet::new();
+
+    if let Some(mailto_addrs) = email_addrs {
+        for addr in mailto_addrs {
+            recipients.insert(addr.clone());
+        }
+    }
+    if let Some(users) = users {
+        for user in users {
+            if let Some(addr) = context::context().lookup_email_for_user(user) {
+                recipients.insert(addr);
+            }
+        }
+    }
+    recipients
+}
diff --git a/proxmox-notify/src/endpoints/common/mod.rs b/proxmox-notify/src/endpoints/common/mod.rs
new file mode 100644
index 0000000..60e0761
--- /dev/null
+++ b/proxmox-notify/src/endpoints/common/mod.rs
@@ -0,0 +1,2 @@
+#[cfg(any(feature = "sendmail", feature = "smtp"))]
+pub(crate) mod mail;
diff --git a/proxmox-notify/src/endpoints/mod.rs b/proxmox-notify/src/endpoints/mod.rs
index d1cec65..97f79fc 100644
--- a/proxmox-notify/src/endpoints/mod.rs
+++ b/proxmox-notify/src/endpoints/mod.rs
@@ -2,3 +2,7 @@
 pub mod gotify;
 #[cfg(feature = "sendmail")]
 pub mod sendmail;
+#[cfg(feature = "smtp")]
+pub mod smtp;
+
+mod common;
diff --git a/proxmox-notify/src/endpoints/sendmail.rs b/proxmox-notify/src/endpoints/sendmail.rs
index 3ef33b6..4b3d5cd 100644
--- a/proxmox-notify/src/endpoints/sendmail.rs
+++ b/proxmox-notify/src/endpoints/sendmail.rs
@@ -1,11 +1,10 @@
-use std::collections::HashSet;
-
 use serde::{Deserialize, Serialize};
 
 use proxmox_schema::api_types::COMMENT_SCHEMA;
 use proxmox_schema::{api, Updater};
 
 use crate::context::context;
+use crate::endpoints::common::mail;
 use crate::renderer::TemplateRenderer;
 use crate::schema::{EMAIL_SCHEMA, ENTITY_NAME_SCHEMA, USER_SCHEMA};
 use crate::{renderer, Content, Endpoint, Error, Notification};
@@ -82,21 +81,10 @@ pub struct SendmailEndpoint {
 
 impl Endpoint for SendmailEndpoint {
     fn send(&self, notification: &Notification) -> Result<(), Error> {
-        let mut recipients = HashSet::new();
-
-        if let Some(mailto_addrs) = self.config.mailto.as_ref() {
-            for addr in mailto_addrs {
-                recipients.insert(addr.clone());
-            }
-        }
-
-        if let Some(users) = self.config.mailto_user.as_ref() {
-            for user in users {
-                if let Some(addr) = context().lookup_email_for_user(user) {
-                    recipients.insert(addr);
-                }
-            }
-        }
+        let recipients = mail::get_recipients(
+            self.config.mailto.as_deref(),
+            self.config.mailto_user.as_deref(),
+        );
 
         let recipients_str: Vec<&str> = recipients.iter().map(String::as_str).collect();
         let mailfrom = self
diff --git a/proxmox-notify/src/endpoints/smtp.rs b/proxmox-notify/src/endpoints/smtp.rs
new file mode 100644
index 0000000..9c92da0
--- /dev/null
+++ b/proxmox-notify/src/endpoints/smtp.rs
@@ -0,0 +1,258 @@
+use lettre::message::{Mailbox, MultiPart, SinglePart};
+use lettre::transport::smtp::client::{Tls, TlsParameters};
+use lettre::{message::header::ContentType, Message, SmtpTransport, Transport};
+use serde::{Deserialize, Serialize};
+use std::time::Duration;
+
+use proxmox_schema::api_types::COMMENT_SCHEMA;
+use proxmox_schema::{api, Updater};
+
+use crate::context::context;
+use crate::endpoints::common::mail;
+use crate::renderer::TemplateRenderer;
+use crate::schema::{EMAIL_SCHEMA, ENTITY_NAME_SCHEMA, USER_SCHEMA};
+use crate::{renderer, Content, Endpoint, Error, Notification};
+
+pub(crate) const SMTP_TYPENAME: &str = "smtp";
+
+const SMTP_PORT: u16 = 25;
+const SMTP_SUBMISSION_STARTTLS_PORT: u16 = 587;
+const SMTP_SUBMISSION_TLS_PORT: u16 = 465;
+const SMTP_TIMEOUT: u16 = 5;
+
+#[api]
+#[derive(Debug, Serialize, Deserialize, Default, Clone, Copy)]
+#[serde(rename_all = "kebab-case")]
+/// Connection security
+pub enum SmtpMode {
+    /// No encryption (insecure), plain SMTP
+    Insecure,
+    /// Upgrade to TLS after connecting
+    #[serde(rename = "starttls")]
+    StartTls,
+    /// Use TLS-secured connection
+    #[default]
+    Tls,
+}
+
+#[api(
+    properties: {
+        name: {
+            schema: ENTITY_NAME_SCHEMA,
+        },
+        mailto: {
+            type: Array,
+                items: {
+                    schema: EMAIL_SCHEMA,
+            },
+            optional: true,
+        },
+        "mailto-user": {
+            type: Array,
+            items: {
+                schema: USER_SCHEMA,
+            },
+            optional: true,
+        },
+        comment: {
+            optional: true,
+            schema: COMMENT_SCHEMA,
+        },
+        filter: {
+            optional: true,
+            schema: ENTITY_NAME_SCHEMA,
+        },
+    },
+)]
+#[derive(Debug, Serialize, Deserialize, Updater, Default)]
+#[serde(rename_all = "kebab-case")]
+/// Config for Sendmail notification endpoints
+pub struct SmtpConfig {
+    /// Name of the endpoint
+    #[updater(skip)]
+    pub name: String,
+    /// Host name or IP of the SMTP relay
+    pub server: String,
+    /// Port to use when connecting to the SMTP relay
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub port: Option<u16>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub mode: Option<SmtpMode>,
+    /// Username for authentication
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub username: Option<String>,
+    /// Mail recipients
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub mailto: Option<Vec<String>>,
+    /// Mail recipients
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub mailto_user: Option<Vec<String>>,
+    /// `From` address for the mail
+    pub from_address: String,
+    /// Author of the mail
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub author: Option<String>,
+    /// Comment
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub comment: Option<String>,
+    /// Filter to apply
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub filter: Option<String>,
+}
+
+#[derive(Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum DeleteableSmtpProperty {
+    Author,
+    Comment,
+    Filter,
+    Mailto,
+    MailtoUser,
+    Password,
+    Port,
+    Username,
+}
+
+#[api]
+#[derive(Serialize, Deserialize, Clone, Updater, Debug)]
+#[serde(rename_all = "kebab-case")]
+/// Private configuration for SMTP notification endpoints.
+/// This config will be saved to a separate configuration file with stricter
+/// permissions (root:root 0600)
+pub struct SmtpPrivateConfig {
+    /// Name of the endpoint
+    #[updater(skip)]
+    pub name: String,
+    /// Authentication token
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub password: Option<String>,
+}
+
+/// A sendmail notification endpoint.
+pub struct SmtpEndpoint {
+    pub config: SmtpConfig,
+    pub private_config: SmtpPrivateConfig,
+}
+
+impl Endpoint for SmtpEndpoint {
+    fn send(&self, notification: &Notification) -> Result<(), Error> {
+        let tls_parameters = TlsParameters::new(self.config.server.clone())
+            .map_err(|err| Error::NotifyFailed(self.name().into(), Box::new(err)))?;
+
+        let (port, tls) = match self.config.mode.unwrap_or_default() {
+            SmtpMode::Insecure => {
+                let port = self.config.port.unwrap_or(SMTP_PORT);
+                (port, Tls::None)
+            }
+            SmtpMode::StartTls => {
+                let port = self.config.port.unwrap_or(SMTP_SUBMISSION_STARTTLS_PORT);
+                (port, Tls::Required(tls_parameters))
+            }
+            SmtpMode::Tls => {
+                let port = self.config.port.unwrap_or(SMTP_SUBMISSION_TLS_PORT);
+                (port, Tls::Wrapper(tls_parameters))
+            }
+        };
+
+        let mut transport_builder = SmtpTransport::builder_dangerous(&self.config.server)
+            .tls(tls)
+            .port(port)
+            .timeout(Some(Duration::from_secs(SMTP_TIMEOUT.into())));
+
+        if let Some(username) = self.config.username.as_deref() {
+            if let Some(password) = self.private_config.password.as_deref() {
+                transport_builder = transport_builder.credentials((username, password).into());
+            } else {
+                return Err(Error::NotifyFailed(
+                    self.name().into(),
+                    Box::new(Error::Generic(
+                        "username is set but no password was provided".to_owned(),
+                    )),
+                ));
+            }
+        }
+
+        let transport = transport_builder.build();
+
+        let recipients = mail::get_recipients(
+            self.config.mailto.as_deref(),
+            self.config.mailto_user.as_deref(),
+        );
+        let mail_from = self.config.from_address.clone();
+
+        let parse_address = |addr: &str| -> Result<Mailbox, Error> {
+            addr.parse()
+                .map_err(|err| Error::NotifyFailed(self.name().into(), Box::new(err)))
+        };
+
+        let author = self
+            .config
+            .author
+            .clone()
+            .unwrap_or_else(|| context().default_sendmail_author());
+
+        let mut email_builder =
+            Message::builder().from(parse_address(&format!("{author} <{mail_from}>"))?);
+
+        for recipient in recipients {
+            email_builder = email_builder.to(parse_address(&recipient)?);
+        }
+
+        let email = match &notification.content {
+            Content::Template {
+                title_template,
+                body_template,
+                data,
+            } => {
+                let subject =
+                    renderer::render_template(TemplateRenderer::Plaintext, title_template, data)?;
+                let html_part =
+                    renderer::render_template(TemplateRenderer::Html, body_template, data)?;
+                let text_part =
+                    renderer::render_template(TemplateRenderer::Plaintext, body_template, data)?;
+
+                email_builder = email_builder.subject(subject);
+
+                email_builder
+                    .multipart(
+                        MultiPart::alternative()
+                            .singlepart(
+                                SinglePart::builder()
+                                    .header(ContentType::TEXT_PLAIN)
+                                    .body(text_part),
+                            )
+                            .singlepart(
+                                SinglePart::builder()
+                                    .header(ContentType::TEXT_HTML)
+                                    .body(html_part),
+                            ),
+                    )
+                    .map_err(|err| Error::NotifyFailed(self.name().into(), Box::new(err)))?
+            }
+            #[cfg(feature = "mail-forwarder")]
+            Content::ForwardedMail { ref raw, title, .. } => {
+                email_builder = email_builder.subject(title);
+
+                // Forwarded messages are embedded inline as 'message/rfc822'
+                // this let's us avoid rewriting any headers (e.g. From)
+                email_builder
+                    .singlepart(
+                        SinglePart::builder()
+                            .header(ContentType::parse("message/rfc822").unwrap())
+                            .body(raw.to_owned()),
+                    )
+                    .map_err(|err| Error::NotifyFailed(self.name().into(), Box::new(err)))?
+            }
+        };
+
+        transport
+            .send(&email)
+            .map_err(|err| Error::NotifyFailed(self.name().into(), err.into()))?;
+
+        Ok(())
+    }
+
+    fn name(&self) -> &str {
+        &self.config.name
+    }
+}
diff --git a/proxmox-notify/src/lib.rs b/proxmox-notify/src/lib.rs
index ada1b0a..427f03a 100644
--- a/proxmox-notify/src/lib.rs
+++ b/proxmox-notify/src/lib.rs
@@ -379,6 +379,22 @@ impl Bus {
                 .map(|e| (e.name().into(), e)),
             );
         }
+        #[cfg(feature = "smtp")]
+        {
+            use endpoints::smtp::SMTP_TYPENAME;
+            use endpoints::smtp::{SmtpConfig, SmtpEndpoint, SmtpPrivateConfig};
+            endpoints.extend(
+                parse_endpoints_with_private_config!(
+                    config,
+                    SmtpConfig,
+                    SmtpPrivateConfig,
+                    SmtpEndpoint,
+                    SMTP_TYPENAME
+                )?
+                .into_iter()
+                .map(|e| (e.name().into(), e)),
+            );
+        }
 
         let matchers = config
             .config
-- 
2.39.2





  parent reply	other threads:[~2023-11-08 15:40 UTC|newest]

Thread overview: 18+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2023-11-08 15:39 [pve-devel] [PATCH v4 many 00/11] notifications: add SMTP endpoint Lukas Wagner
2023-11-08 15:39 ` [pve-devel] [PATCH v4 debcargo-conf 01/11] cherry-pick chumsky 0.9.2 from debian unstable Lukas Wagner
2023-11-08 15:39 ` [pve-devel] [PATCH v4 debcargo-conf 02/11] update lettre to 0.11.1 Lukas Wagner
2023-11-08 15:39 ` [pve-devel] [PATCH v4 proxmox 03/11] sys: email: add `forward` Lukas Wagner
2023-11-08 15:39 ` [pve-devel] [PATCH v4 proxmox 04/11] notify: add mechanisms for email message forwarding Lukas Wagner
2023-11-08 15:39 ` Lukas Wagner [this message]
2023-11-08 15:40 ` [pve-devel] [PATCH v4 proxmox 06/11] notify: add api for smtp endpoints Lukas Wagner
2023-11-08 15:40 ` [pve-devel] [PATCH v4 proxmox-perl-rs 07/11] notify: add bindings for smtp API calls Lukas Wagner
2023-11-08 15:40 ` [pve-devel] [PATCH v4 pve-manager 08/11] notify: add API routes for smtp endpoints Lukas Wagner
2023-11-08 15:40 ` [pve-devel] [PATCH v4 proxmox-widget-toolkit 09/11] panel: notification: add gui for SMTP endpoints Lukas Wagner
2023-11-08 15:40 ` [pve-devel] [PATCH v4 pve-docs 10/11] notifications: document " Lukas Wagner
2023-11-08 15:40 ` [pve-devel] [PATCH v4 pve-docs 11/11] notifications: document 'comment' option for targets/matchers Lukas Wagner
2023-11-08 15:52 ` [pve-devel] [PATCH v4 many 00/11] notifications: add SMTP endpoint Dietmar Maurer
2023-11-09 10:23   ` Lukas Wagner
2023-11-09 12:16     ` Dietmar Maurer
2023-11-09 12:34       ` Lukas Wagner
2023-11-09 13:10         ` Thomas Lamprecht
2023-11-09 15:35           ` Dietmar Maurer

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=20231108154005.895814-6-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