public inbox for pbs-devel@lists.proxmox.com
 help / color / mirror / Atom feed
* [PATCH docs/manager/proxmox{,-perl-rs,-widget-toolkit,-backup} v5 00/27] fix #7238: Add XOAUTH2 authentication support for SMTP notification targets
@ 2026-05-05  8:32 Arthur Bied-Charreton
  2026-05-05  8:32 ` [PATCH proxmox v5 01/27] add oauth2 and ureq to workspace dependencies Arthur Bied-Charreton
                   ` (26 more replies)
  0 siblings, 27 replies; 28+ messages in thread
From: Arthur Bied-Charreton @ 2026-05-05  8:32 UTC (permalink / raw)
  To: pve-devel, pbs-devel

This series adds XOAUTH2 support for SMTP notification targets in PVE
and PBS, motivated by Microsoft's upcoming deprecation of basic
authentication for SMTP [0]. Google and Microsoft are supported as
OAuth2 providers.

The main challenge is the OAuth2 refresh tokens management. The
implementations differ from provider to provider, and for Microsoft
specifically, the token needs to be writable by proxmox-notify, since
Microsoft 365/Copilot/... OAuth2 rotates it at every use.

This series solves this by introducing state files, managed entirely
in proxmox-notify. Each SMTP endpoint gets its own. Tokens are
refreshed proactively in PVE's and PBS's respective daily update jobs
under a notification config lock, in order to prevent concurrent
config/state file updates.

Provider-specific stuff:

* Google: The token is refreshed in-place, meaning its lifetime is
  extended at every use without it being *actually* rotated [1]. The
  token in the state file does not change. Only the last_refreshed
  timestamp is updated in daily update jobs.
* Microsoft: The token is rotated by Microsoft at each request, and
  each token has a fixed lifetime of 90 days [2]. While the
  recommendation is to persist the newly issued token right away, we
  would need to hold a lock each time a notification is sent in order
  to do so safely, and the rotation does not invalidate the old token.
  After some off-list discussion (thanks to all involved), we landed on
  only persisting the rotated token once a day, under a full
  notifications config lock.

For access token requests in proxmox-notify, the oauth2 crate is used
with a custom proxmox-http backend, which makes it easier to take
proxy configs into account and avoids pulling in reqwest.

Endpoints performing the initial authorization code -> refresh token
exchange are added to PVE and PBS. It is not possible to do this
directly from the UI, because Microsoft rejects browser-originated
token requests.

This series requires the following version requirement bumps:
* proxmox-perl-rs -> bumped proxmox-notify
* pve-manager -> bumped proxmox-widget-toolkit and proxmox-perl-rs
* proxmox-backup -> bumped proxmox-widget-toolkit and proxmox-notify

The UI part of the OAuth2 flow is opt-in for consumers, the widget
toolkit may be bumped without bumping all products directly.

Note that this introduces a breaking change in the proxmox-notify API.

Changes since RFC:
https://lore.proxmox.com/pve-devel/20260213160415.609868-1-a.bied-charreton@proxmox.com/

Changes since v1:
https://lore.proxmox.com/pve-devel/20260325131444.366808-1-a.bied-charreton@proxmox.com/

Changes since v2:
https://lore.proxmox.com/pve-devel/20260415070220.100306-1-a.bied-charreton@proxmox.com/

Changes since v3:
https://lore.proxmox.com/pve-devel/20260421115957.402589-1-a.bied-charreton@proxmox.com/

Changes since v4:

    pve-docs, proxmox-backup/docs:
        * Remove the `/etc/hosts` hack for Google redirect URIs
        * Use example.com as example domain
        * Improve wording and fix typo
        * Document that from-address is used as SMTP authentication
          identity

    proxmox-notify:
        * Do not persist new refresh tokens while sending notification
        * Replace unwrap with `if let Some(...)` in State::save
        * Improve some doc comments
        * Create state files' parent directories with different options
          than the state file itself

    proxmox-backup,pve-manager:
        * Lock notifications config when updating notifications state
          in daily update jobs
        * Move initial authorization code -> refresh token exchange to
          the backend instead of hitting provider endpoints directly
          from the browser (Azure AD rejects browser-originated token
          requests)

    proxmox-widget-toolkit:
        * Reject before making the refresh token request if the
          authorization window does not return an authorization
          code
        * Delete `oauth2-refresh-token` if empty to prevent sending
          Some(""), which would overwrite an existing refresh token
        * Add refreshTokenUrl config field to SmtpEditPanel

[0] https://techcommunity.microsoft.com/blog/exchange/updated-exchange-online-smtp-auth-basic-authentication-deprecation-timeline/4489835
[1] https://developers.google.com/identity/protocols/oauth2#expiration
[2] https://learn.microsoft.com/en-us/entra/identity-platform/refresh-tokens#token-lifetime


proxmox:

Arthur Bied-Charreton (9):
  add oauth2 and ureq to workspace dependencies
  notify: smtp: introduce xoauth2 module
  notify: smtp: introduce state management
  notify: smtp: factor out transport building logic
  notify: smtp: update API with OAuth2 parameters
  notify: smtp: add API to exchange authorization code for refresh token
  notify: smtp: infer auth method for backwards compatibility
  notify: smtp: add state handling logic
  notify: smtp: add XOAUTH2 authentication support

 Cargo.toml                                   |   2 +
 proxmox-http/Cargo.toml                      |   2 +-
 proxmox-notify/Cargo.toml                    |  16 +-
 proxmox-notify/debian/control                |  66 ++--
 proxmox-notify/src/api/common.rs             |  19 ++
 proxmox-notify/src/api/smtp.rs               | 203 ++++++++++--
 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         | 236 ++++++++++++--
 proxmox-notify/src/endpoints/smtp/xoauth2.rs | 310 +++++++++++++++++++
 proxmox-notify/src/lib.rs                    |  40 ++-
 proxmox-openid/Cargo.toml                    |   2 +-
 14 files changed, 885 insertions(+), 97 deletions(-)
 create mode 100644 proxmox-notify/src/endpoints/smtp/xoauth2.rs


proxmox-perl-rs:

Arthur Bied-Charreton (3):
  pve-rs: notify: smtp: add OAuth2 parameters to bindings
  pve-rs: notify: add binding for triggering state refresh
  pve-rs: notify: add binding for initial OAuth2 refresh token exchange

 common/src/bindings/notify.rs | 102 +++++++++++++++++-----------------
 1 file changed, 50 insertions(+), 52 deletions(-)


proxmox-widget-toolkit:

Arthur Bied-Charreton (3):
  utils: add OAuth2 flow handlers
  utils: oauth2: add callback handler
  notifications: add opt-in OAuth2 support for SMTP targets

 src/Utils.js                   | 130 ++++++++++++++++
 src/panel/SmtpEditPanel.js     | 266 ++++++++++++++++++++++++++++++---
 src/window/EndpointEditBase.js |   2 +
 3 files changed, 374 insertions(+), 24 deletions(-)


pve-manager:

Arthur Bied-Charreton (5):
  notifications: smtp: api: add XOAUTH2 parameters
  notifications: add endpoint for initial OAuth2 refresh token exchange
  pveupdate: refresh notification targets' OAuth2 state
  login: handle OAuth2 callback
  fix #7238: notifications: smtp: add XOAUTH2 support

 PVE/API2/Cluster/Notifications.pm | 179 ++++++++++++++++++++++++++----
 bin/pveupdate                     |  13 +++
 www/manager6/Utils.js             |  11 ++
 www/manager6/Workspace.js         |   2 +
 4 files changed, 182 insertions(+), 23 deletions(-)


proxmox-backup:

Arthur Bied-Charreton (6):
  notifications: add XOAUTH2 parameters to endpoints
  notifications: add endpoint for initial OAuth2 refresh token exchange
  login: handle OAuth2 callback
  fix #7238: notifications: smtp: add XOAUTH2 support
  daily-update: refresh OAuth2 state for SMTP notification endpoints
  notifications: add OAuth2 section to SMTP targets docs

 docs/notifications.rst                |  89 ++++++++++++++++++++++-
 src/api2/config/notifications/mod.rs  |   1 +
 src/api2/config/notifications/smtp.rs | 101 +++++++++++++++++++++++++-
 src/bin/proxmox-daily-update.rs       |  11 +++
 www/Application.js                    |   4 +-
 www/OnlineHelpInfo.js                 |   8 ++
 www/Utils.js                          |   2 +
 7 files changed, 210 insertions(+), 6 deletions(-)


pve-docs:

Arthur Bied-Charreton (1):
  notifications: add OAuth2 section to SMTP targets docs

 notifications.adoc | 88 ++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 88 insertions(+)


Summary over all repositories:
  30 files changed, 1789 insertions(+), 202 deletions(-)

-- 
Generated by murpp 0.11.0



^ permalink raw reply	[flat|nested] 28+ messages in thread

* [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, &notification)
     }
 
+    /// 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

end of thread, other threads:[~2026-05-05  8:42 UTC | newest]

Thread overview: 28+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
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 ` [PATCH proxmox v5 03/27] notify: smtp: introduce state management Arthur Bied-Charreton
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 ` [PATCH proxmox v5 05/27] notify: smtp: update API with OAuth2 parameters 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
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 ` [PATCH proxmox v5 08/27] notify: smtp: add state handling logic Arthur Bied-Charreton
2026-05-05  8:32 ` [PATCH proxmox v5 09/27] notify: smtp: add XOAUTH2 authentication support 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
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 ` [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 ` [PATCH proxmox-widget-toolkit v5 13/27] utils: add OAuth2 flow handlers Arthur Bied-Charreton
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 ` [PATCH proxmox-widget-toolkit v5 15/27] notifications: add opt-in OAuth2 support for SMTP targets Arthur Bied-Charreton
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 ` [PATCH pve-manager v5 17/27] notifications: add endpoint for initial OAuth2 refresh token exchange Arthur Bied-Charreton
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 ` [PATCH pve-manager v5 19/27] login: handle OAuth2 callback Arthur Bied-Charreton
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 ` [PATCH proxmox-backup v5 21/27] notifications: add XOAUTH2 parameters to endpoints 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
2026-05-05  8:32 ` [PATCH proxmox-backup v5 23/27] login: handle OAuth2 callback Arthur Bied-Charreton
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 ` [PATCH proxmox-backup v5 25/27] daily-update: refresh OAuth2 state for SMTP notification endpoints 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

This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal