From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits)) (No client certificate requested) by lists.proxmox.com (Postfix) with ESMTPS id 08587A98B for ; Mon, 7 Aug 2023 15:07:08 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id EB24035A15 for ; Mon, 7 Aug 2023 15:06:36 +0200 (CEST) Received: from proxmox-new.maurer-it.com (proxmox-new.maurer-it.com [94.136.29.106]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits)) (No client certificate requested) by firstgate.proxmox.com (Proxmox) with ESMTPS for ; Mon, 7 Aug 2023 15:06:33 +0200 (CEST) Received: from proxmox-new.maurer-it.com (localhost.localdomain [127.0.0.1]) by proxmox-new.maurer-it.com (Proxmox) with ESMTP id 26BB443140 for ; Mon, 7 Aug 2023 15:06:33 +0200 (CEST) From: Lukas Wagner To: pve-devel@lists.proxmox.com Date: Mon, 7 Aug 2023 15:06:12 +0200 Message-Id: <20230807130619.428423-2-l.wagner@proxmox.com> X-Mailer: git-send-email 2.39.2 In-Reply-To: <20230807130619.428423-1-l.wagner@proxmox.com> References: <20230807130619.428423-1-l.wagner@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 0 AWL -0.051 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Subject: [pve-devel] [PATCH proxmox 1/8] notify: add 'smtp' endpoint X-BeenThere: pve-devel@lists.proxmox.com X-Mailman-Version: 2.1.29 Precedence: list List-Id: Proxmox VE development discussion List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , X-List-Received-Date: Mon, 07 Aug 2023 13:07:08 -0000 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 --- 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 | 240 ++++++++++++++++++++ proxmox-notify/src/lib.rs | 28 +++ 9 files changed, 330 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 e334ac1..4fa9fa1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,6 +59,7 @@ http = "0.2" hyper = "0.14.5" lazy_static = "1.4" ldap3 = { version = "0.11", default-features = false } +lettre = "0.10.4" libc = "0.2.107" log = "0.4.17" native-tls = "0.2" diff --git a/proxmox-notify/Cargo.toml b/proxmox-notify/Cargo.toml index 1541b8b..6020a7c 100644 --- a/proxmox-notify/Cargo.toml +++ b/proxmox-notify/Cargo.toml @@ -10,6 +10,7 @@ exclude.workspace = true [dependencies] handlebars = { workspace = true } lazy_static.workspace = true +lettre = {workspace = true, optional = true} log.workspace = true once_cell.workspace = true openssl.workspace = true @@ -25,6 +26,7 @@ serde = { workspace = true, features = ["derive"]} serde_json.workspace = true [features] -default = ["sendmail", "gotify"] +default = ["sendmail", "gotify", "smtp"] 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 cdbf42a..138a3e0 100644 --- a/proxmox-notify/src/config.rs +++ b/proxmox-notify/src/config.rs @@ -27,6 +27,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}; @@ -73,6 +84,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 { + 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 26e2a17..28b230a 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, Endpoint, Error, Notification}; @@ -86,21 +85,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 properties = notification.properties.as_ref(); diff --git a/proxmox-notify/src/endpoints/smtp.rs b/proxmox-notify/src/endpoints/smtp.rs new file mode 100644 index 0000000..7d4aca5 --- /dev/null +++ b/proxmox-notify/src/endpoints/smtp.rs @@ -0,0 +1,240 @@ +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 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, 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; + +#[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, + #[serde(skip_serializing_if = "Option::is_none")] + pub mode: Option, + /// Username for authentication + #[serde(skip_serializing_if = "Option::is_none")] + pub username: Option, + /// Mail recipients + #[serde(skip_serializing_if = "Option::is_none")] + pub mailto: Option>, + /// Mail recipients + #[serde(skip_serializing_if = "Option::is_none")] + pub mailto_user: Option>, + /// `From` address for the mail + pub from_address: String, + /// Author of the mail + #[serde(skip_serializing_if = "Option::is_none")] + pub author: Option, + /// Comment + #[serde(skip_serializing_if = "Option::is_none")] + pub comment: Option, + /// Filter to apply + #[serde(skip_serializing_if = "Option::is_none")] + pub filter: Option, +} + +#[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, +} + +/// 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 recipients = mail::get_recipients( + self.config.mailto.as_deref(), + self.config.mailto_user.as_deref(), + ); + + let properties = notification.properties.as_ref(); + + let subject = renderer::render_template( + TemplateRenderer::Plaintext, + ¬ification.title, + properties, + )?; + let html_part = + renderer::render_template(TemplateRenderer::Html, ¬ification.body, properties)?; + let text_part = + renderer::render_template(TemplateRenderer::Plaintext, ¬ification.body, properties)?; + + let parse_address = |addr: &str| -> Result { + 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 mailfrom = self.config.from_address.clone(); + let mut email_builder = Message::builder() + .from(parse_address(&format!("{author} <{mailfrom}>"))?) + .subject(subject); + + for recipient in recipients { + email_builder = email_builder.to(parse_address(&recipient)?); + } + + let email = 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)))?; + + 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); + + 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(); + transport + .send(&email) + .map_err(|err| Error::NotifyFailed(self.name().into(), err.into()))?; + + Ok(()) + } + + fn name(&self) -> &str { + &self.config.name + } + + fn filter(&self) -> Option<&str> { + self.config.filter.as_deref() + } +} diff --git a/proxmox-notify/src/lib.rs b/proxmox-notify/src/lib.rs index 7500778..ceaca62 100644 --- a/proxmox-notify/src/lib.rs +++ b/proxmox-notify/src/lib.rs @@ -25,13 +25,22 @@ mod config; #[derive(Debug)] pub enum Error { + /// There was an error serializing the config ConfigSerialization(Box), + /// There was an error deserializing the config ConfigDeserialization(Box), + /// An endpoint failed to send a notification NotifyFailed(String, Box), + /// A target does not exist TargetDoesNotExist(String), + /// Testing one or more notification targets failed TargetTestFailed(Vec>), + /// A filter could not be applied FilterFailed(String), + /// The notification's template string could not be rendered RenderError(Box), + /// Generic error for anything else + Generic(String), } impl Display for Error { @@ -60,6 +69,7 @@ impl Display for Error { write!(f, "could not apply filter: {message}") } Error::RenderError(err) => write!(f, "could not render notification template: {err}"), + Error::Generic(message) => f.write_str(message), } } } @@ -74,6 +84,7 @@ impl StdError for Error { Error::TargetTestFailed(errs) => Some(&*errs[0]), Error::FilterFailed(_) => None, Error::RenderError(err) => Some(&**err), + Error::Generic(_) => None, } } } @@ -266,6 +277,23 @@ impl Bus { ); } + #[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 groups: HashMap = config .config .convert_to_typed_array(GROUP_TYPENAME) -- 2.39.2