From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [IPv6:2a01:7e0:0:424::9]) by lore.proxmox.com (Postfix) with ESMTPS id D73B71FF141 for ; Fri, 13 Feb 2026 17:04:25 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id AF15E9A22; Fri, 13 Feb 2026 17:04:35 +0100 (CET) From: Arthur Bied-Charreton To: pve-devel@lists.proxmox.com Subject: [PATCH proxmox 4/7] notify (smtp): Update API with OAuth2 parameters Date: Fri, 13 Feb 2026 17:04:02 +0100 Message-ID: <20260213160415.609868-5-a.bied-charreton@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260213160415.609868-1-a.bied-charreton@proxmox.com> References: <20260213160415.609868-1-a.bied-charreton@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 0 AWL -0.087 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 RCVD_IN_VALIDITY_CERTIFIED_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to Validity was blocked. See https://knowledge.validity.com/hc/en-us/articles/20961730681243 for more information. RCVD_IN_VALIDITY_RPBL_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to Validity was blocked. See https://knowledge.validity.com/hc/en-us/articles/20961730681243 for more information. RCVD_IN_VALIDITY_SAFE_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to Validity was blocked. See https://knowledge.validity.com/hc/en-us/articles/20961730681243 for more information. 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: D4F7S6GNBXSGW5YI5UYWRGHN57EZMFGJ X-Message-ID-Hash: D4F7S6GNBXSGW5YI5UYWRGHN57EZMFGJ 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 ID to the public config, client secret to the private config. The refresh token, which is to be managed separately as state, is taken as an extra parameter by {add,update}_endpoint to avoid it landing in a section config. Signed-off-by: Arthur Bied-Charreton --- proxmox-notify/src/api/smtp.rs | 91 ++++++++++++++++++++-------- proxmox-notify/src/endpoints/smtp.rs | 42 +++++++++++++ 2 files changed, 109 insertions(+), 24 deletions(-) diff --git a/proxmox-notify/src/api/smtp.rs b/proxmox-notify/src/api/smtp.rs index 470701bf..4231cdae 100644 --- a/proxmox-notify/src/api/smtp.rs +++ b/proxmox-notify/src/api/smtp.rs @@ -38,10 +38,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 @@ -76,6 +81,28 @@ pub fn add_endpoint( }) } +/// Apply `updater` to the private config identified by `name`, and set +/// the private config entry afterwards. +fn update_private_config( + config: &mut Config, + name: &str, + updater: impl FnOnce(&mut SmtpPrivateConfig), +) -> Result<(), HttpError> { + let mut private_config: SmtpPrivateConfig = config + .private_config + .lookup(SMTP_TYPENAME, name) + .map_err(|e| { + http_err!( + INTERNAL_SERVER_ERROR, + "no private config found for SMTP endpoint: {e}" + ) + })?; + + updater(&mut private_config); + + super::set_private_config_entry(config, private_config, SMTP_TYPENAME, name) +} + /// Update existing smtp endpoint /// /// The caller is responsible for any needed permission checks. @@ -83,11 +110,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 +135,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 +171,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!( @@ -170,6 +197,15 @@ pub fn update_endpoint( ); } + 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 .set_data(name, SMTP_TYPENAME, &endpoint) @@ -204,7 +240,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 +253,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 +262,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 +295,7 @@ pub mod tests { Default::default(), None, None, + None, ) .is_err()); @@ -273,6 +313,7 @@ pub mod tests { Default::default(), Default::default(), None, + None, Some(&[0; 32]), ) .is_err()); @@ -304,6 +345,7 @@ pub mod tests { }, Default::default(), None, + None, Some(&digest), )?; @@ -327,6 +369,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 1340d8ea..361c4da9 100644 --- a/proxmox-notify/src/endpoints/smtp.rs +++ b/proxmox-notify/src/endpoints/smtp.rs @@ -84,11 +84,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"))] @@ -135,12 +145,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")] @@ -151,9 +188,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. -- 2.47.3