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 B8E951FF138 for ; Wed, 04 Feb 2026 17:14:41 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 204281A5C1; Wed, 4 Feb 2026 17:14:36 +0100 (CET) From: Arthur Bied-Charreton To: pve-devel@lists.proxmox.com Subject: [PATCH proxmox 4/5] notify: smtp: add OAuth2/XOAUTH2 authentication support Date: Wed, 4 Feb 2026 17:13:43 +0100 Message-ID: <20260204161354.458814-5-a.bied-charreton@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260204161354.458814-1-a.bied-charreton@proxmox.com> References: <20260204161354.458814-1-a.bied-charreton@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 0 AWL -0.115 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: IOG6QM4YHCQ4BFNEGXJS6K6OXYREWMIR X-Message-ID-Hash: IOG6QM4YHCQ4BFNEGXJS6K6OXYREWMIR 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 Google & Microsoft OAuth2 support for SMTP notification targets. The SmtpEndpoint implements refresh_state() to allow proactively refreshing tokens through pveupdate. The SMTP API functions are updated to handle OAuth2 state. The refresh token initially comes from the frontend, and it is more state than it is configuration, therefore it is passed as a single parameter in {add,update}_endpoint and persisted separately. Signed-off-by: Arthur Bied-Charreton --- proxmox-notify/src/api/smtp.rs | 144 ++++++++++++++---- proxmox-notify/src/endpoints/smtp.rs | 210 +++++++++++++++++++++++++-- 2 files changed, 315 insertions(+), 39 deletions(-) diff --git a/proxmox-notify/src/api/smtp.rs b/proxmox-notify/src/api/smtp.rs index 470701bf..9bd4826d 100644 --- a/proxmox-notify/src/api/smtp.rs +++ b/proxmox-notify/src/api/smtp.rs @@ -1,11 +1,14 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + 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, + SmtpPrivateConfigUpdater, SmtpState, SMTP_TYPENAME, }; -use crate::Config; +use crate::{Config, State}; /// Get a list of all smtp endpoints. /// @@ -30,6 +33,30 @@ pub fn get_endpoint(config: &Config, name: &str) -> Result) -> Result<(), HttpError> { + let mut state = State::from_path(context().state_file_path()).unwrap_or_default(); + + match entry { + Some(entry) => state.set(name, &entry).map_err(|e| { + http_err!( + INTERNAL_SERVER_ERROR, + "could not update state for endpoint '{}': {e}", + name + ) + })?, + None => state.remove(name), + } + + state.persist(context().state_file_path()).map_err(|e| { + http_err!( + INTERNAL_SERVER_ERROR, + "could not update state for endpoint '{}': {e}", + name + ) + }) +} + /// Add a new smtp endpoint. /// /// The caller is responsible for any needed permission checks. @@ -38,10 +65,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 @@ -64,6 +96,17 @@ pub fn add_endpoint( &endpoint_config.name, )?; + update_state( + &endpoint_config.name, + Some(SmtpState { + oauth2_refresh_token, + last_refreshed: SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(), + }), + )?; + config .config .set_data(&endpoint_config.name, SMTP_TYPENAME, &endpoint_config) @@ -76,6 +119,28 @@ pub fn add_endpoint( }) } +/// Apply `updater` to the private config identified by `name`, and set +/// the private config entry afterwards. +pub 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 +148,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 +173,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 +209,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 +235,25 @@ pub fn update_endpoint( ); } + let private_config = SmtpPrivateConfig { + name: name.into(), + password: private_endpoint_config_updater.password, + oauth2_client_secret: private_endpoint_config_updater.oauth2_client_secret, + }; + + super::set_private_config_entry(config, private_config, SMTP_TYPENAME, name)?; + + update_state( + name, + Some(SmtpState { + oauth2_refresh_token, + last_refreshed: SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(), + }), + )?; + config .config .set_data(name, SMTP_TYPENAME, &endpoint) @@ -196,6 +280,7 @@ pub fn delete_endpoint(config: &mut Config, name: &str) -> Result<(), HttpError> super::remove_private_config_entry(config, name)?; config.config.sections.remove(name); + update_state(name, None)?; Ok(()) } @@ -204,7 +289,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 +302,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 +311,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 +344,7 @@ pub mod tests { Default::default(), None, None, + None, ) .is_err()); @@ -273,6 +362,7 @@ pub mod tests { Default::default(), Default::default(), None, + None, Some(&[0; 32]), ) .is_err()); @@ -304,6 +394,7 @@ pub mod tests { }, Default::default(), None, + None, Some(&digest), )?; @@ -327,6 +418,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 69c4048c..b7194fff 100644 --- a/proxmox-notify/src/endpoints/smtp.rs +++ b/proxmox-notify/src/endpoints/smtp.rs @@ -1,12 +1,15 @@ use std::borrow::Cow; -use std::time::Duration; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use lettre::message::header::{HeaderName, HeaderValue}; use lettre::message::{Mailbox, MultiPart, SinglePart}; +use lettre::transport::smtp::authentication::{Credentials, Mechanism}; use lettre::transport::smtp::client::{Tls, TlsParameters}; use lettre::{message::header::ContentType, Message, SmtpTransport, Transport}; use serde::{Deserialize, Serialize}; +use oauth2::{ClientId, ClientSecret, RefreshToken}; + use proxmox_schema::api_types::COMMENT_SCHEMA; use proxmox_schema::{api, Updater}; @@ -80,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"))] @@ -131,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)] +#[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, +} + #[derive(Serialize, Deserialize, Clone, Debug, Default)] #[serde(rename_all = "kebab-case")] pub struct SmtpState { @@ -157,9 +197,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. @@ -168,6 +213,60 @@ pub struct SmtpEndpoint { pub private_config: SmtpPrivateConfig, } +impl SmtpEndpoint { + fn get_access_token( + &self, + refresh_token: &str, + auth_method: &SmtpAuthMethod, + ) -> Result { + let client_id = ClientId::new( + self.config + .oauth2_client_id + .as_ref() + .ok_or_else(|| Error::Generic("oauth2-client-id not set".into()))? + .to_string(), + ); + let client_secret = ClientSecret::new( + self.private_config + .oauth2_client_secret + .as_ref() + .ok_or_else(|| Error::Generic("oauth2-client-secret not set".into()))? + .to_string(), + ); + let refresh_token = RefreshToken::new(refresh_token.into()); + + match auth_method { + SmtpAuthMethod::GoogleOAuth2 => { + xoauth2::get_google_token(client_id, client_secret, refresh_token) + } + SmtpAuthMethod::MicrosoftOAuth2 => xoauth2::get_microsoft_token( + client_id, + client_secret, + &self.config.oauth2_tenant_id.as_ref().ok_or(Error::Generic( + "tenant ID not set, required for Microsoft OAuth2".into(), + ))?, + refresh_token, + ), + _ => Err(Error::Generic("OAuth2 not configured".into())), + } + } + + /// Infer the auth method based on the presence of a password field in the private config. + /// + /// This is required for backwards compatibility for configs created before the `auth_method` + /// field was added, i.e., the presence of a password implicitly meant plain authentication + /// was to be used. + fn auth_method(&self) -> Option { + self.config.auth_method.clone().or_else(|| { + if self.private_config.password.is_some() { + Some(SmtpAuthMethod::Plain) + } else { + None + } + }) + } +} + impl Endpoint for SmtpEndpoint { fn send(&self, notification: &Notification, state: &mut State) -> Result<(), Error> { let mut endpoint_state = state.get_or_default::(self.name())?; @@ -190,23 +289,58 @@ impl Endpoint for SmtpEndpoint { } }; - let mut transport_builder = SmtpTransport::builder_dangerous(&self.config.server) + let 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_builder = match &self.auth_method() { + None => transport_builder, + Some(SmtpAuthMethod::Plain) => match ( + self.config.username.as_deref(), + self.private_config.password.as_deref(), + ) { + (Some(username), Some(password)) => { + transport_builder.credentials((username, password).into()) + } + (Some(_), None) => { + return Err(Error::NotifyFailed( + self.name().into(), + Box::new(Error::Generic( + "username is set but no password was provided".to_owned(), + )), + )) + } + _ => transport_builder, + }, + Some(method) => { + let token_exchange_result = self.get_access_token( + endpoint_state + .oauth2_refresh_token + .as_ref() + .ok_or(Error::NotifyFailed( + self.name().into(), + Box::new(Error::Generic("no refresh token found for endpoint".into())), + ))?, + method, + )?; + + if let Some(new_refresh_token) = token_exchange_result.refresh_token { + endpoint_state.oauth2_refresh_token = Some(new_refresh_token.into_secret()); + } + endpoint_state.last_refreshed = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + transport_builder + .credentials(Credentials::new( + self.config.from_address.clone(), + token_exchange_result.access_token.into_secret(), + )) + .authentication(vec![Mechanism::Xoauth2]) } - } + }; let transport = transport_builder.build(); @@ -298,6 +432,56 @@ impl Endpoint for SmtpEndpoint { fn disabled(&self) -> bool { self.config.disable.unwrap_or_default() } + + fn refresh_state(&self, state: &mut State) -> Result { + let endpoint_state = match state.get::(self.name())? { + None => return Ok(false), + Some(s) => s, + }; + + let Some(refresh_token) = endpoint_state.oauth2_refresh_token else { + return Ok(false); + }; + + // The refresh job is configured in pveupdate, which runs once for each node. + // Don't refresh if we already did it recently. + if SystemTime::now() + .duration_since(UNIX_EPOCH + Duration::from_secs(endpoint_state.last_refreshed)) + .map_err(|e| Error::Generic(e.to_string()))? + < Duration::from_secs(60 * 60 * 12) + { + return Ok(false); + } + + let Some(auth_method) = self.config.auth_method.as_ref() else { + return Ok(false); + }; + + let new_state = match self + .get_access_token(&refresh_token, auth_method)? + .refresh_token + { + // Microsoft OAuth2, new token was returned + Some(new_refresh_token) => SmtpState { + oauth2_refresh_token: Some(new_refresh_token.into_secret()), + last_refreshed: SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(), + }, + // Google OAuth2, refresh token's lifetime was extended + None => SmtpState { + oauth2_refresh_token: Some(refresh_token), + last_refreshed: SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(), + }, + }; + + state.set(self.name(), &new_state)?; + Ok(true) + } } /// Construct a lettre `Message` from a raw email message. -- 2.47.3