* [PATCH proxmox v5 01/27] add oauth2 and ureq to workspace dependencies
2026-05-05 8:32 [PATCH docs/manager/proxmox{,-perl-rs,-widget-toolkit,-backup} v5 00/27] fix #7238: Add XOAUTH2 authentication support for SMTP notification targets Arthur Bied-Charreton
@ 2026-05-05 8:32 ` Arthur Bied-Charreton
2026-05-05 8:32 ` [PATCH proxmox v5 02/27] notify: smtp: introduce xoauth2 module Arthur Bied-Charreton
` (25 subsequent siblings)
26 siblings, 0 replies; 28+ messages in thread
From: Arthur Bied-Charreton @ 2026-05-05 8:32 UTC (permalink / raw)
To: pve-devel, pbs-devel
oauth2 will be needed by proxmox-notify for XOAUTH2 support and ureq is
used by multiple crates in proxmox.git. Pull them in as workspace
dependencies and update proxmox-openid and proxmox-http accordingly.
Signed-off-by: Arthur Bied-Charreton <a.bied-charreton@proxmox.com>
---
This patch was added in a previous version of the series where
proxmox-notify was using ureq. This is no longer the case, so the
ureq change is not strictly related to this series. Still leaving it
in here as a general improvement.
Cargo.toml | 2 ++
proxmox-http/Cargo.toml | 2 +-
proxmox-openid/Cargo.toml | 2 +-
3 files changed, 4 insertions(+), 2 deletions(-)
diff --git a/Cargo.toml b/Cargo.toml
index cf49a8b0..37fc1f12 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -118,6 +118,7 @@ native-tls = "0.2"
nix = "0.29"
nom = "7"
# used by proxmox-disks, can be replaced by OnceLock from std once it supports get_or_try_init
+oauth2 = { version = "5", default-features = false }
once_cell = "1.3.1"
openssl = "0.10"
pam-sys = "0.5"
@@ -148,6 +149,7 @@ tracing-journald = "0.3.1"
tracing-log = { version = "0.2", default-features = false }
tracing-subscriber = "0.3.16"
udev = "0.9"
+ureq = { version = "3", default-features = false }
url = "2.2"
walkdir = "2"
zstd = "0.13"
diff --git a/proxmox-http/Cargo.toml b/proxmox-http/Cargo.toml
index 66b11650..dff2a04a 100644
--- a/proxmox-http/Cargo.toml
+++ b/proxmox-http/Cargo.toml
@@ -27,7 +27,7 @@ sync_wrapper = { workspace = true, optional = true }
tokio = { workspace = true, features = [], optional = true }
tokio-openssl = { workspace = true, optional = true }
tower-service = { workspace = true, optional = true }
-ureq = { version = "3.0", features = ["native-tls"], optional = true, default-features = false }
+ureq = { features = ["native-tls"], optional = true, default-features = false, workspace = true }
url = { workspace = true, optional = true }
proxmox-async = { workspace = true, optional = true }
diff --git a/proxmox-openid/Cargo.toml b/proxmox-openid/Cargo.toml
index 5b031800..79329451 100644
--- a/proxmox-openid/Cargo.toml
+++ b/proxmox-openid/Cargo.toml
@@ -22,7 +22,7 @@ thiserror.workspace = true
native-tls.workspace = true
openidconnect = { version = "4", default-features = false, features = ["accept-rfc3339-timestamps"] }
-ureq = { version = "3", default-features = false, features = ["native-tls", "gzip"] }
+ureq = { default-features = false, features = ["native-tls", "gzip"], workspace = true }
proxmox-time.workspace = true
proxmox-sys = { workspace = true, features = ["timer"] }
--
2.47.3
^ permalink raw reply related [flat|nested] 28+ messages in thread* [PATCH proxmox v5 02/27] notify: smtp: introduce xoauth2 module
2026-05-05 8:32 [PATCH docs/manager/proxmox{,-perl-rs,-widget-toolkit,-backup} v5 00/27] fix #7238: Add XOAUTH2 authentication support for SMTP notification targets Arthur Bied-Charreton
2026-05-05 8:32 ` [PATCH proxmox v5 01/27] add oauth2 and ureq to workspace dependencies Arthur Bied-Charreton
@ 2026-05-05 8:32 ` Arthur Bied-Charreton
2026-05-05 8:32 ` [PATCH proxmox v5 03/27] notify: smtp: introduce state management Arthur Bied-Charreton
` (24 subsequent siblings)
26 siblings, 0 replies; 28+ messages in thread
From: Arthur Bied-Charreton @ 2026-05-05 8:32 UTC (permalink / raw)
To: pve-devel, pbs-devel
The xoauth2 module handles some of the implementation details related to
supporting XOAUTH2 for SMTP notification targets.
The get_{google,microsoft}_token functions handle provider-specific
token exchange. A newtype wrapper over proxmox_http's Client
implementing the SyncHttpClient trait is added to allow using
proxmox-http as a backend for oauth2 and respecting the proxy configs.
Signed-off-by: Arthur Bied-Charreton <a.bied-charreton@proxmox.com>
---
proxmox-notify/Cargo.toml | 4 +-
proxmox-notify/debian/control | 35 ++---
proxmox-notify/src/endpoints/smtp.rs | 2 +
proxmox-notify/src/endpoints/smtp/xoauth2.rs | 133 +++++++++++++++++++
4 files changed, 158 insertions(+), 16 deletions(-)
create mode 100644 proxmox-notify/src/endpoints/smtp/xoauth2.rs
diff --git a/proxmox-notify/Cargo.toml b/proxmox-notify/Cargo.toml
index bc63e19d..72d3b322 100644
--- a/proxmox-notify/Cargo.toml
+++ b/proxmox-notify/Cargo.toml
@@ -19,6 +19,8 @@ http = { workspace = true, optional = true }
lettre = { workspace = true, optional = true }
tracing.workspace = true
mail-parser = { workspace = true, optional = true }
+oauth2 = { workspace = true, optional = true }
+
openssl.workspace = true
percent-encoding = { workspace = true, optional = true }
regex.workspace = true
@@ -44,5 +46,5 @@ sendmail = ["dep:proxmox-sys", "dep:proxmox-sendmail"]
gotify = ["dep:proxmox-http", "dep:http"]
pve-context = ["dep:proxmox-sys"]
pbs-context = ["dep:proxmox-sys"]
-smtp = ["dep:lettre"]
+smtp = ["dep:lettre", "dep:oauth2", "dep:proxmox-http", "dep:http"]
webhook = ["dep:http", "dep:percent-encoding", "dep:proxmox-base64", "dep:proxmox-http"]
diff --git a/proxmox-notify/debian/control b/proxmox-notify/debian/control
index e588e485..6f30cdd1 100644
--- a/proxmox-notify/debian/control
+++ b/proxmox-notify/debian/control
@@ -4,13 +4,14 @@ Priority: optional
Build-Depends: debhelper-compat (= 13),
dh-sequence-cargo
Build-Depends-Arch: cargo:native <!nocheck>,
- rustc:native (>= 1.82) <!nocheck>,
+ rustc:native (>= 1.85) <!nocheck>,
libstd-rust-dev <!nocheck>,
librust-anyhow-1+default-dev <!nocheck>,
librust-const-format-0.2+default-dev <!nocheck>,
librust-handlebars-5+default-dev <!nocheck>,
librust-http-1+default-dev <!nocheck>,
librust-lettre-0.11+default-dev (>= 0.11.1-~~) <!nocheck>,
+ librust-oauth2-5-dev <!nocheck>,
librust-openssl-0.10+default-dev <!nocheck>,
librust-percent-encoding-2+default-dev (>= 2.1-~~) <!nocheck>,
librust-proxmox-base64-1+default-dev <!nocheck>,
@@ -18,14 +19,14 @@ Build-Depends-Arch: cargo:native <!nocheck>,
librust-proxmox-http-1+default-dev (>= 1.0.5-~~) <!nocheck>,
librust-proxmox-http-error-1+default-dev <!nocheck>,
librust-proxmox-human-byte-1+default-dev <!nocheck>,
- librust-proxmox-schema-5+api-macro-dev (>= 5.0.1-~~) <!nocheck>,
- librust-proxmox-schema-5+api-types-dev (>= 5.0.1-~~) <!nocheck>,
- librust-proxmox-schema-5+default-dev (>= 5.0.1-~~) <!nocheck>,
+ librust-proxmox-schema-5+api-macro-dev (>= 5.1.1-~~) <!nocheck>,
+ librust-proxmox-schema-5+api-types-dev (>= 5.1.1-~~) <!nocheck>,
+ librust-proxmox-schema-5+default-dev (>= 5.1.1-~~) <!nocheck>,
librust-proxmox-section-config-3+default-dev (>= 3.1.0-~~) <!nocheck>,
- librust-proxmox-sendmail-1+default-dev (>= 1.0.1-~~) <!nocheck>,
+ librust-proxmox-sendmail-1+default-dev (>= 1.0.2-~~) <!nocheck>,
librust-proxmox-serde-1+default-dev <!nocheck>,
librust-proxmox-serde-1+serde-json-dev <!nocheck>,
- librust-proxmox-sys-1+default-dev <!nocheck>,
+ librust-proxmox-sys-1+default-dev (>= 1.0.1-~~) <!nocheck>,
librust-proxmox-time-2+default-dev (>= 2.1.0-~~) <!nocheck>,
librust-proxmox-uuid-1+default-dev (>= 1.1.0-~~) <!nocheck>,
librust-proxmox-uuid-1+serde-dev (>= 1.1.0-~~) <!nocheck>,
@@ -52,9 +53,9 @@ Depends:
librust-openssl-0.10+default-dev,
librust-proxmox-http-error-1+default-dev,
librust-proxmox-human-byte-1+default-dev,
- librust-proxmox-schema-5+api-macro-dev (>= 5.0.1-~~),
- librust-proxmox-schema-5+api-types-dev (>= 5.0.1-~~),
- librust-proxmox-schema-5+default-dev (>= 5.0.1-~~),
+ librust-proxmox-schema-5+api-macro-dev (>= 5.1.1-~~),
+ librust-proxmox-schema-5+api-types-dev (>= 5.1.1-~~),
+ librust-proxmox-schema-5+default-dev (>= 5.1.1-~~),
librust-proxmox-section-config-3+default-dev (>= 3.1.0-~~),
librust-proxmox-serde-1+default-dev,
librust-proxmox-serde-1+serde-json-dev,
@@ -124,8 +125,8 @@ Depends:
${misc:Depends},
librust-proxmox-notify-dev (= ${binary:Version}),
librust-mail-parser-0.11+default-dev,
- librust-proxmox-sendmail-1+mail-forwarder-dev (>= 1.0.1-~~),
- librust-proxmox-sys-1+default-dev
+ librust-proxmox-sendmail-1+mail-forwarder-dev (>= 1.0.2-~~),
+ librust-proxmox-sys-1+default-dev (>= 1.0.1-~~)
Provides:
librust-proxmox-notify-1+mail-forwarder-dev (= ${binary:Version}),
librust-proxmox-notify-1.0+mail-forwarder-dev (= ${binary:Version}),
@@ -140,7 +141,7 @@ Multi-Arch: same
Depends:
${misc:Depends},
librust-proxmox-notify-dev (= ${binary:Version}),
- librust-proxmox-sys-1+default-dev
+ librust-proxmox-sys-1+default-dev (>= 1.0.1-~~)
Provides:
librust-proxmox-notify+pve-context-dev (= ${binary:Version}),
librust-proxmox-notify-1+pbs-context-dev (= ${binary:Version}),
@@ -161,8 +162,8 @@ Multi-Arch: same
Depends:
${misc:Depends},
librust-proxmox-notify-dev (= ${binary:Version}),
- librust-proxmox-sendmail-1+default-dev (>= 1.0.1-~~),
- librust-proxmox-sys-1+default-dev
+ librust-proxmox-sendmail-1+default-dev (>= 1.0.2-~~),
+ librust-proxmox-sys-1+default-dev (>= 1.0.1-~~)
Provides:
librust-proxmox-notify-1+sendmail-dev (= ${binary:Version}),
librust-proxmox-notify-1.0+sendmail-dev (= ${binary:Version}),
@@ -177,7 +178,11 @@ Multi-Arch: same
Depends:
${misc:Depends},
librust-proxmox-notify-dev (= ${binary:Version}),
- librust-lettre-0.11+default-dev (>= 0.11.1-~~)
+ librust-http-1+default-dev,
+ librust-lettre-0.11+default-dev (>= 0.11.1-~~),
+ librust-oauth2-5-dev,
+ librust-proxmox-http-1+client-sync-dev (>= 1.0.5-~~),
+ librust-proxmox-http-1+default-dev (>= 1.0.5-~~)
Provides:
librust-proxmox-notify-1+smtp-dev (= ${binary:Version}),
librust-proxmox-notify-1.0+smtp-dev (= ${binary:Version}),
diff --git a/proxmox-notify/src/endpoints/smtp.rs b/proxmox-notify/src/endpoints/smtp.rs
index 6785932f..d1cdb540 100644
--- a/proxmox-notify/src/endpoints/smtp.rs
+++ b/proxmox-notify/src/endpoints/smtp.rs
@@ -23,6 +23,8 @@ const SMTP_SUBMISSION_STARTTLS_PORT: u16 = 587;
const SMTP_SUBMISSION_TLS_PORT: u16 = 465;
const SMTP_TIMEOUT: u16 = 5;
+mod xoauth2;
+
#[api]
#[derive(Debug, Serialize, Deserialize, Default, Clone, Copy)]
#[serde(rename_all = "kebab-case")]
diff --git a/proxmox-notify/src/endpoints/smtp/xoauth2.rs b/proxmox-notify/src/endpoints/smtp/xoauth2.rs
new file mode 100644
index 00000000..7f4e8e06
--- /dev/null
+++ b/proxmox-notify/src/endpoints/smtp/xoauth2.rs
@@ -0,0 +1,133 @@
+use oauth2::{
+ basic::BasicClient, AccessToken, AuthUrl, ClientId, ClientSecret, RefreshToken, TokenResponse,
+ TokenUrl,
+};
+use proxmox_http::{HttpOptions, ProxyConfig};
+
+use crate::{context::context, Error};
+
+/// Implements `oauth2`'s `SyncHttpClient` trait.
+///
+/// This allows `oauth2` to use `proxmox-http` as a backend for OAuth2 requests.
+struct SyncHttpClient(proxmox_http::client::sync::Client);
+
+impl SyncHttpClient {
+ fn new() -> Result<Self, Error> {
+ let proxy_config = context()
+ .http_proxy_config()
+ .map(|p| ProxyConfig::parse_proxy_url(&p))
+ .transpose()
+ .map_err(|e| Error::Generic(format!("invalid HTTP proxy config: {e}")))?;
+
+ let client = proxmox_http::client::sync::Client::new(HttpOptions {
+ proxy_config,
+ ..Default::default()
+ });
+
+ Ok(Self(client))
+ }
+}
+
+impl oauth2::SyncHttpClient for SyncHttpClient {
+ type Error = oauth2::HttpClientError<Error>;
+
+ fn call(&self, request: oauth2::HttpRequest) -> Result<oauth2::HttpResponse, Self::Error> {
+ let (parts, body) = request.into_parts();
+ let request = http::Request::from_parts(parts, body.as_slice());
+
+ proxmox_http::HttpClient::<&[u8], Vec<u8>>::request(&self.0, request)
+ .map_err(|e| oauth2::HttpClientError::Other(e.to_string()))
+ }
+}
+
+/// The result yielded by an OAuth2 token exchange.
+///
+/// A successful OAuth2 token exchange will always return an access token to be
+/// used for authentication.
+///
+/// Some providers additionally yield a new refresh token that should replace the
+/// old one.
+pub(crate) struct TokenExchangeResult {
+ pub access_token: AccessToken,
+ pub refresh_token: Option<RefreshToken>,
+}
+
+/// Perform a Microsoft OAuth2 token exchange.
+///
+/// Microsoft Identity Platform refresh tokens have static lifetimes of 90 days, with each
+/// token exchange yielding a new refresh token. The new refresh token is assigned a new
+/// static lifetime, starting from the moment the token exchange was performed.
+///
+/// The old refresh token is not invalidated, rather it keeps the static lifetime it was
+/// assigned at generation time. This means that at any given point in time, there can be
+/// many different refresh tokens that are *all* valid.
+///
+/// Therefore, while the saved token should be rotated eventually, in practice it is safe
+/// to persist the new refresh token only once every 24 hours for example.
+///
+/// https://learn.microsoft.com/en-us/entra/identity-platform/refresh-tokens#token-lifetime
+pub(crate) fn get_microsoft_token(
+ client_id: ClientId,
+ client_secret: ClientSecret,
+ tenant_id: &str,
+ refresh_token: RefreshToken,
+) -> Result<TokenExchangeResult, Error> {
+ let client = BasicClient::new(client_id)
+ .set_client_secret(client_secret)
+ .set_auth_uri(
+ AuthUrl::new(format!(
+ "https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/authorize"
+ ))
+ .map_err(|e| Error::Generic(format!("invalid auth URL: {e}")))?,
+ )
+ .set_token_uri(
+ TokenUrl::new(format!(
+ "https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token"
+ ))
+ .map_err(|e| Error::Generic(format!("invalid token URL: {e}")))?,
+ );
+
+ let token_result = client
+ .exchange_refresh_token(&refresh_token)
+ .request(&SyncHttpClient::new()?)
+ .map_err(|e| Error::Generic(format!("could not get access token: {e}")))?;
+
+ Ok(TokenExchangeResult {
+ access_token: token_result.access_token().clone(),
+ refresh_token: token_result.refresh_token().cloned(),
+ })
+}
+
+/// Perform a Google OAuth2 token exchange.
+///
+/// Google refresh tokens' TTL is extended at every use. As long as
+/// a token has been used at least once in the past 6 months, and no
+/// other expiration reason applies, the same token can be kept.
+///
+/// https://developers.google.com/identity/protocols/oauth2#expiration
+pub(crate) fn get_google_token(
+ client_id: ClientId,
+ client_secret: ClientSecret,
+ refresh_token: RefreshToken,
+) -> Result<TokenExchangeResult, Error> {
+ let client = BasicClient::new(client_id)
+ .set_client_secret(client_secret)
+ .set_auth_uri(
+ AuthUrl::new("https://accounts.google.com/o/oauth2/v2/auth".into())
+ .map_err(|e| Error::Generic(format!("invalid auth URL: {e}")))?,
+ )
+ .set_token_uri(
+ TokenUrl::new("https://oauth2.googleapis.com/token".into())
+ .map_err(|e| Error::Generic(format!("invalid token URL: {e}")))?,
+ );
+
+ let token_result = client
+ .exchange_refresh_token(&refresh_token)
+ .request(&SyncHttpClient::new()?)
+ .map_err(|e| Error::Generic(format!("could not get access token: {e}")))?;
+
+ Ok(TokenExchangeResult {
+ access_token: token_result.access_token().clone(),
+ refresh_token: token_result.refresh_token().cloned(),
+ })
+}
--
2.47.3
^ permalink raw reply related [flat|nested] 28+ messages in thread* [PATCH proxmox v5 03/27] notify: smtp: introduce state management
2026-05-05 8:32 [PATCH docs/manager/proxmox{,-perl-rs,-widget-toolkit,-backup} v5 00/27] fix #7238: Add XOAUTH2 authentication support for SMTP notification targets Arthur Bied-Charreton
2026-05-05 8:32 ` [PATCH proxmox v5 01/27] add oauth2 and ureq to workspace dependencies Arthur Bied-Charreton
2026-05-05 8:32 ` [PATCH proxmox v5 02/27] notify: smtp: introduce xoauth2 module Arthur Bied-Charreton
@ 2026-05-05 8:32 ` Arthur Bied-Charreton
2026-05-05 8:32 ` [PATCH proxmox v5 04/27] notify: smtp: factor out transport building logic Arthur Bied-Charreton
` (23 subsequent siblings)
26 siblings, 0 replies; 28+ messages in thread
From: Arthur Bied-Charreton @ 2026-05-05 8:32 UTC (permalink / raw)
To: pve-devel, pbs-devel
Export a new State struct in the xoauth2 module with associated
functionality for loading, updating, and persisting the OAuth2 state
for SMTP endpoints.
The API for loading and saving the state is exposed through the Context
trait, and the state struct is made public to allow each product to
implement the storage of state files itself.
The nix crate is added for the sys::stat::Mode struct, and
proxmox-sys is now pulled in unconditionally since it is used in the
Context implementations.
Signed-off-by: Arthur Bied-Charreton <a.bied-charreton@proxmox.com>
---
proxmox-notify/Cargo.toml | 12 ++-
proxmox-notify/debian/control | 41 +++----
proxmox-notify/src/context/mod.rs | 13 +++
proxmox-notify/src/context/pbs.rs | 24 +++++
proxmox-notify/src/context/pve.rs | 26 ++++-
proxmox-notify/src/context/test.rs | 23 ++++
proxmox-notify/src/endpoints/smtp.rs | 2 +
proxmox-notify/src/endpoints/smtp/xoauth2.rs | 108 +++++++++++++++++++
proxmox-notify/src/lib.rs | 12 +++
9 files changed, 228 insertions(+), 33 deletions(-)
diff --git a/proxmox-notify/Cargo.toml b/proxmox-notify/Cargo.toml
index 72d3b322..2600df51 100644
--- a/proxmox-notify/Cargo.toml
+++ b/proxmox-notify/Cargo.toml
@@ -35,16 +35,18 @@ proxmox-schema = { workspace = true, features = ["api-macro", "api-types"] }
proxmox-section-config = { workspace = true }
proxmox-serde.workspace = true
proxmox-sendmail = { workspace = true, optional = true }
-proxmox-sys = { workspace = true, optional = true }
+proxmox-sys = { workspace = true }
proxmox-time.workspace = true
proxmox-uuid = { workspace = true, features = ["serde"] }
+nix = { workspace = true }
+
[features]
default = ["sendmail", "gotify", "smtp", "webhook"]
-mail-forwarder = ["dep:mail-parser", "dep:proxmox-sys", "proxmox-sendmail/mail-forwarder"]
-sendmail = ["dep:proxmox-sys", "dep:proxmox-sendmail"]
+mail-forwarder = ["dep:mail-parser", "proxmox-sendmail/mail-forwarder"]
+sendmail = ["dep:proxmox-sendmail"]
gotify = ["dep:proxmox-http", "dep:http"]
-pve-context = ["dep:proxmox-sys"]
-pbs-context = ["dep:proxmox-sys"]
+pve-context = []
+pbs-context = []
smtp = ["dep:lettre", "dep:oauth2", "dep:proxmox-http", "dep:http"]
webhook = ["dep:http", "dep:percent-encoding", "dep:proxmox-base64", "dep:proxmox-http"]
diff --git a/proxmox-notify/debian/control b/proxmox-notify/debian/control
index 6f30cdd1..852085a4 100644
--- a/proxmox-notify/debian/control
+++ b/proxmox-notify/debian/control
@@ -11,6 +11,7 @@ Build-Depends-Arch: cargo:native <!nocheck>,
librust-handlebars-5+default-dev <!nocheck>,
librust-http-1+default-dev <!nocheck>,
librust-lettre-0.11+default-dev (>= 0.11.1-~~) <!nocheck>,
+ librust-nix-0.29+default-dev <!nocheck>,
librust-oauth2-5-dev <!nocheck>,
librust-openssl-0.10+default-dev <!nocheck>,
librust-percent-encoding-2+default-dev (>= 2.1-~~) <!nocheck>,
@@ -50,6 +51,7 @@ Depends:
librust-anyhow-1+default-dev,
librust-const-format-0.2+default-dev,
librust-handlebars-5+default-dev,
+ librust-nix-0.29+default-dev,
librust-openssl-0.10+default-dev,
librust-proxmox-http-error-1+default-dev,
librust-proxmox-human-byte-1+default-dev,
@@ -59,6 +61,7 @@ Depends:
librust-proxmox-section-config-3+default-dev (>= 3.1.0-~~),
librust-proxmox-serde-1+default-dev,
librust-proxmox-serde-1+serde-json-dev,
+ librust-proxmox-sys-1+default-dev (>= 1.0.1-~~),
librust-proxmox-time-2+default-dev (>= 2.1.0-~~),
librust-proxmox-uuid-1+default-dev (>= 1.1.0-~~),
librust-proxmox-uuid-1+serde-dev (>= 1.1.0-~~),
@@ -72,14 +75,21 @@ Recommends:
Suggests:
librust-proxmox-notify+gotify-dev (= ${binary:Version}),
librust-proxmox-notify+mail-forwarder-dev (= ${binary:Version}),
- librust-proxmox-notify+pbs-context-dev (= ${binary:Version}),
librust-proxmox-notify+sendmail-dev (= ${binary:Version}),
librust-proxmox-notify+smtp-dev (= ${binary:Version}),
librust-proxmox-notify+webhook-dev (= ${binary:Version})
Provides:
+ librust-proxmox-notify+pbs-context-dev (= ${binary:Version}),
+ librust-proxmox-notify+pve-context-dev (= ${binary:Version}),
librust-proxmox-notify-1-dev (= ${binary:Version}),
+ librust-proxmox-notify-1+pbs-context-dev (= ${binary:Version}),
+ librust-proxmox-notify-1+pve-context-dev (= ${binary:Version}),
librust-proxmox-notify-1.0-dev (= ${binary:Version}),
- librust-proxmox-notify-1.0.3-dev (= ${binary:Version})
+ librust-proxmox-notify-1.0+pbs-context-dev (= ${binary:Version}),
+ librust-proxmox-notify-1.0+pve-context-dev (= ${binary:Version}),
+ librust-proxmox-notify-1.0.3-dev (= ${binary:Version}),
+ librust-proxmox-notify-1.0.3+pbs-context-dev (= ${binary:Version}),
+ librust-proxmox-notify-1.0.3+pve-context-dev (= ${binary:Version})
Description: Notification base and plugins - Rust source code
Source code for Debianized Rust crate "proxmox-notify"
@@ -125,8 +135,7 @@ Depends:
${misc:Depends},
librust-proxmox-notify-dev (= ${binary:Version}),
librust-mail-parser-0.11+default-dev,
- librust-proxmox-sendmail-1+mail-forwarder-dev (>= 1.0.2-~~),
- librust-proxmox-sys-1+default-dev (>= 1.0.1-~~)
+ librust-proxmox-sendmail-1+mail-forwarder-dev (>= 1.0.2-~~)
Provides:
librust-proxmox-notify-1+mail-forwarder-dev (= ${binary:Version}),
librust-proxmox-notify-1.0+mail-forwarder-dev (= ${binary:Version}),
@@ -135,35 +144,13 @@ Description: Notification base and plugins - feature "mail-forwarder"
This metapackage enables feature "mail-forwarder" for the Rust proxmox-notify
crate, by pulling in any additional dependencies needed by that feature.
-Package: librust-proxmox-notify+pbs-context-dev
-Architecture: any
-Multi-Arch: same
-Depends:
- ${misc:Depends},
- librust-proxmox-notify-dev (= ${binary:Version}),
- librust-proxmox-sys-1+default-dev (>= 1.0.1-~~)
-Provides:
- librust-proxmox-notify+pve-context-dev (= ${binary:Version}),
- librust-proxmox-notify-1+pbs-context-dev (= ${binary:Version}),
- librust-proxmox-notify-1+pve-context-dev (= ${binary:Version}),
- librust-proxmox-notify-1.0+pbs-context-dev (= ${binary:Version}),
- librust-proxmox-notify-1.0+pve-context-dev (= ${binary:Version}),
- librust-proxmox-notify-1.0.3+pbs-context-dev (= ${binary:Version}),
- librust-proxmox-notify-1.0.3+pve-context-dev (= ${binary:Version})
-Description: Notification base and plugins - feature "pbs-context" and 1 more
- This metapackage enables feature "pbs-context" for the Rust proxmox-notify
- crate, by pulling in any additional dependencies needed by that feature.
- .
- Additionally, this package also provides the "pve-context" feature.
-
Package: librust-proxmox-notify+sendmail-dev
Architecture: any
Multi-Arch: same
Depends:
${misc:Depends},
librust-proxmox-notify-dev (= ${binary:Version}),
- librust-proxmox-sendmail-1+default-dev (>= 1.0.2-~~),
- librust-proxmox-sys-1+default-dev (>= 1.0.1-~~)
+ librust-proxmox-sendmail-1+default-dev (>= 1.0.2-~~)
Provides:
librust-proxmox-notify-1+sendmail-dev (= ${binary:Version}),
librust-proxmox-notify-1.0+sendmail-dev (= ${binary:Version}),
diff --git a/proxmox-notify/src/context/mod.rs b/proxmox-notify/src/context/mod.rs
index 8b6e2c43..17b59e96 100644
--- a/proxmox-notify/src/context/mod.rs
+++ b/proxmox-notify/src/context/mod.rs
@@ -1,6 +1,8 @@
use std::fmt::Debug;
use std::sync::Mutex;
+#[cfg(feature = "smtp")]
+use crate::endpoints::smtp::State;
use crate::renderer::TemplateSource;
use crate::Error;
@@ -32,6 +34,17 @@ pub trait Context: Send + Sync + Debug {
namespace: Option<&str>,
source: TemplateSource,
) -> Result<Option<String>, Error>;
+ /// Load OAuth state for `endpoint_name`.
+ #[cfg(feature = "smtp")]
+ fn load_oauth_state(&self, endpoint_name: &str) -> Result<State, Error>;
+ /// Save OAuth state `state` for `endpoint_name`. Passing `None` deletes
+ /// the state file for `endpoint_name`.
+ ///
+ /// This should only be used in paths where the caller is expected to hold a lock on
+ /// the notifications config, as concurrent updates to the config and state files
+ /// could lead to invalid states.
+ #[cfg(feature = "smtp")]
+ fn save_oauth_state(&self, endpoint_name: &str, state: Option<State>) -> Result<(), Error>;
}
#[cfg(not(test))]
diff --git a/proxmox-notify/src/context/pbs.rs b/proxmox-notify/src/context/pbs.rs
index 3e5da59c..6377dc97 100644
--- a/proxmox-notify/src/context/pbs.rs
+++ b/proxmox-notify/src/context/pbs.rs
@@ -1,5 +1,6 @@
use std::path::Path;
+use proxmox_sys::fs::CreateOptions;
use serde::Deserialize;
use tracing::error;
@@ -7,6 +8,8 @@ use proxmox_schema::{ObjectSchema, Schema, StringSchema};
use proxmox_section_config::{SectionConfig, SectionConfigPlugin};
use crate::context::{common, Context};
+#[cfg(feature = "smtp")]
+use crate::endpoints::smtp::State;
use crate::renderer::TemplateSource;
use crate::Error;
@@ -125,6 +128,27 @@ impl Context for PBSContext {
.map_err(|err| Error::Generic(format!("could not load template: {err}")))?;
Ok(template_string)
}
+
+ #[cfg(feature = "smtp")]
+ fn load_oauth_state(&self, endpoint_name: &str) -> Result<State, Error> {
+ let path =
+ format!("/var/lib/proxmox-backup/notifications/oauth-state/{endpoint_name}.json");
+ State::load(path)
+ }
+
+ #[cfg(feature = "smtp")]
+ fn save_oauth_state(&self, endpoint_name: &str, state: Option<State>) -> Result<(), Error> {
+ let path =
+ format!("/var/lib/proxmox-backup/notifications/oauth-state/{endpoint_name}.json");
+ match state {
+ Some(s) => s.save(
+ path,
+ CreateOptions::new().perm(nix::sys::stat::Mode::from_bits_truncate(0o600)),
+ CreateOptions::new().perm(nix::sys::stat::Mode::from_bits_truncate(0o700)),
+ ),
+ None => Ok(State::delete(path)),
+ }
+ }
}
#[cfg(test)]
diff --git a/proxmox-notify/src/context/pve.rs b/proxmox-notify/src/context/pve.rs
index a97cce26..95b95931 100644
--- a/proxmox-notify/src/context/pve.rs
+++ b/proxmox-notify/src/context/pve.rs
@@ -1,7 +1,12 @@
+use std::path::Path;
+
+use proxmox_sys::fs::CreateOptions;
+
use crate::context::{common, Context};
+#[cfg(feature = "smtp")]
+use crate::endpoints::smtp::State;
use crate::renderer::TemplateSource;
use crate::Error;
-use std::path::Path;
fn lookup_mail_address(content: &str, user: &str) -> Option<String> {
common::normalize_for_return(content.lines().find_map(|line| {
@@ -74,6 +79,25 @@ impl Context for PVEContext {
.map_err(|err| Error::Generic(format!("could not load template: {err}")))?;
Ok(template_string)
}
+
+ #[cfg(feature = "smtp")]
+ fn load_oauth_state(&self, endpoint_name: &str) -> Result<State, Error> {
+ let path = format!("/etc/pve/priv/notifications/oauth-state/{endpoint_name}.json");
+ State::load(path)
+ }
+
+ #[cfg(feature = "smtp")]
+ fn save_oauth_state(&self, endpoint_name: &str, state: Option<State>) -> Result<(), Error> {
+ let path = format!("/etc/pve/priv/notifications/oauth-state/{endpoint_name}.json");
+ match state {
+ Some(s) => s.save(
+ path,
+ CreateOptions::new().perm(nix::sys::stat::Mode::from_bits_truncate(0o600)),
+ CreateOptions::new().perm(nix::sys::stat::Mode::from_bits_truncate(0o700)),
+ ),
+ None => Ok(State::delete(path)),
+ }
+ }
}
pub static PVE_CONTEXT: PVEContext = PVEContext;
diff --git a/proxmox-notify/src/context/test.rs b/proxmox-notify/src/context/test.rs
index 2c236b4c..557fff87 100644
--- a/proxmox-notify/src/context/test.rs
+++ b/proxmox-notify/src/context/test.rs
@@ -1,4 +1,8 @@
+use proxmox_sys::fs::CreateOptions;
+
use crate::context::Context;
+#[cfg(feature = "smtp")]
+use crate::endpoints::smtp::State;
use crate::renderer::TemplateSource;
use crate::Error;
@@ -40,4 +44,23 @@ impl Context for TestContext {
) -> Result<Option<String>, Error> {
Ok(Some(String::new()))
}
+
+ #[cfg(feature = "smtp")]
+ fn load_oauth_state(&self, endpoint_name: &str) -> Result<State, Error> {
+ let path = format!("/tmp/notifications/oauth-state/{endpoint_name}.json");
+ State::load(path)
+ }
+
+ #[cfg(feature = "smtp")]
+ fn save_oauth_state(&self, endpoint_name: &str, state: Option<State>) -> Result<(), Error> {
+ let path = format!("/tmp/notifications/oauth-state/{endpoint_name}.json");
+ match state {
+ Some(s) => s.save(
+ path,
+ CreateOptions::new().perm(nix::sys::stat::Mode::from_bits_truncate(0o650)),
+ CreateOptions::new().perm(nix::sys::stat::Mode::from_bits_truncate(0o750)),
+ ),
+ None => Ok(State::delete(path)),
+ }
+ }
}
diff --git a/proxmox-notify/src/endpoints/smtp.rs b/proxmox-notify/src/endpoints/smtp.rs
index d1cdb540..172bcdba 100644
--- a/proxmox-notify/src/endpoints/smtp.rs
+++ b/proxmox-notify/src/endpoints/smtp.rs
@@ -25,6 +25,8 @@ const SMTP_TIMEOUT: u16 = 5;
mod xoauth2;
+pub use xoauth2::State;
+
#[api]
#[derive(Debug, Serialize, Deserialize, Default, Clone, Copy)]
#[serde(rename_all = "kebab-case")]
diff --git a/proxmox-notify/src/endpoints/smtp/xoauth2.rs b/proxmox-notify/src/endpoints/smtp/xoauth2.rs
index 7f4e8e06..78d90d24 100644
--- a/proxmox-notify/src/endpoints/smtp/xoauth2.rs
+++ b/proxmox-notify/src/endpoints/smtp/xoauth2.rs
@@ -1,11 +1,119 @@
+use std::path::Path;
+
use oauth2::{
basic::BasicClient, AccessToken, AuthUrl, ClientId, ClientSecret, RefreshToken, TokenResponse,
TokenUrl,
};
use proxmox_http::{HttpOptions, ProxyConfig};
+use serde::{Deserialize, Serialize};
+use tracing::{debug, error};
use crate::{context::context, Error};
+#[derive(Serialize, Deserialize, Clone, Debug, Default)]
+#[serde(rename_all = "kebab-case")]
+/// Persistent state for XOAUTH2 SMTP endpoints.
+///
+/// This struct represents the per-endpoint state loaded and saved by [`Context::load_oauth_state`]
+/// and [`Context::save_oauth_state`] from/at product-specific paths.
+pub struct State {
+ /// OAuth2 refresh token for this endpoint.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub oauth2_refresh_token: Option<String>,
+ /// Unix timestamp (seconds) of the last time a fresh refresh token was acquired and persisted,
+ /// which includes both proactive refreshes via [`Endpoint::trigger_state_refresh`] and
+ /// re-authorizations via [`api::smtp::update_endpoint`].
+ pub last_refreshed: i64,
+}
+
+impl State {
+ /// Instantiate a new [`State`]. `last_refreshed` is expected to be the UNIX
+ /// timestamp (seconds) of the instantiation time.
+ pub fn new(refresh_token: String, last_refreshed: i64) -> Self {
+ Self {
+ oauth2_refresh_token: Some(refresh_token),
+ last_refreshed,
+ }
+ }
+
+ /// Load state from `path` instantiating a default object if no state exists.
+ ///
+ /// # Errors
+ /// An [`Error`] is returned if deserialization of the state object or reading the state
+ /// file fails.
+ pub fn load<P: AsRef<Path>>(path: P) -> Result<State, Error> {
+ let path_str = path.as_ref().to_string_lossy();
+ match proxmox_sys::fs::file_get_optional_contents(&path)
+ .map_err(|e| Error::StateRetrieval(path_str.to_string(), e.into()))?
+ {
+ Some(bytes) => {
+ debug!("loaded state file from {path_str}");
+ serde_json::from_slice(&bytes)
+ .map_err(|e| Error::StateRetrieval(path_str.to_string(), e.into()))
+ }
+ None => {
+ debug!(
+ "no existing state file found for endpoint at {path_str}, creating empty state"
+ );
+ Ok(State::default())
+ }
+ }
+ }
+
+ /// Persist the state at `path`.
+ ///
+ /// Create the state file's parent directories with `dir_options` and the state file itself
+ /// with `file_options`.
+ ///
+ /// # Errors
+ /// An [`Error`] is returned if serialization of the state object, or the final write, fail.
+ pub fn save<P: AsRef<Path>>(
+ self,
+ path: P,
+ file_options: proxmox_sys::fs::CreateOptions,
+ dir_options: proxmox_sys::fs::CreateOptions,
+ ) -> Result<(), Error> {
+ let path_str = path.as_ref().to_string_lossy();
+
+ debug!("attempting to persist state at {path_str}");
+
+ if let Some(parent) = path.as_ref().parent() {
+ proxmox_sys::fs::create_path(parent, Some(dir_options), Some(dir_options))
+ .map_err(|e| Error::StatePersistence(path_str.to_string(), e.into()))?;
+ }
+
+ let s = serde_json::to_string_pretty(&self)
+ .map_err(|e| Error::StatePersistence(path_str.to_string(), e.into()))?;
+
+ proxmox_sys::fs::replace_file(&path, s.as_bytes(), file_options, false)
+ .map_err(|e| Error::StatePersistence(path_str.to_string(), e.into()))
+ }
+
+ /// Delete the state file at `path`.
+ ///
+ /// Errors are logged but not propagated.
+ pub fn delete<P: AsRef<Path>>(path: P) {
+ if let Err(e) = std::fs::remove_file(&path)
+ && e.kind() != std::io::ErrorKind::NotFound
+ {
+ let path_str = path.as_ref().to_string_lossy();
+ error!("could not delete state file at {path_str}: {e}");
+ }
+ }
+
+ /// Set `last_refreshed`.
+ pub fn set_last_refreshed(mut self, last_refreshed: i64) -> Self {
+ self.last_refreshed = last_refreshed;
+ self
+ }
+
+ /// Set `oauth2_refresh_token`.
+ pub fn set_oauth2_refresh_token(mut self, oauth2_refresh_token: Option<String>) -> Self {
+ self.oauth2_refresh_token = oauth2_refresh_token;
+ self
+ }
+}
+
/// Implements `oauth2`'s `SyncHttpClient` trait.
///
/// This allows `oauth2` to use `proxmox-http` as a backend for OAuth2 requests.
diff --git a/proxmox-notify/src/lib.rs b/proxmox-notify/src/lib.rs
index 879f8326..619dd7db 100644
--- a/proxmox-notify/src/lib.rs
+++ b/proxmox-notify/src/lib.rs
@@ -41,6 +41,10 @@ pub enum Error {
FilterFailed(String),
/// The notification's template string could not be rendered
RenderError(Box<dyn StdError + Send + Sync>),
+ /// The state for an endpoint could not be persisted
+ StatePersistence(String, Box<dyn StdError + Send + Sync>),
+ /// The state for an endpoint could not be retrieved
+ StateRetrieval(String, Box<dyn StdError + Send + Sync>),
/// Generic error for anything else
Generic(String),
}
@@ -70,6 +74,12 @@ impl Display for Error {
Error::FilterFailed(message) => {
write!(f, "could not apply filter: {message}")
}
+ Error::StatePersistence(path, err) => {
+ write!(f, "could not persist state at {path}: {err}")
+ }
+ Error::StateRetrieval(path, err) => {
+ write!(f, "could not retrieve state from {path}: {err}")
+ }
Error::RenderError(err) => write!(f, "could not render notification template: {err}"),
Error::Generic(message) => f.write_str(message),
}
@@ -86,6 +96,8 @@ impl StdError for Error {
Error::TargetTestFailed(errs) => Some(&*errs[0]),
Error::FilterFailed(_) => None,
Error::RenderError(err) => Some(&**err),
+ Error::StatePersistence(_, err) => Some(&**err),
+ Error::StateRetrieval(_, err) => Some(&**err),
Error::Generic(_) => None,
}
}
--
2.47.3
^ permalink raw reply related [flat|nested] 28+ messages in thread* [PATCH proxmox v5 04/27] notify: smtp: factor out transport building logic
2026-05-05 8:32 [PATCH docs/manager/proxmox{,-perl-rs,-widget-toolkit,-backup} v5 00/27] fix #7238: Add XOAUTH2 authentication support for SMTP notification targets Arthur Bied-Charreton
` (2 preceding siblings ...)
2026-05-05 8:32 ` [PATCH proxmox v5 03/27] notify: smtp: introduce state management Arthur Bied-Charreton
@ 2026-05-05 8:32 ` Arthur Bied-Charreton
2026-05-05 8:32 ` [PATCH proxmox v5 05/27] notify: smtp: update API with OAuth2 parameters Arthur Bied-Charreton
` (22 subsequent siblings)
26 siblings, 0 replies; 28+ messages in thread
From: Arthur Bied-Charreton @ 2026-05-05 8:32 UTC (permalink / raw)
To: pve-devel, pbs-devel
As a preparatory step for introducing XOAUTH2 support, which will make
the transport building logic more complex, factor it out into its own
function.
Signed-off-by: Arthur Bied-Charreton <a.bied-charreton@proxmox.com>
Reviewed-by: Lukas Wagner <l.wagner@proxmox.com>
---
proxmox-notify/src/endpoints/smtp.rs | 44 ++++++++++++++++------------
1 file changed, 25 insertions(+), 19 deletions(-)
diff --git a/proxmox-notify/src/endpoints/smtp.rs b/proxmox-notify/src/endpoints/smtp.rs
index 172bcdba..48fdbd8f 100644
--- a/proxmox-notify/src/endpoints/smtp.rs
+++ b/proxmox-notify/src/endpoints/smtp.rs
@@ -161,6 +161,30 @@ pub struct SmtpEndpoint {
pub private_config: SmtpPrivateConfig,
}
+impl SmtpEndpoint {
+ fn build_transport(&self, tls: Tls, port: u16) -> Result<SmtpTransport, Error> {
+ 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(),
+ )),
+ ));
+ }
+ };
+
+ Ok(transport_builder.build())
+ }
+}
+
impl Endpoint for SmtpEndpoint {
fn send(&self, notification: &Notification) -> Result<(), Error> {
let tls_parameters = TlsParameters::new(self.config.server.clone())
@@ -181,25 +205,7 @@ impl Endpoint for SmtpEndpoint {
}
};
- 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 transport = self.build_transport(tls, port)?;
let recipients = mail::get_recipients(
self.config.mailto.as_slice(),
--
2.47.3
^ permalink raw reply related [flat|nested] 28+ messages in thread* [PATCH proxmox v5 05/27] notify: smtp: update API with OAuth2 parameters
2026-05-05 8:32 [PATCH docs/manager/proxmox{,-perl-rs,-widget-toolkit,-backup} v5 00/27] fix #7238: Add XOAUTH2 authentication support for SMTP notification targets Arthur Bied-Charreton
` (3 preceding siblings ...)
2026-05-05 8:32 ` [PATCH proxmox v5 04/27] notify: smtp: factor out transport building logic Arthur Bied-Charreton
@ 2026-05-05 8:32 ` Arthur Bied-Charreton
2026-05-05 8:32 ` [PATCH proxmox v5 06/27] notify: smtp: add API to exchange authorization code for refresh token Arthur Bied-Charreton
` (21 subsequent siblings)
26 siblings, 0 replies; 28+ messages in thread
From: Arthur Bied-Charreton @ 2026-05-05 8:32 UTC (permalink / raw)
To: pve-devel, pbs-devel
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 in per-endpoint state files.
Signed-off-by: Arthur Bied-Charreton <a.bied-charreton@proxmox.com>
---
proxmox-notify/src/api/smtp.rs | 101 ++++++++++++++++++++-------
proxmox-notify/src/endpoints/smtp.rs | 45 +++++++++++-
2 files changed, 118 insertions(+), 28 deletions(-)
diff --git a/proxmox-notify/src/api/smtp.rs b/proxmox-notify/src/api/smtp.rs
index 470701bf..d1482047 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<SmtpConfig, HttpError
.map_err(|_| http_err!(NOT_FOUND, "endpoint '{name}' not found"))
}
+/// 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 = 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<SmtpPrivateConfig, HttpError> {
+ 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,16 @@ pub fn get_endpoint(config: &Config, name: &str) -> Result<SmtpConfig, HttpError
/// - an entity with the same name already exists (`400 Bad request`)
/// - the configuration could not be saved (`500 Internal server error`)
/// - mailto *and* mailto_user are both set to `None`
+///
+/// The OAuth2 refresh token lives in a per-endpoint state file rather than
+/// the notifications config, so it is passed through as a separate parameter.
+/// Callers pass `Some(_)` only when seeding the state at create time or after
+/// a re-authorization.
pub fn add_endpoint(
config: &mut Config,
endpoint_config: SmtpConfig,
private_endpoint_config: SmtpPrivateConfig,
+ oauth2_refresh_token: Option<String>,
) -> Result<(), HttpError> {
if endpoint_config.name != private_endpoint_config.name {
// Programming error by the user of the crate, thus we panic
@@ -83,11 +115,17 @@ 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`
+///
+/// The OAuth2 refresh token lives in a per-endpoint state file rather than
+/// the notifications config, so it is passed through as a separate parameter.
+/// Callers pass `Some(_)` only when seeding the state at create time or after
+/// a re-authorization.
pub fn update_endpoint(
config: &mut Config,
name: &str,
updater: SmtpConfigUpdater,
private_endpoint_config_updater: SmtpPrivateConfigUpdater,
+ oauth2_refresh_token: Option<String>,
delete: Option<&[DeleteableSmtpProperty]>,
digest: Option<&[u8]>,
) -> Result<(), HttpError> {
@@ -103,20 +141,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 +177,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 +202,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 +236,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 +246,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 +259,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 +268,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 +301,7 @@ pub mod tests {
Default::default(),
None,
None,
+ None,
)
.is_err());
@@ -273,6 +319,7 @@ pub mod tests {
Default::default(),
Default::default(),
None,
+ None,
Some(&[0; 32]),
)
.is_err());
@@ -304,6 +351,7 @@ pub mod tests {
},
Default::default(),
None,
+ None,
Some(&digest),
)?;
@@ -327,6 +375,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..19b97113 100644
--- a/proxmox-notify/src/endpoints/smtp.rs
+++ b/proxmox-notify/src/endpoints/smtp.rs
@@ -83,11 +83,20 @@ pub struct SmtpConfig {
pub port: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mode: Option<SmtpMode>,
+ /// Method to be used for authentication.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub auth_method: Option<SmtpAuthMethod>,
/// 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<String>,
+ /// Client ID for XOAUTH2 authentication method.
+ /// If set to `None`, no authentication will be performed.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub oauth2_client_id: Option<String>,
+ /// 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<String>,
/// 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 +143,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 +186,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<String>,
+
+ /// OAuth2 client secret
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub oauth2_client_secret: Option<String>,
}
/// A sendmail notification endpoint.
--
2.47.3
^ permalink raw reply related [flat|nested] 28+ messages in thread* [PATCH proxmox v5 06/27] notify: smtp: add API to exchange authorization code for refresh token
2026-05-05 8:32 [PATCH docs/manager/proxmox{,-perl-rs,-widget-toolkit,-backup} v5 00/27] fix #7238: Add XOAUTH2 authentication support for SMTP notification targets Arthur Bied-Charreton
` (4 preceding siblings ...)
2026-05-05 8:32 ` [PATCH proxmox v5 05/27] notify: smtp: update API with OAuth2 parameters Arthur Bied-Charreton
@ 2026-05-05 8:32 ` Arthur Bied-Charreton
2026-05-05 8:32 ` [PATCH proxmox v5 07/27] notify: smtp: infer auth method for backwards compatibility Arthur Bied-Charreton
` (20 subsequent siblings)
26 siblings, 0 replies; 28+ messages in thread
From: Arthur Bied-Charreton @ 2026-05-05 8:32 UTC (permalink / raw)
To: pve-devel, pbs-devel
Expose a function in SMTP API to exchange the initial authorization
code for a refresh token, which can then be used by proxmox-notify to
keep requesting access tokens without requiring the user to
re-authorize.
Azure AD's "Web" client type rejects browser-originated token requests,
so this must run on the backend.
Signed-off-by: Arthur Bied-Charreton <a.bied-charreton@proxmox.com>
---
proxmox-notify/src/api/smtp.rs | 48 +++++++++++++
proxmox-notify/src/endpoints/smtp.rs | 2 +-
proxmox-notify/src/endpoints/smtp/xoauth2.rs | 73 +++++++++++++++++++-
3 files changed, 120 insertions(+), 3 deletions(-)
diff --git a/proxmox-notify/src/api/smtp.rs b/proxmox-notify/src/api/smtp.rs
index d1482047..1797b8f8 100644
--- a/proxmox-notify/src/api/smtp.rs
+++ b/proxmox-notify/src/api/smtp.rs
@@ -1,8 +1,10 @@
+use oauth2::{AuthorizationCode, ClientId, ClientSecret, RedirectUrl};
use proxmox_http_error::HttpError;
use crate::api::{http_bail, http_err};
use crate::context::context;
use crate::endpoints::smtp::{
+ xoauth2::{exchange_google_code, exchange_microsoft_code},
DeleteableSmtpProperty, SmtpAuthMethod, SmtpConfig, SmtpConfigUpdater, SmtpPrivateConfig,
SmtpPrivateConfigUpdater, State, SMTP_TYPENAME,
};
@@ -223,6 +225,52 @@ pub fn update_endpoint(
})
}
+/// Exchange an OAuth2 authorization code for a refresh token.
+///
+/// `auth_method` selects the provider; `tenant_id` is required iff
+/// `auth_method == MicrosoftOAuth2`.
+///
+/// The exchange runs server-side because Azure AD's "Web" client type rejects
+/// browser-originated token requests.
+pub fn exchange_oauth2_code(
+ auth_method: SmtpAuthMethod,
+ client_id: String,
+ client_secret: String,
+ tenant_id: Option<String>,
+ authorization_code: String,
+ redirect_uri: String,
+) -> Result<String, HttpError> {
+ let client_id = ClientId::new(client_id);
+ let client_secret = ClientSecret::new(client_secret);
+ let code = AuthorizationCode::new(authorization_code);
+ let redirect_uri = RedirectUrl::new(redirect_uri)
+ .map_err(|e| http_err!(BAD_REQUEST, "invalid redirect-uri: {e}"))?;
+
+ match auth_method {
+ SmtpAuthMethod::GoogleOAuth2 => {
+ exchange_google_code(client_id, client_secret, code, redirect_uri)
+ .map_err(|e| http_err!(BAD_REQUEST, "{e}"))
+ }
+ SmtpAuthMethod::MicrosoftOAuth2 => {
+ let tenant = tenant_id.as_deref().ok_or_else(|| {
+ http_err!(BAD_REQUEST, "tenant-id is required for Microsoft OAuth2")
+ })?;
+ if tenant.is_empty()
+ || !tenant
+ .chars()
+ .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.')
+ {
+ http_bail!(BAD_REQUEST, "invalid tenant-id");
+ }
+ exchange_microsoft_code(client_id, client_secret, tenant, code, redirect_uri)
+ .map_err(|e| http_err!(BAD_REQUEST, "{e}"))
+ }
+ SmtpAuthMethod::Plain => {
+ http_bail!(BAD_REQUEST, "auth-method must be an OAuth2 method");
+ }
+ }
+}
+
/// Delete existing smtp endpoint
///
/// The caller is responsible for any needed permission checks.
diff --git a/proxmox-notify/src/endpoints/smtp.rs b/proxmox-notify/src/endpoints/smtp.rs
index 19b97113..74297969 100644
--- a/proxmox-notify/src/endpoints/smtp.rs
+++ b/proxmox-notify/src/endpoints/smtp.rs
@@ -23,7 +23,7 @@ const SMTP_SUBMISSION_STARTTLS_PORT: u16 = 587;
const SMTP_SUBMISSION_TLS_PORT: u16 = 465;
const SMTP_TIMEOUT: u16 = 5;
-mod xoauth2;
+pub(crate) mod xoauth2;
pub use xoauth2::State;
diff --git a/proxmox-notify/src/endpoints/smtp/xoauth2.rs b/proxmox-notify/src/endpoints/smtp/xoauth2.rs
index 78d90d24..095f7aae 100644
--- a/proxmox-notify/src/endpoints/smtp/xoauth2.rs
+++ b/proxmox-notify/src/endpoints/smtp/xoauth2.rs
@@ -1,8 +1,8 @@
use std::path::Path;
use oauth2::{
- basic::BasicClient, AccessToken, AuthUrl, ClientId, ClientSecret, RefreshToken, TokenResponse,
- TokenUrl,
+ basic::BasicClient, AccessToken, AuthUrl, AuthorizationCode, ClientId, ClientSecret,
+ RedirectUrl, RefreshToken, TokenResponse, TokenUrl,
};
use proxmox_http::{HttpOptions, ProxyConfig};
use serde::{Deserialize, Serialize};
@@ -206,6 +206,45 @@ pub(crate) fn get_microsoft_token(
})
}
+/// Exchange a Microsoft OAuth2 authorization code for a refresh token.
+///
+/// Azure AD's "Web" client-type rejects browser-originated token requests
+/// so this MUST run server-side regardless of where the auth popup was
+/// opened from.
+pub(crate) fn exchange_microsoft_code(
+ client_id: ClientId,
+ client_secret: ClientSecret,
+ tenant_id: &str,
+ code: AuthorizationCode,
+ redirect_uri: RedirectUrl,
+) -> Result<String, Error> {
+ let client = BasicClient::new(client_id)
+ .set_client_secret(client_secret)
+ .set_auth_uri(
+ AuthUrl::new(format!(
+ "https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/authorize"
+ ))
+ .map_err(|e| Error::Generic(format!("invalid auth URL: {e}")))?,
+ )
+ .set_token_uri(
+ TokenUrl::new(format!(
+ "https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token"
+ ))
+ .map_err(|e| Error::Generic(format!("invalid token URL: {e}")))?,
+ )
+ .set_redirect_uri(redirect_uri);
+
+ let token_result = client
+ .exchange_code(code)
+ .request(&SyncHttpClient::new()?)
+ .map_err(|e| Error::Generic(format!("OAuth2 token exchange failed: {e}")))?;
+
+ token_result
+ .refresh_token()
+ .map(|t| t.secret().to_owned())
+ .ok_or_else(|| Error::Generic("token endpoint did not return a refresh token".into()))
+}
+
/// Perform a Google OAuth2 token exchange.
///
/// Google refresh tokens' TTL is extended at every use. As long as
@@ -239,3 +278,33 @@ pub(crate) fn get_google_token(
refresh_token: token_result.refresh_token().cloned(),
})
}
+
+/// Exchange a Google OAuth2 authorization code for a refresh token.
+pub(crate) fn exchange_google_code(
+ client_id: ClientId,
+ client_secret: ClientSecret,
+ code: AuthorizationCode,
+ redirect_uri: RedirectUrl,
+) -> Result<String, Error> {
+ let client = BasicClient::new(client_id)
+ .set_client_secret(client_secret)
+ .set_auth_uri(
+ AuthUrl::new("https://accounts.google.com/o/oauth2/v2/auth".into())
+ .map_err(|e| Error::Generic(format!("invalid auth URL: {e}")))?,
+ )
+ .set_token_uri(
+ TokenUrl::new("https://oauth2.googleapis.com/token".into())
+ .map_err(|e| Error::Generic(format!("invalid token URL: {e}")))?,
+ )
+ .set_redirect_uri(redirect_uri);
+
+ let token_result = client
+ .exchange_code(code)
+ .request(&SyncHttpClient::new()?)
+ .map_err(|e| Error::Generic(format!("OAuth2 token exchange failed: {e}")))?;
+
+ token_result
+ .refresh_token()
+ .map(|t| t.secret().to_owned())
+ .ok_or_else(|| Error::Generic("token endpoint did not return a refresh token".into()))
+}
--
2.47.3
^ permalink raw reply related [flat|nested] 28+ messages in thread* [PATCH proxmox v5 07/27] notify: smtp: infer auth method for backwards compatibility
2026-05-05 8:32 [PATCH docs/manager/proxmox{,-perl-rs,-widget-toolkit,-backup} v5 00/27] fix #7238: Add XOAUTH2 authentication support for SMTP notification targets Arthur Bied-Charreton
` (5 preceding siblings ...)
2026-05-05 8:32 ` [PATCH proxmox v5 06/27] notify: smtp: add API to exchange authorization code for refresh token Arthur Bied-Charreton
@ 2026-05-05 8:32 ` Arthur Bied-Charreton
2026-05-05 8:32 ` [PATCH proxmox v5 08/27] notify: smtp: add state handling logic Arthur Bied-Charreton
` (19 subsequent siblings)
26 siblings, 0 replies; 28+ messages in thread
From: Arthur Bied-Charreton @ 2026-05-05 8:32 UTC (permalink / raw)
To: pve-devel, pbs-devel
If not set, infer the auth_method SmtpConfig field from the presence
of a password in the config. This makes sure the new API stays
backwards-compatible with old scripts and updates old configurations
when they are edited.
Signed-off-by: Arthur Bied-Charreton <a.bied-charreton@proxmox.com>
---
proxmox-notify/src/api/smtp.rs | 22 +++++++++++++++++++++-
1 file changed, 21 insertions(+), 1 deletion(-)
diff --git a/proxmox-notify/src/api/smtp.rs b/proxmox-notify/src/api/smtp.rs
index 1797b8f8..0668e89d 100644
--- a/proxmox-notify/src/api/smtp.rs
+++ b/proxmox-notify/src/api/smtp.rs
@@ -58,6 +58,22 @@ fn get_private_config(config: &Config, name: &str) -> Result<SmtpPrivateConfig,
})
}
+/// Before the `auth_method` field was introduced into [`SmtpConfig`], the authentication method
+/// was inferred from the presence of a password in the config.
+///
+/// Infer it in the same way, returning a new [`SmtpConfig`] object that can then be persisted
+/// to migrate old configs.
+fn infer_auth_method(config: SmtpConfig, private_config: SmtpPrivateConfig) -> SmtpConfig {
+ if config.auth_method.is_none() && private_config.password.is_some() {
+ SmtpConfig {
+ auth_method: Some(SmtpAuthMethod::Plain),
+ ..config
+ }
+ } else {
+ config
+ }
+}
+
/// Add a new smtp endpoint.
///
/// The caller is responsible for any needed permission checks.
@@ -93,11 +109,13 @@ pub fn add_endpoint(
super::set_private_config_entry(
config,
- private_endpoint_config,
+ &private_endpoint_config,
SMTP_TYPENAME,
&endpoint_config.name,
)?;
+ let endpoint_config = infer_auth_method(endpoint_config, private_endpoint_config);
+
config
.config
.set_data(&endpoint_config.name, SMTP_TYPENAME, &endpoint_config)
@@ -213,6 +231,8 @@ pub fn update_endpoint(
}
})?;
+ let endpoint = infer_auth_method(endpoint, get_private_config(config, name)?);
+
config
.config
.set_data(name, SMTP_TYPENAME, &endpoint)
--
2.47.3
^ permalink raw reply related [flat|nested] 28+ messages in thread* [PATCH proxmox v5 08/27] notify: smtp: add state handling logic
2026-05-05 8:32 [PATCH docs/manager/proxmox{,-perl-rs,-widget-toolkit,-backup} v5 00/27] fix #7238: Add XOAUTH2 authentication support for SMTP notification targets Arthur Bied-Charreton
` (6 preceding siblings ...)
2026-05-05 8:32 ` [PATCH proxmox v5 07/27] notify: smtp: infer auth method for backwards compatibility Arthur Bied-Charreton
@ 2026-05-05 8:32 ` Arthur Bied-Charreton
2026-05-05 8:32 ` [PATCH proxmox v5 09/27] notify: smtp: add XOAUTH2 authentication support Arthur Bied-Charreton
` (18 subsequent siblings)
26 siblings, 0 replies; 28+ messages in thread
From: Arthur Bied-Charreton @ 2026-05-05 8:32 UTC (permalink / raw)
To: pve-devel, pbs-devel
Create new state file in add_endpoint, create/update existing one in
update_endpoint and delete it in delete_endpoint.
Add trigger_state_refresh to the Endpoint trait, with no-op default
implementation. Implement it in SmtpEndpoint's Endpoint impl to trigger
an OAuth2 token exchange, in order to rotate an existing token, or
extend its lifetime.
The intended callers are daily update jobs, so on a cluster
`trigger_state_refresh` may be invoked once per node within a
relatively short window. The `last_refreshed` cutoff suppresses
redundant token exchanges.
Signed-off-by: Arthur Bied-Charreton <a.bied-charreton@proxmox.com>
Reviewed-by: Lukas Wagner <l.wagner@proxmox.com>
---
proxmox-notify/src/api/common.rs | 19 +++++++
proxmox-notify/src/api/smtp.rs | 32 +++++++++++
proxmox-notify/src/endpoints/smtp.rs | 80 +++++++++++++++++++++++++++-
proxmox-notify/src/lib.rs | 28 +++++++++-
4 files changed, 157 insertions(+), 2 deletions(-)
diff --git a/proxmox-notify/src/api/common.rs b/proxmox-notify/src/api/common.rs
index fa2356e2..220be3de 100644
--- a/proxmox-notify/src/api/common.rs
+++ b/proxmox-notify/src/api/common.rs
@@ -3,6 +3,25 @@ use proxmox_http_error::HttpError;
use super::http_err;
use crate::{Bus, Config, Notification};
+/// Refresh all notification targets' internal state.
+///
+/// The caller is responsible for any needed permission checks and must hold a lock
+/// on the notifications config. Concurrent updates could otherwise leave the state
+/// files inconsistent with the notifications config (e.g. a stale state file for
+/// an endpoint that was deleted concurrently).
+pub fn trigger_state_refresh(config: &Config) -> Result<(), HttpError> {
+ let bus = Bus::from_config(config).map_err(|err| {
+ http_err!(
+ INTERNAL_SERVER_ERROR,
+ "Could not instantiate notification bus: {err}"
+ )
+ })?;
+
+ bus.trigger_state_refresh();
+
+ Ok(())
+}
+
/// Send a notification to a given target.
///
/// The caller is responsible for any needed permission checks.
diff --git a/proxmox-notify/src/api/smtp.rs b/proxmox-notify/src/api/smtp.rs
index 0668e89d..24d3d865 100644
--- a/proxmox-notify/src/api/smtp.rs
+++ b/proxmox-notify/src/api/smtp.rs
@@ -116,6 +116,19 @@ pub fn add_endpoint(
let endpoint_config = infer_auth_method(endpoint_config, private_endpoint_config);
+ if let Some(token) = oauth2_refresh_token {
+ let oauth_state = State::new(token, proxmox_time::epoch_i64());
+ context()
+ .save_oauth_state(&endpoint_config.name, Some(oauth_state))
+ .map_err(|e| {
+ http_err!(
+ INTERNAL_SERVER_ERROR,
+ "could not create state file for '{}': {e}",
+ &endpoint_config.name
+ )
+ })?;
+ }
+
config
.config
.set_data(&endpoint_config.name, SMTP_TYPENAME, &endpoint_config)
@@ -233,6 +246,18 @@ pub fn update_endpoint(
let endpoint = infer_auth_method(endpoint, get_private_config(config, name)?);
+ if let Some(token) = oauth2_refresh_token {
+ let oauth_state = context()
+ .load_oauth_state(name)
+ .map_err(|e| http_err!(INTERNAL_SERVER_ERROR, "{e}"))?
+ .set_oauth2_refresh_token(Some(token))
+ .set_last_refreshed(proxmox_time::epoch_i64());
+
+ context()
+ .save_oauth_state(name, Some(oauth_state))
+ .map_err(|e| http_err!(INTERNAL_SERVER_ERROR, "{e}"))?;
+ }
+
config
.config
.set_data(name, SMTP_TYPENAME, &endpoint)
@@ -305,6 +330,13 @@ pub fn delete_endpoint(config: &mut Config, name: &str) -> Result<(), HttpError>
super::remove_private_config_entry(config, name)?;
+ context().save_oauth_state(name, None).map_err(|e| {
+ http_err!(
+ INTERNAL_SERVER_ERROR,
+ "could not delete state for '{name}': {e}"
+ )
+ })?;
+
config.config.sections.remove(name);
Ok(())
diff --git a/proxmox-notify/src/endpoints/smtp.rs b/proxmox-notify/src/endpoints/smtp.rs
index 74297969..dcc01bb5 100644
--- a/proxmox-notify/src/endpoints/smtp.rs
+++ b/proxmox-notify/src/endpoints/smtp.rs
@@ -1,11 +1,13 @@
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::client::{Tls, TlsParameters};
use lettre::{message::header::ContentType, Message, SmtpTransport, Transport};
+use oauth2::{ClientId, ClientSecret, RefreshToken};
use serde::{Deserialize, Serialize};
+use tracing::info;
use proxmox_schema::api_types::COMMENT_SCHEMA;
use proxmox_schema::{api, Updater};
@@ -22,6 +24,7 @@ const SMTP_PORT: u16 = 25;
const SMTP_SUBMISSION_STARTTLS_PORT: u16 = 587;
const SMTP_SUBMISSION_TLS_PORT: u16 = 465;
const SMTP_TIMEOUT: u16 = 5;
+const SMTP_STATE_REFRESH_CUTOFF: Duration = Duration::from_secs(60 * 60 * 12);
pub(crate) mod xoauth2;
@@ -203,6 +206,43 @@ pub struct SmtpEndpoint {
}
impl SmtpEndpoint {
+ fn get_access_token(
+ &self,
+ refresh_token: &str,
+ auth_method: &SmtpAuthMethod,
+ ) -> Result<xoauth2::TokenExchangeResult, Error> {
+ 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())),
+ }
+ }
+
fn build_transport(&self, tls: Tls, port: u16) -> Result<SmtpTransport, Error> {
let mut transport_builder = SmtpTransport::builder_dangerous(&self.config.server)
.tls(tls)
@@ -334,6 +374,44 @@ impl Endpoint for SmtpEndpoint {
fn disabled(&self) -> bool {
self.config.disable.unwrap_or_default()
}
+
+ fn trigger_state_refresh(&self) -> Result<(), Error> {
+ let state = context().load_oauth_state(self.name())?;
+
+ let Some(refresh_token) = &state.oauth2_refresh_token else {
+ return Ok(());
+ };
+
+ // The intended callers are daily update jobs, so on a PVE cluster this function
+ // may be called once per node in a relatively short window. This cutoff prevents
+ // unnecessary token exchanges.
+ if SystemTime::now()
+ .duration_since(UNIX_EPOCH + Duration::from_secs(state.last_refreshed as u64))
+ .map_err(|e| Error::Generic(e.to_string()))?
+ < SMTP_STATE_REFRESH_CUTOFF
+ {
+ return Ok(());
+ }
+
+ let Some(auth_method) = self.config.auth_method.as_ref() else {
+ return Ok(());
+ };
+
+ let state = match self
+ .get_access_token(refresh_token, auth_method)?
+ .refresh_token
+ {
+ Some(tok) => state.set_oauth2_refresh_token(Some(tok.into_secret())), // New token was returned, rotate
+ None => state,
+ }
+ .set_last_refreshed(proxmox_time::epoch_i64());
+
+ context().save_oauth_state(self.name(), Some(state))?;
+
+ info!("OAuth2 state refreshed for endpoint `{}`", self.name());
+
+ Ok(())
+ }
}
/// Construct a lettre `Message` from a raw email message.
diff --git a/proxmox-notify/src/lib.rs b/proxmox-notify/src/lib.rs
index 619dd7db..ee4ffcd3 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;
@@ -169,6 +169,15 @@ pub trait Endpoint {
/// Check if the endpoint is disabled
fn disabled(&self) -> bool;
+
+ /// Refresh endpoint's state.
+ ///
+ /// Implementations may persist endpoint-internal state (e.g. SMTP XOAUTH2 refresh
+ /// tokens). Callers must hold the notifications config lock so that state files
+ /// cannot drift relative to concurrently edited/deleted configs.
+ fn trigger_state_refresh(&self) -> Result<(), Error> {
+ Ok(())
+ }
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -605,6 +614,23 @@ impl Bus {
Ok(())
}
+
+ /// Refresh all endpoints' internal state.
+ ///
+ /// The caller must hold a lock to the notifications config, see
+ /// [`Endpoint::trigger_state_refresh`]'s docs.
+ ///
+ /// This function works on a best effort basis, if an endpoint's state cannot
+ /// be updated for whatever reason, the error is logged and the next one(s)
+ /// are attempted.
+ pub fn trigger_state_refresh(&self) {
+ for (name, endpoint) in &self.endpoints {
+ match endpoint.trigger_state_refresh() {
+ Ok(()) => debug!("triggered state refresh for endpoint '{name}'"),
+ Err(e) => error!("could not trigger state refresh for endpoint '{name}': {e}"),
+ };
+ }
+ }
}
#[cfg(test)]
--
2.47.3
^ permalink raw reply related [flat|nested] 28+ messages in thread* [PATCH proxmox v5 09/27] notify: smtp: add XOAUTH2 authentication support
2026-05-05 8:32 [PATCH docs/manager/proxmox{,-perl-rs,-widget-toolkit,-backup} v5 00/27] fix #7238: Add XOAUTH2 authentication support for SMTP notification targets Arthur Bied-Charreton
` (7 preceding siblings ...)
2026-05-05 8:32 ` [PATCH proxmox v5 08/27] notify: smtp: add state handling logic Arthur Bied-Charreton
@ 2026-05-05 8:32 ` Arthur Bied-Charreton
2026-05-05 8:32 ` [PATCH proxmox-perl-rs v5 10/27] pve-rs: notify: smtp: add OAuth2 parameters to bindings Arthur Bied-Charreton
` (17 subsequent siblings)
26 siblings, 0 replies; 28+ messages in thread
From: Arthur Bied-Charreton @ 2026-05-05 8:32 UTC (permalink / raw)
To: pve-devel, pbs-devel
Extend the transport building logic to authenticate via XOAUTH2 if
configured.
Previously, the authentication method was determined based on the
presence of a password in the config. In order to ensure backwards
compatibility with older configurations, continue to do so if
auth_method is not set, ensuring configurations that used PLAIN auth
before this series keep working as expected.
Signed-off-by: Arthur Bied-Charreton <a.bied-charreton@proxmox.com>
---
proxmox-notify/src/endpoints/smtp.rs | 87 +++++++++++++++++++++++-----
1 file changed, 74 insertions(+), 13 deletions(-)
diff --git a/proxmox-notify/src/endpoints/smtp.rs b/proxmox-notify/src/endpoints/smtp.rs
index dcc01bb5..353fe4cd 100644
--- a/proxmox-notify/src/endpoints/smtp.rs
+++ b/proxmox-notify/src/endpoints/smtp.rs
@@ -3,11 +3,12 @@ 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 oauth2::{ClientId, ClientSecret, RefreshToken};
use serde::{Deserialize, Serialize};
-use tracing::info;
+use tracing::{debug, info};
use proxmox_schema::api_types::COMMENT_SCHEMA;
use proxmox_schema::{api, Updater};
@@ -199,7 +200,7 @@ pub struct SmtpPrivateConfig {
pub oauth2_client_secret: Option<String>,
}
-/// A sendmail notification endpoint.
+/// A SMTP notification endpoint.
pub struct SmtpEndpoint {
pub config: SmtpConfig,
pub private_config: SmtpPrivateConfig,
@@ -243,22 +244,82 @@ impl SmtpEndpoint {
}
}
+ /// 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<SmtpAuthMethod> {
+ self.config.auth_method.or_else(|| {
+ if self.private_config.password.is_some() {
+ Some(SmtpAuthMethod::Plain)
+ } else {
+ None
+ }
+ })
+ }
+
+ /// Build an [`SmtpTransport`] for [`Endpoint::send`].
+ ///
+ /// For OAuth2 auth methods this loads the refresh token from the per-endpoint state file
+ /// and performs a token exchange at every call. It never persists a rotated token, that
+ /// responsibility is on [`Self::trigger_state_refresh`], which is called under a notifications
+ /// config lock.
fn build_transport(&self, tls: Tls, port: u16) -> Result<SmtpTransport, Error> {
- 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 state = context().load_oauth_state(self.name())?;
+
+ let refresh_token =
+ state
+ .oauth2_refresh_token
+ .clone()
+ .ok_or(Error::NotifyFailed(
+ self.name().into(),
+ Box::new(Error::Generic("no refresh token found".into())),
+ ))?;
+
+ debug!(
+ "requesting OAuth2 access token for endpoint '{}'",
+ self.config.name
+ );
+
+ let token_exchange_result = self.get_access_token(&refresh_token, method)?;
+
+ debug!(
+ "OAuth2 token exchange successful for endpoint '{}'",
+ self.config.name
+ );
+
+ transport_builder
+ .credentials(Credentials::new(
+ self.config.from_address.to_owned(),
+ token_exchange_result.access_token.into_secret(),
+ ))
+ .authentication(vec![Mechanism::Xoauth2])
}
};
--
2.47.3
^ permalink raw reply related [flat|nested] 28+ messages in thread* [PATCH proxmox-perl-rs v5 10/27] pve-rs: notify: smtp: add OAuth2 parameters to bindings
2026-05-05 8:32 [PATCH docs/manager/proxmox{,-perl-rs,-widget-toolkit,-backup} v5 00/27] fix #7238: Add XOAUTH2 authentication support for SMTP notification targets Arthur Bied-Charreton
` (8 preceding siblings ...)
2026-05-05 8:32 ` [PATCH proxmox v5 09/27] notify: smtp: add XOAUTH2 authentication support Arthur Bied-Charreton
@ 2026-05-05 8:32 ` Arthur Bied-Charreton
2026-05-05 8:32 ` [PATCH proxmox-perl-rs v5 11/27] pve-rs: notify: add binding for triggering state refresh Arthur Bied-Charreton
` (16 subsequent siblings)
26 siblings, 0 replies; 28+ messages in thread
From: Arthur Bied-Charreton @ 2026-05-05 8:32 UTC (permalink / raw)
To: pve-devel, pbs-devel
Update the proxmox-notify SMTP API bindings with the OAuth2 parameters.
While touching this code, it made sense to change the bindings'
signatures to take the whole config hashes, as opposed to an ever
growing list of single parameters.
This has the advantage of reducing churn in the bindings when new fields
are added to the SmtpConfig structs, as well as getting rid of possible
errors that could occur due to passing parameters in the wrong order
from Perl code.
Note that this is a breaking change to the internal API, the calling
code in pve-manager needs to be updated along with these bindings.
`oauth2_refresh_token` is passed as a standalone parameter, rather than
as part of SmtpPrivateConfig because it lives in a per-endpoint state
file that proxmox-notify manages, not in the notifications config.
Signed-off-by: Arthur Bied-Charreton <a.bied-charreton@proxmox.com>
Reviewed-by: Lukas Wagner <l.wagner@proxmox.com>
---
common/src/bindings/notify.rs | 65 +++++++----------------------------
1 file changed, 13 insertions(+), 52 deletions(-)
diff --git a/common/src/bindings/notify.rs b/common/src/bindings/notify.rs
index 409270a..ff1e6cf 100644
--- a/common/src/bindings/notify.rs
+++ b/common/src/bindings/notify.rs
@@ -26,7 +26,7 @@ pub mod proxmox_rs_notify {
DeleteableSendmailProperty, SendmailConfig, SendmailConfigUpdater,
};
use proxmox_notify::endpoints::smtp::{
- DeleteableSmtpProperty, SmtpConfig, SmtpConfigUpdater, SmtpMode, SmtpPrivateConfig,
+ DeleteableSmtpProperty, SmtpConfig, SmtpConfigUpdater, SmtpPrivateConfig,
SmtpPrivateConfigUpdater,
};
use proxmox_notify::endpoints::webhook::{
@@ -390,37 +390,16 @@ pub mod proxmox_rs_notify {
#[allow(clippy::too_many_arguments)]
pub fn add_smtp_endpoint(
#[try_from_ref] this: &NotificationConfig,
- name: String,
- server: String,
- port: Option<u16>,
- mode: Option<SmtpMode>,
- username: Option<String>,
- password: Option<String>,
- mailto: Option<Vec<String>>,
- mailto_user: Option<Vec<String>>,
- from_address: String,
- author: Option<String>,
- comment: Option<String>,
- disable: Option<bool>,
+ smtp_config: SmtpConfig,
+ smtp_private_config: SmtpPrivateConfig,
+ oauth2_refresh_token: Option<String>,
) -> Result<(), HttpError> {
let mut config = this.config.lock().unwrap();
api::smtp::add_endpoint(
&mut config,
- SmtpConfig {
- name: name.clone(),
- server,
- port,
- mode,
- username,
- mailto: mailto.unwrap_or_default(),
- mailto_user: mailto_user.unwrap_or_default(),
- from_address,
- author,
- comment,
- disable,
- origin: None,
- },
- SmtpPrivateConfig { name, password },
+ smtp_config,
+ smtp_private_config,
+ oauth2_refresh_token,
)
}
@@ -432,17 +411,9 @@ pub mod proxmox_rs_notify {
pub fn update_smtp_endpoint(
#[try_from_ref] this: &NotificationConfig,
name: &str,
- server: Option<String>,
- port: Option<u16>,
- mode: Option<SmtpMode>,
- username: Option<String>,
- password: Option<String>,
- mailto: Option<Vec<String>>,
- mailto_user: Option<Vec<String>>,
- from_address: Option<String>,
- author: Option<String>,
- comment: Option<String>,
- disable: Option<bool>,
+ smtp_config_updater: SmtpConfigUpdater,
+ smtp_private_config_updater: SmtpPrivateConfigUpdater,
+ oauth2_refresh_token: Option<String>,
delete: Option<Vec<DeleteableSmtpProperty>>,
digest: Option<&str>,
) -> Result<(), HttpError> {
@@ -452,19 +423,9 @@ pub mod proxmox_rs_notify {
api::smtp::update_endpoint(
&mut config,
name,
- SmtpConfigUpdater {
- server,
- port,
- mode,
- username,
- mailto,
- mailto_user,
- from_address,
- author,
- comment,
- disable,
- },
- SmtpPrivateConfigUpdater { password },
+ smtp_config_updater,
+ smtp_private_config_updater,
+ oauth2_refresh_token,
delete.as_deref(),
digest.as_deref(),
)
--
2.47.3
^ permalink raw reply related [flat|nested] 28+ messages in thread* [PATCH proxmox-perl-rs v5 11/27] pve-rs: notify: add binding for triggering state refresh
2026-05-05 8:32 [PATCH docs/manager/proxmox{,-perl-rs,-widget-toolkit,-backup} v5 00/27] fix #7238: Add XOAUTH2 authentication support for SMTP notification targets Arthur Bied-Charreton
` (9 preceding siblings ...)
2026-05-05 8:32 ` [PATCH proxmox-perl-rs v5 10/27] pve-rs: notify: smtp: add OAuth2 parameters to bindings Arthur Bied-Charreton
@ 2026-05-05 8:32 ` Arthur Bied-Charreton
2026-05-05 8:32 ` [PATCH proxmox-perl-rs v5 12/27] pve-rs: notify: add binding for initial OAuth2 refresh token exchange Arthur Bied-Charreton
` (15 subsequent siblings)
26 siblings, 0 replies; 28+ messages in thread
From: Arthur Bied-Charreton @ 2026-05-05 8:32 UTC (permalink / raw)
To: pve-devel, pbs-devel
Expose the new `trigger_state_refresh` proxmox-notify API to allow
update jobs to trigger a refresh of the crate's internal state.
This currently only serves the purpose of preventing unused OAuth2 refresh
tokens from expiring.
Signed-off-by: Arthur Bied-Charreton <a.bied-charreton@proxmox.com>
Reviewed-by: Lukas Wagner <l.wagner@proxmox.com>
---
common/src/bindings/notify.rs | 14 ++++++++++++++
1 file changed, 14 insertions(+)
diff --git a/common/src/bindings/notify.rs b/common/src/bindings/notify.rs
index ff1e6cf..8316013 100644
--- a/common/src/bindings/notify.rs
+++ b/common/src/bindings/notify.rs
@@ -141,6 +141,20 @@ pub mod proxmox_rs_notify {
api::common::send(&config, ¬ification)
}
+ /// Method: Refresh the state for all endpoints.
+ ///
+ /// This iterates through all configured targets, refreshing their state if needed.
+ /// The caller is responsible for locking the notifications config.
+ ///
+ /// See [`api::common::trigger_state_refresh`]
+ #[export(serialize_error)]
+ pub fn trigger_state_refresh(
+ #[try_from_ref] this: &NotificationConfig,
+ ) -> Result<(), HttpError> {
+ let config = this.config.lock().unwrap();
+ api::common::trigger_state_refresh(&config)
+ }
+
/// Method: Get a list of all notification targets.
///
/// See [`api::get_targets`].
--
2.47.3
^ permalink raw reply related [flat|nested] 28+ messages in thread* [PATCH proxmox-perl-rs v5 12/27] pve-rs: notify: add binding for initial OAuth2 refresh token exchange
2026-05-05 8:32 [PATCH docs/manager/proxmox{,-perl-rs,-widget-toolkit,-backup} v5 00/27] fix #7238: Add XOAUTH2 authentication support for SMTP notification targets Arthur Bied-Charreton
` (10 preceding siblings ...)
2026-05-05 8:32 ` [PATCH proxmox-perl-rs v5 11/27] pve-rs: notify: add binding for triggering state refresh Arthur Bied-Charreton
@ 2026-05-05 8:32 ` Arthur Bied-Charreton
2026-05-05 8:32 ` [PATCH proxmox-widget-toolkit v5 13/27] utils: add OAuth2 flow handlers Arthur Bied-Charreton
` (14 subsequent siblings)
26 siblings, 0 replies; 28+ messages in thread
From: Arthur Bied-Charreton @ 2026-05-05 8:32 UTC (permalink / raw)
To: pve-devel, pbs-devel
Expose the exchange_oauth2_code function from proxmox-notify's SMTP API.
This will allow PVE to expose it as an endpoint, which is needed for
the initial OAuth2 refresh token exchange that has to run in the
backend.
Signed-off-by: Arthur Bied-Charreton <a.bied-charreton@proxmox.com>
---
common/src/bindings/notify.rs | 25 ++++++++++++++++++++++++-
1 file changed, 24 insertions(+), 1 deletion(-)
diff --git a/common/src/bindings/notify.rs b/common/src/bindings/notify.rs
index 8316013..a0f79e4 100644
--- a/common/src/bindings/notify.rs
+++ b/common/src/bindings/notify.rs
@@ -26,7 +26,7 @@ pub mod proxmox_rs_notify {
DeleteableSendmailProperty, SendmailConfig, SendmailConfigUpdater,
};
use proxmox_notify::endpoints::smtp::{
- DeleteableSmtpProperty, SmtpConfig, SmtpConfigUpdater, SmtpPrivateConfig,
+ DeleteableSmtpProperty, SmtpAuthMethod, SmtpConfig, SmtpConfigUpdater, SmtpPrivateConfig,
SmtpPrivateConfigUpdater,
};
use proxmox_notify::endpoints::webhook::{
@@ -445,6 +445,29 @@ pub mod proxmox_rs_notify {
)
}
+ /// Method: Exchange an OAuth2 authorization code for a refresh token.
+ ///
+ /// See [`api::smtp::exchange_oauth2_code`]
+ #[export(serialize_error)]
+ pub fn exchange_smtp_oauth2_code(
+ #[try_from_ref] _this: &NotificationConfig,
+ auth_method: SmtpAuthMethod,
+ client_id: String,
+ client_secret: String,
+ tenant_id: Option<String>,
+ authorization_code: String,
+ redirect_uri: String,
+ ) -> Result<String, HttpError> {
+ api::smtp::exchange_oauth2_code(
+ auth_method,
+ client_id,
+ client_secret,
+ tenant_id,
+ authorization_code,
+ redirect_uri,
+ )
+ }
+
/// Method: Delete an SMTP endpoint.
///
/// See [`api::smtp::delete_endpoint`].
--
2.47.3
^ permalink raw reply related [flat|nested] 28+ messages in thread* [PATCH proxmox-widget-toolkit v5 13/27] utils: add OAuth2 flow handlers
2026-05-05 8:32 [PATCH docs/manager/proxmox{,-perl-rs,-widget-toolkit,-backup} v5 00/27] fix #7238: Add XOAUTH2 authentication support for SMTP notification targets Arthur Bied-Charreton
` (11 preceding siblings ...)
2026-05-05 8:32 ` [PATCH proxmox-perl-rs v5 12/27] pve-rs: notify: add binding for initial OAuth2 refresh token exchange Arthur Bied-Charreton
@ 2026-05-05 8:32 ` Arthur Bied-Charreton
2026-05-05 8:32 ` [PATCH proxmox-widget-toolkit v5 14/27] utils: oauth2: add callback handler Arthur Bied-Charreton
` (13 subsequent siblings)
26 siblings, 0 replies; 28+ messages in thread
From: Arthur Bied-Charreton @ 2026-05-05 8:32 UTC (permalink / raw)
To: pve-devel, pbs-devel
Introduce the Proxmox.OAuth2 singleton supporting Google and Microsoft
OAuth2. The flow is handled by opening a new window with the
authorization URL, and expecting to receive the resulting authorization
code from the redirect handler via a BroadcastChannel [0], which allows
communication between any two browsing contexts.
[0]
https://developer.mozilla.org/en-US/docs/Web/API/BroadcastChannel
Signed-off-by: Arthur Bied-Charreton <a.bied-charreton@proxmox.com>
---
src/Utils.js | 99 ++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 99 insertions(+)
diff --git a/src/Utils.js b/src/Utils.js
index 5457ffa..8ab4609 100644
--- a/src/Utils.js
+++ b/src/Utils.js
@@ -1723,6 +1723,105 @@ Ext.define('Proxmox.Utils', {
},
});
+Ext.define('Proxmox.OAuth2', {
+ singleton: true,
+
+ handleGoogleFlow: function (clientId, clientSecret, refreshTokenUrl) {
+ return this._handleFlow({
+ authMethod: 'google-oauth2',
+ clientId,
+ clientSecret,
+ refreshTokenUrl,
+ authUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
+ scope: 'https://mail.google.com',
+ extraAuthParams: {
+ access_type: 'offline',
+ prompt: 'consent',
+ },
+ });
+ },
+
+ handleMicrosoftFlow: function (clientId, clientSecret, tenantId, refreshTokenUrl) {
+ return this._handleFlow({
+ authMethod: 'microsoft-oauth2',
+ tenantId,
+ clientId,
+ clientSecret,
+ refreshTokenUrl,
+ authUrl: `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize`,
+ scope: 'https://outlook.office.com/SMTP.Send offline_access',
+ extraAuthParams: {
+ prompt: 'consent',
+ },
+ });
+ },
+
+ _handleFlow: function (config) {
+ return new Promise((resolve, reject) => {
+ let redirectUri = window.location.origin;
+ let channelName = `oauth2_${crypto.randomUUID()}`;
+ let state = encodeURIComponent(JSON.stringify({ channelName }));
+
+ let authParams = new URLSearchParams({
+ client_id: config.clientId,
+ response_type: 'code',
+ redirect_uri: redirectUri,
+ scope: config.scope,
+ state,
+ ...config.extraAuthParams,
+ });
+
+ let authUrl = `${config.authUrl}?${authParams}`;
+
+ let channel = new BroadcastChannel(channelName);
+ // Opens OAuth2 authorization window. The app's redirect handler must
+ // extract the authorization code from the callback URL and send it via
+ // the BroadcastChannel whose name we passed along as a state parameter.
+ let popup = window.open(authUrl);
+ if (!popup) {
+ reject(gettext('Could not open authorization window'));
+ return;
+ }
+
+ channel.addEventListener('message', (event) => {
+ if (popup && !popup.closed) {
+ popup.close();
+ }
+ channel.close();
+
+ let code = event.data.code;
+ if (!code) {
+ reject(
+ gettext('Did not receive any authorization code from authorization window'),
+ );
+ return;
+ }
+
+ let params = {
+ 'auth-method': config.authMethod,
+ 'client-id': config.clientId,
+ 'client-secret': config.clientSecret,
+ 'authorization-code': code,
+ 'redirect-uri': redirectUri,
+ };
+ if (config.tenantId) {
+ params['tenant-id'] = config.tenantId;
+ }
+
+ Proxmox.Async.api2({
+ url: config.refreshTokenUrl,
+ method: 'POST',
+ params,
+ })
+ .then(({ result }) => resolve(result.data))
+ .catch((response) => {
+ reject(response.htmlStatus || gettext('Token exchange failed'));
+ });
+ });
+ });
+ },
+});
+
Ext.define('Proxmox.Async', {
singleton: true,
--
2.47.3
^ permalink raw reply related [flat|nested] 28+ messages in thread* [PATCH proxmox-widget-toolkit v5 14/27] utils: oauth2: add callback handler
2026-05-05 8:32 [PATCH docs/manager/proxmox{,-perl-rs,-widget-toolkit,-backup} v5 00/27] fix #7238: Add XOAUTH2 authentication support for SMTP notification targets Arthur Bied-Charreton
` (12 preceding siblings ...)
2026-05-05 8:32 ` [PATCH proxmox-widget-toolkit v5 13/27] utils: add OAuth2 flow handlers Arthur Bied-Charreton
@ 2026-05-05 8:32 ` Arthur Bied-Charreton
2026-05-05 8:32 ` [PATCH proxmox-widget-toolkit v5 15/27] notifications: add opt-in OAuth2 support for SMTP targets Arthur Bied-Charreton
` (12 subsequent siblings)
26 siblings, 0 replies; 28+ messages in thread
From: Arthur Bied-Charreton @ 2026-05-05 8:32 UTC (permalink / raw)
To: pve-devel, pbs-devel
The OAuth2 flow triggered by OAuth2._handleFlow redirects to
window.location.origin after successful authorization.
This callback handler detects whether a login was triggered as the
result of such a redirect based on the presence of the code, scope and
state URL parameters. It then communicates the authorization results
back to the parent window.
Windows opened via scripts are normally also script-closable, however
this property is lost after a redirect, which is why handleCallback
relies on its parent window to kill it after it receives the data it
needs.
Signed-off-by: Arthur Bied-Charreton <a.bied-charreton@proxmox.com>
---
src/Utils.js | 31 +++++++++++++++++++++++++++++++
1 file changed, 31 insertions(+)
diff --git a/src/Utils.js b/src/Utils.js
index 8ab4609..f4946ff 100644
--- a/src/Utils.js
+++ b/src/Utils.js
@@ -1820,6 +1820,37 @@ Ext.define('Proxmox.OAuth2', {
});
});
},
+
+ handleCallback: function (params) {
+ let code = params.get('code');
+ let scope = params.get('scope');
+ let state = params.get('state');
+
+ // If true, this login was triggered as the result of an OAuth2 redirect. If it
+ // comes from the SMTP XOAUTH2 authorization flow, the state parameter should contain
+ // a UUID identifying a BroadcastChannel, prefixed with 'oauth2_'. The initiator of
+ // the OAuth2 flow (see _handleFlow) expects to receive the resulting code via this
+ // BroadcastChannel.
+ //
+ // Since we got here through a redirect, this window is not script-closable, and we rely
+ // on the parent window to close it in its BroadcastChannel's message handler.
+ if (code && state) {
+ try {
+ let { channelName } = JSON.parse(decodeURIComponent(state));
+ if (!channelName || !channelName.startsWith('oauth2_')) {
+ // Ignore OpenID logins
+ return false;
+ }
+ let bc = new BroadcastChannel(channelName);
+ bc.postMessage({ code, scope });
+ return true;
+ } catch (_) {
+ // There is nothing we can really do here, JSON.parse failed so we do not
+ // know the name of the channel we should communicate errors back through.
+ }
+ }
+ return false;
+ },
});
Ext.define('Proxmox.Async', {
--
2.47.3
^ permalink raw reply related [flat|nested] 28+ messages in thread* [PATCH proxmox-widget-toolkit v5 15/27] notifications: add opt-in OAuth2 support for SMTP targets
2026-05-05 8:32 [PATCH docs/manager/proxmox{,-perl-rs,-widget-toolkit,-backup} v5 00/27] fix #7238: Add XOAUTH2 authentication support for SMTP notification targets Arthur Bied-Charreton
` (13 preceding siblings ...)
2026-05-05 8:32 ` [PATCH proxmox-widget-toolkit v5 14/27] utils: oauth2: add callback handler Arthur Bied-Charreton
@ 2026-05-05 8:32 ` Arthur Bied-Charreton
2026-05-05 8:32 ` [PATCH pve-manager v5 16/27] notifications: smtp: api: add XOAUTH2 parameters Arthur Bied-Charreton
` (11 subsequent siblings)
26 siblings, 0 replies; 28+ messages in thread
From: Arthur Bied-Charreton @ 2026-05-05 8:32 UTC (permalink / raw)
To: pve-devel, pbs-devel
Add Google & Microsoft OAuth2 authorization support to SMTP endpoint
config.
The enableOAuth2 config flag in pmxSmtpEditPanel allows consumers to opt
into this new feature, so it can be gradually introduced into products.
When disabled, no changes are visible from the UI.
The exchange trading the initial authorization code for a refresh token
needs to happen in the backend, Azure AD does not allow
browser-originated token requests for "Web" client types, which we
require in order to be able to keep tokens valid without requiring
re-authorization by users.
Since the exposed endpoints for performing this exchange live at
different paths in PBS and PVE, expect the URL as a config argument
passed by the products and throw if it is not set.
Signed-off-by: Arthur Bied-Charreton <a.bied-charreton@proxmox.com>
---
src/panel/SmtpEditPanel.js | 266 ++++++++++++++++++++++++++++++---
src/window/EndpointEditBase.js | 2 +
2 files changed, 244 insertions(+), 24 deletions(-)
diff --git a/src/panel/SmtpEditPanel.js b/src/panel/SmtpEditPanel.js
index 37e4d51..3c32029 100644
--- a/src/panel/SmtpEditPanel.js
+++ b/src/panel/SmtpEditPanel.js
@@ -6,12 +6,31 @@ Ext.define('Proxmox.panel.SmtpEditPanel', {
type: 'smtp',
+ enableOAuth2: false,
+ // Backend URL for endpoint exchanging the initial OAuth2 authorization code
+ // for a refresh token.
+ refreshTokenUrl: undefined,
+
+ initConfig: function (config) {
+ this.callParent(arguments);
+
+ if (config.enableOAuth2 && !config.refreshTokenUrl) {
+ throw new Error('refreshTokenUrl must be set if XOAUTH2 is enabled');
+ }
+
+ return config;
+ },
+
viewModel: {
xtype: 'viewmodel',
data: {
mode: 'tls',
- authentication: true,
- originalAuthentication: true,
+ authMethod: 'plain',
+ oAuth2ClientId: '',
+ oAuth2ClientSecret: '',
+ oAuth2TenantId: '',
+ oAuth2RefreshToken: '',
+ originalAuthMethod: undefined,
},
formulas: {
portEmptyText: function (get) {
@@ -30,14 +49,47 @@ Ext.define('Proxmox.panel.SmtpEditPanel', {
}
return `${Proxmox.Utils.defaultText} (${port})`;
},
+ authKind: function (get) {
+ let method = get('authMethod');
+ let isOAuth2 = method === 'google-oauth2' || method === 'microsoft-oauth2';
+ return isOAuth2 && !this.getView().enableOAuth2 ? 'none' : method;
+ },
+ isOAuth2Authentication: function (get) {
+ let kind = get('authKind');
+ return kind === 'google-oauth2' || kind === 'microsoft-oauth2';
+ },
+ enableAuthorize: function (get) {
+ if (!get('isOAuth2Authentication')) {
+ return false;
+ }
+ let clientId = get('oAuth2ClientId')?.trim();
+ let clientSecret = get('oAuth2ClientSecret')?.trim();
+ if (!clientId || !clientSecret) {
+ return false;
+ }
+ if (get('authKind') === 'microsoft-oauth2') {
+ return !!get('oAuth2TenantId')?.trim();
+ }
+ return true;
+ },
passwordEmptyText: function (get) {
let isCreate = this.getView().isCreate;
-
- let auth = get('authentication');
- let origAuth = get('originalAuthentication');
- let shouldShowUnchanged = !isCreate && auth && origAuth;
-
- return shouldShowUnchanged ? gettext('Unchanged') : '';
+ let isPlain = get('authKind') === 'plain';
+ let wasPlain = get('originalAuthMethod') === 'plain';
+ return !isCreate && isPlain && wasPlain ? gettext('Unchanged') : '';
+ },
+ oAuth2ClientSecretEmptyText: function (get) {
+ let isCreate = this.getView().isCreate;
+ let isOAuth2 = get('isOAuth2Authentication');
+ let origMethod = get('originalAuthMethod');
+ let wasOAuth2 = origMethod === 'google-oauth2' || origMethod === 'microsoft-oauth2';
+ return !isCreate && isOAuth2 && wasOAuth2 ? gettext('Unchanged') : '';
+ },
+ isAuthorized: function (get) {
+ return get('isOAuth2Authentication') && !!get('oAuth2RefreshToken');
+ },
+ authorizeButtonDisabled: function (get) {
+ return !get('enableAuthorize') || get('isAuthorized');
},
},
},
@@ -102,11 +154,25 @@ Ext.define('Proxmox.panel.SmtpEditPanel', {
],
column2: [
{
- xtype: 'proxmoxcheckbox',
- fieldLabel: gettext('Authenticate'),
- name: 'authentication',
- bind: {
- value: '{authentication}',
+ xtype: 'proxmoxKVComboBox',
+ fieldLabel: gettext('Authentication'),
+ name: 'auth-method',
+ comboItems: [
+ ['none', gettext('None')],
+ ['plain', gettext('Username/Password')],
+ ['google-oauth2', gettext('OAuth2 (Google)')],
+ ['microsoft-oauth2', gettext('OAuth2 (Microsoft)')],
+ ],
+ bind: '{authMethod}',
+ cbind: {
+ deleteEmpty: '{!isCreate}',
+ },
+ listeners: {
+ render: function () {
+ if (!this.up('pmxSmtpEditPanel').enableOAuth2) {
+ this.getStore().filter('key', /^(none|plain)$/);
+ }
+ },
},
},
{
@@ -118,7 +184,8 @@ Ext.define('Proxmox.panel.SmtpEditPanel', {
deleteEmpty: '{!isCreate}',
},
bind: {
- disabled: '{!authentication}',
+ hidden: '{authKind !== "plain"}',
+ disabled: '{authKind !== "plain"}',
},
},
{
@@ -130,10 +197,109 @@ Ext.define('Proxmox.panel.SmtpEditPanel', {
allowBlank: '{!isCreate}',
},
bind: {
- disabled: '{!authentication}',
+ hidden: '{authKind !== "plain"}',
+ disabled: '{authKind !== "plain"}',
emptyText: '{passwordEmptyText}',
},
},
+ {
+ xtype: 'proxmoxtextfield',
+ fieldLabel: gettext('Client ID'),
+ name: 'oauth2-client-id',
+ allowBlank: false,
+ bind: {
+ hidden: '{!isOAuth2Authentication}',
+ disabled: '{!isOAuth2Authentication}',
+ value: '{oAuth2ClientId}',
+ },
+ cbind: {
+ deleteEmpty: '{!isCreate}',
+ },
+ },
+ {
+ xtype: 'proxmoxtextfield',
+ inputType: 'password',
+ fieldLabel: gettext('Client Secret'),
+ name: 'oauth2-client-secret',
+ bind: {
+ hidden: '{!isOAuth2Authentication}',
+ disabled: '{!isOAuth2Authentication}',
+ value: '{oAuth2ClientSecret}',
+ emptyText: '{oAuth2ClientSecretEmptyText}',
+ },
+ cbind: {
+ allowBlank: '{!isCreate}',
+ },
+ },
+ {
+ xtype: 'proxmoxtextfield',
+ fieldLabel: gettext('Tenant ID'),
+ name: 'oauth2-tenant-id',
+ allowBlank: false,
+ bind: {
+ hidden: '{authKind !== "microsoft-oauth2"}',
+ disabled: '{authKind !== "microsoft-oauth2"}',
+ value: '{oAuth2TenantId}',
+ },
+ cbind: {
+ deleteEmpty: '{!isCreate}',
+ },
+ },
+ {
+ xtype: 'fieldcontainer',
+ fieldLabel: gettext('Authorize'),
+ layout: 'hbox',
+ bind: {
+ hidden: '{!isOAuth2Authentication}',
+ },
+ items: [
+ {
+ xtype: 'button',
+ text: gettext('Authorize'),
+ handler: async function () {
+ let panel = this.up('pmxSmtpEditPanel');
+ let form = panel.up('form');
+ let values = form.getValues();
+
+ try {
+ let refreshToken = await panel.handleOAuth2Flow(values);
+ panel.getViewModel().set('oAuth2RefreshToken', refreshToken);
+ } catch (e) {
+ Ext.Msg.alert('Error', e);
+ }
+ },
+ bind: {
+ disabled: '{authorizeButtonDisabled}',
+ },
+ },
+ {
+ xtype: 'displayfield',
+ renderer: Ext.identityFn,
+ value: `<i class="fa fa-check-circle good"></i> <span class="good">${gettext('Authorized')}</span>`,
+ margin: '0 0 0 8',
+ bind: {
+ hidden: '{!isAuthorized}',
+ },
+ },
+ ],
+ },
+ {
+ xtype: 'hiddenfield',
+ name: 'oauth2-refresh-token',
+ allowBlank: false,
+ bind: {
+ value: '{oAuth2RefreshToken}',
+ disabled: '{!isOAuth2Authentication}',
+ },
+ // Silently block form submissions on create until the user has clicked Authorize
+ // and obtained a refresh token.
+ getErrors: function () {
+ if (this.disabled || !this.up('pmxSmtpEditPanel').isCreate) {
+ return [];
+ }
+ return this.getValue() ? [] : [''];
+ },
+ },
],
columnB: [
{
@@ -172,7 +338,25 @@ Ext.define('Proxmox.panel.SmtpEditPanel', {
},
},
],
+ handleOAuth2Flow: function (values) {
+ let authMethod = values['auth-method'];
+ let refreshTokenUrl = this.refreshTokenUrl;
+ if (authMethod === 'microsoft-oauth2') {
+ return Proxmox.OAuth2.handleMicrosoftFlow(
+ values['oauth2-client-id'],
+ values['oauth2-client-secret'],
+ values['oauth2-tenant-id'],
+ refreshTokenUrl,
+ );
+ } else if (authMethod === 'google-oauth2') {
+ return Proxmox.OAuth2.handleGoogleFlow(
+ values['oauth2-client-id'],
+ values['oauth2-client-secret'],
+ refreshTokenUrl,
+ );
+ }
+ },
onGetValues: function (values) {
let me = this;
@@ -180,9 +364,31 @@ Ext.define('Proxmox.panel.SmtpEditPanel', {
values.mailto = values.mailto.split(/[\s,;]+/);
}
- if (!values.authentication && !me.isCreate) {
- Proxmox.Utils.assemble_field_data(values, { delete: 'username' });
- Proxmox.Utils.assemble_field_data(values, { delete: 'password' });
+ let authMethod = values['auth-method'];
+ if (!this.enableOAuth2 || authMethod === 'none') {
+ delete values['auth-method'];
+ }
+
+ if (!values['oauth2-refresh-token']) {
+ delete values['oauth2-refresh-token'];
+ }
+
+ if (!me.isCreate) {
+ let oauthFields = ['oauth2-client-id', 'oauth2-client-secret', 'oauth2-tenant-id'];
+ let deletionsByMethod = {
+ none: [
+ 'username',
+ 'password',
+ ...(this.enableOAuth2 ? ['auth-method', ...oauthFields] : []),
+ ],
+ plain: this.enableOAuth2 ? oauthFields : [],
+ 'microsoft-oauth2': ['username', 'password'],
+ 'google-oauth2': ['username', 'password', 'oauth2-tenant-id'],
+ };
+
+ for (let field of deletionsByMethod[authMethod] || []) {
+ Proxmox.Utils.assemble_field_data(values, { delete: field });
+ }
}
if (values.enable) {
@@ -199,19 +405,31 @@ Ext.define('Proxmox.panel.SmtpEditPanel', {
return values;
},
-
onSetValues: function (values) {
let me = this;
- values.authentication = !!values.username;
values.enable = !values.disable;
+
+ if (values['auth-method'] === undefined && this.enableOAuth2) {
+ if (values['oauth2-tenant-id']) {
+ values['auth-method'] = 'microsoft-oauth2';
+ } else if (values['oauth2-client-id']) {
+ values['auth-method'] = 'google-oauth2';
+ } else if (values.username) {
+ values['auth-method'] = 'plain';
+ } else {
+ values['auth-method'] = 'none';
+ }
+ }
+
delete values.disable;
// Fix race condition in chromium-based browsers. Without this, the
- // 'Authenticate' remains ticked (the default value) if loading an
- // SMTP target without authentication.
- me.getViewModel().set('authentication', values.authentication);
- me.getViewModel().set('originalAuthentication', values.authentication);
+ // auth method remains set to 'plain' (the default) when loading a
+ // target with a different method set, which in some cases leads to
+ // the 'unchanged' empty text for the OAuth2 client secret being
+ // skipped.
+ me.getViewModel().set('originalAuthMethod', values['auth-method']);
return values;
},
diff --git a/src/window/EndpointEditBase.js b/src/window/EndpointEditBase.js
index 8c1bfc1..d7810a2 100644
--- a/src/window/EndpointEditBase.js
+++ b/src/window/EndpointEditBase.js
@@ -47,6 +47,8 @@ Ext.define('Proxmox.window.EndpointEditBase', {
baseUrl: me.baseUrl,
type: me.type,
defaultMailAuthor: endpointConfig.defaultMailAuthor,
+ enableOAuth2: endpointConfig.enableOAuth2,
+ refreshTokenUrl: endpointConfig.refreshTokenUrl,
},
],
});
--
2.47.3
^ permalink raw reply related [flat|nested] 28+ messages in thread* [PATCH pve-manager v5 16/27] notifications: smtp: api: add XOAUTH2 parameters
2026-05-05 8:32 [PATCH docs/manager/proxmox{,-perl-rs,-widget-toolkit,-backup} v5 00/27] fix #7238: Add XOAUTH2 authentication support for SMTP notification targets Arthur Bied-Charreton
` (14 preceding siblings ...)
2026-05-05 8:32 ` [PATCH proxmox-widget-toolkit v5 15/27] notifications: add opt-in OAuth2 support for SMTP targets Arthur Bied-Charreton
@ 2026-05-05 8:32 ` Arthur Bied-Charreton
2026-05-05 8:32 ` [PATCH pve-manager v5 17/27] notifications: add endpoint for initial OAuth2 refresh token exchange Arthur Bied-Charreton
` (10 subsequent siblings)
26 siblings, 0 replies; 28+ messages in thread
From: Arthur Bied-Charreton @ 2026-05-05 8:32 UTC (permalink / raw)
To: pve-devel, pbs-devel
Add auth-method, oauth2-client-id, oauth2-client-secret,
oauth2-tenant-id and oauth2-refresh-token parameters to prepare for
XOAUTH2 support.
The authentication method was previously implicit and inferred by
proxmox-notify based on the presence of a password. It is now made
explicit, however still kept optional and inferred in the
{update,create}_endpoint handlers to avoid breaking the API.
The calls to {add,update}_smtp_endpoint are updated to pass the configs
as hashes instead of flat parameter lists, following the API changes in
proxmox-perl-rs.
Signed-off-by: Arthur Bied-Charreton <a.bied-charreton@proxmox.com>
---
PVE/API2/Cluster/Notifications.pm | 102 +++++++++++++++++++++++-------
1 file changed, 79 insertions(+), 23 deletions(-)
diff --git a/PVE/API2/Cluster/Notifications.pm b/PVE/API2/Cluster/Notifications.pm
index 8b455227..8e118483 100644
--- a/PVE/API2/Cluster/Notifications.pm
+++ b/PVE/API2/Cluster/Notifications.pm
@@ -941,6 +941,13 @@ my $smtp_properties = {
default => 'tls',
optional => 1,
},
+ 'auth-method' => {
+ description =>
+ 'Determine which authentication method shall be used for the connection.',
+ type => 'string',
+ enum => [qw(google-oauth2 microsoft-oauth2 plain)],
+ optional => 1,
+ },
username => {
description => 'Username for SMTP authentication',
type => 'string',
@@ -951,6 +958,26 @@ my $smtp_properties = {
type => 'string',
optional => 1,
},
+ 'oauth2-client-id' => {
+ description => 'OAuth2 client ID',
+ type => 'string',
+ optional => 1,
+ },
+ 'oauth2-client-secret' => {
+ description => 'OAuth2 client secret',
+ type => 'string',
+ optional => 1,
+ },
+ 'oauth2-tenant-id' => {
+ description => 'OAuth2 tenant ID, only required for Microsoft OAuth2 endpoints',
+ type => 'string',
+ optional => 1,
+ },
+ 'oauth2-refresh-token' => {
+ description => 'OAuth2 refresh token',
+ type => 'string',
+ optional => 1,
+ },
mailto => {
type => 'array',
items => {
@@ -1108,6 +1135,11 @@ __PACKAGE__->register_method({
my $mode = extract_param($param, 'mode');
my $username = extract_param($param, 'username');
my $password = extract_param($param, 'password');
+ my $auth_method = extract_param($param, 'auth-method');
+ my $oauth2_client_secret = extract_param($param, 'oauth2-client-secret');
+ my $oauth2_client_id = extract_param($param, 'oauth2-client-id');
+ my $oauth2_tenant_id = extract_param($param, 'oauth2-tenant-id');
+ my $oauth2_refresh_token = extract_param($param, 'oauth2-refresh-token');
my $mailto = extract_param($param, 'mailto');
my $mailto_user = extract_param($param, 'mailto-user');
my $from_address = extract_param($param, 'from-address');
@@ -1120,18 +1152,28 @@ __PACKAGE__->register_method({
my $config = PVE::Notify::read_config();
$config->add_smtp_endpoint(
- $name,
- $server,
- $port,
- $mode,
- $username,
- $password,
- $mailto,
- $mailto_user,
- $from_address,
- $author,
- $comment,
- $disable,
+ {
+ name => $name,
+ server => $server,
+ port => $port,
+ mode => $mode,
+ username => $username,
+ 'auth-method' => $auth_method,
+ 'oauth2-client-id' => $oauth2_client_id,
+ 'oauth2-tenant-id' => $oauth2_tenant_id,
+ mailto => defined($mailto) ? $mailto : [],
+ 'mailto-user' => defined($mailto_user) ? $mailto_user : [],
+ 'from-address' => $from_address,
+ author => $author,
+ comment => $comment,
+ disable => $disable,
+ },
+ {
+ name => $name,
+ password => $password,
+ 'oauth2-client-secret' => $oauth2_client_secret,
+ },
+ $oauth2_refresh_token,
);
PVE::Notify::write_config($config);
@@ -1187,6 +1229,11 @@ __PACKAGE__->register_method({
my $mode = extract_param($param, 'mode');
my $username = extract_param($param, 'username');
my $password = extract_param($param, 'password');
+ my $auth_method = extract_param($param, 'auth-method');
+ my $oauth2_client_secret = extract_param($param, 'oauth2-client-secret');
+ my $oauth2_client_id = extract_param($param, 'oauth2-client-id');
+ my $oauth2_tenant_id = extract_param($param, 'oauth2-tenant-id');
+ my $oauth2_refresh_token = extract_param($param, 'oauth2-refresh-token');
my $mailto = extract_param($param, 'mailto');
my $mailto_user = extract_param($param, 'mailto-user');
my $from_address = extract_param($param, 'from-address');
@@ -1203,17 +1250,26 @@ __PACKAGE__->register_method({
$config->update_smtp_endpoint(
$name,
- $server,
- $port,
- $mode,
- $username,
- $password,
- $mailto,
- $mailto_user,
- $from_address,
- $author,
- $comment,
- $disable,
+ {
+ server => $server,
+ port => $port,
+ mode => $mode,
+ username => $username,
+ 'auth-method' => $auth_method,
+ 'oauth2-client-id' => $oauth2_client_id,
+ 'oauth2-tenant-id' => $oauth2_tenant_id,
+ mailto => $mailto,
+ 'mailto-user' => $mailto_user,
+ 'from-address' => $from_address,
+ author => $author,
+ comment => $comment,
+ disable => $disable,
+ },
+ {
+ password => $password,
+ 'oauth2-client-secret' => $oauth2_client_secret,
+ },
+ $oauth2_refresh_token,
$delete,
$digest,
);
--
2.47.3
^ permalink raw reply related [flat|nested] 28+ messages in thread* [PATCH pve-manager v5 17/27] notifications: add endpoint for initial OAuth2 refresh token exchange
2026-05-05 8:32 [PATCH docs/manager/proxmox{,-perl-rs,-widget-toolkit,-backup} v5 00/27] fix #7238: Add XOAUTH2 authentication support for SMTP notification targets Arthur Bied-Charreton
` (15 preceding siblings ...)
2026-05-05 8:32 ` [PATCH pve-manager v5 16/27] notifications: smtp: api: add XOAUTH2 parameters Arthur Bied-Charreton
@ 2026-05-05 8:32 ` Arthur Bied-Charreton
2026-05-05 8:32 ` [PATCH pve-manager v5 18/27] pveupdate: refresh notification targets' OAuth2 state Arthur Bied-Charreton
` (9 subsequent siblings)
26 siblings, 0 replies; 28+ messages in thread
From: Arthur Bied-Charreton @ 2026-05-05 8:32 UTC (permalink / raw)
To: pve-devel, pbs-devel
Expose endpoint under /cluster/notifications/smtp-oauth2-token to
exchange the initial authorization code for a refresh token.
Azure AD's "Web" client type, which we require in order to be able to
keep getting new access tokens in the backend without requiring
re-authorization by users, rejects browser-originated token requests,
so this must run on the backend.
Signed-off-by: Arthur Bied-Charreton <a.bied-charreton@proxmox.com>
---
PVE/API2/Cluster/Notifications.pm | 77 +++++++++++++++++++++++++++++++
1 file changed, 77 insertions(+)
diff --git a/PVE/API2/Cluster/Notifications.pm b/PVE/API2/Cluster/Notifications.pm
index 8e118483..830070e9 100644
--- a/PVE/API2/Cluster/Notifications.pm
+++ b/PVE/API2/Cluster/Notifications.pm
@@ -81,6 +81,7 @@ __PACKAGE__->register_method({
{ name => 'targets' },
{ name => 'matcher-fields' },
{ name => 'matcher-field-values' },
+ { name => 'smtp-oauth2-token' },
];
return $result;
@@ -321,6 +322,82 @@ __PACKAGE__->register_method({
},
});
+__PACKAGE__->register_method({
+ name => 'smtp_oauth2_token',
+ path => 'smtp-oauth2-token',
+ protected => 1,
+ method => 'POST',
+ description => 'Exchanges the initial OAuth2 authorization code for a refresh token',
+ permissions => {
+ check => [
+ 'and',
+ ['perm', '/mapping/notifications', ['Mapping.Modify']],
+ [
+ 'or',
+ ['perm', '/', ['Sys.Audit', 'Sys.Modify']],
+ ['perm', '/', ['Sys.AccessNetwork']],
+ ],
+ ],
+ },
+ parameters => {
+ additionalProperties => 0,
+ properties => {
+ 'auth-method' => {
+ description => 'Authentication method',
+ type => 'string',
+ enum => [qw(google-oauth2 microsoft-oauth2)],
+ },
+ 'client-id' => {
+ description => 'OAuth2 client ID',
+ type => 'string',
+ },
+ 'client-secret' => {
+ description => 'OAuth2 client secret',
+ type => 'string',
+ },
+ 'tenant-id' => {
+ description => 'OAuth2 tenant ID, only required for Microsoft OAuth2 endpoints',
+ type => 'string',
+ optional => 1,
+ },
+ 'authorization-code' => {
+ description => 'Initial OAuth2 authorization code',
+ type => 'string',
+ },
+ 'redirect-uri' => {
+ description => "OAuth2 redirect URI",
+ type => 'string',
+ },
+ },
+ },
+ returns => { type => 'string' },
+ code => sub {
+ my ($param) = @_;
+
+ my $auth_method = extract_param($param, 'auth-method');
+ my $client_id = extract_param($param, 'client-id');
+ my $client_secret = extract_param($param, 'client-secret');
+ my $tenant_id = extract_param($param, 'tenant-id');
+ my $authorization_code = extract_param($param, 'authorization-code');
+ my $redirect_uri = extract_param($param, 'redirect-uri');
+
+ my $refresh_token = eval {
+ my $config = PVE::Notify::read_config();
+ $config->exchange_smtp_oauth2_code(
+ $auth_method,
+ $client_id,
+ $client_secret,
+ $tenant_id,
+ $authorization_code,
+ $redirect_uri,
+ );
+ };
+ raise_api_error($@) if $@;
+
+ return $refresh_token;
+ },
+});
+
__PACKAGE__->register_method({
name => 'test_target',
path => 'targets/{name}/test',
--
2.47.3
^ permalink raw reply related [flat|nested] 28+ messages in thread* [PATCH pve-manager v5 18/27] pveupdate: refresh notification targets' OAuth2 state
2026-05-05 8:32 [PATCH docs/manager/proxmox{,-perl-rs,-widget-toolkit,-backup} v5 00/27] fix #7238: Add XOAUTH2 authentication support for SMTP notification targets Arthur Bied-Charreton
` (16 preceding siblings ...)
2026-05-05 8:32 ` [PATCH pve-manager v5 17/27] notifications: add endpoint for initial OAuth2 refresh token exchange Arthur Bied-Charreton
@ 2026-05-05 8:32 ` Arthur Bied-Charreton
2026-05-05 8:32 ` [PATCH pve-manager v5 19/27] login: handle OAuth2 callback Arthur Bied-Charreton
` (8 subsequent siblings)
26 siblings, 0 replies; 28+ messages in thread
From: Arthur Bied-Charreton @ 2026-05-05 8:32 UTC (permalink / raw)
To: pve-devel, pbs-devel
Avoid OAuth2 refresh token expiry by forcing a notification target
refresh in PVE's daily-update job.
This is done under a notifications config lock because failure to do so
may result in the state file updates happening concurrently with other
configuration file updates, leading to, for example, a stale state file
if an endpoint was deleted in the meantime.
Signed-off-by: Arthur Bied-Charreton <a.bied-charreton@proxmox.com>
---
bin/pveupdate | 13 +++++++++++++
1 file changed, 13 insertions(+)
diff --git a/bin/pveupdate b/bin/pveupdate
index b1960c35..25cd4f21 100755
--- a/bin/pveupdate
+++ b/bin/pveupdate
@@ -195,4 +195,17 @@ sub cleanup_tasks {
cleanup_tasks();
+eval {
+ PVE::Notify::lock_config(sub {
+ my $config = PVE::Notify::read_config();
+ # Refresh internal state for notification targets. This writes the
+ # state files for SMTP XOAUTH2 endpoints, which we do not want to
+ # happen concurrently with config R/W cycles.
+ $config->trigger_state_refresh();
+ });
+};
+if (my $err = $@) {
+ syslog('err', "refresh notification targets failed: $err");
+}
+
exit(0);
--
2.47.3
^ permalink raw reply related [flat|nested] 28+ messages in thread* [PATCH pve-manager v5 19/27] login: handle OAuth2 callback
2026-05-05 8:32 [PATCH docs/manager/proxmox{,-perl-rs,-widget-toolkit,-backup} v5 00/27] fix #7238: Add XOAUTH2 authentication support for SMTP notification targets Arthur Bied-Charreton
` (17 preceding siblings ...)
2026-05-05 8:32 ` [PATCH pve-manager v5 18/27] pveupdate: refresh notification targets' OAuth2 state Arthur Bied-Charreton
@ 2026-05-05 8:32 ` Arthur Bied-Charreton
2026-05-05 8:32 ` [PATCH pve-manager v5 20/27] fix #7238: notifications: smtp: add XOAUTH2 support Arthur Bied-Charreton
` (7 subsequent siblings)
26 siblings, 0 replies; 28+ messages in thread
From: Arthur Bied-Charreton @ 2026-05-05 8:32 UTC (permalink / raw)
To: pve-devel, pbs-devel
The callback handler detects whether a login was triggered as the result
of an OAuth2 redirect and handles it accordingly by sending the
authorization code to the parent window. No-op on normal logins.
Signed-off-by: Arthur Bied-Charreton <a.bied-charreton@proxmox.com>
---
www/manager6/Workspace.js | 2 ++
1 file changed, 2 insertions(+)
diff --git a/www/manager6/Workspace.js b/www/manager6/Workspace.js
index 0f0e6ffb..8dfa522d 100644
--- a/www/manager6/Workspace.js
+++ b/www/manager6/Workspace.js
@@ -158,6 +158,8 @@ Ext.define('PVE.StdWorkspace', {
onLogin: function (loginData) {
let me = this;
+ Proxmox.OAuth2.handleCallback(new URLSearchParams(window.location.search));
+
me.updateUserInfo();
if (loginData) {
--
2.47.3
^ permalink raw reply related [flat|nested] 28+ messages in thread* [PATCH pve-manager v5 20/27] fix #7238: notifications: smtp: add XOAUTH2 support
2026-05-05 8:32 [PATCH docs/manager/proxmox{,-perl-rs,-widget-toolkit,-backup} v5 00/27] fix #7238: Add XOAUTH2 authentication support for SMTP notification targets Arthur Bied-Charreton
` (18 preceding siblings ...)
2026-05-05 8:32 ` [PATCH pve-manager v5 19/27] login: handle OAuth2 callback Arthur Bied-Charreton
@ 2026-05-05 8:32 ` Arthur Bied-Charreton
2026-05-05 8:32 ` [PATCH proxmox-backup v5 21/27] notifications: add XOAUTH2 parameters to endpoints Arthur Bied-Charreton
` (6 subsequent siblings)
26 siblings, 0 replies; 28+ messages in thread
From: Arthur Bied-Charreton @ 2026-05-05 8:32 UTC (permalink / raw)
To: pve-devel, pbs-devel
XOAUTH2 is made opt-in by proxmox-widget-toolkit. Opt into it in PVE,
introducing XOAUTH2 support for SMTP notification targets, and pass the
backend URL for getting a refresh token in PVE to the SMTP edit panel.
Signed-off-by: Arthur Bied-Charreton <a.bied-charreton@proxmox.com>
---
www/manager6/Utils.js | 11 +++++++++++
1 file changed, 11 insertions(+)
diff --git a/www/manager6/Utils.js b/www/manager6/Utils.js
index be95d216..8f3fbddb 100644
--- a/www/manager6/Utils.js
+++ b/www/manager6/Utils.js
@@ -2220,5 +2220,16 @@ Ext.define('PVE.Utils', {
replication: gettext('Replication job notifications'),
fencing: gettext('Node fencing notifications'),
});
+
+ Proxmox.Schema.overrideEndpointTypes({
+ smtp: {
+ name: 'SMTP',
+ ipanel: 'pmxSmtpEditPanel',
+ iconCls: 'fa-envelope-o',
+ defaultMailAuthor: 'Proxmox VE',
+ enableOAuth2: true,
+ refreshTokenUrl: '/cluster/notifications/smtp-oauth2-token',
+ },
+ });
},
});
--
2.47.3
^ permalink raw reply related [flat|nested] 28+ messages in thread* [PATCH proxmox-backup v5 21/27] notifications: add XOAUTH2 parameters to endpoints
2026-05-05 8:32 [PATCH docs/manager/proxmox{,-perl-rs,-widget-toolkit,-backup} v5 00/27] fix #7238: Add XOAUTH2 authentication support for SMTP notification targets Arthur Bied-Charreton
` (19 preceding siblings ...)
2026-05-05 8:32 ` [PATCH pve-manager v5 20/27] fix #7238: notifications: smtp: add XOAUTH2 support Arthur Bied-Charreton
@ 2026-05-05 8:32 ` Arthur Bied-Charreton
2026-05-05 8:32 ` [PATCH proxmox-backup v5 22/27] notifications: add endpoint for initial OAuth2 refresh token exchange Arthur Bied-Charreton
` (5 subsequent siblings)
26 siblings, 0 replies; 28+ messages in thread
From: Arthur Bied-Charreton @ 2026-05-05 8:32 UTC (permalink / raw)
To: pve-devel, pbs-devel
Add oauth2-client-secret and oauth2-refresh-token to POST and PUT
endpoints for SMTP notifications and pass them along to the
proxmox-notify functions.
The non-private parameters oauth2-client-id and oauth2-tenant-id are
passed as part of the SmtpConfig struct.
Signed-off-by: Arthur Bied-Charreton <a.bied-charreton@proxmox.com>
---
src/api2/config/notifications/smtp.rs | 36 ++++++++++++++++++++++++---
1 file changed, 33 insertions(+), 3 deletions(-)
diff --git a/src/api2/config/notifications/smtp.rs b/src/api2/config/notifications/smtp.rs
index 8df2ab18..4d88bd65 100644
--- a/src/api2/config/notifications/smtp.rs
+++ b/src/api2/config/notifications/smtp.rs
@@ -72,7 +72,15 @@ pub fn get_endpoint(name: String, rpcenv: &mut dyn RpcEnvironment) -> Result<Smt
password: {
optional: true,
description: "SMTP authentication password"
- }
+ },
+ "oauth2-client-secret": {
+ optional: true,
+ description: "Client secret for SMTP XOAUTH2"
+ },
+ "oauth2-refresh-token": {
+ optional: true,
+ description: "Refresh token for SMTP XOAUTH2"
+ },
},
},
access: {
@@ -83,6 +91,8 @@ pub fn get_endpoint(name: String, rpcenv: &mut dyn RpcEnvironment) -> Result<Smt
pub fn add_endpoint(
endpoint: SmtpConfig,
password: Option<String>,
+ oauth2_client_secret: Option<String>,
+ oauth2_refresh_token: Option<String>,
_rpcenv: &mut dyn RpcEnvironment,
) -> Result<(), Error> {
let _lock = pbs_config::notifications::lock_config()?;
@@ -90,9 +100,15 @@ pub fn add_endpoint(
let private_endpoint_config = SmtpPrivateConfig {
name: endpoint.name.clone(),
password,
+ oauth2_client_secret,
};
- proxmox_notify::api::smtp::add_endpoint(&mut config, endpoint, private_endpoint_config)?;
+ proxmox_notify::api::smtp::add_endpoint(
+ &mut config,
+ endpoint,
+ private_endpoint_config,
+ oauth2_refresh_token,
+ )?;
pbs_config::notifications::save_config(config)?;
Ok(())
@@ -113,6 +129,14 @@ pub fn add_endpoint(
description: "SMTP authentication password",
optional: true,
},
+ "oauth2-client-secret": {
+ optional: true,
+ description: "Client secret for SMTP XOAUTH2"
+ },
+ "oauth2-refresh-token": {
+ optional: true,
+ description: "Refresh token for SMTP XOAUTH2"
+ },
delete: {
description: "List of properties to delete.",
type: Array,
@@ -136,6 +160,8 @@ pub fn update_endpoint(
name: String,
updater: SmtpConfigUpdater,
password: Option<String>,
+ oauth2_client_secret: Option<String>,
+ oauth2_refresh_token: Option<String>,
delete: Option<Vec<DeleteableSmtpProperty>>,
digest: Option<String>,
_rpcenv: &mut dyn RpcEnvironment,
@@ -148,7 +174,11 @@ pub fn update_endpoint(
&mut config,
&name,
updater,
- SmtpPrivateConfigUpdater { password },
+ SmtpPrivateConfigUpdater {
+ password,
+ oauth2_client_secret,
+ },
+ oauth2_refresh_token,
delete.as_deref(),
digest.as_deref(),
)?;
--
2.47.3
^ permalink raw reply related [flat|nested] 28+ messages in thread* [PATCH proxmox-backup v5 22/27] notifications: add endpoint for initial OAuth2 refresh token exchange
2026-05-05 8:32 [PATCH docs/manager/proxmox{,-perl-rs,-widget-toolkit,-backup} v5 00/27] fix #7238: Add XOAUTH2 authentication support for SMTP notification targets Arthur Bied-Charreton
` (20 preceding siblings ...)
2026-05-05 8:32 ` [PATCH proxmox-backup v5 21/27] notifications: add XOAUTH2 parameters to endpoints Arthur Bied-Charreton
@ 2026-05-05 8:32 ` Arthur Bied-Charreton
2026-05-05 8:32 ` [PATCH proxmox-backup v5 23/27] login: handle OAuth2 callback Arthur Bied-Charreton
` (4 subsequent siblings)
26 siblings, 0 replies; 28+ messages in thread
From: Arthur Bied-Charreton @ 2026-05-05 8:32 UTC (permalink / raw)
To: pve-devel, pbs-devel
Expose endpoint under /config/notifications/smtp-oauth2-token to
exchange the initial authorization code for a refresh token.
Azure AD's "Web" client type, which we require in order to be able to
keep getting new access tokens in the backend without requiring
re-authorization by users, rejects browser-originated token requests, so
this must run on the backend.
Signed-off-by: Arthur Bied-Charreton <a.bied-charreton@proxmox.com>
---
src/api2/config/notifications/mod.rs | 1 +
src/api2/config/notifications/smtp.rs | 65 ++++++++++++++++++++++++++-
2 files changed, 65 insertions(+), 1 deletion(-)
diff --git a/src/api2/config/notifications/mod.rs b/src/api2/config/notifications/mod.rs
index 2b94f715..a05c8982 100644
--- a/src/api2/config/notifications/mod.rs
+++ b/src/api2/config/notifications/mod.rs
@@ -29,6 +29,7 @@ const SUBDIRS: SubdirMap = &sorted!([
("endpoints", &ENDPOINT_ROUTER),
("matcher-fields", &FIELD_ROUTER),
("matcher-field-values", &VALUE_ROUTER),
+ ("smtp-oauth2-token", &smtp::OAUTH2_TOKEN_ROUTER),
("targets", &targets::ROUTER),
("matchers", &matchers::ROUTER),
]);
diff --git a/src/api2/config/notifications/smtp.rs b/src/api2/config/notifications/smtp.rs
index 4d88bd65..0de23241 100644
--- a/src/api2/config/notifications/smtp.rs
+++ b/src/api2/config/notifications/smtp.rs
@@ -5,7 +5,7 @@ use proxmox_notify::endpoints::smtp::{
DeleteableSmtpProperty, SmtpConfig, SmtpConfigUpdater, SmtpPrivateConfig,
SmtpPrivateConfigUpdater,
};
-use proxmox_notify::schema::ENTITY_NAME_SCHEMA;
+use proxmox_notify::{endpoints::smtp::SmtpAuthMethod, schema::ENTITY_NAME_SCHEMA};
use proxmox_router::{Permission, Router, RpcEnvironment};
use proxmox_schema::api;
@@ -219,3 +219,66 @@ pub const ROUTER: Router = Router::new()
.get(&API_METHOD_LIST_ENDPOINTS)
.post(&API_METHOD_ADD_ENDPOINT)
.match_all("name", &ITEM_ROUTER);
+
+#[api(
+ protected: true,
+ input: {
+ properties: {
+ "auth-method": {
+ type: SmtpAuthMethod,
+ },
+ "client-id": {
+ description: "OAuth2 client ID.",
+ type: String,
+ },
+ "client-secret": {
+ description: "OAuth2 client secret.",
+ type: String,
+ },
+ "tenant-id": {
+ description: "Microsoft tenant ID (required for microsoft-oauth2).",
+ type: String,
+ optional: true,
+ },
+ "authorization-code": {
+ description: "Authorization code returned by the IdP.",
+ type: String,
+ },
+ "redirect-uri": {
+ description: "Redirect URI used in the authorization request.",
+ type: String,
+ },
+ },
+ },
+ returns: {
+ description: "OAuth2 refresh token",
+ type: String,
+ },
+ access: {
+ permission: &Permission::Privilege(&["system", "notifications"], PRIV_SYS_MODIFY, false),
+ },
+)]
+/// Exchange an OAuth2 authorization code for a refresh token.
+///
+/// The token request is performed server-side so that providers (notably Azure AD
+/// Web app registrations) which forbid cross-origin token redemption accept it.
+pub fn exchange_oauth2_code(
+ auth_method: SmtpAuthMethod,
+ client_id: String,
+ client_secret: String,
+ tenant_id: Option<String>,
+ authorization_code: String,
+ redirect_uri: String,
+) -> Result<String, Error> {
+ proxmox_notify::api::smtp::exchange_oauth2_code(
+ auth_method,
+ client_id,
+ client_secret,
+ tenant_id,
+ authorization_code,
+ redirect_uri,
+ )
+ .map_err(Into::into)
+}
+
+pub const OAUTH2_TOKEN_ROUTER: Router = Router::new().post(&API_METHOD_EXCHANGE_OAUTH2_CODE);
--
2.47.3
^ permalink raw reply related [flat|nested] 28+ messages in thread* [PATCH proxmox-backup v5 23/27] login: handle OAuth2 callback
2026-05-05 8:32 [PATCH docs/manager/proxmox{,-perl-rs,-widget-toolkit,-backup} v5 00/27] fix #7238: Add XOAUTH2 authentication support for SMTP notification targets Arthur Bied-Charreton
` (21 preceding siblings ...)
2026-05-05 8:32 ` [PATCH proxmox-backup v5 22/27] notifications: add endpoint for initial OAuth2 refresh token exchange Arthur Bied-Charreton
@ 2026-05-05 8:32 ` Arthur Bied-Charreton
2026-05-05 8:32 ` [PATCH proxmox-backup v5 24/27] fix #7238: notifications: smtp: add XOAUTH2 support Arthur Bied-Charreton
` (3 subsequent siblings)
26 siblings, 0 replies; 28+ messages in thread
From: Arthur Bied-Charreton @ 2026-05-05 8:32 UTC (permalink / raw)
To: pve-devel, pbs-devel
The callback handler detects whether a login was triggered as the result
of an OAuth2 redirect and handles it accordingly by sending the
authorization code to the parent window. No-op on normal logins.
Explicitly check the return value of handleCallback to prevent
triggering the OpenID login logic.
Signed-off-by: Arthur Bied-Charreton <a.bied-charreton@proxmox.com>
---
www/Application.js | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/www/Application.js b/www/Application.js
index e82ccdd0..fa5d4037 100644
--- a/www/Application.js
+++ b/www/Application.js
@@ -57,10 +57,12 @@ Ext.define('PBS.Application', {
var provider = new Ext.state.LocalStorageProvider({ prefix: 'ext-pbs-' });
Ext.state.Manager.setProvider(provider);
+ let isOAuth2Callback = Proxmox.OAuth2.handleCallback(new URLSearchParams(window.location.search));
+
let isOpenIDLogin = Proxmox.Utils.getOpenIDRedirectionAuthorization() !== undefined;
let alreadyLoggedIn = Proxmox.Utils.authOK();
- if (isOpenIDLogin || !alreadyLoggedIn) {
+ if (!isOAuth2Callback && (isOpenIDLogin || !alreadyLoggedIn)) {
me.changeView('loginview', true); // show login window if not loggedin
} else {
me.changeView('mainview', true);
--
2.47.3
^ permalink raw reply related [flat|nested] 28+ messages in thread* [PATCH proxmox-backup v5 24/27] fix #7238: notifications: smtp: add XOAUTH2 support
2026-05-05 8:32 [PATCH docs/manager/proxmox{,-perl-rs,-widget-toolkit,-backup} v5 00/27] fix #7238: Add XOAUTH2 authentication support for SMTP notification targets Arthur Bied-Charreton
` (22 preceding siblings ...)
2026-05-05 8:32 ` [PATCH proxmox-backup v5 23/27] login: handle OAuth2 callback Arthur Bied-Charreton
@ 2026-05-05 8:32 ` Arthur Bied-Charreton
2026-05-05 8:32 ` [PATCH proxmox-backup v5 25/27] daily-update: refresh OAuth2 state for SMTP notification endpoints Arthur Bied-Charreton
` (2 subsequent siblings)
26 siblings, 0 replies; 28+ messages in thread
From: Arthur Bied-Charreton @ 2026-05-05 8:32 UTC (permalink / raw)
To: pve-devel, pbs-devel
The XOAUTH2 authorization feature for SMTP is made opt-in in
proxmox-widget-toolkit. Opt into it in PBS, introducing support for
XOAUTH2 in SMTP notification targets, and pass the smtp-oauth2-token
endpoint URL for PBS to the SMTP edit panel for refresh token exchange.
Signed-off-by: Arthur Bied-Charreton <a.bied-charreton@proxmox.com>
---
www/Utils.js | 2 ++
1 file changed, 2 insertions(+)
diff --git a/www/Utils.js b/www/Utils.js
index bf4b025c..837877b3 100644
--- a/www/Utils.js
+++ b/www/Utils.js
@@ -515,6 +515,8 @@ Ext.define('PBS.Utils', {
name: 'SMTP',
ipanel: 'pmxSmtpEditPanel',
iconCls: 'fa-envelope-o',
+ enableOAuth2: true,
+ refreshTokenUrl: '/config/notifications/smtp-oauth2-token',
defaultMailAuthor: 'Proxmox Backup Server - $hostname',
},
gotify: {
--
2.47.3
^ permalink raw reply related [flat|nested] 28+ messages in thread* [PATCH proxmox-backup v5 25/27] daily-update: refresh OAuth2 state for SMTP notification endpoints
2026-05-05 8:32 [PATCH docs/manager/proxmox{,-perl-rs,-widget-toolkit,-backup} v5 00/27] fix #7238: Add XOAUTH2 authentication support for SMTP notification targets Arthur Bied-Charreton
` (23 preceding siblings ...)
2026-05-05 8:32 ` [PATCH proxmox-backup v5 24/27] fix #7238: notifications: smtp: add XOAUTH2 support Arthur Bied-Charreton
@ 2026-05-05 8:32 ` Arthur Bied-Charreton
2026-05-05 8:32 ` [PATCH proxmox-backup v5 26/27] notifications: add OAuth2 section to SMTP targets docs Arthur Bied-Charreton
2026-05-05 8:32 ` [PATCH pve-docs v5 27/27] " Arthur Bied-Charreton
26 siblings, 0 replies; 28+ messages in thread
From: Arthur Bied-Charreton @ 2026-05-05 8:32 UTC (permalink / raw)
To: pve-devel, pbs-devel
Run trigger_state_refresh from the daily update job so OAuth2 tokens are
exchanged at least once per day, preventing Google refresh tokens from
expiring and persisting newly returned Microsoft ones.
This is done under a notifications config lock. Failure to do so may
lead to state file updates happening concurrently with other config
updates.
Signed-off-by: Arthur Bied-Charreton <a.bied-charreton@proxmox.com>
---
src/bin/proxmox-daily-update.rs | 11 +++++++++++
1 file changed, 11 insertions(+)
diff --git a/src/bin/proxmox-daily-update.rs b/src/bin/proxmox-daily-update.rs
index fc6e4f46..0e8282c0 100644
--- a/src/bin/proxmox-daily-update.rs
+++ b/src/bin/proxmox-daily-update.rs
@@ -22,6 +22,13 @@ async fn wait_for_local_worker(upid_str: &str) -> Result<(), Error> {
Ok(())
}
+fn refresh_notification_state() -> Result<(), anyhow::Error> {
+ let _lock = pbs_config::notifications::lock_config()?;
+ let conf = pbs_config::notifications::config()?;
+ proxmox_notify::api::common::trigger_state_refresh(&conf)?;
+ Ok(())
+}
+
/// Daily update
async fn do_update(rpcenv: &mut dyn RpcEnvironment) -> Result<(), Error> {
let param = json!({});
@@ -61,6 +68,10 @@ async fn do_update(rpcenv: &mut dyn RpcEnvironment) -> Result<(), Error> {
log::error!("error checking certificates: {err}");
}
+ if let Err(err) = tokio::task::spawn_blocking(refresh_notification_state).await? {
+ log::error!("Error refreshing notification endpoints' internal state: {err}");
+ }
+
// TODO: cleanup tasks like in PVE?
Ok(())
--
2.47.3
^ permalink raw reply related [flat|nested] 28+ messages in thread* [PATCH proxmox-backup v5 26/27] notifications: add OAuth2 section to SMTP targets docs
2026-05-05 8:32 [PATCH docs/manager/proxmox{,-perl-rs,-widget-toolkit,-backup} v5 00/27] fix #7238: Add XOAUTH2 authentication support for SMTP notification targets Arthur Bied-Charreton
` (24 preceding siblings ...)
2026-05-05 8:32 ` [PATCH proxmox-backup v5 25/27] daily-update: refresh OAuth2 state for SMTP notification endpoints Arthur Bied-Charreton
@ 2026-05-05 8:32 ` Arthur Bied-Charreton
2026-05-05 8:32 ` [PATCH pve-docs v5 27/27] " Arthur Bied-Charreton
26 siblings, 0 replies; 28+ messages in thread
From: Arthur Bied-Charreton @ 2026-05-05 8:32 UTC (permalink / raw)
To: pve-devel, pbs-devel
Document the new SMTP notification target options, especially that:
1. User intervention is required for initial setup, and
2. Microsoft OAuth2 apps *must not* be configured as SPAs by the user,
since it would prevent PBS from automatically extending the refresh
token's lifetime.
Signed-off-by: Arthur Bied-Charreton <a.bied-charreton@proxmox.com>
---
docs/notifications.rst | 89 +++++++++++++++++++++++++++++++++++++++++-
www/OnlineHelpInfo.js | 8 ++++
2 files changed, 96 insertions(+), 1 deletion(-)
diff --git a/docs/notifications.rst b/docs/notifications.rst
index 440c700a..60b23b5b 100644
--- a/docs/notifications.rst
+++ b/docs/notifications.rst
@@ -69,6 +69,94 @@ address will be used.
See :ref:`notifications.cfg` for all configuration options.
+.. _notification_targets_smtp_oauth2:
+
+OAuth2 Authentication
+"""""""""""""""""""""
+
+Proxmox Backup Server supports OAuth2 authentication for SMTP targets via the
+XOAUTH2 mechanism. This is currently available for Google and Microsoft mail
+providers.
+
+Creating an OAuth2 Application
+''''''''''''''''''''''''''''''
+
+Before configuring OAuth2 in Proxmox Backup Server, you must register an OAuth2
+application with your mail provider:
+
+* `Google <https://developers.google.com/identity/protocols/oauth2/web-server>`_
+* `Microsoft Entra ID <https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-register-app>`_
+
+Choose **Web application** as application type.
+
+During registration, add a redirect URI pointing to the Proxmox Backup Server
+web interface URL from which you will perform the authorization flow, for
+example:
+
+* ``https://pbs1.example.com:8007``
+* ``https://localhost:8007``
+
+You can add multiple redirect URIs to allow the authorization flow to work from
+any node.
+
+.. NOTE:: Google does not allow bare IP addresses as redirect URIs. If you need
+ to work around this, specify a dummy domain as the redirect URI and make sure
+ your local machine resolves it to the proper IP address (by, for example,
+ adding a line to ``/etc/hosts``).
+
+Configuring OAuth2 in Proxmox Backup Server
+'''''''''''''''''''''''''''''''''''''''''''
+
+In the web UI, open the notification target's edit panel and select
+``OAuth2 (Google)`` or ``OAuth2 (Microsoft)`` as the authentication method.
+Fill in the client ID and secret. For Microsoft, also fill in the tenant ID.
+
+Click **Authorize**. This opens a new window where you can sign in with your
+mail provider and grant the requested permissions. On success, a refresh token
+is obtained and stored.
+
+.. NOTE:: For OAuth2 targets, the configured ``from-address`` is also used as
+ the SMTP authentication identity, so it must match the mailbox authorized
+ with the provider.
+
+Token refresh happens automatically, at least once every 24 hours. If the token
+expires due to extended downtime or is revoked, you will need to re-authorize
+the endpoint: Open the notification target's edit panel, fill in your client
+secret, and click **Authorize** again.
+
+.. NOTE:: OAuth2 cannot be configured through direct configuration file
+ editing. Use the web interface, or alternatively ``proxmox-backup-manager``,
+ to configure OAuth2 targets. Note that when using ``proxmox-backup-manager``,
+ you are responsible for providing the initial refresh token.
+
+::
+
+ proxmox-backup-manager notification endpoint smtp create oauth2-smtp \
+ --server smtp.example.com \
+ --from-address from@example.com \
+ --mailto-user root@pam \
+ --auth-method google-oauth2 \
+ --oauth2-client-id <client ID> \
+ --oauth2-client-secret <client secret> \
+ --oauth2-refresh-token <refresh token>
+
+For Microsoft, use ``--auth-method microsoft-oauth2`` and add
+``--oauth2-tenant-id <tenant ID>``.
+
+.. _notification_targets_smtp_oauth2_microsoft:
+
+Microsoft
+'''''''''
+
+.. WARNING:: For Microsoft, the application must **not** be registered as a
+ Single-Page Application (SPA). Proxmox Backup Server requires long-lived
+ refresh tokens, and Microsoft does not allow extending the lifetime of
+ refresh tokens granted for SPAs.
+
+Register your OAuth2 application as a standard **Web** application in the
+Entra admin center. In addition to the client ID and secret, you will also
+need the **tenant ID** from your application registration.
+
.. _notification_targets_gotify:
Gotify
@@ -417,4 +505,3 @@ Counter Threshold Description and Usage
``s3-download`` Amount of bytes downloaded from the S3 endpoint,
independent of request method.
==================== ==========================================================
-
diff --git a/www/OnlineHelpInfo.js b/www/OnlineHelpInfo.js
index e118b0ad..f10d5924 100644
--- a/www/OnlineHelpInfo.js
+++ b/www/OnlineHelpInfo.js
@@ -251,6 +251,14 @@ const proxmoxOnlineHelpInfo = {
"link": "/docs/notifications.html#notification-targets-smtp",
"title": "SMTP"
},
+ "notification-targets-smtp-oauth2": {
+ "link": "/docs/notifications.html#notification-targets-smtp-oauth2",
+ "title": "OAuth2 Authentication"
+ },
+ "notification-targets-smtp-oauth2-microsoft": {
+ "link": "/docs/notifications.html#notification-targets-smtp-oauth2-microsoft",
+ "title": "Microsoft"
+ },
"notification-targets-gotify": {
"link": "/docs/notifications.html#notification-targets-gotify",
"title": "Gotify"
--
2.47.3
^ permalink raw reply related [flat|nested] 28+ messages in thread* [PATCH pve-docs v5 27/27] notifications: add OAuth2 section to SMTP targets docs
2026-05-05 8:32 [PATCH docs/manager/proxmox{,-perl-rs,-widget-toolkit,-backup} v5 00/27] fix #7238: Add XOAUTH2 authentication support for SMTP notification targets Arthur Bied-Charreton
` (25 preceding siblings ...)
2026-05-05 8:32 ` [PATCH proxmox-backup v5 26/27] notifications: add OAuth2 section to SMTP targets docs Arthur Bied-Charreton
@ 2026-05-05 8:32 ` Arthur Bied-Charreton
26 siblings, 0 replies; 28+ messages in thread
From: Arthur Bied-Charreton @ 2026-05-05 8:32 UTC (permalink / raw)
To: pve-devel, pbs-devel
Document the new config entries and the requirements to use XOAUTH2 for SMTP
notification targets, and add notes/warnings to communicate that:
1. User intervention is required for initial OAuth2 target setup, and
2. Microsoft OAuth2 apps *must not* be configured as SPAs by the user,
since it would prevent PVE from automatically extending the refresh
token's lifetime
Signed-off-by: Arthur Bied-Charreton <a.bied-charreton@proxmox.com>
---
notifications.adoc | 88 ++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 88 insertions(+)
diff --git a/notifications.adoc b/notifications.adoc
index ea8fc75..33b1230 100644
--- a/notifications.adoc
+++ b/notifications.adoc
@@ -108,10 +108,17 @@ The configuration for SMTP target plugins has the following options:
* `from-address`: Sets the From-address of the email. SMTP relays might require
that this address is owned by the user in order to avoid spoofing. The `From`
header in the email will be set to `$author <$from-address>`.
+* `auth-method`: Sets the authentication method (`plain`, `google-oauth2` or
+ `microsoft-oauth2`).
* `username`: Username to use during authentication. If no username is set,
no authentication will be performed. The PLAIN and LOGIN authentication
methods are supported.
* `password`: Password to use when authenticating.
+* `oauth2-client-id`: Client ID for the OAuth2 application, if applicable.
+* `oauth2-client-secret`: Client secret for the OAuth2 application, if
+ applicable.
+* `oauth2-tenant-id`: Tenant ID for the OAuth2 application, if applicable.
+ Only required for Microsoft OAuth2.
* `mode`: Sets the encryption mode (`insecure`, `starttls` or `tls`). Defaults
to `tls`.
* `server`: Address/IP of the SMTP relay.
@@ -140,6 +147,87 @@ smtp: example
password somepassword
----
+[[notification_targets_smtp_oauth2]]
+==== OAuth2 Authentication
+
+{pve} supports OAuth2 authentication for SMTP targets via the XOAUTH2
+mechanism. This is currently available for Google and Microsoft mail providers.
+
+===== Creating an OAuth2 Application
+
+Before configuring OAuth2 in {pve}, you must register an OAuth2 application
+with your mail provider:
+
+* https://developers.google.com/identity/protocols/oauth2/web-server[Google]
+* https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-register-app[Microsoft Entra ID]
+
+Choose *Web application* as application type.
+
+During registration, add a redirect URI pointing to the {pve} web interface
+URL from which you will perform the authorization flow, for example:
+
+* `https://pve1.example.com:8006`
+* `https://localhost:8006`
+
+You can add multiple redirect URIs to allow the authorization flow to work from
+any cluster node.
+
+NOTE: Google does not allow bare IP addresses as redirect URIs. If you need to
+work around this, specify a dummy domain as the redirect URI and make sure that
+your local machine resolves it to the proper IP address (by, for example, adding
+a line to `/etc/hosts`).
+
+===== Configuring OAuth2 in {pve}
+
+In the web UI, open the notification target's edit panel and select
+`OAuth2 (Google)` or `OAuth2 (Microsoft)` as the authentication method. Fill in
+the client ID and secret. For Microsoft, also fill in the tenant ID.
+
+Click *Authorize*. This opens a new window where you can sign in with your
+mail provider and grant the requested permissions. On success, a refresh token
+is obtained and stored.
+
+NOTE: For OAuth2 targets, the configured `from-address` is also used as the SMTP
+authentication identity, so it must match the mailbox authorized with the
+provider.
+
+Token refresh happens automatically, at least once every 24 hours. If the token
+expires due to extended cluster downtime or is revoked, you will need to
+re-authorize the endpoint: Open the notification target's edit panel, fill in
+your client secret, and click *Authorize* again.
+
+NOTE: OAuth2 cannot be configured through direct configuration file editing. Use
+the web interface, or alternatively `pvesh`, to configure OAuth2 targets. Note
+that when using `pvesh`, you are responsible for providing the initial refresh
+token.
+
+----
+pvesh create /cluster/notifications/endpoints/smtp \
+ --name oauth2-smtp \
+ --server smtp.example.com \
+ --from-address from@example.com \
+ --mailto-user root@pam \
+ --auth-method google-oauth2 \
+ --oauth2-client-id <client ID> \
+ --oauth2-client-secret <client secret> \
+ --oauth2-refresh-token <refresh token>
+----
+
+For Microsoft, use `--auth-method microsoft-oauth2` and add
+`--oauth2-tenant-id <tenant ID>`.
+
+[[notification_targets_smtp_oauth2_microsoft]]
+===== Microsoft
+
+WARNING: For Microsoft, the application must *not* be registered as a
+Single-Page Application (SPA). {pve} requires long-lived refresh tokens, and
+Microsoft does not allow extending the lifetime of refresh tokens granted for
+SPAs.
+
+Register your OAuth2 application as a standard *Web* application in the Entra
+admin center. In addition to the client ID and secret, you will also need the
+*tenant ID* from your application registration.
+
[[notification_targets_gotify]]
Gotify
~~~~~~
--
2.47.3
^ permalink raw reply related [flat|nested] 28+ messages in thread