diff --git a/Cargo.toml b/Cargo.toml index 3d81d859..9f247be0 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 7a3d434f..64e3ab74 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 } openssl.workspace = true @@ -27,9 +28,10 @@ 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"] pve-context = ["dep:proxmox-sys"] pbs-context = ["dep:proxmox-sys"] +smtp = ["dep:lettre"] diff --git a/proxmox-notify/src/config.rs b/proxmox-notify/src/config.rs index a86995e8..fe25ea74 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 00000000..0929d7c3 --- /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 00000000..60e07619 --- /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 d1cec654..97f79fcc 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 3ef33b65..4b3d5cd3 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 00000000..9c92da04 --- /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, + #[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 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 { + 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 ¬ification.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 ada1b0aa..427f03a3 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