From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) by lore.proxmox.com (Postfix) with ESMTPS id 907C51FF13A for ; Wed, 15 Apr 2026 09:03:15 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id A255F3606; Wed, 15 Apr 2026 09:02:38 +0200 (CEST) From: Arthur Bied-Charreton To: pve-devel@lists.proxmox.com Subject: [PATCH proxmox v3 05/23] notify: smtp: Update API with OAuth2 parameters Date: Wed, 15 Apr 2026 09:02:02 +0200 Message-ID: <20260415070220.100306-6-a.bied-charreton@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260415070220.100306-1-a.bied-charreton@proxmox.com> References: <20260415070220.100306-1-a.bied-charreton@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 0 AWL -0.120 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 KAM_LAZY_DOMAIN_SECURITY 1 Sending domain does not have any anti-forgery methods RDNS_NONE 0.793 Delivered to internal network by a host with no rDNS SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_NONE 0.001 SPF: sender does not publish an SPF Record Message-ID-Hash: D4PD2P2WVBDP3G3VSLINLJN2QAHWZVWL X-Message-ID-Hash: D4PD2P2WVBDP3G3VSLINLJN2QAHWZVWL X-MailFrom: abied-charreton@jett.proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox VE development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: Add the OAuth2 client & tenant IDs to the public config, and the client secret to the private config. The refresh token is more dynamic and will be updated on the fly by proxmox-notify. In order to avoid the config file changing without user interaction, it is passed as its own parameter and will be managed separately. Signed-off-by: Arthur Bied-Charreton Reviewed-by: Lukas Wagner --- proxmox-notify/src/api/smtp.rs | 99 ++++++++++++++++++++-------- proxmox-notify/src/endpoints/smtp.rs | 42 ++++++++++++ proxmox-notify/src/lib.rs | 2 +- 3 files changed, 116 insertions(+), 27 deletions(-) diff --git a/proxmox-notify/src/api/smtp.rs b/proxmox-notify/src/api/smtp.rs index 470701bf..b265c26a 100644 --- a/proxmox-notify/src/api/smtp.rs +++ b/proxmox-notify/src/api/smtp.rs @@ -1,9 +1,10 @@ use proxmox_http_error::HttpError; use crate::api::{http_bail, http_err}; +use crate::context::context; use crate::endpoints::smtp::{ - DeleteableSmtpProperty, SmtpConfig, SmtpConfigUpdater, SmtpPrivateConfig, - SmtpPrivateConfigUpdater, SMTP_TYPENAME, + DeleteableSmtpProperty, SmtpAuthMethod, SmtpConfig, SmtpConfigUpdater, SmtpPrivateConfig, + SmtpPrivateConfigUpdater, State, SMTP_TYPENAME, }; use crate::Config; @@ -30,6 +31,31 @@ pub fn get_endpoint(config: &Config, name: &str) -> Result Result<(), HttpError> { + let mut private_config: SmtpPrivateConfig = get_private_config(config, name)?; + updater(&mut private_config); + + super::set_private_config_entry(config, private_config, SMTP_TYPENAME, name) +} + +fn get_private_config(config: &Config, name: &str) -> Result { + config + .private_config + .lookup(SMTP_TYPENAME, name) + .map_err(|e| { + http_err!( + NOT_FOUND, + "no private config found for SMTP endpoint : '{e}'" + ) + }) +} + /// Add a new smtp endpoint. /// /// The caller is responsible for any needed permission checks. @@ -38,10 +64,15 @@ pub fn get_endpoint(config: &Config, name: &str) -> Result, ) -> Result<(), HttpError> { if endpoint_config.name != private_endpoint_config.name { // Programming error by the user of the crate, thus we panic @@ -83,11 +114,16 @@ pub fn add_endpoint( /// Returns a `HttpError` if: /// - the configuration could not be saved (`500 Internal server error`) /// - mailto *and* mailto_user are both set to `None` +/// +/// `oauth2_refresh_token` is initially passed through the API when an OAuth2 +/// endpoint is created/updated, however its state is not managed through a +/// config, which is why it is passed separately. pub fn update_endpoint( config: &mut Config, name: &str, updater: SmtpConfigUpdater, private_endpoint_config_updater: SmtpPrivateConfigUpdater, + oauth2_refresh_token: Option, delete: Option<&[DeleteableSmtpProperty]>, digest: Option<&[u8]>, ) -> Result<(), HttpError> { @@ -103,20 +139,20 @@ pub fn update_endpoint( DeleteableSmtpProperty::Disable => endpoint.disable = None, DeleteableSmtpProperty::Mailto => endpoint.mailto.clear(), DeleteableSmtpProperty::MailtoUser => endpoint.mailto_user.clear(), - DeleteableSmtpProperty::Password => super::set_private_config_entry( - config, - SmtpPrivateConfig { - name: name.to_string(), - password: None, - }, - SMTP_TYPENAME, - name, - )?, + DeleteableSmtpProperty::Password => { + update_private_config(config, name, |c| c.password = None)? + } + DeleteableSmtpProperty::AuthMethod => endpoint.auth_method = None, + DeleteableSmtpProperty::OAuth2ClientId => endpoint.oauth2_client_id = None, + DeleteableSmtpProperty::OAuth2ClientSecret => { + update_private_config(config, name, |c| c.oauth2_client_secret = None)? + } + DeleteableSmtpProperty::OAuth2TenantId => endpoint.oauth2_tenant_id = None, DeleteableSmtpProperty::Port => endpoint.port = None, DeleteableSmtpProperty::Username => endpoint.username = None, } } - } + }; if let Some(mailto) = updater.mailto { endpoint.mailto = mailto; @@ -139,29 +175,24 @@ pub fn update_endpoint( if let Some(mode) = updater.mode { endpoint.mode = Some(mode); } - if let Some(password) = private_endpoint_config_updater.password { - super::set_private_config_entry( - config, - SmtpPrivateConfig { - name: name.into(), - password: Some(password), - }, - SMTP_TYPENAME, - name, - )?; + if let Some(auth_method) = updater.auth_method { + endpoint.auth_method = Some(auth_method); } - if let Some(author) = updater.author { endpoint.author = Some(author); } - if let Some(comment) = updater.comment { endpoint.comment = Some(comment); } - if let Some(disable) = updater.disable { endpoint.disable = Some(disable); } + if let Some(oauth2_client_id) = updater.oauth2_client_id { + endpoint.oauth2_client_id = Some(oauth2_client_id); + } + if let Some(oauth2_tenant_id) = updater.oauth2_tenant_id { + endpoint.oauth2_tenant_id = Some(oauth2_tenant_id); + } if endpoint.mailto.is_empty() && endpoint.mailto_user.is_empty() { http_bail!( @@ -169,6 +200,14 @@ pub fn update_endpoint( "must at least provide one recipient, either in mailto or in mailto-user" ); } + update_private_config(config, name, |c| { + if let Some(password) = private_endpoint_config_updater.password { + c.password = Some(password); + } + if let Some(oauth2_client_secret) = private_endpoint_config_updater.oauth2_client_secret { + c.oauth2_client_secret = Some(oauth2_client_secret); + } + })?; config .config @@ -195,6 +234,7 @@ pub fn delete_endpoint(config: &mut Config, name: &str) -> Result<(), HttpError> super::ensure_safe_to_delete(config, name)?; super::remove_private_config_entry(config, name)?; + config.config.sections.remove(name); Ok(()) @@ -204,7 +244,7 @@ pub fn delete_endpoint(config: &mut Config, name: &str) -> Result<(), HttpError> pub mod tests { use super::*; use crate::api::test_helpers::*; - use crate::endpoints::smtp::SmtpMode; + use crate::endpoints::smtp::{SmtpAuthMethod, SmtpMode}; pub fn add_smtp_endpoint_for_test(config: &mut Config, name: &str) -> Result<(), HttpError> { add_endpoint( @@ -217,6 +257,7 @@ pub mod tests { author: Some("root".into()), comment: Some("Comment".into()), mode: Some(SmtpMode::StartTls), + auth_method: Some(SmtpAuthMethod::Plain), server: "localhost".into(), port: Some(555), username: Some("username".into()), @@ -225,7 +266,9 @@ pub mod tests { SmtpPrivateConfig { name: name.into(), password: Some("password".into()), + oauth2_client_secret: None, }, + None, )?; assert!(get_endpoint(config, name).is_ok()); @@ -256,6 +299,7 @@ pub mod tests { Default::default(), None, None, + None, ) .is_err()); @@ -273,6 +317,7 @@ pub mod tests { Default::default(), Default::default(), None, + None, Some(&[0; 32]), ) .is_err()); @@ -304,6 +349,7 @@ pub mod tests { }, Default::default(), None, + None, Some(&digest), )?; @@ -327,6 +373,7 @@ pub mod tests { "smtp-endpoint", Default::default(), Default::default(), + None, Some(&[ DeleteableSmtpProperty::Author, DeleteableSmtpProperty::MailtoUser, diff --git a/proxmox-notify/src/endpoints/smtp.rs b/proxmox-notify/src/endpoints/smtp.rs index 48fdbd8f..b92f96f0 100644 --- a/proxmox-notify/src/endpoints/smtp.rs +++ b/proxmox-notify/src/endpoints/smtp.rs @@ -83,11 +83,21 @@ pub struct SmtpConfig { pub port: Option, #[serde(skip_serializing_if = "Option::is_none")] pub mode: Option, + /// Method to be used for authentication. + #[serde(skip_serializing_if = "Option::is_none")] + pub auth_method: Option, /// Username to use during authentication. /// If no username is set, no authentication will be performed. /// The PLAIN and LOGIN authentication methods are supported #[serde(skip_serializing_if = "Option::is_none")] pub username: Option, + /// Client ID for XOAUTH2 authentication method. + #[serde(skip_serializing_if = "Option::is_none")] + pub oauth2_client_id: Option, + /// Tenant ID for XOAUTH2 authentication method. Only required for + /// Microsoft Exchange Online OAuth2. + #[serde(skip_serializing_if = "Option::is_none")] + pub oauth2_tenant_id: Option, /// Mail address to send a mail to. #[serde(default, skip_serializing_if = "Vec::is_empty")] #[updater(serde(skip_serializing_if = "Option::is_none"))] @@ -134,12 +144,39 @@ pub enum DeleteableSmtpProperty { MailtoUser, /// Delete `password` Password, + /// Delete `auth_method` + AuthMethod, + /// Delete `oauth2_client_id` + #[serde(rename = "oauth2-client-id")] + OAuth2ClientId, + /// Delete `oauth2_client_secret` + #[serde(rename = "oauth2-client-secret")] + OAuth2ClientSecret, + /// Delete `oauth2_tenant_id` + #[serde(rename = "oauth2-tenant-id")] + OAuth2TenantId, /// Delete `port` Port, /// Delete `username` Username, } +/// Authentication mode to use for SMTP. +#[api] +#[derive(Serialize, Deserialize, Clone, Debug, Default, Copy)] +#[serde(rename_all = "kebab-case")] +pub enum SmtpAuthMethod { + /// Username + password + #[default] + Plain, + /// Google OAuth2 + #[serde(rename = "google-oauth2")] + GoogleOAuth2, + /// Microsoft OAuth2 + #[serde(rename = "microsoft-oauth2")] + MicrosoftOAuth2, +} + #[api] #[derive(Serialize, Deserialize, Clone, Updater, Debug)] #[serde(rename_all = "kebab-case")] @@ -150,9 +187,14 @@ pub struct SmtpPrivateConfig { /// Name of the endpoint #[updater(skip)] pub name: String, + /// The password to use during authentication. #[serde(skip_serializing_if = "Option::is_none")] pub password: Option, + + /// OAuth2 client secret + #[serde(skip_serializing_if = "Option::is_none")] + pub oauth2_client_secret: Option, } /// A sendmail notification endpoint. diff --git a/proxmox-notify/src/lib.rs b/proxmox-notify/src/lib.rs index 619dd7db..d443b738 100644 --- a/proxmox-notify/src/lib.rs +++ b/proxmox-notify/src/lib.rs @@ -9,7 +9,7 @@ use context::context; use serde::{Deserialize, Serialize}; use serde_json::json; use serde_json::Value; -use tracing::{error, info}; +use tracing::{debug, error, info}; use proxmox_schema::api; use proxmox_section_config::SectionConfigData; -- 2.47.3